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