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