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