luci-base: ensure that button labels are properly html escaped
[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 1 auto; 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('button', {
2307 'class': 'btn',
2308 'click': L.ui.hideModal
2309 }, [ _('Dismiss') ]), ' ',
2310 E('button', {
2311 'class': 'cbi-button cbi-button-positive important',
2312 'click': L.bind(this.apply, this, true)
2313 }, [ _('Save & Apply') ]), ' ',
2314 E('button', {
2315 'class': 'cbi-button cbi-button-reset',
2316 'click': L.bind(this.revert, this)
2317 }, [ _('Revert') ])])])
2318 ]);
2319
2320 for (var config in this.changes) {
2321 if (!this.changes.hasOwnProperty(config))
2322 continue;
2323
2324 list.appendChild(E('h5', '# /etc/config/%s'.format(config)));
2325
2326 for (var i = 0, added = null; i < this.changes[config].length; i++) {
2327 var chg = this.changes[config][i],
2328 tpl = this.changeTemplates['%s-%d'.format(chg[0], chg.length)];
2329
2330 list.appendChild(E(tpl.replace(/%([01234])/g, function(m0, m1) {
2331 switch (+m1) {
2332 case 0:
2333 return config;
2334
2335 case 2:
2336 if (added != null && chg[1] == added[0])
2337 return '@' + added[1] + '[-1]';
2338 else
2339 return chg[1];
2340
2341 case 4:
2342 return "'%h'".format(chg[3].replace(/'/g, "'\"'\"'"));
2343
2344 default:
2345 return chg[m1-1];
2346 }
2347 })));
2348
2349 if (chg[0] == 'add')
2350 added = [ chg[1], chg[2] ];
2351 }
2352 }
2353
2354 list.appendChild(E('br'));
2355 dlg.classList.add('uci-dialog');
2356 },
2357
2358 displayStatus: function(type, content) {
2359 if (type) {
2360 var message = L.ui.showModal('', '');
2361
2362 message.classList.add('alert-message');
2363 DOMTokenList.prototype.add.apply(message.classList, type.split(/\s+/));
2364
2365 if (content)
2366 L.dom.content(message, content);
2367
2368 if (!this.was_polling) {
2369 this.was_polling = L.Request.poll.active();
2370 L.Request.poll.stop();
2371 }
2372 }
2373 else {
2374 L.ui.hideModal();
2375
2376 if (this.was_polling)
2377 L.Request.poll.start();
2378 }
2379 },
2380
2381 rollback: function(checked) {
2382 if (checked) {
2383 this.displayStatus('warning spinning',
2384 E('p', _('Failed to confirm apply within %ds, waiting for rollback…')
2385 .format(L.env.apply_rollback)));
2386
2387 var call = function(r, data, duration) {
2388 if (r.status === 204) {
2389 L.ui.changes.displayStatus('warning', [
2390 E('h4', _('Configuration has been rolled back!')),
2391 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)),
2392 E('div', { 'class': 'right' }, [
2393 E('button', {
2394 'class': 'btn',
2395 'click': L.bind(L.ui.changes.displayStatus, L.ui.changes, false)
2396 }, [ _('Dismiss') ]), ' ',
2397 E('button', {
2398 'class': 'btn cbi-button-action important',
2399 'click': L.bind(L.ui.changes.revert, L.ui.changes)
2400 }, [ _('Revert changes') ]), ' ',
2401 E('button', {
2402 'class': 'btn cbi-button-negative important',
2403 'click': L.bind(L.ui.changes.apply, L.ui.changes, false)
2404 }, [ _('Apply unchecked') ])
2405 ])
2406 ]);
2407
2408 return;
2409 }
2410
2411 var delay = isNaN(duration) ? 0 : Math.max(1000 - duration, 0);
2412 window.setTimeout(function() {
2413 L.Request.request(L.url('admin/uci/confirm'), {
2414 method: 'post',
2415 timeout: L.env.apply_timeout * 1000,
2416 query: { sid: L.env.sessionid, token: L.env.token }
2417 }).then(call);
2418 }, delay);
2419 };
2420
2421 call({ status: 0 });
2422 }
2423 else {
2424 this.displayStatus('warning', [
2425 E('h4', _('Device unreachable!')),
2426 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.'))
2427 ]);
2428 }
2429 },
2430
2431 confirm: function(checked, deadline, override_token) {
2432 var tt;
2433 var ts = Date.now();
2434
2435 this.displayStatus('notice');
2436
2437 if (override_token)
2438 this.confirm_auth = { token: override_token };
2439
2440 var call = function(r, data, duration) {
2441 if (Date.now() >= deadline) {
2442 window.clearTimeout(tt);
2443 L.ui.changes.rollback(checked);
2444 return;
2445 }
2446 else if (r && (r.status === 200 || r.status === 204)) {
2447 document.dispatchEvent(new CustomEvent('uci-applied'));
2448
2449 L.ui.changes.setIndicator(0);
2450 L.ui.changes.displayStatus('notice',
2451 E('p', _('Configuration has been applied.')));
2452
2453 window.clearTimeout(tt);
2454 window.setTimeout(function() {
2455 //L.ui.changes.displayStatus(false);
2456 window.location = window.location.href.split('#')[0];
2457 }, L.env.apply_display * 1000);
2458
2459 return;
2460 }
2461
2462 var delay = isNaN(duration) ? 0 : Math.max(1000 - duration, 0);
2463 window.setTimeout(function() {
2464 L.Request.request(L.url('admin/uci/confirm'), {
2465 method: 'post',
2466 timeout: L.env.apply_timeout * 1000,
2467 query: L.ui.changes.confirm_auth
2468 }).then(call, call);
2469 }, delay);
2470 };
2471
2472 var tick = function() {
2473 var now = Date.now();
2474
2475 L.ui.changes.displayStatus('notice spinning',
2476 E('p', _('Waiting for configuration to get applied… %ds')
2477 .format(Math.max(Math.floor((deadline - Date.now()) / 1000), 0))));
2478
2479 if (now >= deadline)
2480 return;
2481
2482 tt = window.setTimeout(tick, 1000 - (now - ts));
2483 ts = now;
2484 };
2485
2486 tick();
2487
2488 /* wait a few seconds for the settings to become effective */
2489 window.setTimeout(call, Math.max(L.env.apply_holdoff * 1000 - ((ts + L.env.apply_rollback * 1000) - deadline), 1));
2490 },
2491
2492 apply: function(checked) {
2493 this.displayStatus('notice spinning',
2494 E('p', _('Starting configuration apply…')));
2495
2496 L.Request.request(L.url('admin/uci', checked ? 'apply_rollback' : 'apply_unchecked'), {
2497 method: 'post',
2498 query: { sid: L.env.sessionid, token: L.env.token }
2499 }).then(function(r) {
2500 if (r.status === (checked ? 200 : 204)) {
2501 var tok = null; try { tok = r.json(); } catch(e) {}
2502 if (checked && tok !== null && typeof(tok) === 'object' && typeof(tok.token) === 'string')
2503 L.ui.changes.confirm_auth = tok;
2504
2505 L.ui.changes.confirm(checked, Date.now() + L.env.apply_rollback * 1000);
2506 }
2507 else if (checked && r.status === 204) {
2508 L.ui.changes.displayStatus('notice',
2509 E('p', _('There are no changes to apply')));
2510
2511 window.setTimeout(function() {
2512 L.ui.changes.displayStatus(false);
2513 }, L.env.apply_display * 1000);
2514 }
2515 else {
2516 L.ui.changes.displayStatus('warning',
2517 E('p', _('Apply request failed with status <code>%h</code>')
2518 .format(r.responseText || r.statusText || r.status)));
2519
2520 window.setTimeout(function() {
2521 L.ui.changes.displayStatus(false);
2522 }, L.env.apply_display * 1000);
2523 }
2524 });
2525 },
2526
2527 revert: function() {
2528 this.displayStatus('notice spinning',
2529 E('p', _('Reverting configuration…')));
2530
2531 L.Request.request(L.url('admin/uci/revert'), {
2532 method: 'post',
2533 query: { sid: L.env.sessionid, token: L.env.token }
2534 }).then(function(r) {
2535 if (r.status === 200) {
2536 document.dispatchEvent(new CustomEvent('uci-reverted'));
2537
2538 L.ui.changes.setIndicator(0);
2539 L.ui.changes.displayStatus('notice',
2540 E('p', _('Changes have been reverted.')));
2541
2542 window.setTimeout(function() {
2543 //L.ui.changes.displayStatus(false);
2544 window.location = window.location.href.split('#')[0];
2545 }, L.env.apply_display * 1000);
2546 }
2547 else {
2548 L.ui.changes.displayStatus('warning',
2549 E('p', _('Revert request failed with status <code>%h</code>')
2550 .format(r.statusText || r.status)));
2551
2552 window.setTimeout(function() {
2553 L.ui.changes.displayStatus(false);
2554 }, L.env.apply_display * 1000);
2555 }
2556 });
2557 }
2558 }),
2559
2560 addValidator: function(field, type, optional, vfunc /*, ... */) {
2561 if (type == null)
2562 return;
2563
2564 var events = this.varargs(arguments, 3);
2565 if (events.length == 0)
2566 events.push('blur', 'keyup');
2567
2568 try {
2569 var cbiValidator = L.validation.create(field, type, optional, vfunc),
2570 validatorFn = cbiValidator.validate.bind(cbiValidator);
2571
2572 for (var i = 0; i < events.length; i++)
2573 field.addEventListener(events[i], validatorFn);
2574
2575 validatorFn();
2576
2577 return validatorFn;
2578 }
2579 catch (e) { }
2580 },
2581
2582 createHandlerFn: function(ctx, fn /*, ... */) {
2583 if (typeof(fn) == 'string')
2584 fn = ctx[fn];
2585
2586 if (typeof(fn) != 'function')
2587 return null;
2588
2589 return Function.prototype.bind.apply(function() {
2590 var t = arguments[arguments.length - 1].target;
2591
2592 t.classList.add('spinning');
2593 t.disabled = true;
2594
2595 if (t.blur)
2596 t.blur();
2597
2598 Promise.resolve(fn.apply(ctx, arguments)).finally(function() {
2599 t.classList.remove('spinning');
2600 t.disabled = false;
2601 });
2602 }, this.varargs(arguments, 2, ctx));
2603 },
2604
2605 /* Widgets */
2606 Textfield: UITextfield,
2607 Textarea: UITextarea,
2608 Checkbox: UICheckbox,
2609 Select: UISelect,
2610 Dropdown: UIDropdown,
2611 DynamicList: UIDynamicList,
2612 Combobox: UICombobox,
2613 Hiddenfield: UIHiddenfield,
2614 FileUpload: UIFileUpload
2615 });