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