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