luci-base: ui.js: fix input value reading for select widgets
[project/luci.git] / modules / luci-base / htdocs / luci-static / resources / ui.js
1 'use strict';
2 'require validation';
3 'require baseclass';
4 'require request';
5 'require session';
6 'require poll';
7 'require dom';
8 'require rpc';
9 'require uci';
10 'require fs';
11
12 var modalDiv = null,
13 tooltipDiv = null,
14 indicatorDiv = null,
15 tooltipTimeout = null;
16
17 /**
18 * @class AbstractElement
19 * @memberof LuCI.ui
20 * @hideconstructor
21 * @classdesc
22 *
23 * The `AbstractElement` class serves as abstract base for the different widgets
24 * implemented by `LuCI.ui`. It provides the common logic for getting and
25 * setting values, for checking the validity state and for wiring up required
26 * events.
27 *
28 * UI widget instances are usually not supposed to be created by view code
29 * directly, instead they're implicitely created by `LuCI.form` when
30 * instantiating CBI forms.
31 *
32 * This class is automatically instantiated as part of `LuCI.ui`. To use it
33 * in views, use `'require ui'` and refer to `ui.AbstractElement`. To import
34 * it in external JavaScript, use `L.require("ui").then(...)` and access the
35 * `AbstractElement` property of the class instance value.
36 */
37 var UIElement = baseclass.extend(/** @lends LuCI.ui.AbstractElement.prototype */ {
38 /**
39 * @typedef {Object} InitOptions
40 * @memberof LuCI.ui.AbstractElement
41 *
42 * @property {string} [id]
43 * Specifies the widget ID to use. It will be used as HTML `id` attribute
44 * on the toplevel widget DOM node.
45 *
46 * @property {string} [name]
47 * Specifies the widget name which is set as HTML `name` attribute on the
48 * corresponding `<input>` element.
49 *
50 * @property {boolean} [optional=true]
51 * Specifies whether the input field allows empty values.
52 *
53 * @property {string} [datatype=string]
54 * An expression describing the input data validation constraints.
55 * It defaults to `string` which will allow any value.
56 * See {@link LuCI.validation} for details on the expression format.
57 *
58 * @property {function} [validator]
59 * Specifies a custom validator function which is invoked after the
60 * standard validation constraints are checked. The function should return
61 * `true` to accept the given input value. Any other return value type is
62 * converted to a string and treated as validation error message.
63 *
64 * @property {boolean} [disabled=false]
65 * Specifies whether the widget should be rendered in disabled state
66 * (`true`) or not (`false`). Disabled widgets cannot be interacted with
67 * and are displayed in a slightly faded style.
68 */
69
70 /**
71 * Read the current value of the input widget.
72 *
73 * @instance
74 * @memberof LuCI.ui.AbstractElement
75 * @returns {string|string[]|null}
76 * The current value of the input element. For simple inputs like text
77 * fields or selects, the return value type will be a - possibly empty -
78 * string. Complex widgets such as `DynamicList` instances may result in
79 * an array of strings or `null` for unset values.
80 */
81 getValue: function() {
82 if (dom.matches(this.node, 'select') || dom.matches(this.node, 'input'))
83 return this.node.value;
84
85 return null;
86 },
87
88 /**
89 * Set the current value of the input widget.
90 *
91 * @instance
92 * @memberof LuCI.ui.AbstractElement
93 * @param {string|string[]|null} value
94 * The value to set the input element to. For simple inputs like text
95 * fields or selects, the value should be a - possibly empty - string.
96 * Complex widgets such as `DynamicList` instances may accept string array
97 * or `null` values.
98 */
99 setValue: function(value) {
100 if (dom.matches(this.node, 'select') || dom.matches(this.node, 'input'))
101 this.node.value = value;
102 },
103
104 /**
105 * Check whether the current input value is valid.
106 *
107 * @instance
108 * @memberof LuCI.ui.AbstractElement
109 * @returns {boolean}
110 * Returns `true` if the current input value is valid or `false` if it does
111 * not meet the validation constraints.
112 */
113 isValid: function() {
114 return (this.validState !== false);
115 },
116
117 /**
118 * Force validation of the current input value.
119 *
120 * Usually input validation is automatically triggered by various DOM events
121 * bound to the input widget. In some cases it is required though to manually
122 * trigger validation runs, e.g. when programmatically altering values.
123 *
124 * @instance
125 * @memberof LuCI.ui.AbstractElement
126 */
127 triggerValidation: function() {
128 if (typeof(this.vfunc) != 'function')
129 return false;
130
131 var wasValid = this.isValid();
132
133 this.vfunc();
134
135 return (wasValid != this.isValid());
136 },
137
138 /**
139 * Dispatch a custom (synthetic) event in response to received events.
140 *
141 * Sets up event handlers on the given target DOM node for the given event
142 * names that dispatch a custom event of the given type to the widget root
143 * DOM node.
144 *
145 * The primary purpose of this function is to set up a series of custom
146 * uniform standard events such as `widget-update`, `validation-success`,
147 * `validation-failure` etc. which are triggered by various different
148 * widget specific native DOM events.
149 *
150 * @instance
151 * @memberof LuCI.ui.AbstractElement
152 * @param {Node} targetNode
153 * Specifies the DOM node on which the native event listeners should be
154 * registered.
155 *
156 * @param {string} synevent
157 * The name of the custom event to dispatch to the widget root DOM node.
158 *
159 * @param {string[]} events
160 * The native DOM events for which event handlers should be registered.
161 */
162 registerEvents: function(targetNode, synevent, events) {
163 var dispatchFn = L.bind(function(ev) {
164 this.node.dispatchEvent(new CustomEvent(synevent, { bubbles: true }));
165 }, this);
166
167 for (var i = 0; i < events.length; i++)
168 targetNode.addEventListener(events[i], dispatchFn);
169 },
170
171 /**
172 * Setup listeners for native DOM events that may update the widget value.
173 *
174 * Sets up event handlers on the given target DOM node for the given event
175 * names which may cause the input value to update, such as `keyup` or
176 * `onclick` events. In contrast to change events, such update events will
177 * trigger input value validation.
178 *
179 * @instance
180 * @memberof LuCI.ui.AbstractElement
181 * @param {Node} targetNode
182 * Specifies the DOM node on which the event listeners should be registered.
183 *
184 * @param {...string} events
185 * The DOM events for which event handlers should be registered.
186 */
187 setUpdateEvents: function(targetNode /*, ... */) {
188 var datatype = this.options.datatype,
189 optional = this.options.hasOwnProperty('optional') ? this.options.optional : true,
190 validate = this.options.validate,
191 events = this.varargs(arguments, 1);
192
193 this.registerEvents(targetNode, 'widget-update', events);
194
195 if (!datatype && !validate)
196 return;
197
198 this.vfunc = UI.prototype.addValidator.apply(UI.prototype, [
199 targetNode, datatype || 'string',
200 optional, validate
201 ].concat(events));
202
203 this.node.addEventListener('validation-success', L.bind(function(ev) {
204 this.validState = true;
205 }, this));
206
207 this.node.addEventListener('validation-failure', L.bind(function(ev) {
208 this.validState = false;
209 }, this));
210 },
211
212 /**
213 * Setup listeners for native DOM events that may change the widget value.
214 *
215 * Sets up event handlers on the given target DOM node for the given event
216 * names which may cause the input value to change completely, such as
217 * `change` events in a select menu. In contrast to update events, such
218 * change events will not trigger input value validation but they may cause
219 * field dependencies to get re-evaluated and will mark the input widget
220 * as dirty.
221 *
222 * @instance
223 * @memberof LuCI.ui.AbstractElement
224 * @param {Node} targetNode
225 * Specifies the DOM node on which the event listeners should be registered.
226 *
227 * @param {...string} events
228 * The DOM events for which event handlers should be registered.
229 */
230 setChangeEvents: function(targetNode /*, ... */) {
231 var tag_changed = L.bind(function(ev) { this.setAttribute('data-changed', true) }, this.node);
232
233 for (var i = 1; i < arguments.length; i++)
234 targetNode.addEventListener(arguments[i], tag_changed);
235
236 this.registerEvents(targetNode, 'widget-change', this.varargs(arguments, 1));
237 },
238
239 /**
240 * Render the widget, setup event listeners and return resulting markup.
241 *
242 * @instance
243 * @memberof LuCI.ui.AbstractElement
244 *
245 * @returns {Node}
246 * Returns a DOM Node or DocumentFragment containing the rendered
247 * widget markup.
248 */
249 render: function() {}
250 });
251
252 /**
253 * Instantiate a text input widget.
254 *
255 * @constructor Textfield
256 * @memberof LuCI.ui
257 * @augments LuCI.ui.AbstractElement
258 *
259 * @classdesc
260 *
261 * The `Textfield` class implements a standard single line text input field.
262 *
263 * UI widget instances are usually not supposed to be created by view code
264 * directly, instead they're implicitely created by `LuCI.form` when
265 * instantiating CBI forms.
266 *
267 * This class is automatically instantiated as part of `LuCI.ui`. To use it
268 * in views, use `'require ui'` and refer to `ui.Textfield`. To import it in
269 * external JavaScript, use `L.require("ui").then(...)` and access the
270 * `Textfield` property of the class instance value.
271 *
272 * @param {string} [value=null]
273 * The initial input value.
274 *
275 * @param {LuCI.ui.Textfield.InitOptions} [options]
276 * Object describing the widget specific options to initialize the input.
277 */
278 var UITextfield = UIElement.extend(/** @lends LuCI.ui.Textfield.prototype */ {
279 /**
280 * In addition to the [AbstractElement.InitOptions]{@link LuCI.ui.AbstractElement.InitOptions}
281 * the following properties are recognized:
282 *
283 * @typedef {LuCI.ui.AbstractElement.InitOptions} InitOptions
284 * @memberof LuCI.ui.Textfield
285 *
286 * @property {boolean} [password=false]
287 * Specifies whether the input should be rendered as concealed password field.
288 *
289 * @property {boolean} [readonly=false]
290 * Specifies whether the input widget should be rendered readonly.
291 *
292 * @property {number} [maxlength]
293 * Specifies the HTML `maxlength` attribute to set on the corresponding
294 * `<input>` element. Note that this a legacy property that exists for
295 * compatibility reasons. It is usually better to `maxlength(N)` validation
296 * expression.
297 *
298 * @property {string} [placeholder]
299 * Specifies the HTML `placeholder` attribute which is displayed when the
300 * corresponding `<input>` element is empty.
301 */
302 __init__: function(value, options) {
303 this.value = value;
304 this.options = Object.assign({
305 optional: true,
306 password: false
307 }, options);
308 },
309
310 /** @override */
311 render: function() {
312 var frameEl = E('div', { 'id': this.options.id });
313 var inputEl = E('input', {
314 'id': this.options.id ? 'widget.' + this.options.id : null,
315 'name': this.options.name,
316 'type': 'text',
317 'class': this.options.password ? 'cbi-input-password' : 'cbi-input-text',
318 'readonly': this.options.readonly ? '' : null,
319 'disabled': this.options.disabled ? '' : null,
320 'maxlength': this.options.maxlength,
321 'placeholder': this.options.placeholder,
322 'value': this.value,
323 });
324
325 if (this.options.password) {
326 frameEl.appendChild(E('div', { 'class': 'control-group' }, [
327 inputEl,
328 E('button', {
329 'class': 'cbi-button cbi-button-neutral',
330 'title': _('Reveal/hide password'),
331 'aria-label': _('Reveal/hide password'),
332 'click': function(ev) {
333 var e = this.previousElementSibling;
334 e.type = (e.type === 'password') ? 'text' : 'password';
335 ev.preventDefault();
336 }
337 }, '∗')
338 ]));
339
340 window.requestAnimationFrame(function() { inputEl.type = 'password' });
341 }
342 else {
343 frameEl.appendChild(inputEl);
344 }
345
346 return this.bind(frameEl);
347 },
348
349 /** @private */
350 bind: function(frameEl) {
351 var inputEl = frameEl.querySelector('input');
352
353 this.node = frameEl;
354
355 this.setUpdateEvents(inputEl, 'keyup', 'blur');
356 this.setChangeEvents(inputEl, 'change');
357
358 dom.bindClassInstance(frameEl, this);
359
360 return frameEl;
361 },
362
363 /** @override */
364 getValue: function() {
365 var inputEl = this.node.querySelector('input');
366 return inputEl.value;
367 },
368
369 /** @override */
370 setValue: function(value) {
371 var inputEl = this.node.childNodes[+!!this.options.password];
372 inputEl.value = value;
373 }
374 });
375
376 /**
377 * Instantiate a textarea widget.
378 *
379 * @constructor Textarea
380 * @memberof LuCI.ui
381 * @augments LuCI.ui.AbstractElement
382 *
383 * @classdesc
384 *
385 * The `Textarea` class implements a multiline text area input field.
386 *
387 * UI widget instances are usually not supposed to be created by view code
388 * directly, instead they're implicitely created by `LuCI.form` when
389 * instantiating CBI forms.
390 *
391 * This class is automatically instantiated as part of `LuCI.ui`. To use it
392 * in views, use `'require ui'` and refer to `ui.Textarea`. To import it in
393 * external JavaScript, use `L.require("ui").then(...)` and access the
394 * `Textarea` property of the class instance value.
395 *
396 * @param {string} [value=null]
397 * The initial input value.
398 *
399 * @param {LuCI.ui.Textarea.InitOptions} [options]
400 * Object describing the widget specific options to initialize the input.
401 */
402 var UITextarea = UIElement.extend(/** @lends LuCI.ui.Textarea.prototype */ {
403 /**
404 * In addition to the [AbstractElement.InitOptions]{@link LuCI.ui.AbstractElement.InitOptions}
405 * the following properties are recognized:
406 *
407 * @typedef {LuCI.ui.AbstractElement.InitOptions} InitOptions
408 * @memberof LuCI.ui.Textarea
409 *
410 * @property {boolean} [readonly=false]
411 * Specifies whether the input widget should be rendered readonly.
412 *
413 * @property {string} [placeholder]
414 * Specifies the HTML `placeholder` attribute which is displayed when the
415 * corresponding `<textarea>` element is empty.
416 *
417 * @property {boolean} [monospace=false]
418 * Specifies whether a monospace font should be forced for the textarea
419 * contents.
420 *
421 * @property {number} [cols]
422 * Specifies the HTML `cols` attribute to set on the corresponding
423 * `<textarea>` element.
424 *
425 * @property {number} [rows]
426 * Specifies the HTML `rows` attribute to set on the corresponding
427 * `<textarea>` element.
428 *
429 * @property {boolean} [wrap=false]
430 * Specifies whether the HTML `wrap` attribute should be set.
431 */
432 __init__: function(value, options) {
433 this.value = value;
434 this.options = Object.assign({
435 optional: true,
436 wrap: false,
437 cols: null,
438 rows: null
439 }, options);
440 },
441
442 /** @override */
443 render: function() {
444 var style = !this.options.cols ? 'width:100%' : null,
445 frameEl = E('div', { 'id': this.options.id, 'style': style }),
446 value = (this.value != null) ? String(this.value) : '';
447
448 frameEl.appendChild(E('textarea', {
449 'id': this.options.id ? 'widget.' + this.options.id : null,
450 'name': this.options.name,
451 'class': 'cbi-input-textarea',
452 'readonly': this.options.readonly ? '' : null,
453 'disabled': this.options.disabled ? '' : null,
454 'placeholder': this.options.placeholder,
455 'style': style,
456 'cols': this.options.cols,
457 'rows': this.options.rows,
458 'wrap': this.options.wrap ? '' : null
459 }, [ value ]));
460
461 if (this.options.monospace)
462 frameEl.firstElementChild.style.fontFamily = 'monospace';
463
464 return this.bind(frameEl);
465 },
466
467 /** @private */
468 bind: function(frameEl) {
469 var inputEl = frameEl.firstElementChild;
470
471 this.node = frameEl;
472
473 this.setUpdateEvents(inputEl, 'keyup', 'blur');
474 this.setChangeEvents(inputEl, 'change');
475
476 dom.bindClassInstance(frameEl, this);
477
478 return frameEl;
479 },
480
481 /** @override */
482 getValue: function() {
483 return this.node.firstElementChild.value;
484 },
485
486 /** @override */
487 setValue: function(value) {
488 this.node.firstElementChild.value = value;
489 }
490 });
491
492 /**
493 * Instantiate a checkbox widget.
494 *
495 * @constructor Checkbox
496 * @memberof LuCI.ui
497 * @augments LuCI.ui.AbstractElement
498 *
499 * @classdesc
500 *
501 * The `Checkbox` class implements a simple checkbox input field.
502 *
503 * UI widget instances are usually not supposed to be created by view code
504 * directly, instead they're implicitely created by `LuCI.form` when
505 * instantiating CBI forms.
506 *
507 * This class is automatically instantiated as part of `LuCI.ui`. To use it
508 * in views, use `'require ui'` and refer to `ui.Checkbox`. To import it in
509 * external JavaScript, use `L.require("ui").then(...)` and access the
510 * `Checkbox` property of the class instance value.
511 *
512 * @param {string} [value=null]
513 * The initial input value.
514 *
515 * @param {LuCI.ui.Checkbox.InitOptions} [options]
516 * Object describing the widget specific options to initialize the input.
517 */
518 var UICheckbox = UIElement.extend(/** @lends LuCI.ui.Checkbox.prototype */ {
519 /**
520 * In addition to the [AbstractElement.InitOptions]{@link LuCI.ui.AbstractElement.InitOptions}
521 * the following properties are recognized:
522 *
523 * @typedef {LuCI.ui.AbstractElement.InitOptions} InitOptions
524 * @memberof LuCI.ui.Checkbox
525 *
526 * @property {string} [value_enabled=1]
527 * Specifies the value corresponding to a checked checkbox.
528 *
529 * @property {string} [value_disabled=0]
530 * Specifies the value corresponding to an unchecked checkbox.
531 *
532 * @property {string} [hiddenname]
533 * Specifies the HTML `name` attribute of the hidden input backing the
534 * checkbox. This is a legacy property existing for compatibility reasons,
535 * it is required for HTML based form submissions.
536 */
537 __init__: function(value, options) {
538 this.value = value;
539 this.options = Object.assign({
540 value_enabled: '1',
541 value_disabled: '0'
542 }, options);
543 },
544
545 /** @override */
546 render: function() {
547 var id = 'cb%08x'.format(Math.random() * 0xffffffff);
548 var frameEl = E('div', {
549 'id': this.options.id,
550 'class': 'cbi-checkbox'
551 });
552
553 if (this.options.hiddenname)
554 frameEl.appendChild(E('input', {
555 'type': 'hidden',
556 'name': this.options.hiddenname,
557 'value': 1
558 }));
559
560 frameEl.appendChild(E('input', {
561 'id': id,
562 'name': this.options.name,
563 'type': 'checkbox',
564 'value': this.options.value_enabled,
565 'checked': (this.value == this.options.value_enabled) ? '' : null,
566 'disabled': this.options.disabled ? '' : null,
567 'data-widget-id': this.options.id ? 'widget.' + this.options.id : null
568 }));
569
570 frameEl.appendChild(E('label', { 'for': id }));
571
572 return this.bind(frameEl);
573 },
574
575 /** @private */
576 bind: function(frameEl) {
577 this.node = frameEl;
578
579 this.setUpdateEvents(frameEl.lastElementChild.previousElementSibling, 'click', 'blur');
580 this.setChangeEvents(frameEl.lastElementChild.previousElementSibling, 'change');
581
582 dom.bindClassInstance(frameEl, this);
583
584 return frameEl;
585 },
586
587 /**
588 * Test whether the checkbox is currently checked.
589 *
590 * @instance
591 * @memberof LuCI.ui.Checkbox
592 * @returns {boolean}
593 * Returns `true` when the checkbox is currently checked, otherwise `false`.
594 */
595 isChecked: function() {
596 return this.node.lastElementChild.previousElementSibling.checked;
597 },
598
599 /** @override */
600 getValue: function() {
601 return this.isChecked()
602 ? this.options.value_enabled
603 : this.options.value_disabled;
604 },
605
606 /** @override */
607 setValue: function(value) {
608 this.node.lastElementChild.previousElementSibling.checked = (value == this.options.value_enabled);
609 }
610 });
611
612 /**
613 * Instantiate a select dropdown or checkbox/radiobutton group.
614 *
615 * @constructor Select
616 * @memberof LuCI.ui
617 * @augments LuCI.ui.AbstractElement
618 *
619 * @classdesc
620 *
621 * The `Select` class implements either a traditional HTML `<select>` element
622 * or a group of checkboxes or radio buttons, depending on whether multiple
623 * values are enabled or not.
624 *
625 * UI widget instances are usually not supposed to be created by view code
626 * directly, instead they're implicitely created by `LuCI.form` when
627 * instantiating CBI forms.
628 *
629 * This class is automatically instantiated as part of `LuCI.ui`. To use it
630 * in views, use `'require ui'` and refer to `ui.Select`. To import it in
631 * external JavaScript, use `L.require("ui").then(...)` and access the
632 * `Select` property of the class instance value.
633 *
634 * @param {string|string[]} [value=null]
635 * The initial input value(s).
636 *
637 * @param {Object<string, string>} choices
638 * Object containing the selectable choices of the widget. The object keys
639 * serve as values for the different choices while the values are used as
640 * choice labels.
641 *
642 * @param {LuCI.ui.Select.InitOptions} [options]
643 * Object describing the widget specific options to initialize the inputs.
644 */
645 var UISelect = UIElement.extend(/** @lends LuCI.ui.Select.prototype */ {
646 /**
647 * In addition to the [AbstractElement.InitOptions]{@link LuCI.ui.AbstractElement.InitOptions}
648 * the following properties are recognized:
649 *
650 * @typedef {LuCI.ui.AbstractElement.InitOptions} InitOptions
651 * @memberof LuCI.ui.Select
652 *
653 * @property {boolean} [multiple=false]
654 * Specifies whether multiple choice values may be selected.
655 *
656 * @property {string} [widget=select]
657 * Specifies the kind of widget to render. May be either `select` or
658 * `individual`. When set to `select` an HTML `<select>` element will be
659 * used, otherwise a group of checkbox or radio button elements is created,
660 * depending on the value of the `multiple` option.
661 *
662 * @property {string} [orientation=horizontal]
663 * Specifies whether checkbox / radio button groups should be rendered
664 * in a `horizontal` or `vertical` manner. Does not apply to the `select`
665 * widget type.
666 *
667 * @property {boolean|string[]} [sort=false]
668 * Specifies if and how to sort choice values. If set to `true`, the choice
669 * values will be sorted alphabetically. If set to an array of strings, the
670 * choice sort order is derived from the array.
671 *
672 * @property {number} [size]
673 * Specifies the HTML `size` attribute to set on the `<select>` element.
674 * Only applicable to the `select` widget type.
675 *
676 * @property {string} [placeholder=-- Please choose --]
677 * Specifies a placeholder text which is displayed when no choice is
678 * selected yet. Only applicable to the `select` widget type.
679 */
680 __init__: function(value, choices, options) {
681 if (!L.isObject(choices))
682 choices = {};
683
684 if (!Array.isArray(value))
685 value = (value != null && value != '') ? [ value ] : [];
686
687 if (!options.multiple && value.length > 1)
688 value.length = 1;
689
690 this.values = value;
691 this.choices = choices;
692 this.options = Object.assign({
693 multiple: false,
694 widget: 'select',
695 orientation: 'horizontal'
696 }, options);
697
698 if (this.choices.hasOwnProperty(''))
699 this.options.optional = true;
700 },
701
702 /** @override */
703 render: function() {
704 var frameEl = E('div', { 'id': this.options.id }),
705 keys = Object.keys(this.choices);
706
707 if (this.options.sort === true)
708 keys.sort();
709 else if (Array.isArray(this.options.sort))
710 keys = this.options.sort;
711
712 if (this.options.widget != 'radio' && this.options.widget != 'checkbox') {
713 frameEl.appendChild(E('select', {
714 'id': this.options.id ? 'widget.' + this.options.id : null,
715 'name': this.options.name,
716 'size': this.options.size,
717 'class': 'cbi-input-select',
718 'multiple': this.options.multiple ? '' : null,
719 'disabled': this.options.disabled ? '' : null
720 }));
721
722 if (this.options.optional)
723 frameEl.lastChild.appendChild(E('option', {
724 'value': '',
725 'selected': (this.values.length == 0 || this.values[0] == '') ? '' : null
726 }, [ this.choices[''] || this.options.placeholder || _('-- Please choose --') ]));
727
728 for (var i = 0; i < keys.length; i++) {
729 if (keys[i] == null || keys[i] == '')
730 continue;
731
732 frameEl.lastChild.appendChild(E('option', {
733 'value': keys[i],
734 'selected': (this.values.indexOf(keys[i]) > -1) ? '' : null
735 }, [ this.choices[keys[i]] || keys[i] ]));
736 }
737 }
738 else {
739 var brEl = (this.options.orientation === 'horizontal') ? document.createTextNode(' \xa0 ') : E('br');
740
741 for (var i = 0; i < keys.length; i++) {
742 frameEl.appendChild(E('span', {
743 'class': 'cbi-%s'.format(this.options.multiple ? 'checkbox' : 'radio')
744 }, [
745 E('input', {
746 'id': this.options.id ? 'widget.%s.%d'.format(this.options.id, i) : null,
747 'name': this.options.id || this.options.name,
748 'type': this.options.multiple ? 'checkbox' : 'radio',
749 'class': this.options.multiple ? 'cbi-input-checkbox' : 'cbi-input-radio',
750 'value': keys[i],
751 'checked': (this.values.indexOf(keys[i]) > -1) ? '' : null,
752 'disabled': this.options.disabled ? '' : null
753 }),
754 E('label', { 'for': this.options.id ? 'widget.%s.%d'.format(this.options.id, i) : null }),
755 E('span', {
756 'click': function(ev) {
757 ev.currentTarget.previousElementSibling.previousElementSibling.click();
758 }
759 }, [ this.choices[keys[i]] || keys[i] ])
760 ]));
761
762 frameEl.appendChild(brEl.cloneNode());
763 }
764 }
765
766 return this.bind(frameEl);
767 },
768
769 /** @private */
770 bind: function(frameEl) {
771 this.node = frameEl;
772
773 if (this.options.widget != 'radio' && this.options.widget != 'checkbox') {
774 this.setUpdateEvents(frameEl.firstChild, 'change', 'click', 'blur');
775 this.setChangeEvents(frameEl.firstChild, 'change');
776 }
777 else {
778 var radioEls = frameEl.querySelectorAll('input[type="radio"]');
779 for (var i = 0; i < radioEls.length; i++) {
780 this.setUpdateEvents(radioEls[i], 'change', 'click', 'blur');
781 this.setChangeEvents(radioEls[i], 'change', 'click', 'blur');
782 }
783 }
784
785 dom.bindClassInstance(frameEl, this);
786
787 return frameEl;
788 },
789
790 /** @override */
791 getValue: function() {
792 if (this.options.widget != 'radio' && this.options.widget != 'checkbox')
793 return this.node.firstChild.value;
794
795 var radioEls = this.node.querySelectorAll('input[type="radio"]');
796 for (var i = 0; i < radioEls.length; i++)
797 if (radioEls[i].checked)
798 return radioEls[i].value;
799
800 return null;
801 },
802
803 /** @override */
804 setValue: function(value) {
805 if (this.options.widget != 'radio' && this.options.widget != 'checkbox') {
806 if (value == null)
807 value = '';
808
809 for (var i = 0; i < this.node.firstChild.options.length; i++)
810 this.node.firstChild.options[i].selected = (this.node.firstChild.options[i].value == value);
811
812 return;
813 }
814
815 var radioEls = frameEl.querySelectorAll('input[type="radio"]');
816 for (var i = 0; i < radioEls.length; i++)
817 radioEls[i].checked = (radioEls[i].value == value);
818 }
819 });
820
821 /**
822 * Instantiate a rich dropdown choice widget.
823 *
824 * @constructor Dropdown
825 * @memberof LuCI.ui
826 * @augments LuCI.ui.AbstractElement
827 *
828 * @classdesc
829 *
830 * The `Dropdown` class implements a rich, stylable dropdown menu which
831 * supports non-text choice labels.
832 *
833 * UI widget instances are usually not supposed to be created by view code
834 * directly, instead they're implicitely created by `LuCI.form` when
835 * instantiating CBI forms.
836 *
837 * This class is automatically instantiated as part of `LuCI.ui`. To use it
838 * in views, use `'require ui'` and refer to `ui.Dropdown`. To import it in
839 * external JavaScript, use `L.require("ui").then(...)` and access the
840 * `Dropdown` property of the class instance value.
841 *
842 * @param {string|string[]} [value=null]
843 * The initial input value(s).
844 *
845 * @param {Object<string, *>} choices
846 * Object containing the selectable choices of the widget. The object keys
847 * serve as values for the different choices while the values are used as
848 * choice labels.
849 *
850 * @param {LuCI.ui.Dropdown.InitOptions} [options]
851 * Object describing the widget specific options to initialize the dropdown.
852 */
853 var UIDropdown = UIElement.extend(/** @lends LuCI.ui.Dropdown.prototype */ {
854 /**
855 * In addition to the [AbstractElement.InitOptions]{@link LuCI.ui.AbstractElement.InitOptions}
856 * the following properties are recognized:
857 *
858 * @typedef {LuCI.ui.AbstractElement.InitOptions} InitOptions
859 * @memberof LuCI.ui.Dropdown
860 *
861 * @property {boolean} [optional=true]
862 * Specifies whether the dropdown selection is optional. In contrast to
863 * other widgets, the `optional` constraint of dropdowns works differently;
864 * instead of marking the widget invalid on empty values when set to `false`,
865 * the user is not allowed to deselect all choices.
866 *
867 * For single value dropdowns that means that no empty "please select"
868 * choice is offered and for multi value dropdowns, the last selected choice
869 * may not be deselected without selecting another choice first.
870 *
871 * @property {boolean} [multiple]
872 * Specifies whether multiple choice values may be selected. It defaults
873 * to `true` when an array is passed as input value to the constructor.
874 *
875 * @property {boolean|string[]} [sort=false]
876 * Specifies if and how to sort choice values. If set to `true`, the choice
877 * values will be sorted alphabetically. If set to an array of strings, the
878 * choice sort order is derived from the array.
879 *
880 * @property {string} [select_placeholder=-- Please choose --]
881 * Specifies a placeholder text which is displayed when no choice is
882 * selected yet.
883 *
884 * @property {string} [custom_placeholder=-- custom --]
885 * Specifies a placeholder text which is displayed in the text input
886 * field allowing to enter custom choice values. Only applicable if the
887 * `create` option is set to `true`.
888 *
889 * @property {boolean} [create=false]
890 * Specifies whether custom choices may be entered into the dropdown
891 * widget.
892 *
893 * @property {string} [create_query=.create-item-input]
894 * Specifies a CSS selector expression used to find the input element
895 * which is used to enter custom choice values. This should not normally
896 * be used except by widgets derived from the Dropdown class.
897 *
898 * @property {string} [create_template=script[type="item-template"]]
899 * Specifies a CSS selector expression used to find an HTML element
900 * serving as template for newly added custom choice values.
901 *
902 * Any `{{value}}` placeholder string within the template elements text
903 * content will be replaced by the user supplied choice value, the
904 * resulting string is parsed as HTML and appended to the end of the
905 * choice list. The template markup may specify one HTML element with a
906 * `data-label-placeholder` attribute which is replaced by a matching
907 * label value from the `choices` object or with the user supplied value
908 * itself in case `choices` contains no matching choice label.
909 *
910 * If the template element is not found or if no `create_template` selector
911 * expression is specified, the default markup for newly created elements is
912 * `<li data-value="{{value}}"><span data-label-placeholder="true" /></li>`.
913 *
914 * @property {string} [create_markup]
915 * This property allows specifying the markup for custom choices directly
916 * instead of referring to a template element through CSS selectors.
917 *
918 * Apart from that it works exactly like `create_template`.
919 *
920 * @property {number} [display_items=3]
921 * Specifies the maximum amount of choice labels that should be shown in
922 * collapsed dropdown state before further selected choices are cut off.
923 *
924 * Only applicable when `multiple` is `true`.
925 *
926 * @property {number} [dropdown_items=-1]
927 * Specifies the maximum amount of choices that should be shown when the
928 * dropdown is open. If the amount of available choices exceeds this number,
929 * the dropdown area must be scrolled to reach further items.
930 *
931 * If set to `-1`, the dropdown menu will attempt to show all choice values
932 * and only resort to scrolling if the amount of choices exceeds the available
933 * screen space above and below the dropdown widget.
934 *
935 * @property {string} [placeholder]
936 * This property serves as a shortcut to set both `select_placeholder` and
937 * `custom_placeholder`. Either of these properties will fallback to
938 * `placeholder` if not specified.
939 *
940 * @property {boolean} [readonly=false]
941 * Specifies whether the custom choice input field should be rendered
942 * readonly. Only applicable when `create` is `true`.
943 *
944 * @property {number} [maxlength]
945 * Specifies the HTML `maxlength` attribute to set on the custom choice
946 * `<input>` element. Note that this a legacy property that exists for
947 * compatibility reasons. It is usually better to `maxlength(N)` validation
948 * expression. Only applicable when `create` is `true`.
949 */
950 __init__: function(value, choices, options) {
951 if (typeof(choices) != 'object')
952 choices = {};
953
954 if (!Array.isArray(value))
955 this.values = (value != null && value != '') ? [ value ] : [];
956 else
957 this.values = value;
958
959 this.choices = choices;
960 this.options = Object.assign({
961 sort: true,
962 multiple: Array.isArray(value),
963 optional: true,
964 select_placeholder: _('-- Please choose --'),
965 custom_placeholder: _('-- custom --'),
966 display_items: 3,
967 dropdown_items: -1,
968 create: false,
969 create_query: '.create-item-input',
970 create_template: 'script[type="item-template"]'
971 }, options);
972 },
973
974 /** @override */
975 render: function() {
976 var sb = E('div', {
977 'id': this.options.id,
978 'class': 'cbi-dropdown',
979 'multiple': this.options.multiple ? '' : null,
980 'optional': this.options.optional ? '' : null,
981 'disabled': this.options.disabled ? '' : null
982 }, E('ul'));
983
984 var keys = Object.keys(this.choices);
985
986 if (this.options.sort === true)
987 keys.sort();
988 else if (Array.isArray(this.options.sort))
989 keys = this.options.sort;
990
991 if (this.options.create)
992 for (var i = 0; i < this.values.length; i++)
993 if (!this.choices.hasOwnProperty(this.values[i]))
994 keys.push(this.values[i]);
995
996 for (var i = 0; i < keys.length; i++) {
997 var label = this.choices[keys[i]];
998
999 if (dom.elem(label))
1000 label = label.cloneNode(true);
1001
1002 sb.lastElementChild.appendChild(E('li', {
1003 'data-value': keys[i],
1004 'selected': (this.values.indexOf(keys[i]) > -1) ? '' : null
1005 }, [ label || keys[i] ]));
1006 }
1007
1008 if (this.options.create) {
1009 var createEl = E('input', {
1010 'type': 'text',
1011 'class': 'create-item-input',
1012 'readonly': this.options.readonly ? '' : null,
1013 'maxlength': this.options.maxlength,
1014 'placeholder': this.options.custom_placeholder || this.options.placeholder
1015 });
1016
1017 if (this.options.datatype || this.options.validate)
1018 UI.prototype.addValidator(createEl, this.options.datatype || 'string',
1019 true, this.options.validate, 'blur', 'keyup');
1020
1021 sb.lastElementChild.appendChild(E('li', { 'data-value': '-' }, createEl));
1022 }
1023
1024 if (this.options.create_markup)
1025 sb.appendChild(E('script', { type: 'item-template' },
1026 this.options.create_markup));
1027
1028 return this.bind(sb);
1029 },
1030
1031 /** @private */
1032 bind: function(sb) {
1033 var o = this.options;
1034
1035 o.multiple = sb.hasAttribute('multiple');
1036 o.optional = sb.hasAttribute('optional');
1037 o.placeholder = sb.getAttribute('placeholder') || o.placeholder;
1038 o.display_items = parseInt(sb.getAttribute('display-items') || o.display_items);
1039 o.dropdown_items = parseInt(sb.getAttribute('dropdown-items') || o.dropdown_items);
1040 o.create_query = sb.getAttribute('item-create') || o.create_query;
1041 o.create_template = sb.getAttribute('item-template') || o.create_template;
1042
1043 var ul = sb.querySelector('ul'),
1044 more = sb.appendChild(E('span', { class: 'more', tabindex: -1 }, '···')),
1045 open = sb.appendChild(E('span', { class: 'open', tabindex: -1 }, '▾')),
1046 canary = sb.appendChild(E('div')),
1047 create = sb.querySelector(this.options.create_query),
1048 ndisplay = this.options.display_items,
1049 n = 0;
1050
1051 if (this.options.multiple) {
1052 var items = ul.querySelectorAll('li');
1053
1054 for (var i = 0; i < items.length; i++) {
1055 this.transformItem(sb, items[i]);
1056
1057 if (items[i].hasAttribute('selected') && ndisplay-- > 0)
1058 items[i].setAttribute('display', n++);
1059 }
1060 }
1061 else {
1062 if (this.options.optional && !ul.querySelector('li[data-value=""]')) {
1063 var placeholder = E('li', { placeholder: '' },
1064 this.options.select_placeholder || this.options.placeholder);
1065
1066 ul.firstChild
1067 ? ul.insertBefore(placeholder, ul.firstChild)
1068 : ul.appendChild(placeholder);
1069 }
1070
1071 var items = ul.querySelectorAll('li'),
1072 sel = sb.querySelectorAll('[selected]');
1073
1074 sel.forEach(function(s) {
1075 s.removeAttribute('selected');
1076 });
1077
1078 var s = sel[0] || items[0];
1079 if (s) {
1080 s.setAttribute('selected', '');
1081 s.setAttribute('display', n++);
1082 }
1083
1084 ndisplay--;
1085 }
1086
1087 this.saveValues(sb, ul);
1088
1089 ul.setAttribute('tabindex', -1);
1090 sb.setAttribute('tabindex', 0);
1091
1092 if (ndisplay < 0)
1093 sb.setAttribute('more', '')
1094 else
1095 sb.removeAttribute('more');
1096
1097 if (ndisplay == this.options.display_items)
1098 sb.setAttribute('empty', '')
1099 else
1100 sb.removeAttribute('empty');
1101
1102 dom.content(more, (ndisplay == this.options.display_items)
1103 ? (this.options.select_placeholder || this.options.placeholder) : '···');
1104
1105
1106 sb.addEventListener('click', this.handleClick.bind(this));
1107 sb.addEventListener('keydown', this.handleKeydown.bind(this));
1108 sb.addEventListener('cbi-dropdown-close', this.handleDropdownClose.bind(this));
1109 sb.addEventListener('cbi-dropdown-select', this.handleDropdownSelect.bind(this));
1110
1111 if ('ontouchstart' in window) {
1112 sb.addEventListener('touchstart', function(ev) { ev.stopPropagation(); });
1113 window.addEventListener('touchstart', this.closeAllDropdowns);
1114 }
1115 else {
1116 sb.addEventListener('mouseover', this.handleMouseover.bind(this));
1117 sb.addEventListener('focus', this.handleFocus.bind(this));
1118
1119 canary.addEventListener('focus', this.handleCanaryFocus.bind(this));
1120
1121 window.addEventListener('mouseover', this.setFocus);
1122 window.addEventListener('click', this.closeAllDropdowns);
1123 }
1124
1125 if (create) {
1126 create.addEventListener('keydown', this.handleCreateKeydown.bind(this));
1127 create.addEventListener('focus', this.handleCreateFocus.bind(this));
1128 create.addEventListener('blur', this.handleCreateBlur.bind(this));
1129
1130 var li = findParent(create, 'li');
1131
1132 li.setAttribute('unselectable', '');
1133 li.addEventListener('click', this.handleCreateClick.bind(this));
1134 }
1135
1136 this.node = sb;
1137
1138 this.setUpdateEvents(sb, 'cbi-dropdown-open', 'cbi-dropdown-close');
1139 this.setChangeEvents(sb, 'cbi-dropdown-change', 'cbi-dropdown-close');
1140
1141 dom.bindClassInstance(sb, this);
1142
1143 return sb;
1144 },
1145
1146 /** @private */
1147 openDropdown: function(sb) {
1148 var st = window.getComputedStyle(sb, null),
1149 ul = sb.querySelector('ul'),
1150 li = ul.querySelectorAll('li'),
1151 fl = findParent(sb, '.cbi-value-field'),
1152 sel = ul.querySelector('[selected]'),
1153 rect = sb.getBoundingClientRect(),
1154 items = Math.min(this.options.dropdown_items, li.length);
1155
1156 document.querySelectorAll('.cbi-dropdown[open]').forEach(function(s) {
1157 s.dispatchEvent(new CustomEvent('cbi-dropdown-close', {}));
1158 });
1159
1160 sb.setAttribute('open', '');
1161
1162 var pv = ul.cloneNode(true);
1163 pv.classList.add('preview');
1164
1165 if (fl)
1166 fl.classList.add('cbi-dropdown-open');
1167
1168 if ('ontouchstart' in window) {
1169 var vpWidth = Math.max(document.documentElement.clientWidth, window.innerWidth || 0),
1170 vpHeight = Math.max(document.documentElement.clientHeight, window.innerHeight || 0),
1171 start = null;
1172
1173 ul.style.top = sb.offsetHeight + 'px';
1174 ul.style.left = -rect.left + 'px';
1175 ul.style.right = (rect.right - vpWidth) + 'px';
1176 ul.style.maxHeight = (vpHeight * 0.5) + 'px';
1177 ul.style.WebkitOverflowScrolling = 'touch';
1178
1179 function getScrollParent(element) {
1180 var parent = element,
1181 style = getComputedStyle(element),
1182 excludeStaticParent = (style.position === 'absolute');
1183
1184 if (style.position === 'fixed')
1185 return document.body;
1186
1187 while ((parent = parent.parentElement) != null) {
1188 style = getComputedStyle(parent);
1189
1190 if (excludeStaticParent && style.position === 'static')
1191 continue;
1192
1193 if (/(auto|scroll)/.test(style.overflow + style.overflowY + style.overflowX))
1194 return parent;
1195 }
1196
1197 return document.body;
1198 }
1199
1200 var scrollParent = getScrollParent(sb),
1201 scrollFrom = scrollParent.scrollTop,
1202 scrollTo = scrollFrom + rect.top - vpHeight * 0.5;
1203
1204 var scrollStep = function(timestamp) {
1205 if (!start) {
1206 start = timestamp;
1207 ul.scrollTop = sel ? Math.max(sel.offsetTop - sel.offsetHeight, 0) : 0;
1208 }
1209
1210 var duration = Math.max(timestamp - start, 1);
1211 if (duration < 100) {
1212 scrollParent.scrollTop = scrollFrom + (scrollTo - scrollFrom) * (duration / 100);
1213 window.requestAnimationFrame(scrollStep);
1214 }
1215 else {
1216 scrollParent.scrollTop = scrollTo;
1217 }
1218 };
1219
1220 window.requestAnimationFrame(scrollStep);
1221 }
1222 else {
1223 ul.style.maxHeight = '1px';
1224 ul.style.top = ul.style.bottom = '';
1225
1226 window.requestAnimationFrame(function() {
1227 var itemHeight = li[Math.max(0, li.length - 2)].getBoundingClientRect().height,
1228 fullHeight = 0,
1229 spaceAbove = rect.top,
1230 spaceBelow = window.innerHeight - rect.height - rect.top;
1231
1232 for (var i = 0; i < (items == -1 ? li.length : items); i++)
1233 fullHeight += li[i].getBoundingClientRect().height;
1234
1235 if (fullHeight <= spaceBelow) {
1236 ul.style.top = rect.height + 'px';
1237 ul.style.maxHeight = spaceBelow + 'px';
1238 }
1239 else if (fullHeight <= spaceAbove) {
1240 ul.style.bottom = rect.height + 'px';
1241 ul.style.maxHeight = spaceAbove + 'px';
1242 }
1243 else if (spaceBelow >= spaceAbove) {
1244 ul.style.top = rect.height + 'px';
1245 ul.style.maxHeight = (spaceBelow - (spaceBelow % itemHeight)) + 'px';
1246 }
1247 else {
1248 ul.style.bottom = rect.height + 'px';
1249 ul.style.maxHeight = (spaceAbove - (spaceAbove % itemHeight)) + 'px';
1250 }
1251
1252 ul.scrollTop = sel ? Math.max(sel.offsetTop - sel.offsetHeight, 0) : 0;
1253 });
1254 }
1255
1256 var cboxes = ul.querySelectorAll('[selected] input[type="checkbox"]');
1257 for (var i = 0; i < cboxes.length; i++) {
1258 cboxes[i].checked = true;
1259 cboxes[i].disabled = (cboxes.length == 1 && !this.options.optional);
1260 };
1261
1262 ul.classList.add('dropdown');
1263
1264 sb.insertBefore(pv, ul.nextElementSibling);
1265
1266 li.forEach(function(l) {
1267 l.setAttribute('tabindex', 0);
1268 });
1269
1270 sb.lastElementChild.setAttribute('tabindex', 0);
1271
1272 this.setFocus(sb, sel || li[0], true);
1273 },
1274
1275 /** @private */
1276 closeDropdown: function(sb, no_focus) {
1277 if (!sb.hasAttribute('open'))
1278 return;
1279
1280 var pv = sb.querySelector('ul.preview'),
1281 ul = sb.querySelector('ul.dropdown'),
1282 li = ul.querySelectorAll('li'),
1283 fl = findParent(sb, '.cbi-value-field');
1284
1285 li.forEach(function(l) { l.removeAttribute('tabindex'); });
1286 sb.lastElementChild.removeAttribute('tabindex');
1287
1288 sb.removeChild(pv);
1289 sb.removeAttribute('open');
1290 sb.style.width = sb.style.height = '';
1291
1292 ul.classList.remove('dropdown');
1293 ul.style.top = ul.style.bottom = ul.style.maxHeight = '';
1294
1295 if (fl)
1296 fl.classList.remove('cbi-dropdown-open');
1297
1298 if (!no_focus)
1299 this.setFocus(sb, sb);
1300
1301 this.saveValues(sb, ul);
1302 },
1303
1304 /** @private */
1305 toggleItem: function(sb, li, force_state) {
1306 if (li.hasAttribute('unselectable'))
1307 return;
1308
1309 if (this.options.multiple) {
1310 var cbox = li.querySelector('input[type="checkbox"]'),
1311 items = li.parentNode.querySelectorAll('li'),
1312 label = sb.querySelector('ul.preview'),
1313 sel = li.parentNode.querySelectorAll('[selected]').length,
1314 more = sb.querySelector('.more'),
1315 ndisplay = this.options.display_items,
1316 n = 0;
1317
1318 if (li.hasAttribute('selected')) {
1319 if (force_state !== true) {
1320 if (sel > 1 || this.options.optional) {
1321 li.removeAttribute('selected');
1322 cbox.checked = cbox.disabled = false;
1323 sel--;
1324 }
1325 else {
1326 cbox.disabled = true;
1327 }
1328 }
1329 }
1330 else {
1331 if (force_state !== false) {
1332 li.setAttribute('selected', '');
1333 cbox.checked = true;
1334 cbox.disabled = false;
1335 sel++;
1336 }
1337 }
1338
1339 while (label && label.firstElementChild)
1340 label.removeChild(label.firstElementChild);
1341
1342 for (var i = 0; i < items.length; i++) {
1343 items[i].removeAttribute('display');
1344 if (items[i].hasAttribute('selected')) {
1345 if (ndisplay-- > 0) {
1346 items[i].setAttribute('display', n++);
1347 if (label)
1348 label.appendChild(items[i].cloneNode(true));
1349 }
1350 var c = items[i].querySelector('input[type="checkbox"]');
1351 if (c)
1352 c.disabled = (sel == 1 && !this.options.optional);
1353 }
1354 }
1355
1356 if (ndisplay < 0)
1357 sb.setAttribute('more', '');
1358 else
1359 sb.removeAttribute('more');
1360
1361 if (ndisplay === this.options.display_items)
1362 sb.setAttribute('empty', '');
1363 else
1364 sb.removeAttribute('empty');
1365
1366 dom.content(more, (ndisplay === this.options.display_items)
1367 ? (this.options.select_placeholder || this.options.placeholder) : '···');
1368 }
1369 else {
1370 var sel = li.parentNode.querySelector('[selected]');
1371 if (sel) {
1372 sel.removeAttribute('display');
1373 sel.removeAttribute('selected');
1374 }
1375
1376 li.setAttribute('display', 0);
1377 li.setAttribute('selected', '');
1378
1379 this.closeDropdown(sb, true);
1380 }
1381
1382 this.saveValues(sb, li.parentNode);
1383 },
1384
1385 /** @private */
1386 transformItem: function(sb, li) {
1387 var cbox = E('form', {}, E('input', { type: 'checkbox', tabindex: -1, onclick: 'event.preventDefault()' })),
1388 label = E('label');
1389
1390 while (li.firstChild)
1391 label.appendChild(li.firstChild);
1392
1393 li.appendChild(cbox);
1394 li.appendChild(label);
1395 },
1396
1397 /** @private */
1398 saveValues: function(sb, ul) {
1399 var sel = ul.querySelectorAll('li[selected]'),
1400 div = sb.lastElementChild,
1401 name = this.options.name,
1402 strval = '',
1403 values = [];
1404
1405 while (div.lastElementChild)
1406 div.removeChild(div.lastElementChild);
1407
1408 sel.forEach(function (s) {
1409 if (s.hasAttribute('placeholder'))
1410 return;
1411
1412 var v = {
1413 text: s.innerText,
1414 value: s.hasAttribute('data-value') ? s.getAttribute('data-value') : s.innerText,
1415 element: s
1416 };
1417
1418 div.appendChild(E('input', {
1419 type: 'hidden',
1420 name: name,
1421 value: v.value
1422 }));
1423
1424 values.push(v);
1425
1426 strval += strval.length ? ' ' + v.value : v.value;
1427 });
1428
1429 var detail = {
1430 instance: this,
1431 element: sb
1432 };
1433
1434 if (this.options.multiple)
1435 detail.values = values;
1436 else
1437 detail.value = values.length ? values[0] : null;
1438
1439 sb.value = strval;
1440
1441 sb.dispatchEvent(new CustomEvent('cbi-dropdown-change', {
1442 bubbles: true,
1443 detail: detail
1444 }));
1445 },
1446
1447 /** @private */
1448 setValues: function(sb, values) {
1449 var ul = sb.querySelector('ul');
1450
1451 if (this.options.create) {
1452 for (var value in values) {
1453 this.createItems(sb, value);
1454
1455 if (!this.options.multiple)
1456 break;
1457 }
1458 }
1459
1460 if (this.options.multiple) {
1461 var lis = ul.querySelectorAll('li[data-value]');
1462 for (var i = 0; i < lis.length; i++) {
1463 var value = lis[i].getAttribute('data-value');
1464 if (values === null || !(value in values))
1465 this.toggleItem(sb, lis[i], false);
1466 else
1467 this.toggleItem(sb, lis[i], true);
1468 }
1469 }
1470 else {
1471 var ph = ul.querySelector('li[placeholder]');
1472 if (ph)
1473 this.toggleItem(sb, ph);
1474
1475 var lis = ul.querySelectorAll('li[data-value]');
1476 for (var i = 0; i < lis.length; i++) {
1477 var value = lis[i].getAttribute('data-value');
1478 if (values !== null && (value in values))
1479 this.toggleItem(sb, lis[i]);
1480 }
1481 }
1482 },
1483
1484 /** @private */
1485 setFocus: function(sb, elem, scroll) {
1486 if (sb && sb.hasAttribute && sb.hasAttribute('locked-in'))
1487 return;
1488
1489 if (sb.target && findParent(sb.target, 'ul.dropdown'))
1490 return;
1491
1492 document.querySelectorAll('.focus').forEach(function(e) {
1493 if (!matchesElem(e, 'input')) {
1494 e.classList.remove('focus');
1495 e.blur();
1496 }
1497 });
1498
1499 if (elem) {
1500 elem.focus();
1501 elem.classList.add('focus');
1502
1503 if (scroll)
1504 elem.parentNode.scrollTop = elem.offsetTop - elem.parentNode.offsetTop;
1505 }
1506 },
1507
1508 /** @private */
1509 createChoiceElement: function(sb, value, label) {
1510 var tpl = sb.querySelector(this.options.create_template),
1511 markup = null;
1512
1513 if (tpl)
1514 markup = (tpl.textContent || tpl.innerHTML || tpl.firstChild.data).replace(/^<!--|-->$/, '').trim();
1515 else
1516 markup = '<li data-value="{{value}}"><span data-label-placeholder="true" /></li>';
1517
1518 var new_item = E(markup.replace(/{{value}}/g, '%h'.format(value))),
1519 placeholder = new_item.querySelector('[data-label-placeholder]');
1520
1521 if (placeholder) {
1522 var content = E('span', {}, label || this.choices[value] || [ value ]);
1523
1524 while (content.firstChild)
1525 placeholder.parentNode.insertBefore(content.firstChild, placeholder);
1526
1527 placeholder.parentNode.removeChild(placeholder);
1528 }
1529
1530 if (this.options.multiple)
1531 this.transformItem(sb, new_item);
1532
1533 return new_item;
1534 },
1535
1536 /** @private */
1537 createItems: function(sb, value) {
1538 var sbox = this,
1539 val = (value || '').trim(),
1540 ul = sb.querySelector('ul');
1541
1542 if (!sbox.options.multiple)
1543 val = val.length ? [ val ] : [];
1544 else
1545 val = val.length ? val.split(/\s+/) : [];
1546
1547 val.forEach(function(item) {
1548 var new_item = null;
1549
1550 ul.childNodes.forEach(function(li) {
1551 if (li.getAttribute && li.getAttribute('data-value') === item)
1552 new_item = li;
1553 });
1554
1555 if (!new_item) {
1556 new_item = sbox.createChoiceElement(sb, item);
1557
1558 if (!sbox.options.multiple) {
1559 var old = ul.querySelector('li[created]');
1560 if (old)
1561 ul.removeChild(old);
1562
1563 new_item.setAttribute('created', '');
1564 }
1565
1566 new_item = ul.insertBefore(new_item, ul.lastElementChild);
1567 }
1568
1569 sbox.toggleItem(sb, new_item, true);
1570 sbox.setFocus(sb, new_item, true);
1571 });
1572 },
1573
1574 /**
1575 * Remove all existing choices from the dropdown menu.
1576 *
1577 * This function removes all preexisting dropdown choices from the widget,
1578 * keeping only choices currently being selected unless `reset_values` is
1579 * given, in which case all choices and deselected and removed.
1580 *
1581 * @instance
1582 * @memberof LuCI.ui.Dropdown
1583 * @param {boolean} [reset_value=false]
1584 * If set to `true`, deselect and remove selected choices as well instead
1585 * of keeping them.
1586 */
1587 clearChoices: function(reset_value) {
1588 var ul = this.node.querySelector('ul'),
1589 lis = ul ? ul.querySelectorAll('li[data-value]') : [],
1590 len = lis.length - (this.options.create ? 1 : 0),
1591 val = reset_value ? null : this.getValue();
1592
1593 for (var i = 0; i < len; i++) {
1594 var lival = lis[i].getAttribute('data-value');
1595 if (val == null ||
1596 (!this.options.multiple && val != lival) ||
1597 (this.options.multiple && val.indexOf(lival) == -1))
1598 ul.removeChild(lis[i]);
1599 }
1600
1601 if (reset_value)
1602 this.setValues(this.node, {});
1603 },
1604
1605 /**
1606 * Add new choices to the dropdown menu.
1607 *
1608 * This function adds further choices to an existing dropdown menu,
1609 * ignoring choice values which are already present.
1610 *
1611 * @instance
1612 * @memberof LuCI.ui.Dropdown
1613 * @param {string[]} values
1614 * The choice values to add to the dropdown widget.
1615 *
1616 * @param {Object<string, *>} labels
1617 * The choice label values to use when adding dropdown choices. If no
1618 * label is found for a particular choice value, the value itself is used
1619 * as label text. Choice labels may be any valid value accepted by
1620 * {@link LuCI.dom#content}.
1621 */
1622 addChoices: function(values, labels) {
1623 var sb = this.node,
1624 ul = sb.querySelector('ul'),
1625 lis = ul ? ul.querySelectorAll('li[data-value]') : [];
1626
1627 if (!Array.isArray(values))
1628 values = L.toArray(values);
1629
1630 if (!L.isObject(labels))
1631 labels = {};
1632
1633 for (var i = 0; i < values.length; i++) {
1634 var found = false;
1635
1636 for (var j = 0; j < lis.length; j++) {
1637 if (lis[j].getAttribute('data-value') === values[i]) {
1638 found = true;
1639 break;
1640 }
1641 }
1642
1643 if (found)
1644 continue;
1645
1646 ul.insertBefore(
1647 this.createChoiceElement(sb, values[i], labels[values[i]]),
1648 ul.lastElementChild);
1649 }
1650 },
1651
1652 /**
1653 * Close all open dropdown widgets in the current document.
1654 */
1655 closeAllDropdowns: function() {
1656 document.querySelectorAll('.cbi-dropdown[open]').forEach(function(s) {
1657 s.dispatchEvent(new CustomEvent('cbi-dropdown-close', {}));
1658 });
1659 },
1660
1661 /** @private */
1662 handleClick: function(ev) {
1663 var sb = ev.currentTarget;
1664
1665 if (!sb.hasAttribute('open')) {
1666 if (!matchesElem(ev.target, 'input'))
1667 this.openDropdown(sb);
1668 }
1669 else {
1670 var li = findParent(ev.target, 'li');
1671 if (li && li.parentNode.classList.contains('dropdown'))
1672 this.toggleItem(sb, li);
1673 else if (li && li.parentNode.classList.contains('preview'))
1674 this.closeDropdown(sb);
1675 else if (matchesElem(ev.target, 'span.open, span.more'))
1676 this.closeDropdown(sb);
1677 }
1678
1679 ev.preventDefault();
1680 ev.stopPropagation();
1681 },
1682
1683 /** @private */
1684 handleKeydown: function(ev) {
1685 var sb = ev.currentTarget;
1686
1687 if (matchesElem(ev.target, 'input'))
1688 return;
1689
1690 if (!sb.hasAttribute('open')) {
1691 switch (ev.keyCode) {
1692 case 37:
1693 case 38:
1694 case 39:
1695 case 40:
1696 this.openDropdown(sb);
1697 ev.preventDefault();
1698 }
1699 }
1700 else {
1701 var active = findParent(document.activeElement, 'li');
1702
1703 switch (ev.keyCode) {
1704 case 27:
1705 this.closeDropdown(sb);
1706 break;
1707
1708 case 13:
1709 if (active) {
1710 if (!active.hasAttribute('selected'))
1711 this.toggleItem(sb, active);
1712 this.closeDropdown(sb);
1713 ev.preventDefault();
1714 }
1715 break;
1716
1717 case 32:
1718 if (active) {
1719 this.toggleItem(sb, active);
1720 ev.preventDefault();
1721 }
1722 break;
1723
1724 case 38:
1725 if (active && active.previousElementSibling) {
1726 this.setFocus(sb, active.previousElementSibling);
1727 ev.preventDefault();
1728 }
1729 break;
1730
1731 case 40:
1732 if (active && active.nextElementSibling) {
1733 this.setFocus(sb, active.nextElementSibling);
1734 ev.preventDefault();
1735 }
1736 break;
1737 }
1738 }
1739 },
1740
1741 /** @private */
1742 handleDropdownClose: function(ev) {
1743 var sb = ev.currentTarget;
1744
1745 this.closeDropdown(sb, true);
1746 },
1747
1748 /** @private */
1749 handleDropdownSelect: function(ev) {
1750 var sb = ev.currentTarget,
1751 li = findParent(ev.target, 'li');
1752
1753 if (!li)
1754 return;
1755
1756 this.toggleItem(sb, li);
1757 this.closeDropdown(sb, true);
1758 },
1759
1760 /** @private */
1761 handleMouseover: function(ev) {
1762 var sb = ev.currentTarget;
1763
1764 if (!sb.hasAttribute('open'))
1765 return;
1766
1767 var li = findParent(ev.target, 'li');
1768
1769 if (li && li.parentNode.classList.contains('dropdown'))
1770 this.setFocus(sb, li);
1771 },
1772
1773 /** @private */
1774 handleFocus: function(ev) {
1775 var sb = ev.currentTarget;
1776
1777 document.querySelectorAll('.cbi-dropdown[open]').forEach(function(s) {
1778 if (s !== sb || sb.hasAttribute('open'))
1779 s.dispatchEvent(new CustomEvent('cbi-dropdown-close', {}));
1780 });
1781 },
1782
1783 /** @private */
1784 handleCanaryFocus: function(ev) {
1785 this.closeDropdown(ev.currentTarget.parentNode);
1786 },
1787
1788 /** @private */
1789 handleCreateKeydown: function(ev) {
1790 var input = ev.currentTarget,
1791 sb = findParent(input, '.cbi-dropdown');
1792
1793 switch (ev.keyCode) {
1794 case 13:
1795 ev.preventDefault();
1796
1797 if (input.classList.contains('cbi-input-invalid'))
1798 return;
1799
1800 this.createItems(sb, input.value);
1801 input.value = '';
1802 input.blur();
1803 break;
1804 }
1805 },
1806
1807 /** @private */
1808 handleCreateFocus: function(ev) {
1809 var input = ev.currentTarget,
1810 cbox = findParent(input, 'li').querySelector('input[type="checkbox"]'),
1811 sb = findParent(input, '.cbi-dropdown');
1812
1813 if (cbox)
1814 cbox.checked = true;
1815
1816 sb.setAttribute('locked-in', '');
1817 },
1818
1819 /** @private */
1820 handleCreateBlur: function(ev) {
1821 var input = ev.currentTarget,
1822 cbox = findParent(input, 'li').querySelector('input[type="checkbox"]'),
1823 sb = findParent(input, '.cbi-dropdown');
1824
1825 if (cbox)
1826 cbox.checked = false;
1827
1828 sb.removeAttribute('locked-in');
1829 },
1830
1831 /** @private */
1832 handleCreateClick: function(ev) {
1833 ev.currentTarget.querySelector(this.options.create_query).focus();
1834 },
1835
1836 /** @override */
1837 setValue: function(values) {
1838 if (this.options.multiple) {
1839 if (!Array.isArray(values))
1840 values = (values != null && values != '') ? [ values ] : [];
1841
1842 var v = {};
1843
1844 for (var i = 0; i < values.length; i++)
1845 v[values[i]] = true;
1846
1847 this.setValues(this.node, v);
1848 }
1849 else {
1850 var v = {};
1851
1852 if (values != null) {
1853 if (Array.isArray(values))
1854 v[values[0]] = true;
1855 else
1856 v[values] = true;
1857 }
1858
1859 this.setValues(this.node, v);
1860 }
1861 },
1862
1863 /** @override */
1864 getValue: function() {
1865 var div = this.node.lastElementChild,
1866 h = div.querySelectorAll('input[type="hidden"]'),
1867 v = [];
1868
1869 for (var i = 0; i < h.length; i++)
1870 v.push(h[i].value);
1871
1872 return this.options.multiple ? v : v[0];
1873 }
1874 });
1875
1876 /**
1877 * Instantiate a rich dropdown choice widget allowing custom values.
1878 *
1879 * @constructor Combobox
1880 * @memberof LuCI.ui
1881 * @augments LuCI.ui.Dropdown
1882 *
1883 * @classdesc
1884 *
1885 * The `Combobox` class implements a rich, stylable dropdown menu which allows
1886 * to enter custom values. Historically, comboboxes used to be a dedicated
1887 * widget type in LuCI but nowadays they are direct aliases of dropdown widgets
1888 * with a set of enforced default properties for easier instantiation.
1889 *
1890 * UI widget instances are usually not supposed to be created by view code
1891 * directly, instead they're implicitely created by `LuCI.form` when
1892 * instantiating CBI forms.
1893 *
1894 * This class is automatically instantiated as part of `LuCI.ui`. To use it
1895 * in views, use `'require ui'` and refer to `ui.Combobox`. To import it in
1896 * external JavaScript, use `L.require("ui").then(...)` and access the
1897 * `Combobox` property of the class instance value.
1898 *
1899 * @param {string|string[]} [value=null]
1900 * The initial input value(s).
1901 *
1902 * @param {Object<string, *>} choices
1903 * Object containing the selectable choices of the widget. The object keys
1904 * serve as values for the different choices while the values are used as
1905 * choice labels.
1906 *
1907 * @param {LuCI.ui.Combobox.InitOptions} [options]
1908 * Object describing the widget specific options to initialize the dropdown.
1909 */
1910 var UICombobox = UIDropdown.extend(/** @lends LuCI.ui.Combobox.prototype */ {
1911 /**
1912 * Comboboxes support the same properties as
1913 * [Dropdown.InitOptions]{@link LuCI.ui.Dropdown.InitOptions} but enforce
1914 * specific values for the following properties:
1915 *
1916 * @typedef {LuCI.ui.Dropdown.InitOptions} InitOptions
1917 * @memberof LuCI.ui.Combobox
1918 *
1919 * @property {boolean} multiple=false
1920 * Since Comboboxes never allow selecting multiple values, this property
1921 * is forcibly set to `false`.
1922 *
1923 * @property {boolean} create=true
1924 * Since Comboboxes always allow custom choice values, this property is
1925 * forcibly set to `true`.
1926 *
1927 * @property {boolean} optional=true
1928 * Since Comboboxes are always optional, this property is forcibly set to
1929 * `true`.
1930 */
1931 __init__: function(value, choices, options) {
1932 this.super('__init__', [ value, choices, Object.assign({
1933 select_placeholder: _('-- Please choose --'),
1934 custom_placeholder: _('-- custom --'),
1935 dropdown_items: -1,
1936 sort: true
1937 }, options, {
1938 multiple: false,
1939 create: true,
1940 optional: true
1941 }) ]);
1942 }
1943 });
1944
1945 /**
1946 * Instantiate a combo button widget offering multiple action choices.
1947 *
1948 * @constructor ComboButton
1949 * @memberof LuCI.ui
1950 * @augments LuCI.ui.Dropdown
1951 *
1952 * @classdesc
1953 *
1954 * The `ComboButton` class implements a button element which can be expanded
1955 * into a dropdown to chose from a set of different action choices.
1956 *
1957 * UI widget instances are usually not supposed to be created by view code
1958 * directly, instead they're implicitely created by `LuCI.form` when
1959 * instantiating CBI forms.
1960 *
1961 * This class is automatically instantiated as part of `LuCI.ui`. To use it
1962 * in views, use `'require ui'` and refer to `ui.ComboButton`. To import it in
1963 * external JavaScript, use `L.require("ui").then(...)` and access the
1964 * `ComboButton` property of the class instance value.
1965 *
1966 * @param {string|string[]} [value=null]
1967 * The initial input value(s).
1968 *
1969 * @param {Object<string, *>} choices
1970 * Object containing the selectable choices of the widget. The object keys
1971 * serve as values for the different choices while the values are used as
1972 * choice labels.
1973 *
1974 * @param {LuCI.ui.ComboButton.InitOptions} [options]
1975 * Object describing the widget specific options to initialize the button.
1976 */
1977 var UIComboButton = UIDropdown.extend(/** @lends LuCI.ui.ComboButton.prototype */ {
1978 /**
1979 * ComboButtons support the same properties as
1980 * [Dropdown.InitOptions]{@link LuCI.ui.Dropdown.InitOptions} but enforce
1981 * specific values for some properties and add aditional button specific
1982 * properties.
1983 *
1984 * @typedef {LuCI.ui.Dropdown.InitOptions} InitOptions
1985 * @memberof LuCI.ui.ComboButton
1986 *
1987 * @property {boolean} multiple=false
1988 * Since ComboButtons never allow selecting multiple actions, this property
1989 * is forcibly set to `false`.
1990 *
1991 * @property {boolean} create=false
1992 * Since ComboButtons never allow creating custom choices, this property
1993 * is forcibly set to `false`.
1994 *
1995 * @property {boolean} optional=false
1996 * Since ComboButtons must always select one action, this property is
1997 * forcibly set to `false`.
1998 *
1999 * @property {Object<string, string>} [classes]
2000 * Specifies a mapping of choice values to CSS class names. If an action
2001 * choice is selected by the user and if a corresponding entry exists in
2002 * the `classes` object, the class names corresponding to the selected
2003 * value are set on the button element.
2004 *
2005 * This is useful to apply different button styles, such as colors, to the
2006 * combined button depending on the selected action.
2007 *
2008 * @property {function} [click]
2009 * Specifies a handler function to invoke when the user clicks the button.
2010 * This function will be called with the button DOM node as `this` context
2011 * and receive the DOM click event as first as well as the selected action
2012 * choice value as second argument.
2013 */
2014 __init__: function(value, choices, options) {
2015 this.super('__init__', [ value, choices, Object.assign({
2016 sort: true
2017 }, options, {
2018 multiple: false,
2019 create: false,
2020 optional: false
2021 }) ]);
2022 },
2023
2024 /** @override */
2025 render: function(/* ... */) {
2026 var node = UIDropdown.prototype.render.apply(this, arguments),
2027 val = this.getValue();
2028
2029 if (L.isObject(this.options.classes) && this.options.classes.hasOwnProperty(val))
2030 node.setAttribute('class', 'cbi-dropdown ' + this.options.classes[val]);
2031
2032 return node;
2033 },
2034
2035 /** @private */
2036 handleClick: function(ev) {
2037 var sb = ev.currentTarget,
2038 t = ev.target;
2039
2040 if (sb.hasAttribute('open') || dom.matches(t, '.cbi-dropdown > span.open'))
2041 return UIDropdown.prototype.handleClick.apply(this, arguments);
2042
2043 if (this.options.click)
2044 return this.options.click.call(sb, ev, this.getValue());
2045 },
2046
2047 /** @private */
2048 toggleItem: function(sb /*, ... */) {
2049 var rv = UIDropdown.prototype.toggleItem.apply(this, arguments),
2050 val = this.getValue();
2051
2052 if (L.isObject(this.options.classes) && this.options.classes.hasOwnProperty(val))
2053 sb.setAttribute('class', 'cbi-dropdown ' + this.options.classes[val]);
2054 else
2055 sb.setAttribute('class', 'cbi-dropdown');
2056
2057 return rv;
2058 }
2059 });
2060
2061 /**
2062 * Instantiate a dynamic list widget.
2063 *
2064 * @constructor DynamicList
2065 * @memberof LuCI.ui
2066 * @augments LuCI.ui.AbstractElement
2067 *
2068 * @classdesc
2069 *
2070 * The `DynamicList` class implements a widget which allows the user to specify
2071 * an arbitrary amount of input values, either from free formed text input or
2072 * from a set of predefined choices.
2073 *
2074 * UI widget instances are usually not supposed to be created by view code
2075 * directly, instead they're implicitely created by `LuCI.form` when
2076 * instantiating CBI forms.
2077 *
2078 * This class is automatically instantiated as part of `LuCI.ui`. To use it
2079 * in views, use `'require ui'` and refer to `ui.DynamicList`. To import it in
2080 * external JavaScript, use `L.require("ui").then(...)` and access the
2081 * `DynamicList` property of the class instance value.
2082 *
2083 * @param {string|string[]} [value=null]
2084 * The initial input value(s).
2085 *
2086 * @param {Object<string, *>} [choices]
2087 * Object containing the selectable choices of the widget. The object keys
2088 * serve as values for the different choices while the values are used as
2089 * choice labels. If omitted, no default choices are presented to the user,
2090 * instead a plain text input field is rendered allowing the user to add
2091 * arbitrary values to the dynamic list.
2092 *
2093 * @param {LuCI.ui.DynamicList.InitOptions} [options]
2094 * Object describing the widget specific options to initialize the dynamic list.
2095 */
2096 var UIDynamicList = UIElement.extend(/** @lends LuCI.ui.DynamicList.prototype */ {
2097 /**
2098 * In case choices are passed to the dynamic list contructor, the widget
2099 * supports the same properties as [Dropdown.InitOptions]{@link LuCI.ui.Dropdown.InitOptions}
2100 * but enforces specific values for some dropdown properties.
2101 *
2102 * @typedef {LuCI.ui.Dropdown.InitOptions} InitOptions
2103 * @memberof LuCI.ui.DynamicList
2104 *
2105 * @property {boolean} multiple=false
2106 * Since dynamic lists never allow selecting multiple choices when adding
2107 * another list item, this property is forcibly set to `false`.
2108 *
2109 * @property {boolean} optional=true
2110 * Since dynamic lists use an embedded dropdown to present a list of
2111 * predefined choice values, the dropdown must be made optional to allow
2112 * it to remain unselected.
2113 */
2114 __init__: function(values, choices, options) {
2115 if (!Array.isArray(values))
2116 values = (values != null && values != '') ? [ values ] : [];
2117
2118 if (typeof(choices) != 'object')
2119 choices = null;
2120
2121 this.values = values;
2122 this.choices = choices;
2123 this.options = Object.assign({}, options, {
2124 multiple: false,
2125 optional: true
2126 });
2127 },
2128
2129 /** @override */
2130 render: function() {
2131 var dl = E('div', {
2132 'id': this.options.id,
2133 'class': 'cbi-dynlist',
2134 'disabled': this.options.disabled ? '' : null
2135 }, E('div', { 'class': 'add-item' }));
2136
2137 if (this.choices) {
2138 if (this.options.placeholder != null)
2139 this.options.select_placeholder = this.options.placeholder;
2140
2141 var cbox = new UICombobox(null, this.choices, this.options);
2142
2143 dl.lastElementChild.appendChild(cbox.render());
2144 }
2145 else {
2146 var inputEl = E('input', {
2147 'id': this.options.id ? 'widget.' + this.options.id : null,
2148 'type': 'text',
2149 'class': 'cbi-input-text',
2150 'placeholder': this.options.placeholder,
2151 'disabled': this.options.disabled ? '' : null
2152 });
2153
2154 dl.lastElementChild.appendChild(inputEl);
2155 dl.lastElementChild.appendChild(E('div', { 'class': 'btn cbi-button cbi-button-add' }, '+'));
2156
2157 if (this.options.datatype || this.options.validate)
2158 UI.prototype.addValidator(inputEl, this.options.datatype || 'string',
2159 true, this.options.validate, 'blur', 'keyup');
2160 }
2161
2162 for (var i = 0; i < this.values.length; i++) {
2163 var label = this.choices ? this.choices[this.values[i]] : null;
2164
2165 if (dom.elem(label))
2166 label = label.cloneNode(true);
2167
2168 this.addItem(dl, this.values[i], label);
2169 }
2170
2171 return this.bind(dl);
2172 },
2173
2174 /** @private */
2175 bind: function(dl) {
2176 dl.addEventListener('click', L.bind(this.handleClick, this));
2177 dl.addEventListener('keydown', L.bind(this.handleKeydown, this));
2178 dl.addEventListener('cbi-dropdown-change', L.bind(this.handleDropdownChange, this));
2179
2180 this.node = dl;
2181
2182 this.setUpdateEvents(dl, 'cbi-dynlist-change');
2183 this.setChangeEvents(dl, 'cbi-dynlist-change');
2184
2185 dom.bindClassInstance(dl, this);
2186
2187 return dl;
2188 },
2189
2190 /** @private */
2191 addItem: function(dl, value, text, flash) {
2192 var exists = false,
2193 new_item = E('div', { 'class': flash ? 'item flash' : 'item', 'tabindex': 0 }, [
2194 E('span', {}, [ text || value ]),
2195 E('input', {
2196 'type': 'hidden',
2197 'name': this.options.name,
2198 'value': value })]);
2199
2200 dl.querySelectorAll('.item').forEach(function(item) {
2201 if (exists)
2202 return;
2203
2204 var hidden = item.querySelector('input[type="hidden"]');
2205
2206 if (hidden && hidden.parentNode !== item)
2207 hidden = null;
2208
2209 if (hidden && hidden.value === value)
2210 exists = true;
2211 });
2212
2213 if (!exists) {
2214 var ai = dl.querySelector('.add-item');
2215 ai.parentNode.insertBefore(new_item, ai);
2216 }
2217
2218 dl.dispatchEvent(new CustomEvent('cbi-dynlist-change', {
2219 bubbles: true,
2220 detail: {
2221 instance: this,
2222 element: dl,
2223 value: value,
2224 add: true
2225 }
2226 }));
2227 },
2228
2229 /** @private */
2230 removeItem: function(dl, item) {
2231 var value = item.querySelector('input[type="hidden"]').value;
2232 var sb = dl.querySelector('.cbi-dropdown');
2233 if (sb)
2234 sb.querySelectorAll('ul > li').forEach(function(li) {
2235 if (li.getAttribute('data-value') === value) {
2236 if (li.hasAttribute('dynlistcustom'))
2237 li.parentNode.removeChild(li);
2238 else
2239 li.removeAttribute('unselectable');
2240 }
2241 });
2242
2243 item.parentNode.removeChild(item);
2244
2245 dl.dispatchEvent(new CustomEvent('cbi-dynlist-change', {
2246 bubbles: true,
2247 detail: {
2248 instance: this,
2249 element: dl,
2250 value: value,
2251 remove: true
2252 }
2253 }));
2254 },
2255
2256 /** @private */
2257 handleClick: function(ev) {
2258 var dl = ev.currentTarget,
2259 item = findParent(ev.target, '.item');
2260
2261 if (this.options.disabled)
2262 return;
2263
2264 if (item) {
2265 this.removeItem(dl, item);
2266 }
2267 else if (matchesElem(ev.target, '.cbi-button-add')) {
2268 var input = ev.target.previousElementSibling;
2269 if (input.value.length && !input.classList.contains('cbi-input-invalid')) {
2270 this.addItem(dl, input.value, null, true);
2271 input.value = '';
2272 }
2273 }
2274 },
2275
2276 /** @private */
2277 handleDropdownChange: function(ev) {
2278 var dl = ev.currentTarget,
2279 sbIn = ev.detail.instance,
2280 sbEl = ev.detail.element,
2281 sbVal = ev.detail.value;
2282
2283 if (sbVal === null)
2284 return;
2285
2286 sbIn.setValues(sbEl, null);
2287 sbVal.element.setAttribute('unselectable', '');
2288
2289 if (sbVal.element.hasAttribute('created')) {
2290 sbVal.element.removeAttribute('created');
2291 sbVal.element.setAttribute('dynlistcustom', '');
2292 }
2293
2294 var label = sbVal.text;
2295
2296 if (sbVal.element) {
2297 label = E([]);
2298
2299 for (var i = 0; i < sbVal.element.childNodes.length; i++)
2300 label.appendChild(sbVal.element.childNodes[i].cloneNode(true));
2301 }
2302
2303 this.addItem(dl, sbVal.value, label, true);
2304 },
2305
2306 /** @private */
2307 handleKeydown: function(ev) {
2308 var dl = ev.currentTarget,
2309 item = findParent(ev.target, '.item');
2310
2311 if (item) {
2312 switch (ev.keyCode) {
2313 case 8: /* backspace */
2314 if (item.previousElementSibling)
2315 item.previousElementSibling.focus();
2316
2317 this.removeItem(dl, item);
2318 break;
2319
2320 case 46: /* delete */
2321 if (item.nextElementSibling) {
2322 if (item.nextElementSibling.classList.contains('item'))
2323 item.nextElementSibling.focus();
2324 else
2325 item.nextElementSibling.firstElementChild.focus();
2326 }
2327
2328 this.removeItem(dl, item);
2329 break;
2330 }
2331 }
2332 else if (matchesElem(ev.target, '.cbi-input-text')) {
2333 switch (ev.keyCode) {
2334 case 13: /* enter */
2335 if (ev.target.value.length && !ev.target.classList.contains('cbi-input-invalid')) {
2336 this.addItem(dl, ev.target.value, null, true);
2337 ev.target.value = '';
2338 ev.target.blur();
2339 ev.target.focus();
2340 }
2341
2342 ev.preventDefault();
2343 break;
2344 }
2345 }
2346 },
2347
2348 /** @override */
2349 getValue: function() {
2350 var items = this.node.querySelectorAll('.item > input[type="hidden"]'),
2351 input = this.node.querySelector('.add-item > input[type="text"]'),
2352 v = [];
2353
2354 for (var i = 0; i < items.length; i++)
2355 v.push(items[i].value);
2356
2357 if (input && input.value != null && input.value.match(/\S/) &&
2358 input.classList.contains('cbi-input-invalid') == false &&
2359 v.filter(function(s) { return s == input.value }).length == 0)
2360 v.push(input.value);
2361
2362 return v;
2363 },
2364
2365 /** @override */
2366 setValue: function(values) {
2367 if (!Array.isArray(values))
2368 values = (values != null && values != '') ? [ values ] : [];
2369
2370 var items = this.node.querySelectorAll('.item');
2371
2372 for (var i = 0; i < items.length; i++)
2373 if (items[i].parentNode === this.node)
2374 this.removeItem(this.node, items[i]);
2375
2376 for (var i = 0; i < values.length; i++)
2377 this.addItem(this.node, values[i],
2378 this.choices ? this.choices[values[i]] : null);
2379 },
2380
2381 /**
2382 * Add new suggested choices to the dynamic list.
2383 *
2384 * This function adds further choices to an existing dynamic list,
2385 * ignoring choice values which are already present.
2386 *
2387 * @instance
2388 * @memberof LuCI.ui.DynamicList
2389 * @param {string[]} values
2390 * The choice values to add to the dynamic lists suggestion dropdown.
2391 *
2392 * @param {Object<string, *>} labels
2393 * The choice label values to use when adding suggested choices. If no
2394 * label is found for a particular choice value, the value itself is used
2395 * as label text. Choice labels may be any valid value accepted by
2396 * {@link LuCI.dom#content}.
2397 */
2398 addChoices: function(values, labels) {
2399 var dl = this.node.lastElementChild.firstElementChild;
2400 dom.callClassMethod(dl, 'addChoices', values, labels);
2401 },
2402
2403 /**
2404 * Remove all existing choices from the dynamic list.
2405 *
2406 * This function removes all preexisting suggested choices from the widget.
2407 *
2408 * @instance
2409 * @memberof LuCI.ui.DynamicList
2410 */
2411 clearChoices: function() {
2412 var dl = this.node.lastElementChild.firstElementChild;
2413 dom.callClassMethod(dl, 'clearChoices');
2414 }
2415 });
2416
2417 /**
2418 * Instantiate a hidden input field widget.
2419 *
2420 * @constructor Hiddenfield
2421 * @memberof LuCI.ui
2422 * @augments LuCI.ui.AbstractElement
2423 *
2424 * @classdesc
2425 *
2426 * The `Hiddenfield` class implements an HTML `<input type="hidden">` field
2427 * which allows to store form data without exposing it to the user.
2428 *
2429 * UI widget instances are usually not supposed to be created by view code
2430 * directly, instead they're implicitely created by `LuCI.form` when
2431 * instantiating CBI forms.
2432 *
2433 * This class is automatically instantiated as part of `LuCI.ui`. To use it
2434 * in views, use `'require ui'` and refer to `ui.Hiddenfield`. To import it in
2435 * external JavaScript, use `L.require("ui").then(...)` and access the
2436 * `Hiddenfield` property of the class instance value.
2437 *
2438 * @param {string|string[]} [value=null]
2439 * The initial input value.
2440 *
2441 * @param {LuCI.ui.AbstractElement.InitOptions} [options]
2442 * Object describing the widget specific options to initialize the hidden input.
2443 */
2444 var UIHiddenfield = UIElement.extend(/** @lends LuCI.ui.Hiddenfield.prototype */ {
2445 __init__: function(value, options) {
2446 this.value = value;
2447 this.options = Object.assign({
2448
2449 }, options);
2450 },
2451
2452 /** @override */
2453 render: function() {
2454 var hiddenEl = E('input', {
2455 'id': this.options.id,
2456 'type': 'hidden',
2457 'value': this.value
2458 });
2459
2460 return this.bind(hiddenEl);
2461 },
2462
2463 /** @private */
2464 bind: function(hiddenEl) {
2465 this.node = hiddenEl;
2466
2467 dom.bindClassInstance(hiddenEl, this);
2468
2469 return hiddenEl;
2470 },
2471
2472 /** @override */
2473 getValue: function() {
2474 return this.node.value;
2475 },
2476
2477 /** @override */
2478 setValue: function(value) {
2479 this.node.value = value;
2480 }
2481 });
2482
2483 /**
2484 * Instantiate a file upload widget.
2485 *
2486 * @constructor FileUpload
2487 * @memberof LuCI.ui
2488 * @augments LuCI.ui.AbstractElement
2489 *
2490 * @classdesc
2491 *
2492 * The `FileUpload` class implements a widget which allows the user to upload,
2493 * browse, select and delete files beneath a predefined remote directory.
2494 *
2495 * UI widget instances are usually not supposed to be created by view code
2496 * directly, instead they're implicitely created by `LuCI.form` when
2497 * instantiating CBI forms.
2498 *
2499 * This class is automatically instantiated as part of `LuCI.ui`. To use it
2500 * in views, use `'require ui'` and refer to `ui.FileUpload`. To import it in
2501 * external JavaScript, use `L.require("ui").then(...)` and access the
2502 * `FileUpload` property of the class instance value.
2503 *
2504 * @param {string|string[]} [value=null]
2505 * The initial input value.
2506 *
2507 * @param {LuCI.ui.DynamicList.InitOptions} [options]
2508 * Object describing the widget specific options to initialize the file
2509 * upload control.
2510 */
2511 var UIFileUpload = UIElement.extend(/** @lends LuCI.ui.FileUpload.prototype */ {
2512 /**
2513 * In addition to the [AbstractElement.InitOptions]{@link LuCI.ui.AbstractElement.InitOptions}
2514 * the following properties are recognized:
2515 *
2516 * @typedef {LuCI.ui.AbstractElement.InitOptions} InitOptions
2517 * @memberof LuCI.ui.FileUpload
2518 *
2519 * @property {boolean} [show_hidden=false]
2520 * Specifies whether hidden files should be displayed when browsing remote
2521 * files. Note that this is not a security feature, hidden files are always
2522 * present in the remote file listings received, this option merely controls
2523 * whether they're displayed or not.
2524 *
2525 * @property {boolean} [enable_upload=true]
2526 * Specifies whether the widget allows the user to upload files. If set to
2527 * `false`, only existing files may be selected. Note that this is not a
2528 * security feature. Whether file upload requests are accepted remotely
2529 * depends on the ACL setup for the current session. This option merely
2530 * controls whether the upload controls are rendered or not.
2531 *
2532 * @property {boolean} [enable_remove=true]
2533 * Specifies whether the widget allows the user to delete remove files.
2534 * If set to `false`, existing files may not be removed. Note that this is
2535 * not a security feature. Whether file delete requests are accepted
2536 * remotely depends on the ACL setup for the current session. This option
2537 * merely controls whether the file remove controls are rendered or not.
2538 *
2539 * @property {string} [root_directory=/etc/luci-uploads]
2540 * Specifies the remote directory the upload and file browsing actions take
2541 * place in. Browsing to directories outside of the root directory is
2542 * prevented by the widget. Note that this is not a security feature.
2543 * Whether remote directories are browseable or not solely depends on the
2544 * ACL setup for the current session.
2545 */
2546 __init__: function(value, options) {
2547 this.value = value;
2548 this.options = Object.assign({
2549 show_hidden: false,
2550 enable_upload: true,
2551 enable_remove: true,
2552 root_directory: '/etc/luci-uploads'
2553 }, options);
2554 },
2555
2556 /** @private */
2557 bind: function(browserEl) {
2558 this.node = browserEl;
2559
2560 this.setUpdateEvents(browserEl, 'cbi-fileupload-select', 'cbi-fileupload-cancel');
2561 this.setChangeEvents(browserEl, 'cbi-fileupload-select', 'cbi-fileupload-cancel');
2562
2563 dom.bindClassInstance(browserEl, this);
2564
2565 return browserEl;
2566 },
2567
2568 /** @override */
2569 render: function() {
2570 return L.resolveDefault(this.value != null ? fs.stat(this.value) : null).then(L.bind(function(stat) {
2571 var label;
2572
2573 if (L.isObject(stat) && stat.type != 'directory')
2574 this.stat = stat;
2575
2576 if (this.stat != null)
2577 label = [ this.iconForType(this.stat.type), ' %s (%1000mB)'.format(this.truncatePath(this.stat.path), this.stat.size) ];
2578 else if (this.value != null)
2579 label = [ this.iconForType('file'), ' %s (%s)'.format(this.truncatePath(this.value), _('File not accessible')) ];
2580 else
2581 label = [ _('Select file…') ];
2582
2583 return this.bind(E('div', { 'id': this.options.id }, [
2584 E('button', {
2585 'class': 'btn',
2586 'click': UI.prototype.createHandlerFn(this, 'handleFileBrowser'),
2587 'disabled': this.options.disabled ? '' : null
2588 }, label),
2589 E('div', {
2590 'class': 'cbi-filebrowser'
2591 }),
2592 E('input', {
2593 'type': 'hidden',
2594 'name': this.options.name,
2595 'value': this.value
2596 })
2597 ]));
2598 }, this));
2599 },
2600
2601 /** @private */
2602 truncatePath: function(path) {
2603 if (path.length > 50)
2604 path = path.substring(0, 25) + '…' + path.substring(path.length - 25);
2605
2606 return path;
2607 },
2608
2609 /** @private */
2610 iconForType: function(type) {
2611 switch (type) {
2612 case 'symlink':
2613 return E('img', {
2614 'src': L.resource('cbi/link.gif'),
2615 'title': _('Symbolic link'),
2616 'class': 'middle'
2617 });
2618
2619 case 'directory':
2620 return E('img', {
2621 'src': L.resource('cbi/folder.gif'),
2622 'title': _('Directory'),
2623 'class': 'middle'
2624 });
2625
2626 default:
2627 return E('img', {
2628 'src': L.resource('cbi/file.gif'),
2629 'title': _('File'),
2630 'class': 'middle'
2631 });
2632 }
2633 },
2634
2635 /** @private */
2636 canonicalizePath: function(path) {
2637 return path.replace(/\/{2,}/, '/')
2638 .replace(/\/\.(\/|$)/g, '/')
2639 .replace(/[^\/]+\/\.\.(\/|$)/g, '/')
2640 .replace(/\/$/, '');
2641 },
2642
2643 /** @private */
2644 splitPath: function(path) {
2645 var croot = this.canonicalizePath(this.options.root_directory || '/'),
2646 cpath = this.canonicalizePath(path || '/');
2647
2648 if (cpath.length <= croot.length)
2649 return [ croot ];
2650
2651 if (cpath.charAt(croot.length) != '/')
2652 return [ croot ];
2653
2654 var parts = cpath.substring(croot.length + 1).split(/\//);
2655
2656 parts.unshift(croot);
2657
2658 return parts;
2659 },
2660
2661 /** @private */
2662 handleUpload: function(path, list, ev) {
2663 var form = ev.target.parentNode,
2664 fileinput = form.querySelector('input[type="file"]'),
2665 nameinput = form.querySelector('input[type="text"]'),
2666 filename = (nameinput.value != null ? nameinput.value : '').trim();
2667
2668 ev.preventDefault();
2669
2670 if (filename == '' || filename.match(/\//) || fileinput.files[0] == null)
2671 return;
2672
2673 var existing = list.filter(function(e) { return e.name == filename })[0];
2674
2675 if (existing != null && existing.type == 'directory')
2676 return alert(_('A directory with the same name already exists.'));
2677 else if (existing != null && !confirm(_('Overwrite existing file "%s" ?').format(filename)))
2678 return;
2679
2680 var data = new FormData();
2681
2682 data.append('sessionid', L.env.sessionid);
2683 data.append('filename', path + '/' + filename);
2684 data.append('filedata', fileinput.files[0]);
2685
2686 return request.post(L.env.cgi_base + '/cgi-upload', data, {
2687 progress: L.bind(function(btn, ev) {
2688 btn.firstChild.data = '%.2f%%'.format((ev.loaded / ev.total) * 100);
2689 }, this, ev.target)
2690 }).then(L.bind(function(path, ev, res) {
2691 var reply = res.json();
2692
2693 if (L.isObject(reply) && reply.failure)
2694 alert(_('Upload request failed: %s').format(reply.message));
2695
2696 return this.handleSelect(path, null, ev);
2697 }, this, path, ev));
2698 },
2699
2700 /** @private */
2701 handleDelete: function(path, fileStat, ev) {
2702 var parent = path.replace(/\/[^\/]+$/, '') || '/',
2703 name = path.replace(/^.+\//, ''),
2704 msg;
2705
2706 ev.preventDefault();
2707
2708 if (fileStat.type == 'directory')
2709 msg = _('Do you really want to recursively delete the directory "%s" ?').format(name);
2710 else
2711 msg = _('Do you really want to delete "%s" ?').format(name);
2712
2713 if (confirm(msg)) {
2714 var button = this.node.firstElementChild,
2715 hidden = this.node.lastElementChild;
2716
2717 if (path == hidden.value) {
2718 dom.content(button, _('Select file…'));
2719 hidden.value = '';
2720 }
2721
2722 return fs.remove(path).then(L.bind(function(parent, ev) {
2723 return this.handleSelect(parent, null, ev);
2724 }, this, parent, ev)).catch(function(err) {
2725 alert(_('Delete request failed: %s').format(err.message));
2726 });
2727 }
2728 },
2729
2730 /** @private */
2731 renderUpload: function(path, list) {
2732 if (!this.options.enable_upload)
2733 return E([]);
2734
2735 return E([
2736 E('a', {
2737 'href': '#',
2738 'class': 'btn cbi-button-positive',
2739 'click': function(ev) {
2740 var uploadForm = ev.target.nextElementSibling,
2741 fileInput = uploadForm.querySelector('input[type="file"]');
2742
2743 ev.target.style.display = 'none';
2744 uploadForm.style.display = '';
2745 fileInput.click();
2746 }
2747 }, _('Upload file…')),
2748 E('div', { 'class': 'upload', 'style': 'display:none' }, [
2749 E('input', {
2750 'type': 'file',
2751 'style': 'display:none',
2752 'change': function(ev) {
2753 var nameinput = ev.target.parentNode.querySelector('input[type="text"]'),
2754 uploadbtn = ev.target.parentNode.querySelector('button.cbi-button-save');
2755
2756 nameinput.value = ev.target.value.replace(/^.+[\/\\]/, '');
2757 uploadbtn.disabled = false;
2758 }
2759 }),
2760 E('button', {
2761 'class': 'btn',
2762 'click': function(ev) {
2763 ev.preventDefault();
2764 ev.target.previousElementSibling.click();
2765 }
2766 }, [ _('Browse…') ]),
2767 E('div', {}, E('input', { 'type': 'text', 'placeholder': _('Filename') })),
2768 E('button', {
2769 'class': 'btn cbi-button-save',
2770 'click': UI.prototype.createHandlerFn(this, 'handleUpload', path, list),
2771 'disabled': true
2772 }, [ _('Upload file') ])
2773 ])
2774 ]);
2775 },
2776
2777 /** @private */
2778 renderListing: function(container, path, list) {
2779 var breadcrumb = E('p'),
2780 rows = E('ul');
2781
2782 list.sort(function(a, b) {
2783 var isDirA = (a.type == 'directory'),
2784 isDirB = (b.type == 'directory');
2785
2786 if (isDirA != isDirB)
2787 return isDirA < isDirB;
2788
2789 return a.name > b.name;
2790 });
2791
2792 for (var i = 0; i < list.length; i++) {
2793 if (!this.options.show_hidden && list[i].name.charAt(0) == '.')
2794 continue;
2795
2796 var entrypath = this.canonicalizePath(path + '/' + list[i].name),
2797 selected = (entrypath == this.node.lastElementChild.value),
2798 mtime = new Date(list[i].mtime * 1000);
2799
2800 rows.appendChild(E('li', [
2801 E('div', { 'class': 'name' }, [
2802 this.iconForType(list[i].type),
2803 ' ',
2804 E('a', {
2805 'href': '#',
2806 'style': selected ? 'font-weight:bold' : null,
2807 'click': UI.prototype.createHandlerFn(this, 'handleSelect',
2808 entrypath, list[i].type != 'directory' ? list[i] : null)
2809 }, '%h'.format(list[i].name))
2810 ]),
2811 E('div', { 'class': 'mtime hide-xs' }, [
2812 ' %04d-%02d-%02d %02d:%02d:%02d '.format(
2813 mtime.getFullYear(),
2814 mtime.getMonth() + 1,
2815 mtime.getDate(),
2816 mtime.getHours(),
2817 mtime.getMinutes(),
2818 mtime.getSeconds())
2819 ]),
2820 E('div', [
2821 selected ? E('button', {
2822 'class': 'btn',
2823 'click': UI.prototype.createHandlerFn(this, 'handleReset')
2824 }, [ _('Deselect') ]) : '',
2825 this.options.enable_remove ? E('button', {
2826 'class': 'btn cbi-button-negative',
2827 'click': UI.prototype.createHandlerFn(this, 'handleDelete', entrypath, list[i])
2828 }, [ _('Delete') ]) : ''
2829 ])
2830 ]));
2831 }
2832
2833 if (!rows.firstElementChild)
2834 rows.appendChild(E('em', _('No entries in this directory')));
2835
2836 var dirs = this.splitPath(path),
2837 cur = '';
2838
2839 for (var i = 0; i < dirs.length; i++) {
2840 cur = cur ? cur + '/' + dirs[i] : dirs[i];
2841 dom.append(breadcrumb, [
2842 i ? ' » ' : '',
2843 E('a', {
2844 'href': '#',
2845 'click': UI.prototype.createHandlerFn(this, 'handleSelect', cur || '/', null)
2846 }, dirs[i] != '' ? '%h'.format(dirs[i]) : E('em', '(root)')),
2847 ]);
2848 }
2849
2850 dom.content(container, [
2851 breadcrumb,
2852 rows,
2853 E('div', { 'class': 'right' }, [
2854 this.renderUpload(path, list),
2855 E('a', {
2856 'href': '#',
2857 'class': 'btn',
2858 'click': UI.prototype.createHandlerFn(this, 'handleCancel')
2859 }, _('Cancel'))
2860 ]),
2861 ]);
2862 },
2863
2864 /** @private */
2865 handleCancel: function(ev) {
2866 var button = this.node.firstElementChild,
2867 browser = button.nextElementSibling;
2868
2869 browser.classList.remove('open');
2870 button.style.display = '';
2871
2872 this.node.dispatchEvent(new CustomEvent('cbi-fileupload-cancel', {}));
2873
2874 ev.preventDefault();
2875 },
2876
2877 /** @private */
2878 handleReset: function(ev) {
2879 var button = this.node.firstElementChild,
2880 hidden = this.node.lastElementChild;
2881
2882 hidden.value = '';
2883 dom.content(button, _('Select file…'));
2884
2885 this.handleCancel(ev);
2886 },
2887
2888 /** @private */
2889 handleSelect: function(path, fileStat, ev) {
2890 var browser = dom.parent(ev.target, '.cbi-filebrowser'),
2891 ul = browser.querySelector('ul');
2892
2893 if (fileStat == null) {
2894 dom.content(ul, E('em', { 'class': 'spinning' }, _('Loading directory contents…')));
2895 L.resolveDefault(fs.list(path), []).then(L.bind(this.renderListing, this, browser, path));
2896 }
2897 else {
2898 var button = this.node.firstElementChild,
2899 hidden = this.node.lastElementChild;
2900
2901 path = this.canonicalizePath(path);
2902
2903 dom.content(button, [
2904 this.iconForType(fileStat.type),
2905 ' %s (%1000mB)'.format(this.truncatePath(path), fileStat.size)
2906 ]);
2907
2908 browser.classList.remove('open');
2909 button.style.display = '';
2910 hidden.value = path;
2911
2912 this.stat = Object.assign({ path: path }, fileStat);
2913 this.node.dispatchEvent(new CustomEvent('cbi-fileupload-select', { detail: this.stat }));
2914 }
2915 },
2916
2917 /** @private */
2918 handleFileBrowser: function(ev) {
2919 var button = ev.target,
2920 browser = button.nextElementSibling,
2921 path = this.stat ? this.stat.path.replace(/\/[^\/]+$/, '') : (this.options.initial_directory || this.options.root_directory);
2922
2923 if (path.indexOf(this.options.root_directory) != 0)
2924 path = this.options.root_directory;
2925
2926 ev.preventDefault();
2927
2928 return L.resolveDefault(fs.list(path), []).then(L.bind(function(button, browser, path, list) {
2929 document.querySelectorAll('.cbi-filebrowser.open').forEach(function(browserEl) {
2930 dom.findClassInstance(browserEl).handleCancel(ev);
2931 });
2932
2933 button.style.display = 'none';
2934 browser.classList.add('open');
2935
2936 return this.renderListing(browser, path, list);
2937 }, this, button, browser, path));
2938 },
2939
2940 /** @override */
2941 getValue: function() {
2942 return this.node.lastElementChild.value;
2943 },
2944
2945 /** @override */
2946 setValue: function(value) {
2947 this.node.lastElementChild.value = value;
2948 }
2949 });
2950
2951
2952 function scrubMenu(node) {
2953 var hasSatisfiedChild = false;
2954
2955 if (L.isObject(node.children)) {
2956 for (var k in node.children) {
2957 var child = scrubMenu(node.children[k]);
2958
2959 if (child.title)
2960 hasSatisfiedChild = hasSatisfiedChild || child.satisfied;
2961 }
2962 }
2963
2964 if (L.isObject(node.action) &&
2965 node.action.type == 'firstchild' &&
2966 hasSatisfiedChild == false)
2967 node.satisfied = false;
2968
2969 return node;
2970 };
2971
2972 /**
2973 * Handle menu.
2974 *
2975 * @constructor menu
2976 * @memberof LuCI.ui
2977 *
2978 * @classdesc
2979 *
2980 * Handles menus.
2981 */
2982 var UIMenu = baseclass.singleton(/** @lends LuCI.ui.menu.prototype */ {
2983 /**
2984 * @typedef {Object} MenuNode
2985 * @memberof LuCI.ui.menu
2986
2987 * @property {string} name - The internal name of the node, as used in the URL
2988 * @property {number} order - The sort index of the menu node
2989 * @property {string} [title] - The title of the menu node, `null` if the node should be hidden
2990 * @property {satisified} boolean - Boolean indicating whether the menu enries dependencies are satisfied
2991 * @property {readonly} [boolean] - Boolean indicating whether the menu entries underlying ACLs are readonly
2992 * @property {LuCI.ui.menu.MenuNode[]} [children] - Array of child menu nodes.
2993 */
2994
2995 /**
2996 * Load and cache current menu tree.
2997 *
2998 * @returns {Promise<LuCI.ui.menu.MenuNode>}
2999 * Returns a promise resolving to the root element of the menu tree.
3000 */
3001 load: function() {
3002 if (this.menu == null)
3003 this.menu = session.getLocalData('menu');
3004
3005 if (!L.isObject(this.menu)) {
3006 this.menu = request.get(L.url('admin/menu')).then(L.bind(function(menu) {
3007 this.menu = scrubMenu(menu.json());
3008 session.setLocalData('menu', this.menu);
3009
3010 return this.menu;
3011 }, this));
3012 }
3013
3014 return Promise.resolve(this.menu);
3015 },
3016
3017 /**
3018 * Flush the internal menu cache to force loading a new structure on the
3019 * next page load.
3020 */
3021 flushCache: function() {
3022 session.setLocalData('menu', null);
3023 },
3024
3025 /**
3026 * @param {LuCI.ui.menu.MenuNode} [node]
3027 * The menu node to retrieve the children for. Defaults to the menu's
3028 * internal root node if omitted.
3029 *
3030 * @returns {LuCI.ui.menu.MenuNode[]}
3031 * Returns an array of child menu nodes.
3032 */
3033 getChildren: function(node) {
3034 var children = [];
3035
3036 if (node == null)
3037 node = this.menu;
3038
3039 for (var k in node.children) {
3040 if (!node.children.hasOwnProperty(k))
3041 continue;
3042
3043 if (!node.children[k].satisfied)
3044 continue;
3045
3046 if (!node.children[k].hasOwnProperty('title'))
3047 continue;
3048
3049 children.push(Object.assign(node.children[k], { name: k }));
3050 }
3051
3052 return children.sort(function(a, b) {
3053 return ((a.order || 1000) - (b.order || 1000));
3054 });
3055 }
3056 });
3057
3058 /**
3059 * @class ui
3060 * @memberof LuCI
3061 * @hideconstructor
3062 * @classdesc
3063 *
3064 * Provides high level UI helper functionality.
3065 * To import the class in views, use `'require ui'`, to import it in
3066 * external JavaScript, use `L.require("ui").then(...)`.
3067 */
3068 var UI = baseclass.extend(/** @lends LuCI.ui.prototype */ {
3069 __init__: function() {
3070 modalDiv = document.body.appendChild(
3071 dom.create('div', { id: 'modal_overlay' },
3072 dom.create('div', { class: 'modal', role: 'dialog', 'aria-modal': true })));
3073
3074 tooltipDiv = document.body.appendChild(
3075 dom.create('div', { class: 'cbi-tooltip' }));
3076
3077 /* setup old aliases */
3078 L.showModal = this.showModal;
3079 L.hideModal = this.hideModal;
3080 L.showTooltip = this.showTooltip;
3081 L.hideTooltip = this.hideTooltip;
3082 L.itemlist = this.itemlist;
3083
3084 document.addEventListener('mouseover', this.showTooltip.bind(this), true);
3085 document.addEventListener('mouseout', this.hideTooltip.bind(this), true);
3086 document.addEventListener('focus', this.showTooltip.bind(this), true);
3087 document.addEventListener('blur', this.hideTooltip.bind(this), true);
3088
3089 document.addEventListener('luci-loaded', this.tabs.init.bind(this.tabs));
3090 document.addEventListener('luci-loaded', this.changes.init.bind(this.changes));
3091 document.addEventListener('uci-loaded', this.changes.init.bind(this.changes));
3092 },
3093
3094 /**
3095 * Display a modal overlay dialog with the specified contents.
3096 *
3097 * The modal overlay dialog covers the current view preventing interaction
3098 * with the underlying view contents. Only one modal dialog instance can
3099 * be opened. Invoking showModal() while a modal dialog is already open will
3100 * replace the open dialog with a new one having the specified contents.
3101 *
3102 * Additional CSS class names may be passed to influence the appearence of
3103 * the dialog. Valid values for the classes depend on the underlying theme.
3104 *
3105 * @see LuCI.dom.content
3106 *
3107 * @param {string} [title]
3108 * The title of the dialog. If `null`, no title element will be rendered.
3109 *
3110 * @param {*} contents
3111 * The contents to add to the modal dialog. This should be a DOM node or
3112 * a document fragment in most cases. The value is passed as-is to the
3113 * `dom.content()` function - refer to its documentation for applicable
3114 * values.
3115 *
3116 * @param {...string} [classes]
3117 * A number of extra CSS class names which are set on the modal dialog
3118 * element.
3119 *
3120 * @returns {Node}
3121 * Returns a DOM Node representing the modal dialog element.
3122 */
3123 showModal: function(title, children /* , ... */) {
3124 var dlg = modalDiv.firstElementChild;
3125
3126 dlg.setAttribute('class', 'modal');
3127
3128 for (var i = 2; i < arguments.length; i++)
3129 dlg.classList.add(arguments[i]);
3130
3131 dom.content(dlg, dom.create('h4', {}, title));
3132 dom.append(dlg, children);
3133
3134 document.body.classList.add('modal-overlay-active');
3135
3136 return dlg;
3137 },
3138
3139 /**
3140 * Close the open modal overlay dialog.
3141 *
3142 * This function will close an open modal dialog and restore the normal view
3143 * behaviour. It has no effect if no modal dialog is currently open.
3144 *
3145 * Note that this function is stand-alone, it does not rely on `this` and
3146 * will not invoke other class functions so it suitable to be used as event
3147 * handler as-is without the need to bind it first.
3148 */
3149 hideModal: function() {
3150 document.body.classList.remove('modal-overlay-active');
3151 },
3152
3153 /** @private */
3154 showTooltip: function(ev) {
3155 var target = findParent(ev.target, '[data-tooltip]');
3156
3157 if (!target)
3158 return;
3159
3160 if (tooltipTimeout !== null) {
3161 window.clearTimeout(tooltipTimeout);
3162 tooltipTimeout = null;
3163 }
3164
3165 var rect = target.getBoundingClientRect(),
3166 x = rect.left + window.pageXOffset,
3167 y = rect.top + rect.height + window.pageYOffset;
3168
3169 tooltipDiv.className = 'cbi-tooltip';
3170 tooltipDiv.innerHTML = '▲ ';
3171 tooltipDiv.firstChild.data += target.getAttribute('data-tooltip');
3172
3173 if (target.hasAttribute('data-tooltip-style'))
3174 tooltipDiv.classList.add(target.getAttribute('data-tooltip-style'));
3175
3176 if ((y + tooltipDiv.offsetHeight) > (window.innerHeight + window.pageYOffset)) {
3177 y -= (tooltipDiv.offsetHeight + target.offsetHeight);
3178 tooltipDiv.firstChild.data = '▼ ' + tooltipDiv.firstChild.data.substr(2);
3179 }
3180
3181 tooltipDiv.style.top = y + 'px';
3182 tooltipDiv.style.left = x + 'px';
3183 tooltipDiv.style.opacity = 1;
3184
3185 tooltipDiv.dispatchEvent(new CustomEvent('tooltip-open', {
3186 bubbles: true,
3187 detail: { target: target }
3188 }));
3189 },
3190
3191 /** @private */
3192 hideTooltip: function(ev) {
3193 if (ev.target === tooltipDiv || ev.relatedTarget === tooltipDiv ||
3194 tooltipDiv.contains(ev.target) || tooltipDiv.contains(ev.relatedTarget))
3195 return;
3196
3197 if (tooltipTimeout !== null) {
3198 window.clearTimeout(tooltipTimeout);
3199 tooltipTimeout = null;
3200 }
3201
3202 tooltipDiv.style.opacity = 0;
3203 tooltipTimeout = window.setTimeout(function() { tooltipDiv.removeAttribute('style'); }, 250);
3204
3205 tooltipDiv.dispatchEvent(new CustomEvent('tooltip-close', { bubbles: true }));
3206 },
3207
3208 /**
3209 * Add a notification banner at the top of the current view.
3210 *
3211 * A notification banner is an alert message usually displayed at the
3212 * top of the current view, spanning the entire availibe width.
3213 * Notification banners will stay in place until dismissed by the user.
3214 * Multiple banners may be shown at the same time.
3215 *
3216 * Additional CSS class names may be passed to influence the appearence of
3217 * the banner. Valid values for the classes depend on the underlying theme.
3218 *
3219 * @see LuCI.dom.content
3220 *
3221 * @param {string} [title]
3222 * The title of the notification banner. If `null`, no title element
3223 * will be rendered.
3224 *
3225 * @param {*} contents
3226 * The contents to add to the notification banner. This should be a DOM
3227 * node or a document fragment in most cases. The value is passed as-is
3228 * to the `dom.content()` function - refer to its documentation for
3229 * applicable values.
3230 *
3231 * @param {...string} [classes]
3232 * A number of extra CSS class names which are set on the notification
3233 * banner element.
3234 *
3235 * @returns {Node}
3236 * Returns a DOM Node representing the notification banner element.
3237 */
3238 addNotification: function(title, children /*, ... */) {
3239 var mc = document.querySelector('#maincontent') || document.body;
3240 var msg = E('div', {
3241 'class': 'alert-message fade-in',
3242 'style': 'display:flex',
3243 'transitionend': function(ev) {
3244 var node = ev.currentTarget;
3245 if (node.parentNode && node.classList.contains('fade-out'))
3246 node.parentNode.removeChild(node);
3247 }
3248 }, [
3249 E('div', { 'style': 'flex:10' }),
3250 E('div', { 'style': 'flex:1 1 auto; display:flex' }, [
3251 E('button', {
3252 'class': 'btn',
3253 'style': 'margin-left:auto; margin-top:auto',
3254 'click': function(ev) {
3255 dom.parent(ev.target, '.alert-message').classList.add('fade-out');
3256 },
3257
3258 }, [ _('Dismiss') ])
3259 ])
3260 ]);
3261
3262 if (title != null)
3263 dom.append(msg.firstElementChild, E('h4', {}, title));
3264
3265 dom.append(msg.firstElementChild, children);
3266
3267 for (var i = 2; i < arguments.length; i++)
3268 msg.classList.add(arguments[i]);
3269
3270 mc.insertBefore(msg, mc.firstElementChild);
3271
3272 return msg;
3273 },
3274
3275 /**
3276 * Display or update an header area indicator.
3277 *
3278 * An indicator is a small label displayed in the header area of the screen
3279 * providing few amounts of status information such as item counts or state
3280 * toggle indicators.
3281 *
3282 * Multiple indicators may be shown at the same time and indicator labels
3283 * may be made clickable to display extended information or to initiate
3284 * further actions.
3285 *
3286 * Indicators can either use a default `active` or a less accented `inactive`
3287 * style which is useful for indicators representing state toggles.
3288 *
3289 * @param {string} id
3290 * The ID of the indicator. If an indicator with the given ID already exists,
3291 * it is updated with the given label and style.
3292 *
3293 * @param {string} label
3294 * The text to display in the indicator label.
3295 *
3296 * @param {function} [handler]
3297 * A handler function to invoke when the indicator label is clicked/touched
3298 * by the user. If omitted, the indicator is not clickable/touchable.
3299 *
3300 * Note that this parameter only applies to new indicators, when updating
3301 * existing labels it is ignored.
3302 *
3303 * @param {string} [style=active]
3304 * The indicator style to use. May be either `active` or `inactive`.
3305 *
3306 * @returns {boolean}
3307 * Returns `true` when the indicator has been updated or `false` when no
3308 * changes were made.
3309 */
3310 showIndicator: function(id, label, handler, style) {
3311 if (indicatorDiv == null) {
3312 indicatorDiv = document.body.querySelector('#indicators');
3313
3314 if (indicatorDiv == null)
3315 return false;
3316 }
3317
3318 var handlerFn = (typeof(handler) == 'function') ? handler : null,
3319 indicatorElem = indicatorDiv.querySelector('span[data-indicator="%s"]'.format(id));
3320
3321 if (indicatorElem == null) {
3322 var beforeElem = null;
3323
3324 for (beforeElem = indicatorDiv.firstElementChild;
3325 beforeElem != null;
3326 beforeElem = beforeElem.nextElementSibling)
3327 if (beforeElem.getAttribute('data-indicator') > id)
3328 break;
3329
3330 indicatorElem = indicatorDiv.insertBefore(E('span', {
3331 'data-indicator': id,
3332 'data-clickable': handlerFn ? true : null,
3333 'click': handlerFn
3334 }, ['']), beforeElem);
3335 }
3336
3337 if (label == indicatorElem.firstChild.data && style == indicatorElem.getAttribute('data-style'))
3338 return false;
3339
3340 indicatorElem.firstChild.data = label;
3341 indicatorElem.setAttribute('data-style', (style == 'inactive') ? 'inactive' : 'active');
3342 return true;
3343 },
3344
3345 /**
3346 * Remove an header area indicator.
3347 *
3348 * This function removes the given indicator label from the header indicator
3349 * area. When the given indicator is not found, this function does nothing.
3350 *
3351 * @param {string} id
3352 * The ID of the indicator to remove.
3353 *
3354 * @returns {boolean}
3355 * Returns `true` when the indicator has been removed or `false` when the
3356 * requested indicator was not found.
3357 */
3358 hideIndicator: function(id) {
3359 var indicatorElem = indicatorDiv ? indicatorDiv.querySelector('span[data-indicator="%s"]'.format(id)) : null;
3360
3361 if (indicatorElem == null)
3362 return false;
3363
3364 indicatorDiv.removeChild(indicatorElem);
3365 return true;
3366 },
3367
3368 /**
3369 * Formats a series of label/value pairs into list-like markup.
3370 *
3371 * This function transforms a flat array of alternating label and value
3372 * elements into a list-like markup, using the values in `separators` as
3373 * separators and appends the resulting nodes to the given parent DOM node.
3374 *
3375 * Each label is suffixed with `: ` and wrapped into a `<strong>` tag, the
3376 * `<strong>` element and the value corresponding to the label are
3377 * subsequently wrapped into a `<span class="nowrap">` element.
3378 *
3379 * The resulting `<span>` element tuples are joined by the given separators
3380 * to form the final markup which is appened to the given parent DOM node.
3381 *
3382 * @param {Node} node
3383 * The parent DOM node to append the markup to. Any previous child elements
3384 * will be removed.
3385 *
3386 * @param {Array<*>} items
3387 * An alternating array of labels and values. The label values will be
3388 * converted to plain strings, the values are used as-is and may be of
3389 * any type accepted by `LuCI.dom.content()`.
3390 *
3391 * @param {*|Array<*>} [separators=[E('br')]]
3392 * A single value or an array of separator values to separate each
3393 * label/value pair with. The function will cycle through the separators
3394 * when joining the pairs. If omitted, the default separator is a sole HTML
3395 * `<br>` element. Separator values are used as-is and may be of any type
3396 * accepted by `LuCI.dom.content()`.
3397 *
3398 * @returns {Node}
3399 * Returns the parent DOM node the formatted markup has been added to.
3400 */
3401 itemlist: function(node, items, separators) {
3402 var children = [];
3403
3404 if (!Array.isArray(separators))
3405 separators = [ separators || E('br') ];
3406
3407 for (var i = 0; i < items.length; i += 2) {
3408 if (items[i+1] !== null && items[i+1] !== undefined) {
3409 var sep = separators[(i/2) % separators.length],
3410 cld = [];
3411
3412 children.push(E('span', { class: 'nowrap' }, [
3413 items[i] ? E('strong', items[i] + ': ') : '',
3414 items[i+1]
3415 ]));
3416
3417 if ((i+2) < items.length)
3418 children.push(dom.elem(sep) ? sep.cloneNode(true) : sep);
3419 }
3420 }
3421
3422 dom.content(node, children);
3423
3424 return node;
3425 },
3426
3427 /**
3428 * @class
3429 * @memberof LuCI.ui
3430 * @hideconstructor
3431 * @classdesc
3432 *
3433 * The `tabs` class handles tab menu groups used throughout the view area.
3434 * It takes care of setting up tab groups, tracking their state and handling
3435 * related events.
3436 *
3437 * This class is automatically instantiated as part of `LuCI.ui`. To use it
3438 * in views, use `'require ui'` and refer to `ui.tabs`. To import it in
3439 * external JavaScript, use `L.require("ui").then(...)` and access the
3440 * `tabs` property of the class instance value.
3441 */
3442 tabs: baseclass.singleton(/* @lends LuCI.ui.tabs.prototype */ {
3443 /** @private */
3444 init: function() {
3445 var groups = [], prevGroup = null, currGroup = null;
3446
3447 document.querySelectorAll('[data-tab]').forEach(function(tab) {
3448 var parent = tab.parentNode;
3449
3450 if (dom.matches(tab, 'li') && dom.matches(parent, 'ul.cbi-tabmenu'))
3451 return;
3452
3453 if (!parent.hasAttribute('data-tab-group'))
3454 parent.setAttribute('data-tab-group', groups.length);
3455
3456 currGroup = +parent.getAttribute('data-tab-group');
3457
3458 if (currGroup !== prevGroup) {
3459 prevGroup = currGroup;
3460
3461 if (!groups[currGroup])
3462 groups[currGroup] = [];
3463 }
3464
3465 groups[currGroup].push(tab);
3466 });
3467
3468 for (var i = 0; i < groups.length; i++)
3469 this.initTabGroup(groups[i]);
3470
3471 document.addEventListener('dependency-update', this.updateTabs.bind(this));
3472
3473 this.updateTabs();
3474 },
3475
3476 /**
3477 * Initializes a new tab group from the given tab pane collection.
3478 *
3479 * This function cycles through the given tab pane DOM nodes, extracts
3480 * their tab IDs, titles and active states, renders a corresponding
3481 * tab menu and prepends it to the tab panes common parent DOM node.
3482 *
3483 * The tab menu labels will be set to the value of the `data-tab-title`
3484 * attribute of each corresponding pane. The last pane with the
3485 * `data-tab-active` attribute set to `true` will be selected by default.
3486 *
3487 * If no pane is marked as active, the first one will be preselected.
3488 *
3489 * @instance
3490 * @memberof LuCI.ui.tabs
3491 * @param {Array<Node>|NodeList} panes
3492 * A collection of tab panes to build a tab group menu for. May be a
3493 * plain array of DOM nodes or a NodeList collection, such as the result
3494 * of a `querySelectorAll()` call or the `.childNodes` property of a
3495 * DOM node.
3496 */
3497 initTabGroup: function(panes) {
3498 if (typeof(panes) != 'object' || !('length' in panes) || panes.length === 0)
3499 return;
3500
3501 var menu = E('ul', { 'class': 'cbi-tabmenu' }),
3502 group = panes[0].parentNode,
3503 groupId = +group.getAttribute('data-tab-group'),
3504 selected = null;
3505
3506 if (group.getAttribute('data-initialized') === 'true')
3507 return;
3508
3509 for (var i = 0, pane; pane = panes[i]; i++) {
3510 var name = pane.getAttribute('data-tab'),
3511 title = pane.getAttribute('data-tab-title'),
3512 active = pane.getAttribute('data-tab-active') === 'true';
3513
3514 menu.appendChild(E('li', {
3515 'style': this.isEmptyPane(pane) ? 'display:none' : null,
3516 'class': active ? 'cbi-tab' : 'cbi-tab-disabled',
3517 'data-tab': name
3518 }, E('a', {
3519 'href': '#',
3520 'click': this.switchTab.bind(this)
3521 }, title)));
3522
3523 if (active)
3524 selected = i;
3525 }
3526
3527 group.parentNode.insertBefore(menu, group);
3528 group.setAttribute('data-initialized', true);
3529
3530 if (selected === null) {
3531 selected = this.getActiveTabId(panes[0]);
3532
3533 if (selected < 0 || selected >= panes.length || this.isEmptyPane(panes[selected])) {
3534 for (var i = 0; i < panes.length; i++) {
3535 if (!this.isEmptyPane(panes[i])) {
3536 selected = i;
3537 break;
3538 }
3539 }
3540 }
3541
3542 menu.childNodes[selected].classList.add('cbi-tab');
3543 menu.childNodes[selected].classList.remove('cbi-tab-disabled');
3544 panes[selected].setAttribute('data-tab-active', 'true');
3545
3546 this.setActiveTabId(panes[selected], selected);
3547 }
3548
3549 panes[selected].dispatchEvent(new CustomEvent('cbi-tab-active', {
3550 detail: { tab: panes[selected].getAttribute('data-tab') }
3551 }));
3552
3553 this.updateTabs(group);
3554 },
3555
3556 /**
3557 * Checks whether the given tab pane node is empty.
3558 *
3559 * @instance
3560 * @memberof LuCI.ui.tabs
3561 * @param {Node} pane
3562 * The tab pane to check.
3563 *
3564 * @returns {boolean}
3565 * Returns `true` if the pane is empty, else `false`.
3566 */
3567 isEmptyPane: function(pane) {
3568 return dom.isEmpty(pane, function(n) { return n.classList.contains('cbi-tab-descr') });
3569 },
3570
3571 /** @private */
3572 getPathForPane: function(pane) {
3573 var path = [], node = null;
3574
3575 for (node = pane ? pane.parentNode : null;
3576 node != null && node.hasAttribute != null;
3577 node = node.parentNode)
3578 {
3579 if (node.hasAttribute('data-tab'))
3580 path.unshift(node.getAttribute('data-tab'));
3581 else if (node.hasAttribute('data-section-id'))
3582 path.unshift(node.getAttribute('data-section-id'));
3583 }
3584
3585 return path.join('/');
3586 },
3587
3588 /** @private */
3589 getActiveTabState: function() {
3590 var page = document.body.getAttribute('data-page'),
3591 state = session.getLocalData('tab');
3592
3593 if (L.isObject(state) && state.page === page && L.isObject(state.paths))
3594 return state;
3595
3596 session.setLocalData('tab', null);
3597
3598 return { page: page, paths: {} };
3599 },
3600
3601 /** @private */
3602 getActiveTabId: function(pane) {
3603 var path = this.getPathForPane(pane);
3604 return +this.getActiveTabState().paths[path] || 0;
3605 },
3606
3607 /** @private */
3608 setActiveTabId: function(pane, tabIndex) {
3609 var path = this.getPathForPane(pane),
3610 state = this.getActiveTabState();
3611
3612 state.paths[path] = tabIndex;
3613
3614 return session.setLocalData('tab', state);
3615 },
3616
3617 /** @private */
3618 updateTabs: function(ev, root) {
3619 (root || document).querySelectorAll('[data-tab-title]').forEach(L.bind(function(pane) {
3620 var menu = pane.parentNode.previousElementSibling,
3621 tab = menu ? menu.querySelector('[data-tab="%s"]'.format(pane.getAttribute('data-tab'))) : null,
3622 n_errors = pane.querySelectorAll('.cbi-input-invalid').length;
3623
3624 if (!menu || !tab)
3625 return;
3626
3627 if (this.isEmptyPane(pane)) {
3628 tab.style.display = 'none';
3629 tab.classList.remove('flash');
3630 }
3631 else if (tab.style.display === 'none') {
3632 tab.style.display = '';
3633 requestAnimationFrame(function() { tab.classList.add('flash') });
3634 }
3635
3636 if (n_errors) {
3637 tab.setAttribute('data-errors', n_errors);
3638 tab.setAttribute('data-tooltip', _('%d invalid field(s)').format(n_errors));
3639 tab.setAttribute('data-tooltip-style', 'error');
3640 }
3641 else {
3642 tab.removeAttribute('data-errors');
3643 tab.removeAttribute('data-tooltip');
3644 }
3645 }, this));
3646 },
3647
3648 /** @private */
3649 switchTab: function(ev) {
3650 var tab = ev.target.parentNode,
3651 name = tab.getAttribute('data-tab'),
3652 menu = tab.parentNode,
3653 group = menu.nextElementSibling,
3654 groupId = +group.getAttribute('data-tab-group'),
3655 index = 0;
3656
3657 ev.preventDefault();
3658
3659 if (!tab.classList.contains('cbi-tab-disabled'))
3660 return;
3661
3662 menu.querySelectorAll('[data-tab]').forEach(function(tab) {
3663 tab.classList.remove('cbi-tab');
3664 tab.classList.remove('cbi-tab-disabled');
3665 tab.classList.add(
3666 tab.getAttribute('data-tab') === name ? 'cbi-tab' : 'cbi-tab-disabled');
3667 });
3668
3669 group.childNodes.forEach(function(pane) {
3670 if (dom.matches(pane, '[data-tab]')) {
3671 if (pane.getAttribute('data-tab') === name) {
3672 pane.setAttribute('data-tab-active', 'true');
3673 pane.dispatchEvent(new CustomEvent('cbi-tab-active', { detail: { tab: name } }));
3674 UI.prototype.tabs.setActiveTabId(pane, index);
3675 }
3676 else {
3677 pane.setAttribute('data-tab-active', 'false');
3678 }
3679
3680 index++;
3681 }
3682 });
3683 }
3684 }),
3685
3686 /**
3687 * @typedef {Object} FileUploadReply
3688 * @memberof LuCI.ui
3689
3690 * @property {string} name - Name of the uploaded file without directory components
3691 * @property {number} size - Size of the uploaded file in bytes
3692 * @property {string} checksum - The MD5 checksum of the received file data
3693 * @property {string} sha256sum - The SHA256 checksum of the received file data
3694 */
3695
3696 /**
3697 * Display a modal file upload prompt.
3698 *
3699 * This function opens a modal dialog prompting the user to select and
3700 * upload a file to a predefined remote destination path.
3701 *
3702 * @param {string} path
3703 * The remote file path to upload the local file to.
3704 *
3705 * @param {Node} [progessStatusNode]
3706 * An optional DOM text node whose content text is set to the progress
3707 * percentage value during file upload.
3708 *
3709 * @returns {Promise<LuCI.ui.FileUploadReply>}
3710 * Returns a promise resolving to a file upload status object on success
3711 * or rejecting with an error in case the upload failed or has been
3712 * cancelled by the user.
3713 */
3714 uploadFile: function(path, progressStatusNode) {
3715 return new Promise(function(resolveFn, rejectFn) {
3716 UI.prototype.showModal(_('Uploading file…'), [
3717 E('p', _('Please select the file to upload.')),
3718 E('div', { 'style': 'display:flex' }, [
3719 E('div', { 'class': 'left', 'style': 'flex:1' }, [
3720 E('input', {
3721 type: 'file',
3722 style: 'display:none',
3723 change: function(ev) {
3724 var modal = dom.parent(ev.target, '.modal'),
3725 body = modal.querySelector('p'),
3726 upload = modal.querySelector('.cbi-button-action.important'),
3727 file = ev.currentTarget.files[0];
3728
3729 if (file == null)
3730 return;
3731
3732 dom.content(body, [
3733 E('ul', {}, [
3734 E('li', {}, [ '%s: %s'.format(_('Name'), file.name.replace(/^.*[\\\/]/, '')) ]),
3735 E('li', {}, [ '%s: %1024mB'.format(_('Size'), file.size) ])
3736 ])
3737 ]);
3738
3739 upload.disabled = false;
3740 upload.focus();
3741 }
3742 }),
3743 E('button', {
3744 'class': 'btn',
3745 'click': function(ev) {
3746 ev.target.previousElementSibling.click();
3747 }
3748 }, [ _('Browse…') ])
3749 ]),
3750 E('div', { 'class': 'right', 'style': 'flex:1' }, [
3751 E('button', {
3752 'class': 'btn',
3753 'click': function() {
3754 UI.prototype.hideModal();
3755 rejectFn(new Error('Upload has been cancelled'));
3756 }
3757 }, [ _('Cancel') ]),
3758 ' ',
3759 E('button', {
3760 'class': 'btn cbi-button-action important',
3761 'disabled': true,
3762 'click': function(ev) {
3763 var input = dom.parent(ev.target, '.modal').querySelector('input[type="file"]');
3764
3765 if (!input.files[0])
3766 return;
3767
3768 var progress = E('div', { 'class': 'cbi-progressbar', 'title': '0%' }, E('div', { 'style': 'width:0' }));
3769
3770 UI.prototype.showModal(_('Uploading file…'), [ progress ]);
3771
3772 var data = new FormData();
3773
3774 data.append('sessionid', rpc.getSessionID());
3775 data.append('filename', path);
3776 data.append('filedata', input.files[0]);
3777
3778 var filename = input.files[0].name;
3779
3780 request.post(L.env.cgi_base + '/cgi-upload', data, {
3781 timeout: 0,
3782 progress: function(pev) {
3783 var percent = (pev.loaded / pev.total) * 100;
3784
3785 if (progressStatusNode)
3786 progressStatusNode.data = '%.2f%%'.format(percent);
3787
3788 progress.setAttribute('title', '%.2f%%'.format(percent));
3789 progress.firstElementChild.style.width = '%.2f%%'.format(percent);
3790 }
3791 }).then(function(res) {
3792 var reply = res.json();
3793
3794 UI.prototype.hideModal();
3795
3796 if (L.isObject(reply) && reply.failure) {
3797 UI.prototype.addNotification(null, E('p', _('Upload request failed: %s').format(reply.message)));
3798 rejectFn(new Error(reply.failure));
3799 }
3800 else {
3801 reply.name = filename;
3802 resolveFn(reply);
3803 }
3804 }, function(err) {
3805 UI.prototype.hideModal();
3806 rejectFn(err);
3807 });
3808 }
3809 }, [ _('Upload') ])
3810 ])
3811 ])
3812 ]);
3813 });
3814 },
3815
3816 /**
3817 * Perform a device connectivity test.
3818 *
3819 * Attempt to fetch a well known ressource from the remote device via HTTP
3820 * in order to test connectivity. This function is mainly useful to wait
3821 * for the router to come back online after a reboot or reconfiguration.
3822 *
3823 * @param {string} [proto=http]
3824 * The protocol to use for fetching the resource. May be either `http`
3825 * (the default) or `https`.
3826 *
3827 * @param {string} [host=window.location.host]
3828 * Override the host address to probe. By default the current host as seen
3829 * in the address bar is probed.
3830 *
3831 * @returns {Promise<Event>}
3832 * Returns a promise resolving to a `load` event in case the device is
3833 * reachable or rejecting with an `error` event in case it is not reachable
3834 * or rejecting with `null` when the connectivity check timed out.
3835 */
3836 pingDevice: function(proto, ipaddr) {
3837 var target = '%s://%s%s?%s'.format(proto || 'http', ipaddr || window.location.host, L.resource('icons/loading.gif'), Math.random());
3838
3839 return new Promise(function(resolveFn, rejectFn) {
3840 var img = new Image();
3841
3842 img.onload = resolveFn;
3843 img.onerror = rejectFn;
3844
3845 window.setTimeout(rejectFn, 1000);
3846
3847 img.src = target;
3848 });
3849 },
3850
3851 /**
3852 * Wait for device to come back online and reconnect to it.
3853 *
3854 * Poll each given hostname or IP address and navigate to it as soon as
3855 * one of the addresses becomes reachable.
3856 *
3857 * @param {...string} [hosts=[window.location.host]]
3858 * The list of IP addresses and host names to check for reachability.
3859 * If omitted, the current value of `window.location.host` is used by
3860 * default.
3861 */
3862 awaitReconnect: function(/* ... */) {
3863 var ipaddrs = arguments.length ? arguments : [ window.location.host ];
3864
3865 window.setTimeout(L.bind(function() {
3866 poll.add(L.bind(function() {
3867 var tasks = [], reachable = false;
3868
3869 for (var i = 0; i < 2; i++)
3870 for (var j = 0; j < ipaddrs.length; j++)
3871 tasks.push(this.pingDevice(i ? 'https' : 'http', ipaddrs[j])
3872 .then(function(ev) { reachable = ev.target.src.replace(/^(https?:\/\/[^\/]+).*$/, '$1/') }, function() {}));
3873
3874 return Promise.all(tasks).then(function() {
3875 if (reachable) {
3876 poll.stop();
3877 window.location = reachable;
3878 }
3879 });
3880 }, this));
3881 }, this), 5000);
3882 },
3883
3884 /**
3885 * @class
3886 * @memberof LuCI.ui
3887 * @hideconstructor
3888 * @classdesc
3889 *
3890 * The `changes` class encapsulates logic for visualizing, applying,
3891 * confirming and reverting staged UCI changesets.
3892 *
3893 * This class is automatically instantiated as part of `LuCI.ui`. To use it
3894 * in views, use `'require ui'` and refer to `ui.changes`. To import it in
3895 * external JavaScript, use `L.require("ui").then(...)` and access the
3896 * `changes` property of the class instance value.
3897 */
3898 changes: baseclass.singleton(/* @lends LuCI.ui.changes.prototype */ {
3899 init: function() {
3900 if (!L.env.sessionid)
3901 return;
3902
3903 return uci.changes().then(L.bind(this.renderChangeIndicator, this));
3904 },
3905
3906 /**
3907 * Set the change count indicator.
3908 *
3909 * This function updates or hides the UCI change count indicator,
3910 * depending on the passed change count. When the count is greater
3911 * than 0, the change indicator is displayed or updated, otherwise it
3912 * is removed.
3913 *
3914 * @instance
3915 * @memberof LuCI.ui.changes
3916 * @param {number} numChanges
3917 * The number of changes to indicate.
3918 */
3919 setIndicator: function(n) {
3920 if (n > 0) {
3921 UI.prototype.showIndicator('uci-changes',
3922 '%s: %d'.format(_('Unsaved Changes'), n),
3923 L.bind(this.displayChanges, this));
3924 }
3925 else {
3926 UI.prototype.hideIndicator('uci-changes');
3927 }
3928 },
3929
3930 /**
3931 * Update the change count indicator.
3932 *
3933 * This function updates the UCI change count indicator from the given
3934 * UCI changeset structure.
3935 *
3936 * @instance
3937 * @memberof LuCI.ui.changes
3938 * @param {Object<string, Array<LuCI.uci.ChangeRecord>>} changes
3939 * The UCI changeset to count.
3940 */
3941 renderChangeIndicator: function(changes) {
3942 var n_changes = 0;
3943
3944 for (var config in changes)
3945 if (changes.hasOwnProperty(config))
3946 n_changes += changes[config].length;
3947
3948 this.changes = changes;
3949 this.setIndicator(n_changes);
3950 },
3951
3952 /** @private */
3953 changeTemplates: {
3954 'add-3': '<ins>uci add %0 <strong>%3</strong> # =%2</ins>',
3955 'set-3': '<ins>uci set %0.<strong>%2</strong>=%3</ins>',
3956 'set-4': '<var><ins>uci set %0.%2.%3=<strong>%4</strong></ins></var>',
3957 'remove-2': '<del>uci del %0.<strong>%2</strong></del>',
3958 'remove-3': '<var><del>uci del %0.%2.<strong>%3</strong></del></var>',
3959 'order-3': '<var>uci reorder %0.%2=<strong>%3</strong></var>',
3960 'list-add-4': '<var><ins>uci add_list %0.%2.%3=<strong>%4</strong></ins></var>',
3961 'list-del-4': '<var><del>uci del_list %0.%2.%3=<strong>%4</strong></del></var>',
3962 'rename-3': '<var>uci rename %0.%2=<strong>%3</strong></var>',
3963 'rename-4': '<var>uci rename %0.%2.%3=<strong>%4</strong></var>'
3964 },
3965
3966 /**
3967 * Display the current changelog.
3968 *
3969 * Open a modal dialog visualizing the currently staged UCI changes
3970 * and offer options to revert or apply the shown changes.
3971 *
3972 * @instance
3973 * @memberof LuCI.ui.changes
3974 */
3975 displayChanges: function() {
3976 var list = E('div', { 'class': 'uci-change-list' }),
3977 dlg = UI.prototype.showModal(_('Configuration') + ' / ' + _('Changes'), [
3978 E('div', { 'class': 'cbi-section' }, [
3979 E('strong', _('Legend:')),
3980 E('div', { 'class': 'uci-change-legend' }, [
3981 E('div', { 'class': 'uci-change-legend-label' }, [
3982 E('ins', '&#160;'), ' ', _('Section added') ]),
3983 E('div', { 'class': 'uci-change-legend-label' }, [
3984 E('del', '&#160;'), ' ', _('Section removed') ]),
3985 E('div', { 'class': 'uci-change-legend-label' }, [
3986 E('var', {}, E('ins', '&#160;')), ' ', _('Option changed') ]),
3987 E('div', { 'class': 'uci-change-legend-label' }, [
3988 E('var', {}, E('del', '&#160;')), ' ', _('Option removed') ])]),
3989 E('br'), list,
3990 E('div', { 'class': 'right' }, [
3991 E('button', {
3992 'class': 'btn',
3993 'click': UI.prototype.hideModal
3994 }, [ _('Dismiss') ]), ' ',
3995 E('button', {
3996 'class': 'cbi-button cbi-button-positive important',
3997 'click': L.bind(this.apply, this, true)
3998 }, [ _('Save & Apply') ]), ' ',
3999 E('button', {
4000 'class': 'cbi-button cbi-button-reset',
4001 'click': L.bind(this.revert, this)
4002 }, [ _('Revert') ])])])
4003 ]);
4004
4005 for (var config in this.changes) {
4006 if (!this.changes.hasOwnProperty(config))
4007 continue;
4008
4009 list.appendChild(E('h5', '# /etc/config/%s'.format(config)));
4010
4011 for (var i = 0, added = null; i < this.changes[config].length; i++) {
4012 var chg = this.changes[config][i],
4013 tpl = this.changeTemplates['%s-%d'.format(chg[0], chg.length)];
4014
4015 list.appendChild(E(tpl.replace(/%([01234])/g, function(m0, m1) {
4016 switch (+m1) {
4017 case 0:
4018 return config;
4019
4020 case 2:
4021 if (added != null && chg[1] == added[0])
4022 return '@' + added[1] + '[-1]';
4023 else
4024 return chg[1];
4025
4026 case 4:
4027 return "'%h'".format(chg[3].replace(/'/g, "'\"'\"'"));
4028
4029 default:
4030 return chg[m1-1];
4031 }
4032 })));
4033
4034 if (chg[0] == 'add')
4035 added = [ chg[1], chg[2] ];
4036 }
4037 }
4038
4039 list.appendChild(E('br'));
4040 dlg.classList.add('uci-dialog');
4041 },
4042
4043 /** @private */
4044 displayStatus: function(type, content) {
4045 if (type) {
4046 var message = UI.prototype.showModal('', '');
4047
4048 message.classList.add('alert-message');
4049 DOMTokenList.prototype.add.apply(message.classList, type.split(/\s+/));
4050
4051 if (content)
4052 dom.content(message, content);
4053
4054 if (!this.was_polling) {
4055 this.was_polling = request.poll.active();
4056 request.poll.stop();
4057 }
4058 }
4059 else {
4060 UI.prototype.hideModal();
4061
4062 if (this.was_polling)
4063 request.poll.start();
4064 }
4065 },
4066
4067 /** @private */
4068 rollback: function(checked) {
4069 if (checked) {
4070 this.displayStatus('warning spinning',
4071 E('p', _('Failed to confirm apply within %ds, waiting for rollback…')
4072 .format(L.env.apply_rollback)));
4073
4074 var call = function(r, data, duration) {
4075 if (r.status === 204) {
4076 UI.prototype.changes.displayStatus('warning', [
4077 E('h4', _('Configuration changes have been rolled back!')),
4078 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)),
4079 E('div', { 'class': 'right' }, [
4080 E('button', {
4081 'class': 'btn',
4082 'click': L.bind(UI.prototype.changes.displayStatus, UI.prototype.changes, false)
4083 }, [ _('Dismiss') ]), ' ',
4084 E('button', {
4085 'class': 'btn cbi-button-action important',
4086 'click': L.bind(UI.prototype.changes.revert, UI.prototype.changes)
4087 }, [ _('Revert changes') ]), ' ',
4088 E('button', {
4089 'class': 'btn cbi-button-negative important',
4090 'click': L.bind(UI.prototype.changes.apply, UI.prototype.changes, false)
4091 }, [ _('Apply unchecked') ])
4092 ])
4093 ]);
4094
4095 return;
4096 }
4097
4098 var delay = isNaN(duration) ? 0 : Math.max(1000 - duration, 0);
4099 window.setTimeout(function() {
4100 request.request(L.url('admin/uci/confirm'), {
4101 method: 'post',
4102 timeout: L.env.apply_timeout * 1000,
4103 query: { sid: L.env.sessionid, token: L.env.token }
4104 }).then(call);
4105 }, delay);
4106 };
4107
4108 call({ status: 0 });
4109 }
4110 else {
4111 this.displayStatus('warning', [
4112 E('h4', _('Device unreachable!')),
4113 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.'))
4114 ]);
4115 }
4116 },
4117
4118 /** @private */
4119 confirm: function(checked, deadline, override_token) {
4120 var tt;
4121 var ts = Date.now();
4122
4123 this.displayStatus('notice');
4124
4125 if (override_token)
4126 this.confirm_auth = { token: override_token };
4127
4128 var call = function(r, data, duration) {
4129 if (Date.now() >= deadline) {
4130 window.clearTimeout(tt);
4131 UI.prototype.changes.rollback(checked);
4132 return;
4133 }
4134 else if (r && (r.status === 200 || r.status === 204)) {
4135 document.dispatchEvent(new CustomEvent('uci-applied'));
4136
4137 UI.prototype.changes.setIndicator(0);
4138 UI.prototype.changes.displayStatus('notice',
4139 E('p', _('Configuration changes applied.')));
4140
4141 window.clearTimeout(tt);
4142 window.setTimeout(function() {
4143 //UI.prototype.changes.displayStatus(false);
4144 window.location = window.location.href.split('#')[0];
4145 }, L.env.apply_display * 1000);
4146
4147 return;
4148 }
4149
4150 var delay = isNaN(duration) ? 0 : Math.max(1000 - duration, 0);
4151 window.setTimeout(function() {
4152 request.request(L.url('admin/uci/confirm'), {
4153 method: 'post',
4154 timeout: L.env.apply_timeout * 1000,
4155 query: UI.prototype.changes.confirm_auth
4156 }).then(call, call);
4157 }, delay);
4158 };
4159
4160 var tick = function() {
4161 var now = Date.now();
4162
4163 UI.prototype.changes.displayStatus('notice spinning',
4164 E('p', _('Applying configuration changes… %ds')
4165 .format(Math.max(Math.floor((deadline - Date.now()) / 1000), 0))));
4166
4167 if (now >= deadline)
4168 return;
4169
4170 tt = window.setTimeout(tick, 1000 - (now - ts));
4171 ts = now;
4172 };
4173
4174 tick();
4175
4176 /* wait a few seconds for the settings to become effective */
4177 window.setTimeout(call, Math.max(L.env.apply_holdoff * 1000 - ((ts + L.env.apply_rollback * 1000) - deadline), 1));
4178 },
4179
4180 /**
4181 * Apply the staged configuration changes.
4182 *
4183 * Start applying staged configuration changes and open a modal dialog
4184 * with a progress indication to prevent interaction with the view
4185 * during the apply process. The modal dialog will be automatically
4186 * closed and the current view reloaded once the apply process is
4187 * complete.
4188 *
4189 * @instance
4190 * @memberof LuCI.ui.changes
4191 * @param {boolean} [checked=false]
4192 * Whether to perform a checked (`true`) configuration apply or an
4193 * unchecked (`false`) one.
4194
4195 * In case of a checked apply, the configuration changes must be
4196 * confirmed within a specific time interval, otherwise the device
4197 * will begin to roll back the changes in order to restore the previous
4198 * settings.
4199 */
4200 apply: function(checked) {
4201 this.displayStatus('notice spinning',
4202 E('p', _('Starting configuration apply…')));
4203
4204 request.request(L.url('admin/uci', checked ? 'apply_rollback' : 'apply_unchecked'), {
4205 method: 'post',
4206 query: { sid: L.env.sessionid, token: L.env.token }
4207 }).then(function(r) {
4208 if (r.status === (checked ? 200 : 204)) {
4209 var tok = null; try { tok = r.json(); } catch(e) {}
4210 if (checked && tok !== null && typeof(tok) === 'object' && typeof(tok.token) === 'string')
4211 UI.prototype.changes.confirm_auth = tok;
4212
4213 UI.prototype.changes.confirm(checked, Date.now() + L.env.apply_rollback * 1000);
4214 }
4215 else if (checked && r.status === 204) {
4216 UI.prototype.changes.displayStatus('notice',
4217 E('p', _('There are no changes to apply')));
4218
4219 window.setTimeout(function() {
4220 UI.prototype.changes.displayStatus(false);
4221 }, L.env.apply_display * 1000);
4222 }
4223 else {
4224 UI.prototype.changes.displayStatus('warning',
4225 E('p', _('Apply request failed with status <code>%h</code>')
4226 .format(r.responseText || r.statusText || r.status)));
4227
4228 window.setTimeout(function() {
4229 UI.prototype.changes.displayStatus(false);
4230 }, L.env.apply_display * 1000);
4231 }
4232 });
4233 },
4234
4235 /**
4236 * Revert the staged configuration changes.
4237 *
4238 * Start reverting staged configuration changes and open a modal dialog
4239 * with a progress indication to prevent interaction with the view
4240 * during the revert process. The modal dialog will be automatically
4241 * closed and the current view reloaded once the revert process is
4242 * complete.
4243 *
4244 * @instance
4245 * @memberof LuCI.ui.changes
4246 */
4247 revert: function() {
4248 this.displayStatus('notice spinning',
4249 E('p', _('Reverting configuration…')));
4250
4251 request.request(L.url('admin/uci/revert'), {
4252 method: 'post',
4253 query: { sid: L.env.sessionid, token: L.env.token }
4254 }).then(function(r) {
4255 if (r.status === 200) {
4256 document.dispatchEvent(new CustomEvent('uci-reverted'));
4257
4258 UI.prototype.changes.setIndicator(0);
4259 UI.prototype.changes.displayStatus('notice',
4260 E('p', _('Changes have been reverted.')));
4261
4262 window.setTimeout(function() {
4263 //UI.prototype.changes.displayStatus(false);
4264 window.location = window.location.href.split('#')[0];
4265 }, L.env.apply_display * 1000);
4266 }
4267 else {
4268 UI.prototype.changes.displayStatus('warning',
4269 E('p', _('Revert request failed with status <code>%h</code>')
4270 .format(r.statusText || r.status)));
4271
4272 window.setTimeout(function() {
4273 UI.prototype.changes.displayStatus(false);
4274 }, L.env.apply_display * 1000);
4275 }
4276 });
4277 }
4278 }),
4279
4280 /**
4281 * Add validation constraints to an input element.
4282 *
4283 * Compile the given type expression and optional validator function into
4284 * a validation function and bind it to the specified input element events.
4285 *
4286 * @param {Node} field
4287 * The DOM input element node to bind the validation constraints to.
4288 *
4289 * @param {string} type
4290 * The datatype specification to describe validation constraints.
4291 * Refer to the `LuCI.validation` class documentation for details.
4292 *
4293 * @param {boolean} [optional=false]
4294 * Specifies whether empty values are allowed (`true`) or not (`false`).
4295 * If an input element is not marked optional it must not be empty,
4296 * otherwise it will be marked as invalid.
4297 *
4298 * @param {function} [vfunc]
4299 * Specifies a custom validation function which is invoked after the
4300 * other validation constraints are applied. The validation must return
4301 * `true` to accept the passed value. Any other return type is converted
4302 * to a string and treated as validation error message.
4303 *
4304 * @param {...string} [events=blur, keyup]
4305 * The list of events to bind. Each received event will trigger a field
4306 * validation. If omitted, the `keyup` and `blur` events are bound by
4307 * default.
4308 *
4309 * @returns {function}
4310 * Returns the compiled validator function which can be used to manually
4311 * trigger field validation or to bind it to further events.
4312 *
4313 * @see LuCI.validation
4314 */
4315 addValidator: function(field, type, optional, vfunc /*, ... */) {
4316 if (type == null)
4317 return;
4318
4319 var events = this.varargs(arguments, 3);
4320 if (events.length == 0)
4321 events.push('blur', 'keyup');
4322
4323 try {
4324 var cbiValidator = validation.create(field, type, optional, vfunc),
4325 validatorFn = cbiValidator.validate.bind(cbiValidator);
4326
4327 for (var i = 0; i < events.length; i++)
4328 field.addEventListener(events[i], validatorFn);
4329
4330 validatorFn();
4331
4332 return validatorFn;
4333 }
4334 catch (e) { }
4335 },
4336
4337 /**
4338 * Create a pre-bound event handler function.
4339 *
4340 * Generate and bind a function suitable for use in event handlers. The
4341 * generated function automatically disables the event source element
4342 * and adds an active indication to it by adding appropriate CSS classes.
4343 *
4344 * It will also await any promises returned by the wrapped function and
4345 * re-enable the source element after the promises ran to completion.
4346 *
4347 * @param {*} ctx
4348 * The `this` context to use for the wrapped function.
4349 *
4350 * @param {function|string} fn
4351 * Specifies the function to wrap. In case of a function value, the
4352 * function is used as-is. If a string is specified instead, it is looked
4353 * up in `ctx` to obtain the function to wrap. In both cases the bound
4354 * function will be invoked with `ctx` as `this` context
4355 *
4356 * @param {...*} extra_args
4357 * Any further parameter as passed as-is to the bound event handler
4358 * function in the same order as passed to `createHandlerFn()`.
4359 *
4360 * @returns {function|null}
4361 * Returns the pre-bound handler function which is suitable to be passed
4362 * to `addEventListener()`. Returns `null` if the given `fn` argument is
4363 * a string which could not be found in `ctx` or if `ctx[fn]` is not a
4364 * valid function value.
4365 */
4366 createHandlerFn: function(ctx, fn /*, ... */) {
4367 if (typeof(fn) == 'string')
4368 fn = ctx[fn];
4369
4370 if (typeof(fn) != 'function')
4371 return null;
4372
4373 var arg_offset = arguments.length - 2;
4374
4375 return Function.prototype.bind.apply(function() {
4376 var t = arguments[arg_offset].currentTarget;
4377
4378 t.classList.add('spinning');
4379 t.disabled = true;
4380
4381 if (t.blur)
4382 t.blur();
4383
4384 Promise.resolve(fn.apply(ctx, arguments)).finally(function() {
4385 t.classList.remove('spinning');
4386 t.disabled = false;
4387 });
4388 }, this.varargs(arguments, 2, ctx));
4389 },
4390
4391 /**
4392 * Load specified view class path and set it up.
4393 *
4394 * Transforms the given view path into a class name, requires it
4395 * using [LuCI.require()]{@link LuCI#require} and asserts that the
4396 * resulting class instance is a descendant of
4397 * [LuCI.view]{@link LuCI.view}.
4398 *
4399 * By instantiating the view class, its corresponding contents are
4400 * rendered and included into the view area. Any runtime errors are
4401 * catched and rendered using [LuCI.error()]{@link LuCI#error}.
4402 *
4403 * @param {string} path
4404 * The view path to render.
4405 *
4406 * @returns {Promise<LuCI.view>}
4407 * Returns a promise resolving to the loaded view instance.
4408 */
4409 instantiateView: function(path) {
4410 var className = 'view.%s'.format(path.replace(/\//g, '.'));
4411
4412 return L.require(className).then(function(view) {
4413 if (!(view instanceof View))
4414 throw new TypeError('Loaded class %s is not a descendant of View'.format(className));
4415
4416 return view;
4417 }).catch(function(err) {
4418 dom.content(document.querySelector('#view'), null);
4419 L.error(err);
4420 });
4421 },
4422
4423 menu: UIMenu,
4424
4425 AbstractElement: UIElement,
4426
4427 /* Widgets */
4428 Textfield: UITextfield,
4429 Textarea: UITextarea,
4430 Checkbox: UICheckbox,
4431 Select: UISelect,
4432 Dropdown: UIDropdown,
4433 DynamicList: UIDynamicList,
4434 Combobox: UICombobox,
4435 ComboButton: UIComboButton,
4436 Hiddenfield: UIHiddenfield,
4437 FileUpload: UIFileUpload
4438 });
4439
4440 return UI;