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