Merge pull request #3024 from TDT-AG/pr/20190829-material-logo
[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
6 var modalDiv = null,
7 tooltipDiv = null,
8 tooltipTimeout = null;
9
10 var UIElement = L.Class.extend({
11 getValue: function() {
12 if (L.dom.matches(this.node, 'select') || L.dom.matches(this.node, 'input'))
13 return this.node.value;
14
15 return null;
16 },
17
18 setValue: function(value) {
19 if (L.dom.matches(this.node, 'select') || L.dom.matches(this.node, 'input'))
20 this.node.value = value;
21 },
22
23 isValid: function() {
24 return (this.validState !== false);
25 },
26
27 triggerValidation: function() {
28 if (typeof(this.vfunc) != 'function')
29 return false;
30
31 var wasValid = this.isValid();
32
33 this.vfunc();
34
35 return (wasValid != this.isValid());
36 },
37
38 registerEvents: function(targetNode, synevent, events) {
39 var dispatchFn = L.bind(function(ev) {
40 this.node.dispatchEvent(new CustomEvent(synevent, { bubbles: true }));
41 }, this);
42
43 for (var i = 0; i < events.length; i++)
44 targetNode.addEventListener(events[i], dispatchFn);
45 },
46
47 setUpdateEvents: function(targetNode /*, ... */) {
48 var datatype = this.options.datatype,
49 optional = this.options.hasOwnProperty('optional') ? this.options.optional : true,
50 validate = this.options.validate,
51 events = this.varargs(arguments, 1);
52
53 this.registerEvents(targetNode, 'widget-update', events);
54
55 if (!datatype && !validate)
56 return;
57
58 this.vfunc = L.ui.addValidator.apply(L.ui, [
59 targetNode, datatype || 'string',
60 optional, validate
61 ].concat(events));
62
63 this.node.addEventListener('validation-success', L.bind(function(ev) {
64 this.validState = true;
65 }, this));
66
67 this.node.addEventListener('validation-failure', L.bind(function(ev) {
68 this.validState = false;
69 }, this));
70 },
71
72 setChangeEvents: function(targetNode /*, ... */) {
73 this.registerEvents(targetNode, 'widget-change', this.varargs(arguments, 1));
74 }
75 });
76
77 var UITextfield = UIElement.extend({
78 __init__: function(value, options) {
79 this.value = value;
80 this.options = Object.assign({
81 optional: true,
82 password: false
83 }, options);
84 },
85
86 render: function() {
87 var frameEl = E('div', { 'id': this.options.id });
88
89 if (this.options.password) {
90 frameEl.classList.add('nowrap');
91 frameEl.appendChild(E('input', {
92 'type': 'password',
93 'style': 'position:absolute; left:-100000px',
94 'aria-hidden': true,
95 'tabindex': -1,
96 'name': this.options.name ? 'password.%s'.format(this.options.name) : null
97 }));
98 }
99
100 frameEl.appendChild(E('input', {
101 'id': this.options.id ? 'widget.' + this.options.id : null,
102 'name': this.options.name,
103 'type': this.options.password ? 'password' : 'text',
104 'class': this.options.password ? 'cbi-input-password' : 'cbi-input-text',
105 'readonly': this.options.readonly ? '' : null,
106 'maxlength': this.options.maxlength,
107 'placeholder': this.options.placeholder,
108 'value': this.value,
109 }));
110
111 if (this.options.password)
112 frameEl.appendChild(E('button', {
113 'class': 'cbi-button cbi-button-neutral',
114 'title': _('Reveal/hide password'),
115 'aria-label': _('Reveal/hide password'),
116 'click': function(ev) {
117 var e = this.previousElementSibling;
118 e.type = (e.type === 'password') ? 'text' : 'password';
119 ev.preventDefault();
120 }
121 }, '∗'));
122
123 return this.bind(frameEl);
124 },
125
126 bind: function(frameEl) {
127 var inputEl = frameEl.childNodes[+!!this.options.password];
128
129 this.node = frameEl;
130
131 this.setUpdateEvents(inputEl, 'keyup', 'blur');
132 this.setChangeEvents(inputEl, 'change');
133
134 L.dom.bindClassInstance(frameEl, this);
135
136 return frameEl;
137 },
138
139 getValue: function() {
140 var inputEl = this.node.childNodes[+!!this.options.password];
141 return inputEl.value;
142 },
143
144 setValue: function(value) {
145 var inputEl = this.node.childNodes[+!!this.options.password];
146 inputEl.value = value;
147 }
148 });
149
150 var UITextarea = UIElement.extend({
151 __init__: function(value, options) {
152 this.value = value;
153 this.options = Object.assign({
154 optional: true,
155 wrap: false,
156 cols: null,
157 rows: null
158 }, options);
159 },
160
161 render: function() {
162 var frameEl = E('div', { 'id': this.options.id }),
163 value = (this.value != null) ? String(this.value) : '';
164
165 frameEl.appendChild(E('textarea', {
166 'id': this.options.id ? 'widget.' + this.options.id : null,
167 'name': this.options.name,
168 'class': 'cbi-input-textarea',
169 'readonly': this.options.readonly ? '' : null,
170 'placeholder': this.options.placeholder,
171 'style': !this.options.cols ? 'width:100%' : null,
172 'cols': this.options.cols,
173 'rows': this.options.rows,
174 'wrap': this.options.wrap ? '' : null
175 }, [ value ]));
176
177 if (this.options.monospace)
178 frameEl.firstElementChild.style.fontFamily = 'monospace';
179
180 return this.bind(frameEl);
181 },
182
183 bind: function(frameEl) {
184 var inputEl = frameEl.firstElementChild;
185
186 this.node = frameEl;
187
188 this.setUpdateEvents(inputEl, 'keyup', 'blur');
189 this.setChangeEvents(inputEl, 'change');
190
191 L.dom.bindClassInstance(frameEl, this);
192
193 return frameEl;
194 },
195
196 getValue: function() {
197 return this.node.firstElementChild.value;
198 },
199
200 setValue: function(value) {
201 this.node.firstElementChild.value = value;
202 }
203 });
204
205 var UICheckbox = UIElement.extend({
206 __init__: function(value, options) {
207 this.value = value;
208 this.options = Object.assign({
209 value_enabled: '1',
210 value_disabled: '0'
211 }, options);
212 },
213
214 render: function() {
215 var frameEl = E('div', {
216 'id': this.options.id,
217 'class': 'cbi-checkbox'
218 });
219
220 if (this.options.hiddenname)
221 frameEl.appendChild(E('input', {
222 'type': 'hidden',
223 'name': this.options.hiddenname,
224 'value': 1
225 }));
226
227 frameEl.appendChild(E('input', {
228 'id': this.options.id ? 'widget.' + this.options.id : null,
229 'name': this.options.name,
230 'type': 'checkbox',
231 'value': this.options.value_enabled,
232 'checked': (this.value == this.options.value_enabled) ? '' : null
233 }));
234
235 return this.bind(frameEl);
236 },
237
238 bind: function(frameEl) {
239 this.node = frameEl;
240
241 this.setUpdateEvents(frameEl.lastElementChild, 'click', 'blur');
242 this.setChangeEvents(frameEl.lastElementChild, 'change');
243
244 L.dom.bindClassInstance(frameEl, this);
245
246 return frameEl;
247 },
248
249 isChecked: function() {
250 return this.node.lastElementChild.checked;
251 },
252
253 getValue: function() {
254 return this.isChecked()
255 ? this.options.value_enabled
256 : this.options.value_disabled;
257 },
258
259 setValue: function(value) {
260 this.node.lastElementChild.checked = (value == this.options.value_enabled);
261 }
262 });
263
264 var UISelect = UIElement.extend({
265 __init__: function(value, choices, options) {
266 if (!L.isObject(choices))
267 choices = {};
268
269 if (!Array.isArray(value))
270 value = (value != null && value != '') ? [ value ] : [];
271
272 if (!options.multiple && value.length > 1)
273 value.length = 1;
274
275 this.values = value;
276 this.choices = choices;
277 this.options = Object.assign({
278 multiple: false,
279 widget: 'select',
280 orientation: 'horizontal'
281 }, options);
282
283 if (this.choices.hasOwnProperty(''))
284 this.options.optional = true;
285 },
286
287 render: function() {
288 var frameEl = E('div', { 'id': this.options.id }),
289 keys = Object.keys(this.choices);
290
291 if (this.options.sort === true)
292 keys.sort();
293 else if (Array.isArray(this.options.sort))
294 keys = this.options.sort;
295
296 if (this.options.widget == 'select') {
297 frameEl.appendChild(E('select', {
298 'id': this.options.id ? 'widget.' + this.options.id : null,
299 'name': this.options.name,
300 'size': this.options.size,
301 'class': 'cbi-input-select',
302 'multiple': this.options.multiple ? '' : null
303 }));
304
305 if (this.options.optional)
306 frameEl.lastChild.appendChild(E('option', {
307 'value': '',
308 'selected': (this.values.length == 0 || this.values[0] == '') ? '' : null
309 }, this.choices[''] || this.options.placeholder || _('-- Please choose --')));
310
311 for (var i = 0; i < keys.length; i++) {
312 if (keys[i] == null || keys[i] == '')
313 continue;
314
315 frameEl.lastChild.appendChild(E('option', {
316 'value': keys[i],
317 'selected': (this.values.indexOf(keys[i]) > -1) ? '' : null
318 }, this.choices[keys[i]] || keys[i]));
319 }
320 }
321 else {
322 var brEl = (this.options.orientation === 'horizontal') ? document.createTextNode(' ') : E('br');
323
324 for (var i = 0; i < keys.length; i++) {
325 frameEl.appendChild(E('label', {}, [
326 E('input', {
327 'id': this.options.id ? 'widget.' + this.options.id : null,
328 'name': this.options.id || this.options.name,
329 'type': this.options.multiple ? 'checkbox' : 'radio',
330 'class': this.options.multiple ? 'cbi-input-checkbox' : 'cbi-input-radio',
331 'value': keys[i],
332 'checked': (this.values.indexOf(keys[i]) > -1) ? '' : null
333 }),
334 this.choices[keys[i]] || keys[i]
335 ]));
336
337 if (i + 1 == this.options.size)
338 frameEl.appendChild(brEl);
339 }
340 }
341
342 return this.bind(frameEl);
343 },
344
345 bind: function(frameEl) {
346 this.node = frameEl;
347
348 if (this.options.widget == 'select') {
349 this.setUpdateEvents(frameEl.firstChild, 'change', 'click', 'blur');
350 this.setChangeEvents(frameEl.firstChild, 'change');
351 }
352 else {
353 var radioEls = frameEl.querySelectorAll('input[type="radio"]');
354 for (var i = 0; i < radioEls.length; i++) {
355 this.setUpdateEvents(radioEls[i], 'change', 'click', 'blur');
356 this.setChangeEvents(radioEls[i], 'change', 'click', 'blur');
357 }
358 }
359
360 L.dom.bindClassInstance(frameEl, this);
361
362 return frameEl;
363 },
364
365 getValue: function() {
366 if (this.options.widget == 'select')
367 return this.node.firstChild.value;
368
369 var radioEls = frameEl.querySelectorAll('input[type="radio"]');
370 for (var i = 0; i < radioEls.length; i++)
371 if (radioEls[i].checked)
372 return radioEls[i].value;
373
374 return null;
375 },
376
377 setValue: function(value) {
378 if (this.options.widget == 'select') {
379 if (value == null)
380 value = '';
381
382 for (var i = 0; i < this.node.firstChild.options.length; i++)
383 this.node.firstChild.options[i].selected = (this.node.firstChild.options[i].value == value);
384
385 return;
386 }
387
388 var radioEls = frameEl.querySelectorAll('input[type="radio"]');
389 for (var i = 0; i < radioEls.length; i++)
390 radioEls[i].checked = (radioEls[i].value == value);
391 }
392 });
393
394 var UIDropdown = UIElement.extend({
395 __init__: function(value, choices, options) {
396 if (typeof(choices) != 'object')
397 choices = {};
398
399 if (!Array.isArray(value))
400 this.values = (value != null && value != '') ? [ value ] : [];
401 else
402 this.values = value;
403
404 this.choices = choices;
405 this.options = Object.assign({
406 sort: true,
407 multiple: Array.isArray(value),
408 optional: true,
409 select_placeholder: _('-- Please choose --'),
410 custom_placeholder: _('-- custom --'),
411 display_items: 3,
412 dropdown_items: -1,
413 create: false,
414 create_query: '.create-item-input',
415 create_template: 'script[type="item-template"]'
416 }, options);
417 },
418
419 render: function() {
420 var sb = E('div', {
421 'id': this.options.id,
422 'class': 'cbi-dropdown',
423 'multiple': this.options.multiple ? '' : null,
424 'optional': this.options.optional ? '' : null,
425 }, E('ul'));
426
427 var keys = Object.keys(this.choices);
428
429 if (this.options.sort === true)
430 keys.sort();
431 else if (Array.isArray(this.options.sort))
432 keys = this.options.sort;
433
434 if (this.options.create)
435 for (var i = 0; i < this.values.length; i++)
436 if (!this.choices.hasOwnProperty(this.values[i]))
437 keys.push(this.values[i]);
438
439 for (var i = 0; i < keys.length; i++)
440 sb.lastElementChild.appendChild(E('li', {
441 'data-value': keys[i],
442 'selected': (this.values.indexOf(keys[i]) > -1) ? '' : null
443 }, this.choices[keys[i]] || keys[i]));
444
445 if (this.options.create) {
446 var createEl = E('input', {
447 'type': 'text',
448 'class': 'create-item-input',
449 'readonly': this.options.readonly ? '' : null,
450 'maxlength': this.options.maxlength,
451 'placeholder': this.options.custom_placeholder || this.options.placeholder
452 });
453
454 if (this.options.datatype)
455 L.ui.addValidator(createEl, this.options.datatype,
456 true, null, 'blur', 'keyup');
457
458 sb.lastElementChild.appendChild(E('li', { 'data-value': '-' }, createEl));
459 }
460
461 if (this.options.create_markup)
462 sb.appendChild(E('script', { type: 'item-template' },
463 this.options.create_markup));
464
465 return this.bind(sb);
466 },
467
468 bind: function(sb) {
469 var o = this.options;
470
471 o.multiple = sb.hasAttribute('multiple');
472 o.optional = sb.hasAttribute('optional');
473 o.placeholder = sb.getAttribute('placeholder') || o.placeholder;
474 o.display_items = parseInt(sb.getAttribute('display-items') || o.display_items);
475 o.dropdown_items = parseInt(sb.getAttribute('dropdown-items') || o.dropdown_items);
476 o.create_query = sb.getAttribute('item-create') || o.create_query;
477 o.create_template = sb.getAttribute('item-template') || o.create_template;
478
479 var ul = sb.querySelector('ul'),
480 more = sb.appendChild(E('span', { class: 'more', tabindex: -1 }, '···')),
481 open = sb.appendChild(E('span', { class: 'open', tabindex: -1 }, '▾')),
482 canary = sb.appendChild(E('div')),
483 create = sb.querySelector(this.options.create_query),
484 ndisplay = this.options.display_items,
485 n = 0;
486
487 if (this.options.multiple) {
488 var items = ul.querySelectorAll('li');
489
490 for (var i = 0; i < items.length; i++) {
491 this.transformItem(sb, items[i]);
492
493 if (items[i].hasAttribute('selected') && ndisplay-- > 0)
494 items[i].setAttribute('display', n++);
495 }
496 }
497 else {
498 if (this.options.optional && !ul.querySelector('li[data-value=""]')) {
499 var placeholder = E('li', { placeholder: '' },
500 this.options.select_placeholder || this.options.placeholder);
501
502 ul.firstChild
503 ? ul.insertBefore(placeholder, ul.firstChild)
504 : ul.appendChild(placeholder);
505 }
506
507 var items = ul.querySelectorAll('li'),
508 sel = sb.querySelectorAll('[selected]');
509
510 sel.forEach(function(s) {
511 s.removeAttribute('selected');
512 });
513
514 var s = sel[0] || items[0];
515 if (s) {
516 s.setAttribute('selected', '');
517 s.setAttribute('display', n++);
518 }
519
520 ndisplay--;
521 }
522
523 this.saveValues(sb, ul);
524
525 ul.setAttribute('tabindex', -1);
526 sb.setAttribute('tabindex', 0);
527
528 if (ndisplay < 0)
529 sb.setAttribute('more', '')
530 else
531 sb.removeAttribute('more');
532
533 if (ndisplay == this.options.display_items)
534 sb.setAttribute('empty', '')
535 else
536 sb.removeAttribute('empty');
537
538 L.dom.content(more, (ndisplay == this.options.display_items)
539 ? (this.options.select_placeholder || this.options.placeholder) : '···');
540
541
542 sb.addEventListener('click', this.handleClick.bind(this));
543 sb.addEventListener('keydown', this.handleKeydown.bind(this));
544 sb.addEventListener('cbi-dropdown-close', this.handleDropdownClose.bind(this));
545 sb.addEventListener('cbi-dropdown-select', this.handleDropdownSelect.bind(this));
546
547 if ('ontouchstart' in window) {
548 sb.addEventListener('touchstart', function(ev) { ev.stopPropagation(); });
549 window.addEventListener('touchstart', this.closeAllDropdowns);
550 }
551 else {
552 sb.addEventListener('mouseover', this.handleMouseover.bind(this));
553 sb.addEventListener('focus', this.handleFocus.bind(this));
554
555 canary.addEventListener('focus', this.handleCanaryFocus.bind(this));
556
557 window.addEventListener('mouseover', this.setFocus);
558 window.addEventListener('click', this.closeAllDropdowns);
559 }
560
561 if (create) {
562 create.addEventListener('keydown', this.handleCreateKeydown.bind(this));
563 create.addEventListener('focus', this.handleCreateFocus.bind(this));
564 create.addEventListener('blur', this.handleCreateBlur.bind(this));
565
566 var li = findParent(create, 'li');
567
568 li.setAttribute('unselectable', '');
569 li.addEventListener('click', this.handleCreateClick.bind(this));
570 }
571
572 this.node = sb;
573
574 this.setUpdateEvents(sb, 'cbi-dropdown-open', 'cbi-dropdown-close');
575 this.setChangeEvents(sb, 'cbi-dropdown-change', 'cbi-dropdown-close');
576
577 L.dom.bindClassInstance(sb, this);
578
579 return sb;
580 },
581
582 openDropdown: function(sb) {
583 var st = window.getComputedStyle(sb, null),
584 ul = sb.querySelector('ul'),
585 li = ul.querySelectorAll('li'),
586 fl = findParent(sb, '.cbi-value-field'),
587 sel = ul.querySelector('[selected]'),
588 rect = sb.getBoundingClientRect(),
589 items = Math.min(this.options.dropdown_items, li.length);
590
591 document.querySelectorAll('.cbi-dropdown[open]').forEach(function(s) {
592 s.dispatchEvent(new CustomEvent('cbi-dropdown-close', {}));
593 });
594
595 sb.setAttribute('open', '');
596
597 var pv = ul.cloneNode(true);
598 pv.classList.add('preview');
599
600 if (fl)
601 fl.classList.add('cbi-dropdown-open');
602
603 if ('ontouchstart' in window) {
604 var vpWidth = Math.max(document.documentElement.clientWidth, window.innerWidth || 0),
605 vpHeight = Math.max(document.documentElement.clientHeight, window.innerHeight || 0),
606 scrollFrom = window.pageYOffset,
607 scrollTo = scrollFrom + rect.top - vpHeight * 0.5,
608 start = null;
609
610 ul.style.top = sb.offsetHeight + 'px';
611 ul.style.left = -rect.left + 'px';
612 ul.style.right = (rect.right - vpWidth) + 'px';
613 ul.style.maxHeight = (vpHeight * 0.5) + 'px';
614 ul.style.WebkitOverflowScrolling = 'touch';
615
616 var scrollStep = function(timestamp) {
617 if (!start) {
618 start = timestamp;
619 ul.scrollTop = sel ? Math.max(sel.offsetTop - sel.offsetHeight, 0) : 0;
620 }
621
622 var duration = Math.max(timestamp - start, 1);
623 if (duration < 100) {
624 document.body.scrollTop = scrollFrom + (scrollTo - scrollFrom) * (duration / 100);
625 window.requestAnimationFrame(scrollStep);
626 }
627 else {
628 document.body.scrollTop = scrollTo;
629 }
630 };
631
632 window.requestAnimationFrame(scrollStep);
633 }
634 else {
635 ul.style.maxHeight = '1px';
636 ul.style.top = ul.style.bottom = '';
637
638 window.requestAnimationFrame(function() {
639 var itemHeight = li[Math.max(0, li.length - 2)].getBoundingClientRect().height,
640 fullHeight = 0,
641 spaceAbove = rect.top,
642 spaceBelow = window.innerHeight - rect.height - rect.top;
643
644 for (var i = 0; i < (items == -1 ? li.length : items); i++)
645 fullHeight += li[i].getBoundingClientRect().height;
646
647 if (fullHeight <= spaceBelow) {
648 ul.style.top = rect.height + 'px';
649 ul.style.maxHeight = spaceBelow + 'px';
650 }
651 else if (fullHeight <= spaceAbove) {
652 ul.style.bottom = rect.height + 'px';
653 ul.style.maxHeight = spaceAbove + 'px';
654 }
655 else if (spaceBelow >= spaceAbove) {
656 ul.style.top = rect.height + 'px';
657 ul.style.maxHeight = (spaceBelow - (spaceBelow % itemHeight)) + 'px';
658 }
659 else {
660 ul.style.bottom = rect.height + 'px';
661 ul.style.maxHeight = (spaceAbove - (spaceAbove % itemHeight)) + 'px';
662 }
663
664 ul.scrollTop = sel ? Math.max(sel.offsetTop - sel.offsetHeight, 0) : 0;
665 });
666 }
667
668 var cboxes = ul.querySelectorAll('[selected] input[type="checkbox"]');
669 for (var i = 0; i < cboxes.length; i++) {
670 cboxes[i].checked = true;
671 cboxes[i].disabled = (cboxes.length == 1 && !this.options.optional);
672 };
673
674 ul.classList.add('dropdown');
675
676 sb.insertBefore(pv, ul.nextElementSibling);
677
678 li.forEach(function(l) {
679 l.setAttribute('tabindex', 0);
680 });
681
682 sb.lastElementChild.setAttribute('tabindex', 0);
683
684 this.setFocus(sb, sel || li[0], true);
685 },
686
687 closeDropdown: function(sb, no_focus) {
688 if (!sb.hasAttribute('open'))
689 return;
690
691 var pv = sb.querySelector('ul.preview'),
692 ul = sb.querySelector('ul.dropdown'),
693 li = ul.querySelectorAll('li'),
694 fl = findParent(sb, '.cbi-value-field');
695
696 li.forEach(function(l) { l.removeAttribute('tabindex'); });
697 sb.lastElementChild.removeAttribute('tabindex');
698
699 sb.removeChild(pv);
700 sb.removeAttribute('open');
701 sb.style.width = sb.style.height = '';
702
703 ul.classList.remove('dropdown');
704 ul.style.top = ul.style.bottom = ul.style.maxHeight = '';
705
706 if (fl)
707 fl.classList.remove('cbi-dropdown-open');
708
709 if (!no_focus)
710 this.setFocus(sb, sb);
711
712 this.saveValues(sb, ul);
713 },
714
715 toggleItem: function(sb, li, force_state) {
716 if (li.hasAttribute('unselectable'))
717 return;
718
719 if (this.options.multiple) {
720 var cbox = li.querySelector('input[type="checkbox"]'),
721 items = li.parentNode.querySelectorAll('li'),
722 label = sb.querySelector('ul.preview'),
723 sel = li.parentNode.querySelectorAll('[selected]').length,
724 more = sb.querySelector('.more'),
725 ndisplay = this.options.display_items,
726 n = 0;
727
728 if (li.hasAttribute('selected')) {
729 if (force_state !== true) {
730 if (sel > 1 || this.options.optional) {
731 li.removeAttribute('selected');
732 cbox.checked = cbox.disabled = false;
733 sel--;
734 }
735 else {
736 cbox.disabled = true;
737 }
738 }
739 }
740 else {
741 if (force_state !== false) {
742 li.setAttribute('selected', '');
743 cbox.checked = true;
744 cbox.disabled = false;
745 sel++;
746 }
747 }
748
749 while (label && label.firstElementChild)
750 label.removeChild(label.firstElementChild);
751
752 for (var i = 0; i < items.length; i++) {
753 items[i].removeAttribute('display');
754 if (items[i].hasAttribute('selected')) {
755 if (ndisplay-- > 0) {
756 items[i].setAttribute('display', n++);
757 if (label)
758 label.appendChild(items[i].cloneNode(true));
759 }
760 var c = items[i].querySelector('input[type="checkbox"]');
761 if (c)
762 c.disabled = (sel == 1 && !this.options.optional);
763 }
764 }
765
766 if (ndisplay < 0)
767 sb.setAttribute('more', '');
768 else
769 sb.removeAttribute('more');
770
771 if (ndisplay === this.options.display_items)
772 sb.setAttribute('empty', '');
773 else
774 sb.removeAttribute('empty');
775
776 L.dom.content(more, (ndisplay === this.options.display_items)
777 ? (this.options.select_placeholder || this.options.placeholder) : '···');
778 }
779 else {
780 var sel = li.parentNode.querySelector('[selected]');
781 if (sel) {
782 sel.removeAttribute('display');
783 sel.removeAttribute('selected');
784 }
785
786 li.setAttribute('display', 0);
787 li.setAttribute('selected', '');
788
789 this.closeDropdown(sb, true);
790 }
791
792 this.saveValues(sb, li.parentNode);
793 },
794
795 transformItem: function(sb, li) {
796 var cbox = E('form', {}, E('input', { type: 'checkbox', tabindex: -1, onclick: 'event.preventDefault()' })),
797 label = E('label');
798
799 while (li.firstChild)
800 label.appendChild(li.firstChild);
801
802 li.appendChild(cbox);
803 li.appendChild(label);
804 },
805
806 saveValues: function(sb, ul) {
807 var sel = ul.querySelectorAll('li[selected]'),
808 div = sb.lastElementChild,
809 name = this.options.name,
810 strval = '',
811 values = [];
812
813 while (div.lastElementChild)
814 div.removeChild(div.lastElementChild);
815
816 sel.forEach(function (s) {
817 if (s.hasAttribute('placeholder'))
818 return;
819
820 var v = {
821 text: s.innerText,
822 value: s.hasAttribute('data-value') ? s.getAttribute('data-value') : s.innerText,
823 element: s
824 };
825
826 div.appendChild(E('input', {
827 type: 'hidden',
828 name: name,
829 value: v.value
830 }));
831
832 values.push(v);
833
834 strval += strval.length ? ' ' + v.value : v.value;
835 });
836
837 var detail = {
838 instance: this,
839 element: sb
840 };
841
842 if (this.options.multiple)
843 detail.values = values;
844 else
845 detail.value = values.length ? values[0] : null;
846
847 sb.value = strval;
848
849 sb.dispatchEvent(new CustomEvent('cbi-dropdown-change', {
850 bubbles: true,
851 detail: detail
852 }));
853 },
854
855 setValues: function(sb, values) {
856 var ul = sb.querySelector('ul');
857
858 if (this.options.create) {
859 for (var value in values) {
860 this.createItems(sb, value);
861
862 if (!this.options.multiple)
863 break;
864 }
865 }
866
867 if (this.options.multiple) {
868 var lis = ul.querySelectorAll('li[data-value]');
869 for (var i = 0; i < lis.length; i++) {
870 var value = lis[i].getAttribute('data-value');
871 if (values === null || !(value in values))
872 this.toggleItem(sb, lis[i], false);
873 else
874 this.toggleItem(sb, lis[i], true);
875 }
876 }
877 else {
878 var ph = ul.querySelector('li[placeholder]');
879 if (ph)
880 this.toggleItem(sb, ph);
881
882 var lis = ul.querySelectorAll('li[data-value]');
883 for (var i = 0; i < lis.length; i++) {
884 var value = lis[i].getAttribute('data-value');
885 if (values !== null && (value in values))
886 this.toggleItem(sb, lis[i]);
887 }
888 }
889 },
890
891 setFocus: function(sb, elem, scroll) {
892 if (sb && sb.hasAttribute && sb.hasAttribute('locked-in'))
893 return;
894
895 if (sb.target && findParent(sb.target, 'ul.dropdown'))
896 return;
897
898 document.querySelectorAll('.focus').forEach(function(e) {
899 if (!matchesElem(e, 'input')) {
900 e.classList.remove('focus');
901 e.blur();
902 }
903 });
904
905 if (elem) {
906 elem.focus();
907 elem.classList.add('focus');
908
909 if (scroll)
910 elem.parentNode.scrollTop = elem.offsetTop - elem.parentNode.offsetTop;
911 }
912 },
913
914 createItems: function(sb, value) {
915 var sbox = this,
916 val = (value || '').trim(),
917 ul = sb.querySelector('ul');
918
919 if (!sbox.options.multiple)
920 val = val.length ? [ val ] : [];
921 else
922 val = val.length ? val.split(/\s+/) : [];
923
924 val.forEach(function(item) {
925 var new_item = null;
926
927 ul.childNodes.forEach(function(li) {
928 if (li.getAttribute && li.getAttribute('data-value') === item)
929 new_item = li;
930 });
931
932 if (!new_item) {
933 var markup,
934 tpl = sb.querySelector(sbox.options.create_template);
935
936 if (tpl)
937 markup = (tpl.textContent || tpl.innerHTML || tpl.firstChild.data).replace(/^<!--|-->$/, '').trim();
938 else
939 markup = '<li data-value="{{value}}">{{value}}</li>';
940
941 new_item = E(markup.replace(/{{value}}/g, '%h'.format(item)));
942
943 if (sbox.options.multiple) {
944 sbox.transformItem(sb, new_item);
945 }
946 else {
947 var old = ul.querySelector('li[created]');
948 if (old)
949 ul.removeChild(old);
950
951 new_item.setAttribute('created', '');
952 }
953
954 new_item = ul.insertBefore(new_item, ul.lastElementChild);
955 }
956
957 sbox.toggleItem(sb, new_item, true);
958 sbox.setFocus(sb, new_item, true);
959 });
960 },
961
962 closeAllDropdowns: function() {
963 document.querySelectorAll('.cbi-dropdown[open]').forEach(function(s) {
964 s.dispatchEvent(new CustomEvent('cbi-dropdown-close', {}));
965 });
966 },
967
968 handleClick: function(ev) {
969 var sb = ev.currentTarget;
970
971 if (!sb.hasAttribute('open')) {
972 if (!matchesElem(ev.target, 'input'))
973 this.openDropdown(sb);
974 }
975 else {
976 var li = findParent(ev.target, 'li');
977 if (li && li.parentNode.classList.contains('dropdown'))
978 this.toggleItem(sb, li);
979 else if (li && li.parentNode.classList.contains('preview'))
980 this.closeDropdown(sb);
981 else if (matchesElem(ev.target, 'span.open, span.more'))
982 this.closeDropdown(sb);
983 }
984
985 ev.preventDefault();
986 ev.stopPropagation();
987 },
988
989 handleKeydown: function(ev) {
990 var sb = ev.currentTarget;
991
992 if (matchesElem(ev.target, 'input'))
993 return;
994
995 if (!sb.hasAttribute('open')) {
996 switch (ev.keyCode) {
997 case 37:
998 case 38:
999 case 39:
1000 case 40:
1001 this.openDropdown(sb);
1002 ev.preventDefault();
1003 }
1004 }
1005 else {
1006 var active = findParent(document.activeElement, 'li');
1007
1008 switch (ev.keyCode) {
1009 case 27:
1010 this.closeDropdown(sb);
1011 break;
1012
1013 case 13:
1014 if (active) {
1015 if (!active.hasAttribute('selected'))
1016 this.toggleItem(sb, active);
1017 this.closeDropdown(sb);
1018 ev.preventDefault();
1019 }
1020 break;
1021
1022 case 32:
1023 if (active) {
1024 this.toggleItem(sb, active);
1025 ev.preventDefault();
1026 }
1027 break;
1028
1029 case 38:
1030 if (active && active.previousElementSibling) {
1031 this.setFocus(sb, active.previousElementSibling);
1032 ev.preventDefault();
1033 }
1034 break;
1035
1036 case 40:
1037 if (active && active.nextElementSibling) {
1038 this.setFocus(sb, active.nextElementSibling);
1039 ev.preventDefault();
1040 }
1041 break;
1042 }
1043 }
1044 },
1045
1046 handleDropdownClose: function(ev) {
1047 var sb = ev.currentTarget;
1048
1049 this.closeDropdown(sb, true);
1050 },
1051
1052 handleDropdownSelect: function(ev) {
1053 var sb = ev.currentTarget,
1054 li = findParent(ev.target, 'li');
1055
1056 if (!li)
1057 return;
1058
1059 this.toggleItem(sb, li);
1060 this.closeDropdown(sb, true);
1061 },
1062
1063 handleMouseover: function(ev) {
1064 var sb = ev.currentTarget;
1065
1066 if (!sb.hasAttribute('open'))
1067 return;
1068
1069 var li = findParent(ev.target, 'li');
1070
1071 if (li && li.parentNode.classList.contains('dropdown'))
1072 this.setFocus(sb, li);
1073 },
1074
1075 handleFocus: function(ev) {
1076 var sb = ev.currentTarget;
1077
1078 document.querySelectorAll('.cbi-dropdown[open]').forEach(function(s) {
1079 if (s !== sb || sb.hasAttribute('open'))
1080 s.dispatchEvent(new CustomEvent('cbi-dropdown-close', {}));
1081 });
1082 },
1083
1084 handleCanaryFocus: function(ev) {
1085 this.closeDropdown(ev.currentTarget.parentNode);
1086 },
1087
1088 handleCreateKeydown: function(ev) {
1089 var input = ev.currentTarget,
1090 sb = findParent(input, '.cbi-dropdown');
1091
1092 switch (ev.keyCode) {
1093 case 13:
1094 ev.preventDefault();
1095
1096 if (input.classList.contains('cbi-input-invalid'))
1097 return;
1098
1099 this.createItems(sb, input.value);
1100 input.value = '';
1101 input.blur();
1102 break;
1103 }
1104 },
1105
1106 handleCreateFocus: function(ev) {
1107 var input = ev.currentTarget,
1108 cbox = findParent(input, 'li').querySelector('input[type="checkbox"]'),
1109 sb = findParent(input, '.cbi-dropdown');
1110
1111 if (cbox)
1112 cbox.checked = true;
1113
1114 sb.setAttribute('locked-in', '');
1115 },
1116
1117 handleCreateBlur: function(ev) {
1118 var input = ev.currentTarget,
1119 cbox = findParent(input, 'li').querySelector('input[type="checkbox"]'),
1120 sb = findParent(input, '.cbi-dropdown');
1121
1122 if (cbox)
1123 cbox.checked = false;
1124
1125 sb.removeAttribute('locked-in');
1126 },
1127
1128 handleCreateClick: function(ev) {
1129 ev.currentTarget.querySelector(this.options.create_query).focus();
1130 },
1131
1132 setValue: function(values) {
1133 if (this.options.multiple) {
1134 if (!Array.isArray(values))
1135 values = (values != null && values != '') ? [ values ] : [];
1136
1137 var v = {};
1138
1139 for (var i = 0; i < values.length; i++)
1140 v[values[i]] = true;
1141
1142 this.setValues(this.node, v);
1143 }
1144 else {
1145 var v = {};
1146
1147 if (values != null) {
1148 if (Array.isArray(values))
1149 v[values[0]] = true;
1150 else
1151 v[values] = true;
1152 }
1153
1154 this.setValues(this.node, v);
1155 }
1156 },
1157
1158 getValue: function() {
1159 var div = this.node.lastElementChild,
1160 h = div.querySelectorAll('input[type="hidden"]'),
1161 v = [];
1162
1163 for (var i = 0; i < h.length; i++)
1164 v.push(h[i].value);
1165
1166 return this.options.multiple ? v : v[0];
1167 }
1168 });
1169
1170 var UICombobox = UIDropdown.extend({
1171 __init__: function(value, choices, options) {
1172 this.super('__init__', [ value, choices, Object.assign({
1173 select_placeholder: _('-- Please choose --'),
1174 custom_placeholder: _('-- custom --'),
1175 dropdown_items: -1,
1176 sort: true
1177 }, options, {
1178 multiple: false,
1179 create: true,
1180 optional: true
1181 }) ]);
1182 }
1183 });
1184
1185 var UIDynamicList = UIElement.extend({
1186 __init__: function(values, choices, options) {
1187 if (!Array.isArray(values))
1188 values = (values != null && values != '') ? [ values ] : [];
1189
1190 if (typeof(choices) != 'object')
1191 choices = null;
1192
1193 this.values = values;
1194 this.choices = choices;
1195 this.options = Object.assign({}, options, {
1196 multiple: false,
1197 optional: true
1198 });
1199 },
1200
1201 render: function() {
1202 var dl = E('div', {
1203 'id': this.options.id,
1204 'class': 'cbi-dynlist'
1205 }, E('div', { 'class': 'add-item' }));
1206
1207 if (this.choices) {
1208 var cbox = new UICombobox(null, this.choices, this.options);
1209 dl.lastElementChild.appendChild(cbox.render());
1210 }
1211 else {
1212 var inputEl = E('input', {
1213 'id': this.options.id ? 'widget.' + this.options.id : null,
1214 'type': 'text',
1215 'class': 'cbi-input-text',
1216 'placeholder': this.options.placeholder
1217 });
1218
1219 dl.lastElementChild.appendChild(inputEl);
1220 dl.lastElementChild.appendChild(E('div', { 'class': 'cbi-button cbi-button-add' }, '+'));
1221
1222 if (this.options.datatype)
1223 L.ui.addValidator(inputEl, this.options.datatype,
1224 true, null, 'blur', 'keyup');
1225 }
1226
1227 for (var i = 0; i < this.values.length; i++)
1228 this.addItem(dl, this.values[i],
1229 this.choices ? this.choices[this.values[i]] : null);
1230
1231 return this.bind(dl);
1232 },
1233
1234 bind: function(dl) {
1235 dl.addEventListener('click', L.bind(this.handleClick, this));
1236 dl.addEventListener('keydown', L.bind(this.handleKeydown, this));
1237 dl.addEventListener('cbi-dropdown-change', L.bind(this.handleDropdownChange, this));
1238
1239 this.node = dl;
1240
1241 this.setUpdateEvents(dl, 'cbi-dynlist-change');
1242 this.setChangeEvents(dl, 'cbi-dynlist-change');
1243
1244 L.dom.bindClassInstance(dl, this);
1245
1246 return dl;
1247 },
1248
1249 addItem: function(dl, value, text, flash) {
1250 var exists = false,
1251 new_item = E('div', { 'class': flash ? 'item flash' : 'item', 'tabindex': 0 }, [
1252 E('span', {}, text || value),
1253 E('input', {
1254 'type': 'hidden',
1255 'name': this.options.name,
1256 'value': value })]);
1257
1258 dl.querySelectorAll('.item').forEach(function(item) {
1259 if (exists)
1260 return;
1261
1262 var hidden = item.querySelector('input[type="hidden"]');
1263
1264 if (hidden && hidden.parentNode !== item)
1265 hidden = null;
1266
1267 if (hidden && hidden.value === value)
1268 exists = true;
1269 });
1270
1271 if (!exists) {
1272 var ai = dl.querySelector('.add-item');
1273 ai.parentNode.insertBefore(new_item, ai);
1274 }
1275
1276 dl.dispatchEvent(new CustomEvent('cbi-dynlist-change', {
1277 bubbles: true,
1278 detail: {
1279 instance: this,
1280 element: dl,
1281 value: value,
1282 add: true
1283 }
1284 }));
1285 },
1286
1287 removeItem: function(dl, item) {
1288 var value = item.querySelector('input[type="hidden"]').value;
1289 var sb = dl.querySelector('.cbi-dropdown');
1290 if (sb)
1291 sb.querySelectorAll('ul > li').forEach(function(li) {
1292 if (li.getAttribute('data-value') === value) {
1293 if (li.hasAttribute('dynlistcustom'))
1294 li.parentNode.removeChild(li);
1295 else
1296 li.removeAttribute('unselectable');
1297 }
1298 });
1299
1300 item.parentNode.removeChild(item);
1301
1302 dl.dispatchEvent(new CustomEvent('cbi-dynlist-change', {
1303 bubbles: true,
1304 detail: {
1305 instance: this,
1306 element: dl,
1307 value: value,
1308 remove: true
1309 }
1310 }));
1311 },
1312
1313 handleClick: function(ev) {
1314 var dl = ev.currentTarget,
1315 item = findParent(ev.target, '.item');
1316
1317 if (item) {
1318 this.removeItem(dl, item);
1319 }
1320 else if (matchesElem(ev.target, '.cbi-button-add')) {
1321 var input = ev.target.previousElementSibling;
1322 if (input.value.length && !input.classList.contains('cbi-input-invalid')) {
1323 this.addItem(dl, input.value, null, true);
1324 input.value = '';
1325 }
1326 }
1327 },
1328
1329 handleDropdownChange: function(ev) {
1330 var dl = ev.currentTarget,
1331 sbIn = ev.detail.instance,
1332 sbEl = ev.detail.element,
1333 sbVal = ev.detail.value;
1334
1335 if (sbVal === null)
1336 return;
1337
1338 sbIn.setValues(sbEl, null);
1339 sbVal.element.setAttribute('unselectable', '');
1340
1341 if (sbVal.element.hasAttribute('created')) {
1342 sbVal.element.removeAttribute('created');
1343 sbVal.element.setAttribute('dynlistcustom', '');
1344 }
1345
1346 this.addItem(dl, sbVal.value, sbVal.text, true);
1347 },
1348
1349 handleKeydown: function(ev) {
1350 var dl = ev.currentTarget,
1351 item = findParent(ev.target, '.item');
1352
1353 if (item) {
1354 switch (ev.keyCode) {
1355 case 8: /* backspace */
1356 if (item.previousElementSibling)
1357 item.previousElementSibling.focus();
1358
1359 this.removeItem(dl, item);
1360 break;
1361
1362 case 46: /* delete */
1363 if (item.nextElementSibling) {
1364 if (item.nextElementSibling.classList.contains('item'))
1365 item.nextElementSibling.focus();
1366 else
1367 item.nextElementSibling.firstElementChild.focus();
1368 }
1369
1370 this.removeItem(dl, item);
1371 break;
1372 }
1373 }
1374 else if (matchesElem(ev.target, '.cbi-input-text')) {
1375 switch (ev.keyCode) {
1376 case 13: /* enter */
1377 if (ev.target.value.length && !ev.target.classList.contains('cbi-input-invalid')) {
1378 this.addItem(dl, ev.target.value, null, true);
1379 ev.target.value = '';
1380 ev.target.blur();
1381 ev.target.focus();
1382 }
1383
1384 ev.preventDefault();
1385 break;
1386 }
1387 }
1388 },
1389
1390 getValue: function() {
1391 var items = this.node.querySelectorAll('.item > input[type="hidden"]'),
1392 input = this.node.querySelector('.add-item > input[type="text"]'),
1393 v = [];
1394
1395 for (var i = 0; i < items.length; i++)
1396 v.push(items[i].value);
1397
1398 if (input && input.value != null && input.value.match(/\S/) &&
1399 input.classList.contains('cbi-input-invalid') == false &&
1400 v.filter(function(s) { return s == input.value }).length == 0)
1401 v.push(input.value);
1402
1403 return v;
1404 },
1405
1406 setValue: function(values) {
1407 if (!Array.isArray(values))
1408 values = (values != null && values != '') ? [ values ] : [];
1409
1410 var items = this.node.querySelectorAll('.item');
1411
1412 for (var i = 0; i < items.length; i++)
1413 if (items[i].parentNode === this.node)
1414 this.removeItem(this.node, items[i]);
1415
1416 for (var i = 0; i < values.length; i++)
1417 this.addItem(this.node, values[i],
1418 this.choices ? this.choices[values[i]] : null);
1419 }
1420 });
1421
1422 var UIHiddenfield = UIElement.extend({
1423 __init__: function(value, options) {
1424 this.value = value;
1425 this.options = Object.assign({
1426
1427 }, options);
1428 },
1429
1430 render: function() {
1431 var hiddenEl = E('input', {
1432 'id': this.options.id,
1433 'type': 'hidden',
1434 'value': this.value
1435 });
1436
1437 return this.bind(hiddenEl);
1438 },
1439
1440 bind: function(hiddenEl) {
1441 this.node = hiddenEl;
1442
1443 L.dom.bindClassInstance(hiddenEl, this);
1444
1445 return hiddenEl;
1446 },
1447
1448 getValue: function() {
1449 return this.node.value;
1450 },
1451
1452 setValue: function(value) {
1453 this.node.value = value;
1454 }
1455 });
1456
1457 var UIFileUpload = UIElement.extend({
1458 __init__: function(value, options) {
1459 this.value = value;
1460 this.options = Object.assign({
1461 show_hidden: false,
1462 enable_upload: true,
1463 enable_remove: true,
1464 root_directory: '/etc/luci-uploads'
1465 }, options);
1466 },
1467
1468 callFileStat: rpc.declare({
1469 'object': 'file',
1470 'method': 'stat',
1471 'params': [ 'path' ],
1472 'expect': { '': {} }
1473 }),
1474
1475 callFileList: rpc.declare({
1476 'object': 'file',
1477 'method': 'list',
1478 'params': [ 'path' ],
1479 'expect': { 'entries': [] }
1480 }),
1481
1482 callFileRemove: rpc.declare({
1483 'object': 'file',
1484 'method': 'remove',
1485 'params': [ 'path' ]
1486 }),
1487
1488 bind: function(browserEl) {
1489 this.node = browserEl;
1490
1491 this.setUpdateEvents(browserEl, 'cbi-fileupload-select', 'cbi-fileupload-cancel');
1492 this.setChangeEvents(browserEl, 'cbi-fileupload-select', 'cbi-fileupload-cancel');
1493
1494 L.dom.bindClassInstance(browserEl, this);
1495
1496 return browserEl;
1497 },
1498
1499 render: function() {
1500 return Promise.resolve(this.value != null ? this.callFileStat(this.value) : null).then(L.bind(function(stat) {
1501 var label;
1502
1503 if (L.isObject(stat) && stat.type != 'directory')
1504 this.stat = stat;
1505
1506 if (this.stat != null)
1507 label = [ this.iconForType(this.stat.type), ' %s (%1000mB)'.format(this.truncatePath(this.stat.path), this.stat.size) ];
1508 else if (this.value != null)
1509 label = [ this.iconForType('file'), ' %s (%s)'.format(this.truncatePath(this.value), _('File not accessible')) ];
1510 else
1511 label = _('Select file…');
1512
1513 return this.bind(E('div', { 'id': this.options.id }, [
1514 E('button', {
1515 'class': 'btn',
1516 'click': L.ui.createHandlerFn(this, 'handleFileBrowser')
1517 }, label),
1518 E('div', {
1519 'class': 'cbi-filebrowser'
1520 }),
1521 E('input', {
1522 'type': 'hidden',
1523 'name': this.options.name,
1524 'value': this.value
1525 })
1526 ]));
1527 }, this));
1528 },
1529
1530 truncatePath: function(path) {
1531 if (path.length > 50)
1532 path = path.substring(0, 25) + '…' + path.substring(path.length - 25);
1533
1534 return path;
1535 },
1536
1537 iconForType: function(type) {
1538 switch (type) {
1539 case 'symlink':
1540 return E('img', {
1541 'src': L.resource('cbi/link.gif'),
1542 'title': _('Symbolic link'),
1543 'class': 'middle'
1544 });
1545
1546 case 'directory':
1547 return E('img', {
1548 'src': L.resource('cbi/folder.gif'),
1549 'title': _('Directory'),
1550 'class': 'middle'
1551 });
1552
1553 default:
1554 return E('img', {
1555 'src': L.resource('cbi/file.gif'),
1556 'title': _('File'),
1557 'class': 'middle'
1558 });
1559 }
1560 },
1561
1562 canonicalizePath: function(path) {
1563 return path.replace(/\/{2,}/, '/')
1564 .replace(/\/\.(\/|$)/g, '/')
1565 .replace(/[^\/]+\/\.\.(\/|$)/g, '/')
1566 .replace(/\/$/, '');
1567 },
1568
1569 splitPath: function(path) {
1570 var croot = this.canonicalizePath(this.options.root_directory || '/'),
1571 cpath = this.canonicalizePath(path || '/');
1572
1573 if (cpath.length <= croot.length)
1574 return [ croot ];
1575
1576 if (cpath.charAt(croot.length) != '/')
1577 return [ croot ];
1578
1579 var parts = cpath.substring(croot.length + 1).split(/\//);
1580
1581 parts.unshift(croot);
1582
1583 return parts;
1584 },
1585
1586 handleUpload: function(path, list, ev) {
1587 var form = ev.target.parentNode,
1588 fileinput = form.querySelector('input[type="file"]'),
1589 nameinput = form.querySelector('input[type="text"]'),
1590 filename = (nameinput.value != null ? nameinput.value : '').trim();
1591
1592 ev.preventDefault();
1593
1594 if (filename == '' || filename.match(/\//) || fileinput.files[0] == null)
1595 return;
1596
1597 var existing = list.filter(function(e) { return e.name == filename })[0];
1598
1599 if (existing != null && existing.type == 'directory')
1600 return alert(_('A directory with the same name already exists.'));
1601 else if (existing != null && !confirm(_('Overwrite existing file "%s" ?').format(filename)))
1602 return;
1603
1604 var data = new FormData();
1605
1606 data.append('sessionid', L.env.sessionid);
1607 data.append('filename', path + '/' + filename);
1608 data.append('filedata', fileinput.files[0]);
1609
1610 return L.Request.post('/cgi-bin/cgi-upload', data, {
1611 progress: L.bind(function(btn, ev) {
1612 btn.firstChild.data = '%.2f%%'.format((ev.loaded / ev.total) * 100);
1613 }, this, ev.target)
1614 }).then(L.bind(function(path, ev, res) {
1615 var reply = res.json();
1616
1617 if (L.isObject(reply) && reply.failure)
1618 alert(_('Upload request failed: %s').format(reply.message));
1619
1620 return this.handleSelect(path, null, ev);
1621 }, this, path, ev));
1622 },
1623
1624 handleDelete: function(path, fileStat, ev) {
1625 var parent = path.replace(/\/[^\/]+$/, '') || '/',
1626 name = path.replace(/^.+\//, ''),
1627 msg;
1628
1629 ev.preventDefault();
1630
1631 if (fileStat.type == 'directory')
1632 msg = _('Do you really want to recursively delete the directory "%s" ?').format(name);
1633 else
1634 msg = _('Do you really want to delete "%s" ?').format(name);
1635
1636 if (confirm(msg)) {
1637 var button = this.node.firstElementChild,
1638 hidden = this.node.lastElementChild;
1639
1640 if (path == hidden.value) {
1641 L.dom.content(button, _('Select file…'));
1642 hidden.value = '';
1643 }
1644
1645 return this.callFileRemove(path).then(L.bind(function(parent, ev, rc) {
1646 if (rc == 0)
1647 return this.handleSelect(parent, null, ev);
1648 else if (rc == 6)
1649 alert(_('Delete permission denied'));
1650 else
1651 alert(_('Delete request failed: %d %s').format(rc, rpc.getStatusText(rc)));
1652
1653 }, this, parent, ev));
1654 }
1655 },
1656
1657 renderUpload: function(path, list) {
1658 if (!this.options.enable_upload)
1659 return E([]);
1660
1661 return E([
1662 E('a', {
1663 'href': '#',
1664 'class': 'btn cbi-button-positive',
1665 'click': function(ev) {
1666 var uploadForm = ev.target.nextElementSibling,
1667 fileInput = uploadForm.querySelector('input[type="file"]');
1668
1669 ev.target.style.display = 'none';
1670 uploadForm.style.display = '';
1671 fileInput.click();
1672 }
1673 }, _('Upload file…')),
1674 E('div', { 'class': 'upload', 'style': 'display:none' }, [
1675 E('input', {
1676 'type': 'file',
1677 'style': 'display:none',
1678 'change': function(ev) {
1679 var nameinput = ev.target.parentNode.querySelector('input[type="text"]'),
1680 uploadbtn = ev.target.parentNode.querySelector('button.cbi-button-save');
1681
1682 nameinput.value = ev.target.value.replace(/^.+[\/\\]/, '');
1683 uploadbtn.disabled = false;
1684 }
1685 }),
1686 E('button', {
1687 'class': 'btn',
1688 'click': function(ev) {
1689 ev.preventDefault();
1690 ev.target.previousElementSibling.click();
1691 }
1692 }, _('Browse…')),
1693 E('div', {}, E('input', { 'type': 'text', 'placeholder': _('Filename') })),
1694 E('button', {
1695 'class': 'btn cbi-button-save',
1696 'click': L.ui.createHandlerFn(this, 'handleUpload', path, list),
1697 'disabled': true
1698 }, _('Upload file'))
1699 ])
1700 ]);
1701 },
1702
1703 renderListing: function(container, path, list) {
1704 var breadcrumb = E('p'),
1705 rows = E('ul');
1706
1707 list.sort(function(a, b) {
1708 var isDirA = (a.type == 'directory'),
1709 isDirB = (b.type == 'directory');
1710
1711 if (isDirA != isDirB)
1712 return isDirA < isDirB;
1713
1714 return a.name > b.name;
1715 });
1716
1717 for (var i = 0; i < list.length; i++) {
1718 if (!this.options.show_hidden && list[i].name.charAt(0) == '.')
1719 continue;
1720
1721 var entrypath = this.canonicalizePath(path + '/' + list[i].name),
1722 selected = (entrypath == this.node.lastElementChild.value),
1723 mtime = new Date(list[i].mtime * 1000);
1724
1725 rows.appendChild(E('li', [
1726 E('div', { 'class': 'name' }, [
1727 this.iconForType(list[i].type),
1728 ' ',
1729 E('a', {
1730 'href': '#',
1731 'style': selected ? 'font-weight:bold' : null,
1732 'click': L.ui.createHandlerFn(this, 'handleSelect',
1733 entrypath, list[i].type != 'directory' ? list[i] : null)
1734 }, '%h'.format(list[i].name))
1735 ]),
1736 E('div', { 'class': 'mtime hide-xs' }, [
1737 ' %04d-%02d-%02d %02d:%02d:%02d '.format(
1738 mtime.getFullYear(),
1739 mtime.getMonth() + 1,
1740 mtime.getDate(),
1741 mtime.getHours(),
1742 mtime.getMinutes(),
1743 mtime.getSeconds())
1744 ]),
1745 E('div', [
1746 selected ? E('button', {
1747 'class': 'btn',
1748 'click': L.ui.createHandlerFn(this, 'handleReset')
1749 }, _('Deselect')) : '',
1750 this.options.enable_remove ? E('button', {
1751 'class': 'btn cbi-button-negative',
1752 'click': L.ui.createHandlerFn(this, 'handleDelete', entrypath, list[i])
1753 }, _('Delete')) : ''
1754 ])
1755 ]));
1756 }
1757
1758 if (!rows.firstElementChild)
1759 rows.appendChild(E('em', _('No entries in this directory')));
1760
1761 var dirs = this.splitPath(path),
1762 cur = '';
1763
1764 for (var i = 0; i < dirs.length; i++) {
1765 cur = cur ? cur + '/' + dirs[i] : dirs[i];
1766 L.dom.append(breadcrumb, [
1767 i ? ' » ' : '',
1768 E('a', {
1769 'href': '#',
1770 'click': L.ui.createHandlerFn(this, 'handleSelect', cur || '/', null)
1771 }, dirs[i] != '' ? '%h'.format(dirs[i]) : E('em', '(root)')),
1772 ]);
1773 }
1774
1775 L.dom.content(container, [
1776 breadcrumb,
1777 rows,
1778 E('div', { 'class': 'right' }, [
1779 this.renderUpload(path, list),
1780 E('a', {
1781 'href': '#',
1782 'class': 'btn',
1783 'click': L.ui.createHandlerFn(this, 'handleCancel')
1784 }, _('Cancel'))
1785 ]),
1786 ]);
1787 },
1788
1789 handleCancel: function(ev) {
1790 var button = this.node.firstElementChild,
1791 browser = button.nextElementSibling;
1792
1793 browser.classList.remove('open');
1794 button.style.display = '';
1795
1796 this.node.dispatchEvent(new CustomEvent('cbi-fileupload-cancel', {}));
1797 },
1798
1799 handleReset: function(ev) {
1800 var button = this.node.firstElementChild,
1801 hidden = this.node.lastElementChild;
1802
1803 hidden.value = '';
1804 L.dom.content(button, _('Select file…'));
1805
1806 this.handleCancel(ev);
1807 },
1808
1809 handleSelect: function(path, fileStat, ev) {
1810 var browser = L.dom.parent(ev.target, '.cbi-filebrowser'),
1811 ul = browser.querySelector('ul');
1812
1813 if (fileStat == null) {
1814 L.dom.content(ul, E('em', { 'class': 'spinning' }, _('Loading directory contents…')));
1815 this.callFileList(path).then(L.bind(this.renderListing, this, browser, path));
1816 }
1817 else {
1818 var button = this.node.firstElementChild,
1819 hidden = this.node.lastElementChild;
1820
1821 path = this.canonicalizePath(path);
1822
1823 L.dom.content(button, [
1824 this.iconForType(fileStat.type),
1825 ' %s (%1000mB)'.format(this.truncatePath(path), fileStat.size)
1826 ]);
1827
1828 browser.classList.remove('open');
1829 button.style.display = '';
1830 hidden.value = path;
1831
1832 this.stat = Object.assign({ path: path }, fileStat);
1833 this.node.dispatchEvent(new CustomEvent('cbi-fileupload-select', { detail: this.stat }));
1834 }
1835 },
1836
1837 handleFileBrowser: function(ev) {
1838 var button = ev.target,
1839 browser = button.nextElementSibling,
1840 path = this.stat ? this.stat.path.replace(/\/[^\/]+$/, '') : this.options.root_directory;
1841
1842 if (this.options.root_directory.indexOf(path) != 0)
1843 path = this.options.root_directory;
1844
1845 ev.preventDefault();
1846
1847 return this.callFileList(path).then(L.bind(function(button, browser, path, list) {
1848 document.querySelectorAll('.cbi-filebrowser.open').forEach(function(browserEl) {
1849 L.dom.findClassInstance(browserEl).handleCancel(ev);
1850 });
1851
1852 button.style.display = 'none';
1853 browser.classList.add('open');
1854
1855 return this.renderListing(browser, path, list);
1856 }, this, button, browser, path));
1857 },
1858
1859 getValue: function() {
1860 return this.node.lastElementChild.value;
1861 },
1862
1863 setValue: function(value) {
1864 this.node.lastElementChild.value = value;
1865 }
1866 });
1867
1868
1869 return L.Class.extend({
1870 __init__: function() {
1871 modalDiv = document.body.appendChild(
1872 L.dom.create('div', { id: 'modal_overlay' },
1873 L.dom.create('div', { class: 'modal', role: 'dialog', 'aria-modal': true })));
1874
1875 tooltipDiv = document.body.appendChild(
1876 L.dom.create('div', { class: 'cbi-tooltip' }));
1877
1878 /* setup old aliases */
1879 L.showModal = this.showModal;
1880 L.hideModal = this.hideModal;
1881 L.showTooltip = this.showTooltip;
1882 L.hideTooltip = this.hideTooltip;
1883 L.itemlist = this.itemlist;
1884
1885 document.addEventListener('mouseover', this.showTooltip.bind(this), true);
1886 document.addEventListener('mouseout', this.hideTooltip.bind(this), true);
1887 document.addEventListener('focus', this.showTooltip.bind(this), true);
1888 document.addEventListener('blur', this.hideTooltip.bind(this), true);
1889
1890 document.addEventListener('luci-loaded', this.tabs.init.bind(this.tabs));
1891 document.addEventListener('luci-loaded', this.changes.init.bind(this.changes));
1892 document.addEventListener('uci-loaded', this.changes.init.bind(this.changes));
1893 },
1894
1895 /* Modal dialog */
1896 showModal: function(title, children /* , ... */) {
1897 var dlg = modalDiv.firstElementChild;
1898
1899 dlg.setAttribute('class', 'modal');
1900
1901 for (var i = 2; i < arguments.length; i++)
1902 dlg.classList.add(arguments[i]);
1903
1904 L.dom.content(dlg, L.dom.create('h4', {}, title));
1905 L.dom.append(dlg, children);
1906
1907 document.body.classList.add('modal-overlay-active');
1908
1909 return dlg;
1910 },
1911
1912 hideModal: function() {
1913 document.body.classList.remove('modal-overlay-active');
1914 },
1915
1916 /* Tooltip */
1917 showTooltip: function(ev) {
1918 var target = findParent(ev.target, '[data-tooltip]');
1919
1920 if (!target)
1921 return;
1922
1923 if (tooltipTimeout !== null) {
1924 window.clearTimeout(tooltipTimeout);
1925 tooltipTimeout = null;
1926 }
1927
1928 var rect = target.getBoundingClientRect(),
1929 x = rect.left + window.pageXOffset,
1930 y = rect.top + rect.height + window.pageYOffset;
1931
1932 tooltipDiv.className = 'cbi-tooltip';
1933 tooltipDiv.innerHTML = '▲ ';
1934 tooltipDiv.firstChild.data += target.getAttribute('data-tooltip');
1935
1936 if (target.hasAttribute('data-tooltip-style'))
1937 tooltipDiv.classList.add(target.getAttribute('data-tooltip-style'));
1938
1939 if ((y + tooltipDiv.offsetHeight) > (window.innerHeight + window.pageYOffset)) {
1940 y -= (tooltipDiv.offsetHeight + target.offsetHeight);
1941 tooltipDiv.firstChild.data = '▼ ' + tooltipDiv.firstChild.data.substr(2);
1942 }
1943
1944 tooltipDiv.style.top = y + 'px';
1945 tooltipDiv.style.left = x + 'px';
1946 tooltipDiv.style.opacity = 1;
1947
1948 tooltipDiv.dispatchEvent(new CustomEvent('tooltip-open', {
1949 bubbles: true,
1950 detail: { target: target }
1951 }));
1952 },
1953
1954 hideTooltip: function(ev) {
1955 if (ev.target === tooltipDiv || ev.relatedTarget === tooltipDiv ||
1956 tooltipDiv.contains(ev.target) || tooltipDiv.contains(ev.relatedTarget))
1957 return;
1958
1959 if (tooltipTimeout !== null) {
1960 window.clearTimeout(tooltipTimeout);
1961 tooltipTimeout = null;
1962 }
1963
1964 tooltipDiv.style.opacity = 0;
1965 tooltipTimeout = window.setTimeout(function() { tooltipDiv.removeAttribute('style'); }, 250);
1966
1967 tooltipDiv.dispatchEvent(new CustomEvent('tooltip-close', { bubbles: true }));
1968 },
1969
1970 addNotification: function(title, children /*, ... */) {
1971 var mc = document.querySelector('#maincontent') || document.body;
1972 var msg = E('div', {
1973 'class': 'alert-message fade-in',
1974 'style': 'display:flex',
1975 'transitionend': function(ev) {
1976 var node = ev.currentTarget;
1977 if (node.parentNode && node.classList.contains('fade-out'))
1978 node.parentNode.removeChild(node);
1979 }
1980 }, [
1981 E('div', { 'style': 'flex:10' }),
1982 E('div', { 'style': 'flex:1; display:flex' }, [
1983 E('button', {
1984 'class': 'btn',
1985 'style': 'margin-left:auto; margin-top:auto',
1986 'click': function(ev) {
1987 L.dom.parent(ev.target, '.alert-message').classList.add('fade-out');
1988 },
1989
1990 }, _('Dismiss'))
1991 ])
1992 ]);
1993
1994 if (title != null)
1995 L.dom.append(msg.firstElementChild, E('h4', {}, title));
1996
1997 L.dom.append(msg.firstElementChild, children);
1998
1999 for (var i = 2; i < arguments.length; i++)
2000 msg.classList.add(arguments[i]);
2001
2002 mc.insertBefore(msg, mc.firstElementChild);
2003
2004 return msg;
2005 },
2006
2007 /* Widget helper */
2008 itemlist: function(node, items, separators) {
2009 var children = [];
2010
2011 if (!Array.isArray(separators))
2012 separators = [ separators || E('br') ];
2013
2014 for (var i = 0; i < items.length; i += 2) {
2015 if (items[i+1] !== null && items[i+1] !== undefined) {
2016 var sep = separators[(i/2) % separators.length],
2017 cld = [];
2018
2019 children.push(E('span', { class: 'nowrap' }, [
2020 items[i] ? E('strong', items[i] + ': ') : '',
2021 items[i+1]
2022 ]));
2023
2024 if ((i+2) < items.length)
2025 children.push(L.dom.elem(sep) ? sep.cloneNode(true) : sep);
2026 }
2027 }
2028
2029 L.dom.content(node, children);
2030
2031 return node;
2032 },
2033
2034 /* Tabs */
2035 tabs: L.Class.singleton({
2036 init: function() {
2037 var groups = [], prevGroup = null, currGroup = null;
2038
2039 document.querySelectorAll('[data-tab]').forEach(function(tab) {
2040 var parent = tab.parentNode;
2041
2042 if (!parent.hasAttribute('data-tab-group'))
2043 parent.setAttribute('data-tab-group', groups.length);
2044
2045 currGroup = +parent.getAttribute('data-tab-group');
2046
2047 if (currGroup !== prevGroup) {
2048 prevGroup = currGroup;
2049
2050 if (!groups[currGroup])
2051 groups[currGroup] = [];
2052 }
2053
2054 groups[currGroup].push(tab);
2055 });
2056
2057 for (var i = 0; i < groups.length; i++)
2058 this.initTabGroup(groups[i]);
2059
2060 document.addEventListener('dependency-update', this.updateTabs.bind(this));
2061
2062 this.updateTabs();
2063 },
2064
2065 initTabGroup: function(panes) {
2066 if (typeof(panes) != 'object' || !('length' in panes) || panes.length === 0)
2067 return;
2068
2069 var menu = E('ul', { 'class': 'cbi-tabmenu' }),
2070 group = panes[0].parentNode,
2071 groupId = +group.getAttribute('data-tab-group'),
2072 selected = null;
2073
2074 for (var i = 0, pane; pane = panes[i]; i++) {
2075 var name = pane.getAttribute('data-tab'),
2076 title = pane.getAttribute('data-tab-title'),
2077 active = pane.getAttribute('data-tab-active') === 'true';
2078
2079 menu.appendChild(E('li', {
2080 'style': this.isEmptyPane(pane) ? 'display:none' : null,
2081 'class': active ? 'cbi-tab' : 'cbi-tab-disabled',
2082 'data-tab': name
2083 }, E('a', {
2084 'href': '#',
2085 'click': this.switchTab.bind(this)
2086 }, title)));
2087
2088 if (active)
2089 selected = i;
2090 }
2091
2092 group.parentNode.insertBefore(menu, group);
2093
2094 if (selected === null) {
2095 selected = this.getActiveTabId(panes[0]);
2096
2097 if (selected < 0 || selected >= panes.length || this.isEmptyPane(panes[selected])) {
2098 for (var i = 0; i < panes.length; i++) {
2099 if (!this.isEmptyPane(panes[i])) {
2100 selected = i;
2101 break;
2102 }
2103 }
2104 }
2105
2106 menu.childNodes[selected].classList.add('cbi-tab');
2107 menu.childNodes[selected].classList.remove('cbi-tab-disabled');
2108 panes[selected].setAttribute('data-tab-active', 'true');
2109
2110 this.setActiveTabId(panes[selected], selected);
2111 }
2112
2113 this.updateTabs(group);
2114 },
2115
2116 isEmptyPane: function(pane) {
2117 return L.dom.isEmpty(pane, function(n) { return n.classList.contains('cbi-tab-descr') });
2118 },
2119
2120 getPathForPane: function(pane) {
2121 var path = [], node = null;
2122
2123 for (node = pane ? pane.parentNode : null;
2124 node != null && node.hasAttribute != null;
2125 node = node.parentNode)
2126 {
2127 if (node.hasAttribute('data-tab'))
2128 path.unshift(node.getAttribute('data-tab'));
2129 else if (node.hasAttribute('data-section-id'))
2130 path.unshift(node.getAttribute('data-section-id'));
2131 }
2132
2133 return path.join('/');
2134 },
2135
2136 getActiveTabState: function() {
2137 var page = document.body.getAttribute('data-page');
2138
2139 try {
2140 var val = JSON.parse(window.sessionStorage.getItem('tab'));
2141 if (val.page === page && L.isObject(val.paths))
2142 return val;
2143 }
2144 catch(e) {}
2145
2146 window.sessionStorage.removeItem('tab');
2147 return { page: page, paths: {} };
2148 },
2149
2150 getActiveTabId: function(pane) {
2151 var path = this.getPathForPane(pane);
2152 return +this.getActiveTabState().paths[path] || 0;
2153 },
2154
2155 setActiveTabId: function(pane, tabIndex) {
2156 var path = this.getPathForPane(pane);
2157
2158 try {
2159 var state = this.getActiveTabState();
2160 state.paths[path] = tabIndex;
2161
2162 window.sessionStorage.setItem('tab', JSON.stringify(state));
2163 }
2164 catch (e) { return false; }
2165
2166 return true;
2167 },
2168
2169 updateTabs: function(ev, root) {
2170 (root || document).querySelectorAll('[data-tab-title]').forEach(L.bind(function(pane) {
2171 var menu = pane.parentNode.previousElementSibling,
2172 tab = menu ? menu.querySelector('[data-tab="%s"]'.format(pane.getAttribute('data-tab'))) : null,
2173 n_errors = pane.querySelectorAll('.cbi-input-invalid').length;
2174
2175 if (!menu || !tab)
2176 return;
2177
2178 if (this.isEmptyPane(pane)) {
2179 tab.style.display = 'none';
2180 tab.classList.remove('flash');
2181 }
2182 else if (tab.style.display === 'none') {
2183 tab.style.display = '';
2184 requestAnimationFrame(function() { tab.classList.add('flash') });
2185 }
2186
2187 if (n_errors) {
2188 tab.setAttribute('data-errors', n_errors);
2189 tab.setAttribute('data-tooltip', _('%d invalid field(s)').format(n_errors));
2190 tab.setAttribute('data-tooltip-style', 'error');
2191 }
2192 else {
2193 tab.removeAttribute('data-errors');
2194 tab.removeAttribute('data-tooltip');
2195 }
2196 }, this));
2197 },
2198
2199 switchTab: function(ev) {
2200 var tab = ev.target.parentNode,
2201 name = tab.getAttribute('data-tab'),
2202 menu = tab.parentNode,
2203 group = menu.nextElementSibling,
2204 groupId = +group.getAttribute('data-tab-group'),
2205 index = 0;
2206
2207 ev.preventDefault();
2208
2209 if (!tab.classList.contains('cbi-tab-disabled'))
2210 return;
2211
2212 menu.querySelectorAll('[data-tab]').forEach(function(tab) {
2213 tab.classList.remove('cbi-tab');
2214 tab.classList.remove('cbi-tab-disabled');
2215 tab.classList.add(
2216 tab.getAttribute('data-tab') === name ? 'cbi-tab' : 'cbi-tab-disabled');
2217 });
2218
2219 group.childNodes.forEach(function(pane) {
2220 if (L.dom.matches(pane, '[data-tab]')) {
2221 if (pane.getAttribute('data-tab') === name) {
2222 pane.setAttribute('data-tab-active', 'true');
2223 L.ui.tabs.setActiveTabId(pane, index);
2224 }
2225 else {
2226 pane.setAttribute('data-tab-active', 'false');
2227 }
2228
2229 index++;
2230 }
2231 });
2232 }
2233 }),
2234
2235 /* UCI Changes */
2236 changes: L.Class.singleton({
2237 init: function() {
2238 if (!L.env.sessionid)
2239 return;
2240
2241 return uci.changes().then(L.bind(this.renderChangeIndicator, this));
2242 },
2243
2244 setIndicator: function(n) {
2245 var i = document.querySelector('.uci_change_indicator');
2246 if (i == null) {
2247 var poll = document.getElementById('xhr_poll_status');
2248 i = poll.parentNode.insertBefore(E('a', {
2249 'href': '#',
2250 'class': 'uci_change_indicator label notice',
2251 'click': L.bind(this.displayChanges, this)
2252 }), poll);
2253 }
2254
2255 if (n > 0) {
2256 L.dom.content(i, [ _('Unsaved Changes'), ': ', n ]);
2257 i.classList.add('flash');
2258 i.style.display = '';
2259 }
2260 else {
2261 i.classList.remove('flash');
2262 i.style.display = 'none';
2263 }
2264 },
2265
2266 renderChangeIndicator: function(changes) {
2267 var n_changes = 0;
2268
2269 for (var config in changes)
2270 if (changes.hasOwnProperty(config))
2271 n_changes += changes[config].length;
2272
2273 this.changes = changes;
2274 this.setIndicator(n_changes);
2275 },
2276
2277 changeTemplates: {
2278 'add-3': '<ins>uci add %0 <strong>%3</strong> # =%2</ins>',
2279 'set-3': '<ins>uci set %0.<strong>%2</strong>=%3</ins>',
2280 'set-4': '<var><ins>uci set %0.%2.%3=<strong>%4</strong></ins></var>',
2281 'remove-2': '<del>uci del %0.<strong>%2</strong></del>',
2282 'remove-3': '<var><del>uci del %0.%2.<strong>%3</strong></del></var>',
2283 'order-3': '<var>uci reorder %0.%2=<strong>%3</strong></var>',
2284 'list-add-4': '<var><ins>uci add_list %0.%2.%3=<strong>%4</strong></ins></var>',
2285 'list-del-4': '<var><del>uci del_list %0.%2.%3=<strong>%4</strong></del></var>',
2286 'rename-3': '<var>uci rename %0.%2=<strong>%3</strong></var>',
2287 'rename-4': '<var>uci rename %0.%2.%3=<strong>%4</strong></var>'
2288 },
2289
2290 displayChanges: function() {
2291 var list = E('div', { 'class': 'uci-change-list' }),
2292 dlg = L.ui.showModal(_('Configuration') + ' / ' + _('Changes'), [
2293 E('div', { 'class': 'cbi-section' }, [
2294 E('strong', _('Legend:')),
2295 E('div', { 'class': 'uci-change-legend' }, [
2296 E('div', { 'class': 'uci-change-legend-label' }, [
2297 E('ins', '&#160;'), ' ', _('Section added') ]),
2298 E('div', { 'class': 'uci-change-legend-label' }, [
2299 E('del', '&#160;'), ' ', _('Section removed') ]),
2300 E('div', { 'class': 'uci-change-legend-label' }, [
2301 E('var', {}, E('ins', '&#160;')), ' ', _('Option changed') ]),
2302 E('div', { 'class': 'uci-change-legend-label' }, [
2303 E('var', {}, E('del', '&#160;')), ' ', _('Option removed') ])]),
2304 E('br'), list,
2305 E('div', { 'class': 'right' }, [
2306 E('input', {
2307 'type': 'button',
2308 'class': 'btn',
2309 'click': L.ui.hideModal,
2310 'value': _('Dismiss')
2311 }), ' ',
2312 E('input', {
2313 'type': 'button',
2314 'class': 'cbi-button cbi-button-positive important',
2315 'click': L.bind(this.apply, this, true),
2316 'value': _('Save & Apply')
2317 }), ' ',
2318 E('input', {
2319 'type': 'button',
2320 'class': 'cbi-button cbi-button-reset',
2321 'click': L.bind(this.revert, this),
2322 'value': _('Revert')
2323 })])])
2324 ]);
2325
2326 for (var config in this.changes) {
2327 if (!this.changes.hasOwnProperty(config))
2328 continue;
2329
2330 list.appendChild(E('h5', '# /etc/config/%s'.format(config)));
2331
2332 for (var i = 0, added = null; i < this.changes[config].length; i++) {
2333 var chg = this.changes[config][i],
2334 tpl = this.changeTemplates['%s-%d'.format(chg[0], chg.length)];
2335
2336 list.appendChild(E(tpl.replace(/%([01234])/g, function(m0, m1) {
2337 switch (+m1) {
2338 case 0:
2339 return config;
2340
2341 case 2:
2342 if (added != null && chg[1] == added[0])
2343 return '@' + added[1] + '[-1]';
2344 else
2345 return chg[1];
2346
2347 case 4:
2348 return "'%h'".format(chg[3].replace(/'/g, "'\"'\"'"));
2349
2350 default:
2351 return chg[m1-1];
2352 }
2353 })));
2354
2355 if (chg[0] == 'add')
2356 added = [ chg[1], chg[2] ];
2357 }
2358 }
2359
2360 list.appendChild(E('br'));
2361 dlg.classList.add('uci-dialog');
2362 },
2363
2364 displayStatus: function(type, content) {
2365 if (type) {
2366 var message = L.ui.showModal('', '');
2367
2368 message.classList.add('alert-message');
2369 DOMTokenList.prototype.add.apply(message.classList, type.split(/\s+/));
2370
2371 if (content)
2372 L.dom.content(message, content);
2373
2374 if (!this.was_polling) {
2375 this.was_polling = L.Request.poll.active();
2376 L.Request.poll.stop();
2377 }
2378 }
2379 else {
2380 L.ui.hideModal();
2381
2382 if (this.was_polling)
2383 L.Request.poll.start();
2384 }
2385 },
2386
2387 rollback: function(checked) {
2388 if (checked) {
2389 this.displayStatus('warning spinning',
2390 E('p', _('Failed to confirm apply within %ds, waiting for rollback…')
2391 .format(L.env.apply_rollback)));
2392
2393 var call = function(r, data, duration) {
2394 if (r.status === 204) {
2395 L.ui.changes.displayStatus('warning', [
2396 E('h4', _('Configuration has been rolled back!')),
2397 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)),
2398 E('div', { 'class': 'right' }, [
2399 E('input', {
2400 'type': 'button',
2401 'class': 'btn',
2402 'click': L.bind(L.ui.changes.displayStatus, L.ui.changes, false),
2403 'value': _('Dismiss')
2404 }), ' ',
2405 E('input', {
2406 'type': 'button',
2407 'class': 'btn cbi-button-action important',
2408 'click': L.bind(L.ui.changes.revert, L.ui.changes),
2409 'value': _('Revert changes')
2410 }), ' ',
2411 E('input', {
2412 'type': 'button',
2413 'class': 'btn cbi-button-negative important',
2414 'click': L.bind(L.ui.changes.apply, L.ui.changes, false),
2415 'value': _('Apply unchecked')
2416 })
2417 ])
2418 ]);
2419
2420 return;
2421 }
2422
2423 var delay = isNaN(duration) ? 0 : Math.max(1000 - duration, 0);
2424 window.setTimeout(function() {
2425 L.Request.request(L.url('admin/uci/confirm'), {
2426 method: 'post',
2427 timeout: L.env.apply_timeout * 1000,
2428 query: { sid: L.env.sessionid, token: L.env.token }
2429 }).then(call);
2430 }, delay);
2431 };
2432
2433 call({ status: 0 });
2434 }
2435 else {
2436 this.displayStatus('warning', [
2437 E('h4', _('Device unreachable!')),
2438 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.'))
2439 ]);
2440 }
2441 },
2442
2443 confirm: function(checked, deadline, override_token) {
2444 var tt;
2445 var ts = Date.now();
2446
2447 this.displayStatus('notice');
2448
2449 if (override_token)
2450 this.confirm_auth = { token: override_token };
2451
2452 var call = function(r, data, duration) {
2453 if (Date.now() >= deadline) {
2454 window.clearTimeout(tt);
2455 L.ui.changes.rollback(checked);
2456 return;
2457 }
2458 else if (r && (r.status === 200 || r.status === 204)) {
2459 document.dispatchEvent(new CustomEvent('uci-applied'));
2460
2461 L.ui.changes.setIndicator(0);
2462 L.ui.changes.displayStatus('notice',
2463 E('p', _('Configuration has been applied.')));
2464
2465 window.clearTimeout(tt);
2466 window.setTimeout(function() {
2467 //L.ui.changes.displayStatus(false);
2468 window.location = window.location.href.split('#')[0];
2469 }, L.env.apply_display * 1000);
2470
2471 return;
2472 }
2473
2474 var delay = isNaN(duration) ? 0 : Math.max(1000 - duration, 0);
2475 window.setTimeout(function() {
2476 L.Request.request(L.url('admin/uci/confirm'), {
2477 method: 'post',
2478 timeout: L.env.apply_timeout * 1000,
2479 query: L.ui.changes.confirm_auth
2480 }).then(call, call);
2481 }, delay);
2482 };
2483
2484 var tick = function() {
2485 var now = Date.now();
2486
2487 L.ui.changes.displayStatus('notice spinning',
2488 E('p', _('Waiting for configuration to get applied… %ds')
2489 .format(Math.max(Math.floor((deadline - Date.now()) / 1000), 0))));
2490
2491 if (now >= deadline)
2492 return;
2493
2494 tt = window.setTimeout(tick, 1000 - (now - ts));
2495 ts = now;
2496 };
2497
2498 tick();
2499
2500 /* wait a few seconds for the settings to become effective */
2501 window.setTimeout(call, Math.max(L.env.apply_holdoff * 1000 - ((ts + L.env.apply_rollback * 1000) - deadline), 1));
2502 },
2503
2504 apply: function(checked) {
2505 this.displayStatus('notice spinning',
2506 E('p', _('Starting configuration apply…')));
2507
2508 L.Request.request(L.url('admin/uci', checked ? 'apply_rollback' : 'apply_unchecked'), {
2509 method: 'post',
2510 query: { sid: L.env.sessionid, token: L.env.token }
2511 }).then(function(r) {
2512 if (r.status === (checked ? 200 : 204)) {
2513 var tok = null; try { tok = r.json(); } catch(e) {}
2514 if (checked && tok !== null && typeof(tok) === 'object' && typeof(tok.token) === 'string')
2515 L.ui.changes.confirm_auth = tok;
2516
2517 L.ui.changes.confirm(checked, Date.now() + L.env.apply_rollback * 1000);
2518 }
2519 else if (checked && r.status === 204) {
2520 L.ui.changes.displayStatus('notice',
2521 E('p', _('There are no changes to apply')));
2522
2523 window.setTimeout(function() {
2524 L.ui.changes.displayStatus(false);
2525 }, L.env.apply_display * 1000);
2526 }
2527 else {
2528 L.ui.changes.displayStatus('warning',
2529 E('p', _('Apply request failed with status <code>%h</code>')
2530 .format(r.responseText || r.statusText || r.status)));
2531
2532 window.setTimeout(function() {
2533 L.ui.changes.displayStatus(false);
2534 }, L.env.apply_display * 1000);
2535 }
2536 });
2537 },
2538
2539 revert: function() {
2540 this.displayStatus('notice spinning',
2541 E('p', _('Reverting configuration…')));
2542
2543 L.Request.request(L.url('admin/uci/revert'), {
2544 method: 'post',
2545 query: { sid: L.env.sessionid, token: L.env.token }
2546 }).then(function(r) {
2547 if (r.status === 200) {
2548 document.dispatchEvent(new CustomEvent('uci-reverted'));
2549
2550 L.ui.changes.setIndicator(0);
2551 L.ui.changes.displayStatus('notice',
2552 E('p', _('Changes have been reverted.')));
2553
2554 window.setTimeout(function() {
2555 //L.ui.changes.displayStatus(false);
2556 window.location = window.location.href.split('#')[0];
2557 }, L.env.apply_display * 1000);
2558 }
2559 else {
2560 L.ui.changes.displayStatus('warning',
2561 E('p', _('Revert request failed with status <code>%h</code>')
2562 .format(r.statusText || r.status)));
2563
2564 window.setTimeout(function() {
2565 L.ui.changes.displayStatus(false);
2566 }, L.env.apply_display * 1000);
2567 }
2568 });
2569 }
2570 }),
2571
2572 addValidator: function(field, type, optional, vfunc /*, ... */) {
2573 if (type == null)
2574 return;
2575
2576 var events = this.varargs(arguments, 3);
2577 if (events.length == 0)
2578 events.push('blur', 'keyup');
2579
2580 try {
2581 var cbiValidator = L.validation.create(field, type, optional, vfunc),
2582 validatorFn = cbiValidator.validate.bind(cbiValidator);
2583
2584 for (var i = 0; i < events.length; i++)
2585 field.addEventListener(events[i], validatorFn);
2586
2587 validatorFn();
2588
2589 return validatorFn;
2590 }
2591 catch (e) { }
2592 },
2593
2594 createHandlerFn: function(ctx, fn /*, ... */) {
2595 if (typeof(fn) == 'string')
2596 fn = ctx[fn];
2597
2598 if (typeof(fn) != 'function')
2599 return null;
2600
2601 return Function.prototype.bind.apply(function() {
2602 var t = arguments[arguments.length - 1].target;
2603
2604 t.classList.add('spinning');
2605 t.disabled = true;
2606
2607 if (t.blur)
2608 t.blur();
2609
2610 Promise.resolve(fn.apply(ctx, arguments)).finally(function() {
2611 t.classList.remove('spinning');
2612 t.disabled = false;
2613 });
2614 }, this.varargs(arguments, 2, ctx));
2615 },
2616
2617 /* Widgets */
2618 Textfield: UITextfield,
2619 Textarea: UITextarea,
2620 Checkbox: UICheckbox,
2621 Select: UISelect,
2622 Dropdown: UIDropdown,
2623 DynamicList: UIDynamicList,
2624 Combobox: UICombobox,
2625 Hiddenfield: UIHiddenfield,
2626 FileUpload: UIFileUpload
2627 });