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