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