diff options
Diffstat (limited to 'modules/luci-base/htdocs')
9 files changed, 4872 insertions, 1289 deletions
diff --git a/modules/luci-base/htdocs/luci-static/resources/firewall.js b/modules/luci-base/htdocs/luci-static/resources/firewall.js index eaab3bb9f0..4a15312407 100644 --- a/modules/luci-base/htdocs/luci-static/resources/firewall.js +++ b/modules/luci-base/htdocs/luci-static/resources/firewall.js @@ -154,8 +154,8 @@ Firewall = L.Class.extend({ if (section != null && section['.type'] == 'zone') { found = true; - name = zone.name; - uci.remove('firewall', zone['.name']); + name = section.name; + uci.remove('firewall', section['.name']); } else if (name != null) { var sections = uci.sections('firewall', 'zone'); diff --git a/modules/luci-base/htdocs/luci-static/resources/form.js b/modules/luci-base/htdocs/luci-static/resources/form.js index 9c59c650a3..2b02066a40 100644 --- a/modules/luci-base/htdocs/luci-static/resources/form.js +++ b/modules/luci-base/htdocs/luci-static/resources/form.js @@ -1,10 +1,12 @@ 'use strict'; 'require ui'; 'require uci'; +'require dom'; +'require baseclass'; var scope = this; -var CBIJSONConfig = Class.extend({ +var CBIJSONConfig = baseclass.extend({ __init__: function(data) { data = Object.assign({}, data); @@ -171,17 +173,50 @@ var CBIJSONConfig = Class.extend({ } }); -var CBINode = Class.extend({ +/** + * @class AbstractElement + * @memberof LuCI.form + * @hideconstructor + * @classdesc + * + * The `AbstractElement` class serves as abstract base for the different form + * elements implemented by `LuCI.form`. It provides the common logic for + * loading and rendering values, for nesting elements and for defining common + * properties. + * + * This class is private and not directly accessible by user code. + */ +var CBIAbstractElement = baseclass.extend(/** @lends LuCI.form.AbstractElement.prototype */ { __init__: function(title, description) { this.title = title || ''; this.description = description || ''; this.children = []; }, + /** + * Add another form element as children to this element. + * + * @param {AbstractElement} element + * The form element to add. + */ append: function(obj) { this.children.push(obj); }, + /** + * Parse this elements form input. + * + * The `parse()` function recursively walks the form element tree and + * triggers input value reading and validation for each encountered element. + * + * Elements which are hidden due to unsatisified dependencies are skipped. + * + * @returns {Promise<void>} + * Returns a promise resolving once this element's value and the values of + * all child elements have been parsed. The returned promise is rejected + * if any parsed values are not meeting the validation constraints of their + * respective elements. + */ parse: function() { var args = arguments; this.children.forEach(function(child) { @@ -189,10 +224,22 @@ var CBINode = Class.extend({ }); }, + /** + * Render the form element. + * + * The `render()` function recursively walks the form element tree and + * renders the markup for each element, returning the assembled DOM tree. + * + * @abstract + * @returns {Node|Promise<Node>} + * May return a DOM Node or a promise resolving to a DOM node containing + * the form element's markup, including the markup of any child elements. + */ render: function() { L.error('InternalError', 'Not implemented'); }, + /** @private */ loadChildren: function(/* ... */) { var tasks = []; @@ -204,6 +251,7 @@ var CBINode = Class.extend({ return Promise.all(tasks); }, + /** @private */ renderChildren: function(tab_name /*, ... */) { var tasks = [], index = 0; @@ -218,6 +266,15 @@ var CBINode = Class.extend({ return Promise.all(tasks); }, + /** + * Strip any HTML tags from the given input string. + * + * @param {string} input + * The input string to clean. + * + * @returns {string} + * The cleaned input string with HTML removes removed. + */ stripTags: function(s) { if (typeof(s) == 'string' && !s.match(/[<>]/)) return s; @@ -226,6 +283,32 @@ var CBINode = Class.extend({ return x.textContent || x.innerText || ''; }, + /** + * Format the given named property as title string. + * + * This function looks up the given named property and formats its value + * suitable for use as element caption or description string. It also + * strips any HTML tags from the result. + * + * If the property value is a string, it is passed to `String.format()` + * along with any additional parameters passed to `titleFn()`. + * + * If the property value is a function, it is invoked with any additional + * `titleFn()` parameters as arguments and the obtained return value is + * converted to a string. + * + * In all other cases, `null` is returned. + * + * @param {string} property + * The name of the element property to use. + * + * @param {...*} fmt_args + * Extra values to format the title string with. + * + * @returns {string|null} + * The formatted title string or `null` if the property did not exist or + * was neither a string nor a function. + */ titleFn: function(attr /*, ... */) { var s = null; @@ -244,7 +327,34 @@ var CBINode = Class.extend({ } }); -var CBIMap = CBINode.extend({ +/** + * @constructor Map + * @memberof LuCI.form + * @augments LuCI.form.AbstractElement + * + * @classdesc + * + * The `Map` class represents one complete form. A form usually maps one UCI + * configuraton file and is divided into multiple sections containing multiple + * fields each. + * + * It serves as main entry point into the `LuCI.form` for typical view code. + * + * @param {string} config + * The UCI configuration to map. It is automatically loaded along when the + * resulting map instance. + * + * @param {string} [title] + * The title caption of the form. A form title is usually rendered as separate + * headline element before the actual form contents. If omitted, the + * corresponding headline element will not be rendered. + * + * @param {string} [description] + * The description text of the form which is usually rendered as text + * paragraph below the form title and before the actual form conents. + * If omitted, the corresponding paragraph element will not be rendered. + */ +var CBIMap = CBIAbstractElement.extend(/** @lends LuCI.form.Map.prototype */ { __init__: function(config /*, ... */) { this.super('__init__', this.varargs(arguments, 1)); @@ -253,6 +363,36 @@ var CBIMap = CBINode.extend({ this.data = uci; }, + /** + * Find all DOM nodes within this Map which match the given search + * parameters. This function is essentially a convenience wrapper around + * `querySelectorAll()`. + * + * This function is sensitive to the amount of arguments passed to it; + * if only one argument is specified, it is used as selector-expression + * as-is. When two arguments are passed, the first argument is treated + * as attribute name, the second one as attribute value to match. + * + * As an example, `map.findElements('input')` would find all `<input>` + * nodes while `map.findElements('type', 'text')` would find any DOM node + * with a `type="text"` attribute. + * + * @param {string} selector_or_attrname + * If invoked with only one parameter, this argument is a + * `querySelectorAll()` compatible selector expression. If invoked with + * two parameters, this argument is the attribute name to filter for. + * + * @param {string} [attrvalue] + * In case the function is invoked with two parameters, this argument + * specifies the attribute value to match. + * + * @throws {InternalError} + * Throws an `InternalError` if more than two function parameters are + * passed. + * + * @returns {NodeList} + * Returns a (possibly empty) DOM `NodeList` containing the found DOM nodes. + */ findElements: function(/* ... */) { var q = null; @@ -266,16 +406,86 @@ var CBIMap = CBINode.extend({ return this.root.querySelectorAll(q); }, + /** + * Find the first DOM node within this Map which matches the given search + * parameters. This function is essentially a convenience wrapper around + * `findElements()` which only returns the first found node. + * + * This function is sensitive to the amount of arguments passed to it; + * if only one argument is specified, it is used as selector-expression + * as-is. When two arguments are passed, the first argument is treated + * as attribute name, the second one as attribute value to match. + * + * As an example, `map.findElement('input')` would find the first `<input>` + * node while `map.findElement('type', 'text')` would find the first DOM + * node with a `type="text"` attribute. + * + * @param {string} selector_or_attrname + * If invoked with only one parameter, this argument is a `querySelector()` + * compatible selector expression. If invoked with two parameters, this + * argument is the attribute name to filter for. + * + * @param {string} [attrvalue] + * In case the function is invoked with two parameters, this argument + * specifies the attribute value to match. + * + * @throws {InternalError} + * Throws an `InternalError` if more than two function parameters are + * passed. + * + * @returns {Node|null} + * Returns the first found DOM node or `null` if no element matched. + */ findElement: function(/* ... */) { var res = this.findElements.apply(this, arguments); return res.length ? res[0] : null; }, + /** + * Tie another UCI configuration to the map. + * + * By default, a map instance will only load the UCI configuration file + * specified in the constructor but sometimes access to values from + * further configuration files is required. This function allows for such + * use cases by registering further UCI configuration files which are + * needed by the map. + * + * @param {string} config + * The additional UCI configuration file to tie to the map. If the given + * config already is in the list of required files, it will be ignored. + */ chain: function(config) { if (this.parsechain.indexOf(config) == -1) this.parsechain.push(config); }, + /** + * Add a configuration section to the map. + * + * LuCI forms follow the structure of the underlying UCI configurations, + * means that a map, which represents a single UCI configuration, is + * divided into multiple sections which in turn contain an arbitrary + * number of options. + * + * While UCI itself only knows two kinds of sections - named and anonymous + * ones - the form class offers various flavors of form section elements + * to present configuration sections in different ways. Refer to the + * documentation of the different section classes for details. + * + * @param {LuCI.form.AbstractSection} sectionclass + * The section class to use for rendering the configuration section. + * Note that this value must be the class itself, not a class instance + * obtained from calling `new`. It must also be a class dervied from + * `LuCI.form.AbstractSection`. + * + * @param {...string} classargs + * Additional arguments which are passed as-is to the contructor of the + * given section class. Refer to the class specific constructor + * documentation for details. + * + * @returns {LuCI.form.AbstractSection} + * Returns the instantiated section class instance. + */ section: function(cbiClass /*, ... */) { if (!CBIAbstractSection.isSubclass(cbiClass)) L.error('TypeError', 'Class must be a descendent of CBIAbstractSection'); @@ -285,11 +495,37 @@ var CBIMap = CBINode.extend({ return obj; }, + /** + * Load the configuration covered by this map. + * + * The `load()` function first loads all referenced UCI configurations, + * then it recursively walks the form element tree and invokes the + * load function of each child element. + * + * @returns {Promise<void>} + * Returns a promise resolving once the entire form completed loading all + * data. The promise may reject with an error if any configuration failed + * to load or if any of the child elements load functions rejected with + * an error. + */ load: function() { return this.data.load(this.parsechain || [ this.config ]) .then(this.loadChildren.bind(this)); }, + /** + * Parse the form input values. + * + * The `parse()` function recursively walks the form element tree and + * triggers input value reading and validation for each child element. + * + * Elements which are hidden due to unsatisified dependencies are skipped. + * + * @returns {Promise<void>} + * Returns a promise resolving once the entire form completed parsing all + * input values. The returned promise is rejected if any parsed values are + * not meeting the validation constraints of their respective elements. + */ parse: function() { var tasks = []; @@ -300,6 +536,26 @@ var CBIMap = CBINode.extend({ return Promise.all(tasks); }, + /** + * Save the form input values. + * + * This function parses the current form, saves the resulting UCI changes, + * reloads the UCI configuration data and redraws the form elements. + * + * @param {function} [cb] + * An optional callback function that is invoked after the form is parsed + * but before the changed UCI data is saved. This is useful to perform + * additional data manipulation steps before saving the changes. + * + * @param {boolean} [silent=false] + * If set to `true`, trigger an alert message to the user in case saving + * the form data failes. Otherwise fail silently. + * + * @returns {Promise<void>} + * Returns a promise resolving once the entire save operation is complete. + * The returned promise is rejected if any step of the save operation + * failed. + */ save: function(cb, silent) { this.checkDepends(); @@ -315,14 +571,30 @@ var CBIMap = CBINode.extend({ }).finally(this.renderContents.bind(this)); }, + /** + * Reset the form by re-rendering its contents. This will revert all + * unsaved user inputs to their initial form state. + * + * @returns {Promise<Node>} + * Returns a promise resolving to the toplevel form DOM node once the + * re-rendering is complete. + */ reset: function() { return this.renderContents(); }, + /** + * Render the form markup. + * + * @returns {Promise<Node>} + * Returns a promise resolving to the toplevel form DOM node once the + * rendering is complete. + */ render: function() { return this.load().then(this.renderContents.bind(this)); }, + /** @private */ renderContents: function() { var mapEl = this.root || (this.root = E('div', { 'id': 'cbi-%s'.format(this.config), @@ -330,12 +602,12 @@ var CBIMap = CBINode.extend({ 'cbi-dependency-check': L.bind(this.checkDepends, this) })); - L.dom.bindClassInstance(mapEl, this); + dom.bindClassInstance(mapEl, this); return this.renderChildren(null).then(L.bind(function(nodes) { var initialRender = !mapEl.firstChild; - L.dom.content(mapEl, null); + dom.content(mapEl, null); if (this.title != null && this.title != '') mapEl.appendChild(E('h2', { 'name': 'content' }, this.title)); @@ -344,9 +616,9 @@ var CBIMap = CBINode.extend({ mapEl.appendChild(E('div', { 'class': 'cbi-map-descr' }, this.description)); if (this.tabbed) - L.dom.append(mapEl, E('div', { 'class': 'cbi-map-tabbed' }, nodes)); + dom.append(mapEl, E('div', { 'class': 'cbi-map-tabbed' }, nodes)); else - L.dom.append(mapEl, nodes); + dom.append(mapEl, nodes); if (!initialRender) { mapEl.classList.remove('flash'); @@ -367,6 +639,25 @@ var CBIMap = CBINode.extend({ }, this)); }, + /** + * Find a form option element instance. + * + * @param {string} name_or_id + * The name or the full ID of the option element to look up. + * + * @param {string} [section_id] + * The ID of the UCI section containing the option to look up. May be + * omitted if a full ID is passed as first argument. + * + * @param {string} [config] + * The name of the UCI configuration the option instance is belonging to. + * Defaults to the main UCI configuration of the map if omitted. + * + * @returns {Array<LuCI.form.AbstractValue,string>|null} + * Returns a two-element array containing the form option instance as + * first item and the corresponding UCI section ID as second item. + * Returns `null` if the option could not be found. + */ lookupOption: function(name, section_id, config_name) { var id, elem, sid, inst; @@ -377,11 +668,12 @@ var CBIMap = CBINode.extend({ elem = this.findElement('data-field', id); sid = elem ? id.split(/\./)[2] : null; - inst = elem ? L.dom.findClassInstance(elem) : null; + inst = elem ? dom.findClassInstance(elem) : null; return (inst instanceof CBIAbstractValue) ? [ inst, sid ] : null; }, + /** @private */ checkDepends: function(ev, n) { var changed = false; @@ -395,6 +687,7 @@ var CBIMap = CBINode.extend({ ui.tabs.updateTabs(ev, this.root); }, + /** @private */ isDependencySatisfied: function(depends, config_name, section_id) { var def = false; @@ -434,7 +727,34 @@ var CBIMap = CBINode.extend({ } }); -var CBIJSONMap = CBIMap.extend({ +/** + * @constructor JSONMap + * @memberof LuCI.form + * @augments LuCI.form.Map + * + * @classdesc + * + * A `JSONMap` class functions similar to [LuCI.form.Map]{@link LuCI.form.Map} + * but uses a multidimensional JavaScript object instead of UCI configuration + * as data source. + * + * @param {Object<string, Object<string, *>|Array<Object<string, *>>>} data + * The JavaScript object to use as data source. Internally, the object is + * converted into an UCI-like format. Its toplevel keys are treated like UCI + * section types while the object or array-of-object values are treated as + * section contents. + * + * @param {string} [title] + * The title caption of the form. A form title is usually rendered as separate + * headline element before the actual form contents. If omitted, the + * corresponding headline element will not be rendered. + * + * @param {string} [description] + * The description text of the form which is usually rendered as text + * paragraph below the form title and before the actual form conents. + * If omitted, the corresponding paragraph element will not be rendered. + */ +var CBIJSONMap = CBIMap.extend(/** @lends LuCI.form.JSONMap.prototype */ { __init__: function(data /*, ... */) { this.super('__init__', this.varargs(arguments, 1, 'json')); @@ -444,7 +764,21 @@ var CBIJSONMap = CBIMap.extend({ } }); -var CBIAbstractSection = CBINode.extend({ +/** + * @class AbstractSection + * @memberof LuCI.form + * @augments LuCI.form.AbstractElement + * @hideconstructor + * @classdesc + * + * The `AbstractSection` class serves as abstract base for the different form + * section styles implemented by `LuCI.form`. It provides the common logic for + * enumerating underlying configuration section instances, for registering + * form options and for handling tabs to segment child options. + * + * This class is private and not directly accessible by user code. + */ +var CBIAbstractSection = CBIAbstractElement.extend(/** @lends LuCI.form.AbstractSection.prototype */ { __init__: function(map, sectionType /*, ... */) { this.super('__init__', this.varargs(arguments, 2)); @@ -457,14 +791,68 @@ var CBIAbstractSection = CBINode.extend({ this.dynamic = false; }, + /** + * Access the parent option container instance. + * + * In case this section is nested within an option element container, + * this property will hold a reference to the parent option instance. + * + * If this section is not nested, the property is `null`. + * + * @name LuCI.form.AbstractSection.prototype#parentoption + * @type LuCI.form.AbstractValue + * @readonly + */ + + /** + * Enumerate the UCI section IDs covered by this form section element. + * + * @abstract + * @throws {InternalError} + * Throws an `InternalError` exception if the function is not implemented. + * + * @returns {string[]} + * Returns an array of UCI section IDs covered by this form element. + * The sections will be rendered in the same order as the returned array. + */ cfgsections: function() { L.error('InternalError', 'Not implemented'); }, + /** + * Filter UCI section IDs to render. + * + * The filter function is invoked for each UCI section ID of a given type + * and controls whether the given UCI section is rendered or ignored by + * the form section element. + * + * The default implementation always returns `true`. User code or + * classes extending `AbstractSection` may overwrite this function with + * custom implementations. + * + * @abstract + * @param {string} section_id + * The UCI section ID to test. + * + * @returns {boolean} + * Returns `true` when the given UCI section ID should be handled and + * `false` when it should be ignored. + */ filter: function(section_id) { return true; }, + /** + * Load the configuration covered by this section. + * + * The `load()` function recursively walks the section element tree and + * invokes the load function of each child option element. + * + * @returns {Promise<void>} + * Returns a promise resolving once the values of all child elements have + * been loaded. The promise may reject with an error if any of the child + * elements load functions rejected with an error. + */ load: function() { var section_ids = this.cfgsections(), tasks = []; @@ -480,6 +868,20 @@ var CBIAbstractSection = CBINode.extend({ return Promise.all(tasks); }, + /** + * Parse this sections form input. + * + * The `parse()` function recursively walks the section element tree and + * triggers input value reading and validation for each encountered child + * option element. + * + * Options which are hidden due to unsatisified dependencies are skipped. + * + * @returns {Promise<void>} + * Returns a promise resolving once the values of all child elements have + * been parsed. The returned promise is rejected if any parsed values are + * not meeting the validation constraints of their respective elements. + */ parse: function() { var section_ids = this.cfgsections(), tasks = []; @@ -492,6 +894,35 @@ var CBIAbstractSection = CBINode.extend({ return Promise.all(tasks); }, + /** + * Add an option tab to the section. + * + * The child option elements of a section may be divided into multiple + * tabs to provide a better overview to the user. + * + * Before options can be moved into a tab pane, the corresponding tab + * has to be defined first, which is done by calling this function. + * + * Note that once tabs are defined, user code must use the `taboption()` + * method to add options to specific tabs. Option elements added by + * `option()` will not be assigned to any tab and not be rendered in this + * case. + * + * @param {string} name + * The name of the tab to register. It may be freely chosen and just serves + * as an identifier to differentiate tabs. + * + * @param {string} title + * The human readable caption of the tab. + * + * @param {string} [description] + * An additional description text for the corresponding tab pane. It is + * displayed as text paragraph below the tab but before the tab pane + * contents. If omitted, no description will be rendered. + * + * @throws {Error} + * Throws an exeption if a tab with the same `name` already exists. + */ tab: function(name, title, description) { if (this.tabs && this.tabs[name]) throw 'Tab already declared'; @@ -511,6 +942,30 @@ var CBIAbstractSection = CBINode.extend({ this.tab_names.push(name); }, + /** + * Add a configuration option widget to the section. + * + * Note that [taboption()]{@link LuCI.form.AbstractSection#taboption} + * should be used instead if this form section element uses tabs. + * + * @param {LuCI.form.AbstractValue} optionclass + * The option class to use for rendering the configuration option. Note + * that this value must be the class itself, not a class instance obtained + * from calling `new`. It must also be a class dervied from + * [LuCI.form.AbstractSection]{@link LuCI.form.AbstractSection}. + * + * @param {...*} classargs + * Additional arguments which are passed as-is to the contructor of the + * given option class. Refer to the class specific constructor + * documentation for details. + * + * @throws {TypeError} + * Throws a `TypeError` exception in case the passed class value is not a + * descendent of `AbstractValue`. + * + * @returns {LuCI.form.AbstractValue} + * Returns the instantiated option class instance. + */ option: function(cbiClass /*, ... */) { if (!CBIAbstractValue.isSubclass(cbiClass)) throw L.error('TypeError', 'Class must be a descendent of CBIAbstractValue'); @@ -520,6 +975,34 @@ var CBIAbstractSection = CBINode.extend({ return obj; }, + /** + * Add a configuration option widget to a tab of the section. + * + * @param {string} tabname + * The name of the section tab to add the option element to. + * + * @param {LuCI.form.AbstractValue} optionclass + * The option class to use for rendering the configuration option. Note + * that this value must be the class itself, not a class instance obtained + * from calling `new`. It must also be a class dervied from + * [LuCI.form.AbstractSection]{@link LuCI.form.AbstractSection}. + * + * @param {...*} classargs + * Additional arguments which are passed as-is to the contructor of the + * given option class. Refer to the class specific constructor + * documentation for details. + * + * @throws {ReferenceError} + * Throws a `ReferenceError` exception when the given tab name does not + * exist. + * + * @throws {TypeError} + * Throws a `TypeError` exception in case the passed class value is not a + * descendent of `AbstractValue`. + * + * @returns {LuCI.form.AbstractValue} + * Returns the instantiated option class instance. + */ taboption: function(tabName /*, ... */) { if (!this.tabs || !this.tabs[tabName]) throw L.error('ReferenceError', 'Associated tab not declared'); @@ -530,6 +1013,7 @@ var CBIAbstractSection = CBINode.extend({ return obj; }, + /** @private */ renderUCISection: function(section_id) { var renderTasks = []; @@ -543,6 +1027,7 @@ var CBIAbstractSection = CBINode.extend({ .then(this.renderTabContainers.bind(this, section_id)); }, + /** @private */ renderTabContainers: function(section_id, nodes) { var config_name = this.uciconfig || this.map.config, containerEls = E([]); @@ -568,6 +1053,7 @@ var CBIAbstractSection = CBINode.extend({ return containerEls; }, + /** @private */ renderOptions: function(tab_name, section_id) { var in_table = (this instanceof CBITableSection); return this.renderChildren(tab_name, section_id, in_table).then(function(nodes) { @@ -578,6 +1064,7 @@ var CBIAbstractSection = CBINode.extend({ }); }, + /** @private */ checkDepends: function(ev, n) { var changed = false, sids = this.cfgsections(); @@ -655,7 +1142,21 @@ var isContained = function(x, y) { return false; }; -var CBIAbstractValue = CBINode.extend({ +/** + * @class AbstractValue + * @memberof LuCI.form + * @augments LuCI.form.AbstractElement + * @hideconstructor + * @classdesc + * + * The `AbstractValue` class serves as abstract base for the different form + * option styles implemented by `LuCI.form`. It provides the common logic for + * handling option input values, for dependencies among options and for + * validation constraints that should be applied to entered values. + * + * This class is private and not directly accessible by user code. + */ +var CBIAbstractValue = CBIAbstractElement.extend(/** @lends LuCI.form.AbstractValue.prototype */ { __init__: function(map, section, option /*, ... */) { this.super('__init__', this.varargs(arguments, 3)); @@ -672,6 +1173,237 @@ var CBIAbstractValue = CBINode.extend({ this.optional = false; }, + /** + * If set to `false`, the underlying option value is retained upon saving + * the form when the option element is disabled due to unsatisfied + * dependency constraints. + * + * @name LuCI.form.AbstractValue.prototype#rmempty + * @type boolean + * @default true + */ + + /** + * If set to `true`, the underlying ui input widget is allowed to be empty, + * otherwise the option element is marked invalid when no value is entered + * or selected by the user. + * + * @name LuCI.form.AbstractValue.prototype#optional + * @type boolean + * @default false + */ + + /** + * Sets a default value to use when the underlying UCI option is not set. + * + * @name LuCI.form.AbstractValue.prototype#default + * @type * + * @default null + */ + + /** + * Specifies a datatype constraint expression to validate input values + * against. Refer to {@link LuCI.validation} for details on the format. + * + * If the user entered input does not match the datatype validation, the + * option element is marked as invalid. + * + * @name LuCI.form.AbstractValue.prototype#datatype + * @type string + * @default null + */ + + /** + * Specifies a custom validation function to test the user input for + * validity. The validation function must return `true` to accept the + * value. Any other return value type is converted to a string and + * displayed to the user as validation error message. + * + * If the user entered input does not pass the validation function, the + * option element is marked as invalid. + * + * @name LuCI.form.AbstractValue.prototype#validate + * @type function + * @default null + */ + + /** + * Override the UCI configuration name to read the option value from. + * + * By default, the configuration name is inherited from the parent Map. + * By setting this property, a deviating configuration may be specified. + * + * The default is null, means inheriting from the parent form. + * + * @name LuCI.form.AbstractValue.prototype#uciconfig + * @type string + * @default null + */ + + /** + * Override the UCI section name to read the option value from. + * + * By default, the section ID is inherited from the parent section element. + * By setting this property, a deviating section may be specified. + * + * The default is null, means inheriting from the parent section. + * + * @name LuCI.form.AbstractValue.prototype#ucisection + * @type string + * @default null + */ + + /** + * Override the UCI option name to read the value from. + * + * By default, the elements name, which is passed as third argument to + * the constructor, is used as UCI option name. By setting this property, + * a deviating UCI option may be specified. + * + * The default is null, means using the option element name. + * + * @name LuCI.form.AbstractValue.prototype#ucioption + * @type string + * @default null + */ + + /** + * Mark grid section option element as editable. + * + * Options which are displayed in the table portion of a `GridSection` + * instance are rendered as readonly text by default. By setting the + * `editable` property of a child option element to `true`, that element + * is rendered as full input widget within its cell instead of a text only + * preview. + * + * This property has no effect on options that are not children of grid + * section elements. + * + * @name LuCI.form.AbstractValue.prototype#editable + * @type boolean + * @default false + */ + + /** + * Move grid section option element into the table, the modal popup or both. + * + * If this property is `null` (the default), the option element is + * displayed in both the table preview area and the per-section instance + * modal popup of a grid section. When it is set to `false` the option + * is only shown in the table but not the modal popup. When set to `true`, + * the option is only visible in the modal popup but not the table. + * + * This property has no effect on options that are not children of grid + * section elements. + * + * @name LuCI.form.AbstractValue.prototype#modalonly + * @type boolean + * @default null + */ + + /** + * Override the cell width of a table or grid section child option. + * + * If the property is set to a numeric value, it is treated as pixel width + * which is set on the containing cell element of the option, essentially + * forcing a certain column width. When the property is set to a string + * value, it is applied as-is to the CSS `width` property. + * + * This property has no effect on options that are not children of grid or + * table section elements. + * + * @name LuCI.form.AbstractValue.prototype#width + * @type number|string + * @default null + */ + + /** + * Add a dependency contraint to the option. + * + * Dependency constraints allow making the presence of option elements + * dependant on the current values of certain other options within the + * same form. An option element with unsatisfied dependencies will be + * hidden from the view and its current value is omitted when saving. + * + * Multiple constraints (that is, multiple calls to `depends()`) are + * treated as alternatives, forming a logical "or" expression. + * + * By passing an object of name => value pairs as first argument, it is + * possible to depend on multiple options simultaneously, allowing to form + * a logical "and" expression. + * + * Option names may be given in "dot notation" which allows to reference + * option elements outside of the current form section. If a name without + * dot is specified, it refers to an option within the same configuration + * section. If specified as <code>configname.sectionid.optionname</code>, + * options anywhere within the same form may be specified. + * + * The object notation also allows for a number of special keys which are + * not treated as option names but as modifiers to influence the dependency + * constraint evaluation. The associated value of these special "tag" keys + * is ignored. The recognized tags are: + * + * <ul> + * <li> + * <code>!reverse</code><br> + * Invert the dependency, instead of requiring another option to be + * equal to the dependency value, that option should <em>not</em> be + * equal. + * </li> + * <li> + * <code>!contains</code><br> + * Instead of requiring an exact match, the dependency is considered + * satisfied when the dependency value is contained within the option + * value. + * </li> + * <li> + * <code>!default</code><br> + * The dependency is always satisfied + * </li> + * </ul> + * + * Examples: + * + * <ul> + * <li> + * <code>opt.depends("foo", "test")</code><br> + * Require the value of `foo` to be `test`. + * </li> + * <li> + * <code>opt.depends({ foo: "test" })</code><br> + * Equivalent to the previous example. + * </li> + * <li> + * <code>opt.depends({ foo: "test", bar: "qrx" })</code><br> + * Require the value of `foo` to be `test` and the value of `bar` to be + * `qrx`. + * </li> + * <li> + * <code>opt.depends({ foo: "test" })<br> + * opt.depends({ bar: "qrx" })</code><br> + * Require either <code>foo</code> to be set to <code>test</code>, + * <em>or</em> the <code>bar</code> option to be <code>qrx</code>. + * </li> + * <li> + * <code>opt.depends("test.section1.foo", "bar")</code><br> + * Require the "foo" form option within the "section1" section to be + * set to "bar". + * </li> + * <li> + * <code>opt.depends({ foo: "test", "!contains": true })</code><br> + * Require the "foo" option value to contain the substring "test". + * </li> + * </ul> + * + * @param {string|Object<string, string|boolean>} optionname_or_depends + * The name of the option to depend on or an object describing multiple + * dependencies which must be satified (a logical "and" expression). + * + * @param {string} optionvalue + * When invoked with a plain option name as first argument, this parameter + * specifies the expected value. In case an object is passed as first + * argument, this parameter is ignored. + */ depends: function(field, value) { var deps; @@ -683,6 +1415,7 @@ var CBIAbstractValue = CBINode.extend({ this.deps.push(deps); }, + /** @private */ transformDepList: function(section_id, deplist) { var list = deplist || this.deps, deps = []; @@ -718,6 +1451,7 @@ var CBIAbstractValue = CBINode.extend({ return deps; }, + /** @private */ transformChoices: function() { if (!Array.isArray(this.keylist) || this.keylist.length == 0) return null; @@ -730,6 +1464,7 @@ var CBIAbstractValue = CBINode.extend({ return choices; }, + /** @private */ checkDepends: function(section_id) { var config_name = this.uciconfig || this.section.uciconfig || this.map.config, active = this.map.isDependencySatisfied(this.deps, config_name, section_id); @@ -740,6 +1475,7 @@ var CBIAbstractValue = CBINode.extend({ return active; }, + /** @private */ updateDefaultValue: function(section_id) { if (!L.isObject(this.defaults)) return; @@ -764,11 +1500,28 @@ var CBIAbstractValue = CBINode.extend({ var node = this.map.findElement('id', this.cbid(section_id)); if (node && node.getAttribute('data-changed') != 'true' && satisified_defval != null && cfgvalue == null) - L.dom.callClassMethod(node, 'setValue', satisified_defval); + dom.callClassMethod(node, 'setValue', satisified_defval); this.default = satisified_defval; }, + /** + * Obtain the internal ID ("cbid") of the element instance. + * + * Since each form section element may map multiple underlying + * configuration sections, the configuration section ID is required to + * form a fully qualified ID pointing to the specific element instance + * within the given specific section. + * + * @param {string} section_id + * The configuration section ID + * + * @throws {TypeError} + * Throws a `TypeError` exception when no `section_id` was specified. + * + * @returns {string} + * Returns the element ID. + */ cbid: function(section_id) { if (section_id == null) L.error('TypeError', 'Section ID required'); @@ -778,6 +1531,25 @@ var CBIAbstractValue = CBINode.extend({ section_id, this.option); }, + /** + * Load the underlying configuration value. + * + * The default implementation of this method reads and returns the + * underlying UCI option value (or the related JavaScript property for + * `JSONMap` instances). It may be overwritten by user code to load data + * from nonstandard sources. + * + * @param {string} section_id + * The configuration section ID + * + * @throws {TypeError} + * Throws a `TypeError` exception when no `section_id` was specified. + * + * @returns {*|Promise<*>} + * Returns the configuration value to initialize the option element with. + * The return value of this function is filtered through `Promise.resolve()` + * so it may return promises if overridden by user code. + */ load: function(section_id) { if (section_id == null) L.error('TypeError', 'Section ID required'); @@ -788,12 +1560,42 @@ var CBIAbstractValue = CBINode.extend({ this.ucioption || this.option); }, + /** + * Obtain the underlying `LuCI.ui` element instance. + * + * @param {string} section_id + * The configuration section ID + * + * @throws {TypeError} + * Throws a `TypeError` exception when no `section_id` was specified. + * + * @return {LuCI.ui.AbstractElement|null} + * Returns the `LuCI.ui` element instance or `null` in case the form + * option implementation does not use `LuCI.ui` widgets. + */ getUIElement: function(section_id) { var node = this.map.findElement('id', this.cbid(section_id)), - inst = node ? L.dom.findClassInstance(node) : null; + inst = node ? dom.findClassInstance(node) : null; return (inst instanceof ui.AbstractElement) ? inst : null; }, + /** + * Query the underlying configuration value. + * + * The default implementation of this method returns the cached return + * value of [load()]{@link LuCI.form.AbstractValue#load}. It may be + * overwritten by user code to obtain the configuration value in a + * different way. + * + * @param {string} section_id + * The configuration section ID + * + * @throws {TypeError} + * Throws a `TypeError` exception when no `section_id` was specified. + * + * @returns {*} + * Returns the configuration value. + */ cfgvalue: function(section_id, set_value) { if (section_id == null) L.error('TypeError', 'Section ID required'); @@ -806,11 +1608,46 @@ var CBIAbstractValue = CBINode.extend({ return this.data ? this.data[section_id] : null; }, + /** + * Query the current form input value. + * + * The default implementation of this method returns the current input + * value of the underlying [LuCI.ui]{@link LuCI.ui.AbstractElement} widget. + * It may be overwritten by user code to handle input values differently. + * + * @param {string} section_id + * The configuration section ID + * + * @throws {TypeError} + * Throws a `TypeError` exception when no `section_id` was specified. + * + * @returns {*} + * Returns the current input value. + */ formvalue: function(section_id) { var elem = this.getUIElement(section_id); return elem ? elem.getValue() : null; }, + /** + * Obtain a textual input representation. + * + * The default implementation of this method returns the HTML escaped + * current input value of the underlying + * [LuCI.ui]{@link LuCI.ui.AbstractElement} widget. User code or specific + * option element implementations may overwrite this function to apply a + * different logic, e.g. to return `Yes` or `No` depending on the checked + * state of checkbox elements. + * + * @param {string} section_id + * The configuration section ID + * + * @throws {TypeError} + * Throws a `TypeError` exception when no `section_id` was specified. + * + * @returns {string} + * Returns the text representation of the current input value. + */ textvalue: function(section_id) { var cval = this.cfgvalue(section_id); @@ -820,20 +1657,67 @@ var CBIAbstractValue = CBINode.extend({ return (cval != null) ? '%h'.format(cval) : null; }, + /** + * Apply custom validation logic. + * + * This method is invoked whenever incremental validation is performed on + * the user input, e.g. on keyup or blur events. + * + * The default implementation of this method does nothing and always + * returns `true`. User code may overwrite this method to provide + * additional validation logic which is not covered by data type + * constraints. + * + * @abstract + * @param {string} section_id + * The configuration section ID + * + * @param {*} value + * The value to validate + * + * @returns {*} + * The method shall return `true` to accept the given value. Any other + * return value is treated as failure, converted to a string and displayed + * as error message to the user. + */ validate: function(section_id, value) { return true; }, + /** + * Test whether the input value is currently valid. + * + * @param {string} section_id + * The configuration section ID + * + * @returns {boolean} + * Returns `true` if the input value currently is valid, otherwise it + * returns `false`. + */ isValid: function(section_id) { var elem = this.getUIElement(section_id); return elem ? elem.isValid() : true; }, + /** + * Test whether the option element is currently active. + * + * An element is active when it is not hidden due to unsatisfied dependency + * constraints. + * + * @param {string} section_id + * The configuration section ID + * + * @returns {boolean} + * Returns `true` if the option element currently is active, otherwise it + * returns `false`. + */ isActive: function(section_id) { var field = this.map.findElement('data-field', this.cbid(section_id)); return (field != null && !field.classList.contains('hidden')); }, + /** @private */ setActive: function(section_id, active) { var field = this.map.findElement('data-field', this.cbid(section_id)); @@ -845,11 +1729,26 @@ var CBIAbstractValue = CBINode.extend({ return false; }, + /** @private */ triggerValidation: function(section_id) { var elem = this.getUIElement(section_id); return elem ? elem.triggerValidation() : true; }, + /** + * Parse the option element input. + * + * The function is invoked when the `parse()` method has been invoked on + * the parent form and triggers input value reading and validation. + * + * @param {string} section_id + * The configuration section ID + * + * @returns {Promise<void>} + * Returns a promise resolving once the input value has been read and + * validated or rejecting in case the input value does not meet the + * validation constraints. + */ parse: function(section_id) { var active = this.isActive(section_id), cval = this.cfgvalue(section_id), @@ -875,6 +1774,26 @@ var CBIAbstractValue = CBINode.extend({ return Promise.resolve(); }, + /** + * Write the current input value into the configuration. + * + * This function is invoked upon saving the parent form when the option + * element is valid and when its input value has been changed compared to + * the initial value returned by + * [cfgvalue()]{@link LuCI.form.AbstractValue#cfgvalue}. + * + * The default implementation simply sets the given input value in the + * UCI configuration (or the associated JavaScript object property in + * case of `JSONMap` forms). It may be overwritten by user code to + * implement alternative save logic, e.g. to transform the input value + * before it is written. + * + * @param {string} section_id + * The configuration section ID + * + * @param {string|string[]} formvalue + * The input value to write. + */ write: function(section_id, formvalue) { return this.map.data.set( this.uciconfig || this.section.uciconfig || this.map.config, @@ -883,6 +1802,21 @@ var CBIAbstractValue = CBINode.extend({ formvalue); }, + /** + * Remove the corresponding value from the configuration. + * + * This function is invoked upon saving the parent form when the option + * element has been hidden due to unsatisfied dependencies or when the + * user cleared the input value and the option is marked optional. + * + * The default implementation simply removes the associated option from the + * UCI configuration (or the associated JavaScript object property in + * case of `JSONMap` forms). It may be overwritten by user code to + * implement alternative removal logic, e.g. to retain the original value. + * + * @param {string} section_id + * The configuration section ID + */ remove: function(section_id) { return this.map.data.unset( this.uciconfig || this.section.uciconfig || this.map.config, @@ -891,15 +1825,101 @@ var CBIAbstractValue = CBINode.extend({ } }); -var CBITypedSection = CBIAbstractSection.extend({ +/** + * @class TypedSection + * @memberof LuCI.form + * @augments LuCI.form.AbstractSection + * @hideconstructor + * @classdesc + * + * The `TypedSection` class maps all or - if `filter()` is overwritten - a + * subset of the underlying UCI configuration sections of a given type. + * + * Layout wise, the configuration section instances mapped by the section + * element (sometimes referred to as "section nodes") are stacked beneath + * each other in a single column, with an optional section remove button next + * to each section node and a section add button at the end, depending on the + * value of the `addremove` property. + * + * @param {LuCI.form.Map|LuCI.form.JSONMap} form + * The configuration form this section is added to. It is automatically passed + * by [section()]{@link LuCI.form.Map#section}. + * + * @param {string} section_type + * The type of the UCI section to map. + * + * @param {string} [title] + * The title caption of the form section element. + * + * @param {string} [description] + * The description text of the form section element. + */ +var CBITypedSection = CBIAbstractSection.extend(/** @lends LuCI.form.TypedSection.prototype */ { __name__: 'CBI.TypedSection', + /** + * If set to `true`, the user may add or remove instances from the form + * section widget, otherwise only preexisting sections may be edited. + * The default is `false`. + * + * @name LuCI.form.TypedSection.prototype#addremove + * @type boolean + * @default false + */ + + /** + * If set to `true`, mapped section instances are treated as anonymous + * UCI sections, which means that section instance elements will be + * rendered without title element and that no name is required when adding + * new sections. The default is `false`. + * + * @name LuCI.form.TypedSection.prototype#anonymous + * @type boolean + * @default false + */ + + /** + * When set to `true`, instead of rendering section instances one below + * another, treat each instance as separate tab pane and render a tab menu + * at the top of the form section element, allowing the user to switch + * among instances. The default is `false`. + * + * @name LuCI.form.TypedSection.prototype#tabbed + * @type boolean + * @default false + */ + + /** + * Override the caption used for the section add button at the bottom of + * the section form element. If set to a string, it will be used as-is, + * if set to a function, the function will be invoked and its return value + * is used as caption, after converting it to a string. If this property + * is not set, the default is `Add`. + * + * @name LuCI.form.TypedSection.prototype#addbtntitle + * @type string|function + * @default null + */ + + /** + * Override the UCI configuration name to read the section IDs from. By + * default, the configuration name is inherited from the parent `Map`. + * By setting this property, a deviating configuration may be specified. + * The default is `null`, means inheriting from the parent form. + * + * @name LuCI.form.TypedSection.prototype#uciconfig + * @type string + * @default null + */ + + /** @override */ cfgsections: function() { return this.map.data.sections(this.uciconfig || this.map.config, this.sectiontype) .map(function(s) { return s['.name'] }) .filter(L.bind(this.filter, this)); }, + /** @private */ handleAdd: function(ev, name) { var config_name = this.uciconfig || this.map.config; @@ -907,6 +1927,7 @@ var CBITypedSection = CBIAbstractSection.extend({ return this.map.save(null, true); }, + /** @private */ handleRemove: function(section_id, ev) { var config_name = this.uciconfig || this.map.config; @@ -914,6 +1935,7 @@ var CBITypedSection = CBIAbstractSection.extend({ return this.map.save(null, true); }, + /** @private */ renderSectionAdd: function(extra_class) { if (!this.addremove) return E([]); @@ -929,7 +1951,7 @@ var CBITypedSection = CBIAbstractSection.extend({ createEl.appendChild(E('button', { 'class': 'cbi-button cbi-button-add', 'title': btn_title || _('Add'), - 'click': L.ui.createHandlerFn(this, 'handleAdd') + 'click': ui.createHandlerFn(this, 'handleAdd') }, [ btn_title || _('Add') ])); } else { @@ -938,14 +1960,14 @@ var CBITypedSection = CBIAbstractSection.extend({ 'class': 'cbi-section-create-name' }); - L.dom.append(createEl, [ + dom.append(createEl, [ E('div', {}, nameEl), E('input', { 'class': 'cbi-button cbi-button-add', 'type': 'submit', 'value': btn_title || _('Add'), 'title': btn_title || _('Add'), - 'click': L.ui.createHandlerFn(this, function(ev) { + 'click': ui.createHandlerFn(this, function(ev) { if (nameEl.classList.contains('cbi-input-invalid')) return; @@ -960,6 +1982,7 @@ var CBITypedSection = CBIAbstractSection.extend({ return createEl; }, + /** @private */ renderSectionPlaceholder: function() { return E([ E('em', _('This section contains no values yet')), @@ -967,6 +1990,7 @@ var CBITypedSection = CBIAbstractSection.extend({ ]); }, + /** @private */ renderContents: function(cfgsections, nodes) { var section_id = null, config_name = this.uciconfig || this.map.config, @@ -991,7 +2015,7 @@ var CBITypedSection = CBIAbstractSection.extend({ 'class': 'cbi-button', 'name': 'cbi.rts.%s.%s'.format(config_name, cfgsections[i]), 'data-section-id': cfgsections[i], - 'click': L.ui.createHandlerFn(this, 'handleRemove', cfgsections[i]) + 'click': ui.createHandlerFn(this, 'handleRemove', cfgsections[i]) }, [ _('Delete') ]))); } @@ -1011,11 +2035,12 @@ var CBITypedSection = CBIAbstractSection.extend({ sectionEl.appendChild(this.renderSectionAdd()); - L.dom.bindClassInstance(sectionEl, this); + dom.bindClassInstance(sectionEl, this); return sectionEl; }, + /** @override */ render: function() { var cfgsections = this.cfgsections(), renderTasks = []; @@ -1027,13 +2052,175 @@ var CBITypedSection = CBIAbstractSection.extend({ } }); -var CBITableSection = CBITypedSection.extend({ +/** + * @class TableSection + * @memberof LuCI.form + * @augments LuCI.form.TypedSection + * @hideconstructor + * @classdesc + * + * The `TableSection` class maps all or - if `filter()` is overwritten - a + * subset of the underlying UCI configuration sections of a given type. + * + * Layout wise, the configuration section instances mapped by the section + * element (sometimes referred to as "section nodes") are rendered as rows + * within an HTML table element, with an optional section remove button in the + * last column and a section add button below the table, depending on the + * value of the `addremove` property. + * + * @param {LuCI.form.Map|LuCI.form.JSONMap} form + * The configuration form this section is added to. It is automatically passed + * by [section()]{@link LuCI.form.Map#section}. + * + * @param {string} section_type + * The type of the UCI section to map. + * + * @param {string} [title] + * The title caption of the form section element. + * + * @param {string} [description] + * The description text of the form section element. + */ +var CBITableSection = CBITypedSection.extend(/** @lends LuCI.form.TableSection.prototype */ { __name__: 'CBI.TableSection', + /** + * If set to `true`, the user may add or remove instances from the form + * section widget, otherwise only preexisting sections may be edited. + * The default is `false`. + * + * @name LuCI.form.TableSection.prototype#addremove + * @type boolean + * @default false + */ + + /** + * If set to `true`, mapped section instances are treated as anonymous + * UCI sections, which means that section instance elements will be + * rendered without title element and that no name is required when adding + * new sections. The default is `false`. + * + * @name LuCI.form.TableSection.prototype#anonymous + * @type boolean + * @default false + */ + + /** + * Override the caption used for the section add button at the bottom of + * the section form element. If set to a string, it will be used as-is, + * if set to a function, the function will be invoked and its return value + * is used as caption, after converting it to a string. If this property + * is not set, the default is `Add`. + * + * @name LuCI.form.TableSection.prototype#addbtntitle + * @type string|function + * @default null + */ + + /** + * Override the per-section instance title caption shown in the first + * column of the table unless `anonymous` is set to true. If set to a + * string, it will be used as `String.format()` pattern with the name of + * the underlying UCI section as first argument, if set to a function, the + * function will be invoked with the section name as first argument and + * its return value is used as caption, after converting it to a string. + * If this property is not set, the default is the name of the underlying + * UCI configuration section. + * + * @name LuCI.form.TableSection.prototype#sectiontitle + * @type string|function + * @default null + */ + + /** + * Override the per-section instance modal popup title caption shown when + * clicking the `More…` button in a section specifying `max_cols`. If set + * to a string, it will be used as `String.format()` pattern with the name + * of the underlying UCI section as first argument, if set to a function, + * the function will be invoked with the section name as first argument and + * its return value is used as caption, after converting it to a string. + * If this property is not set, the default is the name of the underlying + * UCI configuration section. + * + * @name LuCI.form.TableSection.prototype#modaltitle + * @type string|function + * @default null + */ + + /** + * Override the UCI configuration name to read the section IDs from. By + * default, the configuration name is inherited from the parent `Map`. + * By setting this property, a deviating configuration may be specified. + * The default is `null`, means inheriting from the parent form. + * + * @name LuCI.form.TableSection.prototype#uciconfig + * @type string + * @default null + */ + + /** + * Specify a maximum amount of columns to display. By default, one table + * column is rendered for each child option of the form section element. + * When this option is set to a positive number, then no more columns than + * the given amount are rendered. When the number of child options exceeds + * the specified amount, a `More…` button is rendered in the last column, + * opening a modal dialog presenting all options elements in `NamedSection` + * style when clicked. + * + * @name LuCI.form.TableSection.prototype#max_cols + * @type number + * @default null + */ + + /** + * If set to `true`, alternating `cbi-rowstyle-1` and `cbi-rowstyle-2` CSS + * classes are added to the table row elements. Not all LuCI themes + * implement these row style classes. The default is `false`. + * + * @name LuCI.form.TableSection.prototype#rowcolors + * @type boolean + * @default false + */ + + /** + * Enables a per-section instance row `Edit` button which triggers a certain + * action when clicked. If set to a string, the string value is used + * as `String.format()` pattern with the name of the underlying UCI section + * as first format argument. The result is then interpreted as URL which + * LuCI will navigate to when the user clicks the edit button. + * + * If set to a function, this function will be registered as click event + * handler on the rendered edit button, receiving the section instance + * name as first and the DOM click event as second argument. + * + * @name LuCI.form.TableSection.prototype#extedit + * @type string|function + * @default null + */ + + /** + * If set to `true`, a sort button is added to the last column, allowing + * the user to reorder the section instances mapped by the section form + * element. + * + * @name LuCI.form.TableSection.prototype#sortable + * @type boolean + * @default false + */ + + /** + * The `TableSection` implementation does not support option tabbing, so + * its implementation of `tab()` will always throw an exception when + * invoked. + * + * @override + * @throws Throws an exception when invoked. + */ tab: function() { throw 'Tabs are not supported by TableSection'; }, + /** @private */ renderContents: function(cfgsections, nodes) { var section_id = null, config_name = this.uciconfig || this.map.config, @@ -1099,11 +2286,12 @@ var CBITableSection = CBITypedSection.extend({ sectionEl.appendChild(this.renderSectionAdd('cbi-tblsection-create')); - L.dom.bindClassInstance(sectionEl, this); + dom.bindClassInstance(sectionEl, this); return sectionEl; }, + /** @private */ renderHeaderRows: function(max_cols, has_action) { var has_titles = false, has_descriptions = false, @@ -1146,7 +2334,7 @@ var CBITableSection = CBITypedSection.extend({ 'title': this.titledesc || _('Go to relevant configuration page') }, opt.title)); else - L.dom.content(trEl.lastElementChild, opt.title); + dom.content(trEl.lastElementChild, opt.title); } if (this.sortable || this.extedit || this.addremove || has_more || has_action) @@ -1187,6 +2375,7 @@ var CBITableSection = CBITypedSection.extend({ return trEls; }, + /** @private */ renderRowActions: function(section_id, more_label) { var config_name = this.uciconfig || this.map.config; @@ -1198,10 +2387,10 @@ var CBITableSection = CBITypedSection.extend({ }, E('div')); if (this.sortable) { - L.dom.append(tdEl.lastElementChild, [ + dom.append(tdEl.lastElementChild, [ E('div', { 'title': _('Drag to reorder'), - 'class': 'cbi-button drag-handle center', + 'class': 'btn cbi-button drag-handle center', 'style': 'cursor:move' }, '☰') ]); @@ -1217,7 +2406,7 @@ var CBITableSection = CBITypedSection.extend({ location.href = this.extedit.format(sid); }, this, section_id); - L.dom.append(tdEl.lastElementChild, + dom.append(tdEl.lastElementChild, E('button', { 'title': _('Edit'), 'class': 'cbi-button cbi-button-edit', @@ -1227,11 +2416,11 @@ var CBITableSection = CBITypedSection.extend({ } if (more_label) { - L.dom.append(tdEl.lastElementChild, + dom.append(tdEl.lastElementChild, E('button', { 'title': more_label, 'class': 'cbi-button cbi-button-edit', - 'click': L.ui.createHandlerFn(this, 'renderMoreOptionsModal', section_id) + 'click': ui.createHandlerFn(this, 'renderMoreOptionsModal', section_id) }, [ more_label ]) ); } @@ -1239,11 +2428,11 @@ var CBITableSection = CBITypedSection.extend({ if (this.addremove) { var btn_title = this.titleFn('removebtntitle', section_id); - L.dom.append(tdEl.lastElementChild, + dom.append(tdEl.lastElementChild, E('button', { 'title': btn_title || _('Delete'), 'class': 'cbi-button cbi-button-remove', - 'click': L.ui.createHandlerFn(this, 'handleRemove', section_id) + 'click': ui.createHandlerFn(this, 'handleRemove', section_id) }, [ btn_title || _('Delete') ]) ); } @@ -1251,10 +2440,12 @@ var CBITableSection = CBITypedSection.extend({ return tdEl; }, + /** @private */ handleDragInit: function(ev) { scope.dragState = { node: ev.target }; }, + /** @private */ handleDragStart: function(ev) { if (!scope.dragState || !scope.dragState.node.classList.contains('drag-handle')) { scope.dragState = null; @@ -1262,11 +2453,12 @@ var CBITableSection = CBITypedSection.extend({ return false; } - scope.dragState.node = L.dom.parent(scope.dragState.node, '.tr'); + scope.dragState.node = dom.parent(scope.dragState.node, '.tr'); ev.dataTransfer.setData('text', 'drag'); ev.target.style.opacity = 0.4; }, + /** @private */ handleDragOver: function(ev) { var n = scope.dragState.targetNode, r = scope.dragState.rect, @@ -1286,16 +2478,19 @@ var CBITableSection = CBITypedSection.extend({ return false; }, + /** @private */ handleDragEnter: function(ev) { scope.dragState.rect = ev.currentTarget.getBoundingClientRect(); scope.dragState.targetNode = ev.currentTarget; }, + /** @private */ handleDragLeave: function(ev) { ev.currentTarget.classList.remove('drag-over-above'); ev.currentTarget.classList.remove('drag-over-below'); }, + /** @private */ handleDragEnd: function(ev) { var n = ev.target; @@ -1308,6 +2503,7 @@ var CBITableSection = CBITypedSection.extend({ }); }, + /** @private */ handleDrop: function(ev) { var s = scope.dragState; @@ -1335,22 +2531,50 @@ var CBITableSection = CBITypedSection.extend({ return false; }, + /** @private */ handleModalCancel: function(modalMap, ev) { - return Promise.resolve(L.ui.hideModal()); + return Promise.resolve(ui.hideModal()); }, + /** @private */ handleModalSave: function(modalMap, ev) { return modalMap.save() .then(L.bind(this.map.load, this.map)) .then(L.bind(this.map.reset, this.map)) - .then(L.ui.hideModal) + .then(ui.hideModal) .catch(function() {}); }, + /** + * Add further options to the per-section instanced modal popup. + * + * This function may be overwritten by user code to perform additional + * setup steps before displaying the more options modal which is useful to + * e.g. query additional data or to inject further option elements. + * + * The default implementation of this function does nothing. + * + * @abstract + * @param {LuCI.form.NamedSection} modalSection + * The `NamedSection` instance about to be rendered in the modal popup. + * + * @param {string} section_id + * The ID of the underlying UCI section the modal popup belongs to. + * + * @param {Event} ev + * The DOM event emitted by clicking the `More…` button. + * + * @returns {*|Promise<*>} + * Return values of this function are ignored but if a promise is returned, + * it is run to completion before the rendering is continued, allowing + * custom logic to perform asynchroneous work before the modal dialog + * is shown. + */ addModalOptions: function(modalSection, section_id, ev) { }, + /** @private */ renderMoreOptionsModal: function(section_id, ev) { var parent = this.map, title = parent.title, @@ -1397,16 +2621,16 @@ var CBITableSection = CBITypedSection.extend({ } return Promise.resolve(this.addModalOptions(s, section_id, ev)).then(L.bind(m.render, m)).then(L.bind(function(nodes) { - L.ui.showModal(title, [ + ui.showModal(title, [ nodes, E('div', { 'class': 'right' }, [ E('button', { 'class': 'btn', - 'click': L.ui.createHandlerFn(this, 'handleModalCancel', m) + 'click': ui.createHandlerFn(this, 'handleModalCancel', m) }, [ _('Dismiss') ]), ' ', E('button', { 'class': 'cbi-button cbi-button-positive important', - 'click': L.ui.createHandlerFn(this, 'handleModalSave', m) + 'click': ui.createHandlerFn(this, 'handleModalSave', m) }, [ _('Save') ]) ]) ], 'cbi-modal'); @@ -1414,11 +2638,80 @@ var CBITableSection = CBITypedSection.extend({ } }); -var CBIGridSection = CBITableSection.extend({ +/** + * @class GridSection + * @memberof LuCI.form + * @augments LuCI.form.TableSection + * @hideconstructor + * @classdesc + * + * The `GridSection` class maps all or - if `filter()` is overwritten - a + * subset of the underlying UCI configuration sections of a given type. + * + * A grid section functions similar to a {@link LuCI.form.TableSection} but + * supports tabbing in the modal overlay. Option elements added with + * [option()]{@link LuCI.form.GridSection#option} are shown in the table while + * elements added with [taboption()]{@link LuCI.form.GridSection#taboption} + * are displayed in the modal popup. + * + * Another important difference is that the table cells show a readonly text + * preview of the corresponding option elements by default, unless the child + * option element is explicitely made writable by setting the `editable` + * property to `true`. + * + * Additionally, the grid section honours a `modalonly` property of child + * option elements. Refer to the [AbstractValue]{@link LuCI.form.AbstractValue} + * documentation for details. + * + * Layout wise, a grid section looks mostly identical to table sections. + * + * @param {LuCI.form.Map|LuCI.form.JSONMap} form + * The configuration form this section is added to. It is automatically passed + * by [section()]{@link LuCI.form.Map#section}. + * + * @param {string} section_type + * The type of the UCI section to map. + * + * @param {string} [title] + * The title caption of the form section element. + * + * @param {string} [description] + * The description text of the form section element. + */ +var CBIGridSection = CBITableSection.extend(/** @lends LuCI.form.GridSection.prototype */ { + /** + * Add an option tab to the section. + * + * The modal option elements of a grid section may be divided into multiple + * tabs to provide a better overview to the user. + * + * Before options can be moved into a tab pane, the corresponding tab + * has to be defined first, which is done by calling this function. + * + * Note that tabs are only effective in modal popups, options added with + * `option()` will not be assigned to a specific tab and are rendered in + * the table view only. + * + * @param {string} name + * The name of the tab to register. It may be freely chosen and just serves + * as an identifier to differentiate tabs. + * + * @param {string} title + * The human readable caption of the tab. + * + * @param {string} [description] + * An additional description text for the corresponding tab pane. It is + * displayed as text paragraph below the tab but before the tab pane + * contents. If omitted, no description will be rendered. + * + * @throws {Error} + * Throws an exeption if a tab with the same `name` already exists. + */ tab: function(name, title, description) { CBIAbstractSection.prototype.tab.call(this, name, title, description); }, + /** @private */ handleAdd: function(ev, name) { var config_name = this.uciconfig || this.map.config, section_id = this.map.data.add(config_name, this.sectiontype, name); @@ -1427,11 +2720,13 @@ var CBIGridSection = CBITableSection.extend({ return this.renderMoreOptionsModal(section_id); }, + /** @private */ handleModalSave: function(/* ... */) { return this.super('handleModalSave', arguments) .then(L.bind(function() { this.addedSection = null }, this)); }, + /** @private */ handleModalCancel: function(/* ... */) { var config_name = this.uciconfig || this.map.config; @@ -1443,10 +2738,12 @@ var CBIGridSection = CBITableSection.extend({ return this.super('handleModalCancel', arguments); }, + /** @private */ renderUCISection: function(section_id) { return this.renderOptions(null, section_id); }, + /** @private */ renderChildren: function(tab_name, section_id, in_table) { var tasks = [], index = 0; @@ -1463,6 +2760,7 @@ var CBIGridSection = CBITableSection.extend({ return Promise.all(tasks); }, + /** @private */ renderTextValue: function(section_id, opt) { var title = this.stripTags(opt.title).trim(), descr = this.stripTags(opt.description).trim(), @@ -1477,14 +2775,17 @@ var CBIGridSection = CBITableSection.extend({ }, (value != null) ? value : E('em', _('none'))); }, + /** @private */ renderHeaderRows: function(section_id) { return this.super('renderHeaderRows', [ NaN, true ]); }, + /** @private */ renderRowActions: function(section_id) { return this.super('renderRowActions', [ section_id, _('Edit') ]); }, + /** @override */ parse: function() { var section_ids = this.cfgsections(), tasks = []; @@ -1504,7 +2805,36 @@ var CBIGridSection = CBITableSection.extend({ } }); -var CBINamedSection = CBIAbstractSection.extend({ +/** + * @class NamedSection + * @memberof LuCI.form + * @augments LuCI.form.AbstractSection + * @hideconstructor + * @classdesc + * + * The `NamedSection` class maps exactly one UCI section instance which is + * specified when constructing the class instance. + * + * Layout and functionality wise, a named section is essentially a + * `TypedSection` which allows exactly one section node. + * + * @param {LuCI.form.Map|LuCI.form.JSONMap} form + * The configuration form this section is added to. It is automatically passed + * by [section()]{@link LuCI.form.Map#section}. + * + * @param {string} section_id + * The name (ID) of the UCI section to map. + * + * @param {string} section_type + * The type of the UCI section to map. + * + * @param {string} [title] + * The title caption of the form section element. + * + * @param {string} [description] + * The description text of the form section element. + */ +var CBINamedSection = CBIAbstractSection.extend(/** @lends LuCI.form.NamedSection.prototype */ { __name__: 'CBI.NamedSection', __init__: function(map, section_id /*, ... */) { this.super('__init__', this.varargs(arguments, 2, map)); @@ -1512,10 +2842,40 @@ var CBINamedSection = CBIAbstractSection.extend({ this.section = section_id; }, + /** + * If set to `true`, the user may remove or recreate the sole mapped + * configuration instance from the form section widget, otherwise only a + * preexisting section may be edited. The default is `false`. + * + * @name LuCI.form.NamedSection.prototype#addremove + * @type boolean + * @default false + */ + + /** + * Override the UCI configuration name to read the section IDs from. By + * default, the configuration name is inherited from the parent `Map`. + * By setting this property, a deviating configuration may be specified. + * The default is `null`, means inheriting from the parent form. + * + * @name LuCI.form.NamedSection.prototype#uciconfig + * @type string + * @default null + */ + + /** + * The `NamedSection` class overwrites the generic `cfgsections()` + * implementation to return a one-element array containing the mapped + * section ID as sole element. User code should not normally change this. + * + * @returns {string[]} + * Returns a one-element array containing the mapped section ID. + */ cfgsections: function() { return [ this.section ]; }, + /** @private */ handleAdd: function(ev) { var section_id = this.section, config_name = this.uciconfig || this.map.config; @@ -1524,6 +2884,7 @@ var CBINamedSection = CBIAbstractSection.extend({ return this.map.save(null, true); }, + /** @private */ handleRemove: function(ev) { var section_id = this.section, config_name = this.uciconfig || this.map.config; @@ -1532,6 +2893,7 @@ var CBINamedSection = CBIAbstractSection.extend({ return this.map.save(null, true); }, + /** @private */ renderContents: function(data) { var ucidata = data[0], nodes = data[1], section_id = this.section, @@ -1555,7 +2917,7 @@ var CBINamedSection = CBIAbstractSection.extend({ E('div', { 'class': 'cbi-section-remove right' }, E('button', { 'class': 'cbi-button', - 'click': L.ui.createHandlerFn(this, 'handleRemove') + 'click': ui.createHandlerFn(this, 'handleRemove') }, [ _('Delete') ]))); } @@ -1570,15 +2932,16 @@ var CBINamedSection = CBIAbstractSection.extend({ sectionEl.appendChild( E('button', { 'class': 'cbi-button cbi-button-add', - 'click': L.ui.createHandlerFn(this, 'handleAdd') + 'click': ui.createHandlerFn(this, 'handleAdd') }, [ _('Add') ])); } - L.dom.bindClassInstance(sectionEl, this); + dom.bindClassInstance(sectionEl, this); return sectionEl; }, + /** @override */ render: function() { var config_name = this.uciconfig || this.map.config, section_id = this.section; @@ -1590,23 +2953,87 @@ var CBINamedSection = CBIAbstractSection.extend({ } }); -var CBIValue = CBIAbstractValue.extend({ +/** + * @class Value + * @memberof LuCI.form + * @augments LuCI.form.AbstractValue + * @hideconstructor + * @classdesc + * + * The `Value` class represents a simple one-line form input using the + * {@link LuCI.ui.Textfield} or - in case choices are added - the + * {@link LuCI.ui.Combobox} class as underlying widget. + * + * @param {LuCI.form.Map|LuCI.form.JSONMap} form + * The configuration form this section is added to. It is automatically passed + * by [option()]{@link LuCI.form.AbstractSection#option} or + * [taboption()]{@link LuCI.form.AbstractSection#taboption} when adding the + * option to the section. + * + * @param {LuCI.form.AbstractSection} section + * The configuration section this option is added to. It is automatically passed + * by [option()]{@link LuCI.form.AbstractSection#option} or + * [taboption()]{@link LuCI.form.AbstractSection#taboption} when adding the + * option to the section. + * + * @param {string} option + * The name of the UCI option to map. + * + * @param {string} [title] + * The title caption of the option element. + * + * @param {string} [description] + * The description text of the option element. + */ +var CBIValue = CBIAbstractValue.extend(/** @lends LuCI.form.Value.prototype */ { __name__: 'CBI.Value', + /** + * If set to `true`, the field is rendered as password input, otherwise + * as plain text input. + * + * @name LuCI.form.Value.prototype#password + * @type boolean + * @default false + */ + + /** + * Set a placeholder string to use when the input field is empty. + * + * @name LuCI.form.Value.prototype#placeholder + * @type string + * @default null + */ + + /** + * Add a predefined choice to the form option. By adding one or more + * choices, the plain text input field is turned into a combobox widget + * which prompts the user to select a predefined choice, or to enter a + * custom value. + * + * @param {string} key + * The choice value to add. + * + * @param {Node|string} value + * The caption for the choice value. May be a DOM node, a document fragment + * or a plain text string. If omitted, the `key` value is used as caption. + */ value: function(key, val) { this.keylist = this.keylist || []; this.keylist.push(String(key)); this.vallist = this.vallist || []; - this.vallist.push(L.dom.elem(val) ? val : String(val != null ? val : key)); + this.vallist.push(dom.elem(val) ? val : String(val != null ? val : key)); }, + /** @override */ render: function(option_index, section_id, in_table) { return Promise.resolve(this.cfgvalue(section_id)) .then(this.renderWidget.bind(this, section_id, option_index)) .then(this.renderFrame.bind(this, section_id, in_table, option_index)); }, + /** @private */ renderFrame: function(section_id, in_table, option_index, nodes) { var config_name = this.uciconfig || this.section.uciconfig || this.map.config, depend_list = this.transformDepList(section_id), @@ -1644,7 +3071,16 @@ var CBIValue = CBIAbstractValue.extend({ if (typeof(this.title) === 'string' && this.title !== '') { optionEl.appendChild(E('label', { 'class': 'cbi-value-title', - 'for': 'widget.cbid.%s.%s.%s'.format(config_name, section_id, this.option) + 'for': 'widget.cbid.%s.%s.%s'.format(config_name, section_id, this.option), + 'click': function(ev) { + var node = ev.currentTarget, + elem = node.nextElementSibling.querySelector('#' + node.getAttribute('for')) || node.nextElementSibling.querySelector('[data-widget-id="' + node.getAttribute('for') + '"]'); + + if (elem) { + elem.click(); + elem.focus(); + } + } }, this.titleref ? E('a', { 'class': 'cbi-title-ref', @@ -1660,7 +3096,7 @@ var CBIValue = CBIAbstractValue.extend({ (optionEl.lastChild || optionEl).appendChild(nodes); if (!in_table && typeof(this.description) === 'string' && this.description !== '') - L.dom.append(optionEl.lastChild || optionEl, + dom.append(optionEl.lastChild || optionEl, E('div', { 'class': 'cbi-value-description' }, this.description)); if (depend_list && depend_list.length) @@ -1669,11 +3105,12 @@ var CBIValue = CBIAbstractValue.extend({ optionEl.addEventListener('widget-change', L.bind(this.map.checkDepends, this.map)); - L.dom.bindClassInstance(optionEl, this); + dom.bindClassInstance(optionEl, this); return optionEl; }, + /** @private */ renderWidget: function(section_id, option_index, cfgvalue) { var value = (cfgvalue != null) ? cfgvalue : this.default, choices = this.transformChoices(), @@ -1707,9 +3144,42 @@ var CBIValue = CBIAbstractValue.extend({ } }); -var CBIDynamicList = CBIValue.extend({ +/** + * @class DynamicList + * @memberof LuCI.form + * @augments LuCI.form.Value + * @hideconstructor + * @classdesc + * + * The `DynamicList` class represents a multi value widget allowing the user + * to enter multiple unique values, optionally selected from a set of + * predefined choices. It builds upon the {@link LuCI.ui.DynamicList} widget. + * + * @param {LuCI.form.Map|LuCI.form.JSONMap} form + * The configuration form this section is added to. It is automatically passed + * by [option()]{@link LuCI.form.AbstractSection#option} or + * [taboption()]{@link LuCI.form.AbstractSection#taboption} when adding the + * option to the section. + * + * @param {LuCI.form.AbstractSection} section + * The configuration section this option is added to. It is automatically passed + * by [option()]{@link LuCI.form.AbstractSection#option} or + * [taboption()]{@link LuCI.form.AbstractSection#taboption} when adding the + * option to the section. + * + * @param {string} option + * The name of the UCI option to map. + * + * @param {string} [title] + * The title caption of the option element. + * + * @param {string} [description] + * The description text of the option element. + */ +var CBIDynamicList = CBIValue.extend(/** @lends LuCI.form.DynamicList.prototype */ { __name__: 'CBI.DynamicList', + /** @private */ renderWidget: function(section_id, option_index, cfgvalue) { var value = (cfgvalue != null) ? cfgvalue : this.default, choices = this.transformChoices(), @@ -1728,7 +3198,39 @@ var CBIDynamicList = CBIValue.extend({ }, }); -var CBIListValue = CBIValue.extend({ +/** + * @class ListValue + * @memberof LuCI.form + * @augments LuCI.form.Value + * @hideconstructor + * @classdesc + * + * The `ListValue` class implements a simple static HTML select element + * allowing the user to chose a single value from a set of predefined choices. + * It builds upon the {@link LuCI.ui.Select} widget. + * + * @param {LuCI.form.Map|LuCI.form.JSONMap} form + * The configuration form this section is added to. It is automatically passed + * by [option()]{@link LuCI.form.AbstractSection#option} or + * [taboption()]{@link LuCI.form.AbstractSection#taboption} when adding the + * option to the section. + * + * @param {LuCI.form.AbstractSection} section + * The configuration section this option is added to. It is automatically passed + * by [option()]{@link LuCI.form.AbstractSection#option} or + * [taboption()]{@link LuCI.form.AbstractSection#taboption} when adding the + * option to the section. + * + * @param {string} option + * The name of the UCI option to map. + * + * @param {string} [title] + * The title caption of the option element. + * + * @param {string} [description] + * The description text of the option element. + */ +var CBIListValue = CBIValue.extend(/** @lends LuCI.form.ListValue.prototype */ { __name__: 'CBI.ListValue', __init__: function() { @@ -1737,6 +3239,15 @@ var CBIListValue = CBIValue.extend({ this.deplist = []; }, + /** + * Set the size attribute of the underlying HTML select element. + * + * @name LuCI.form.ListValue.prototype#size + * @type number + * @default null + */ + + /** @private */ renderWidget: function(section_id, option_index, cfgvalue) { var choices = this.transformChoices(); var widget = new ui.Select((cfgvalue != null) ? cfgvalue : this.default, choices, { @@ -1752,7 +3263,38 @@ var CBIListValue = CBIValue.extend({ }, }); -var CBIFlagValue = CBIValue.extend({ +/** + * @class FlagValue + * @memberof LuCI.form + * @augments LuCI.form.Value + * @hideconstructor + * @classdesc + * + * The `FlagValue` element builds upon the {@link LuCI.ui.Checkbox} widget to + * implement a simple checkbox element. + * + * @param {LuCI.form.Map|LuCI.form.JSONMap} form + * The configuration form this section is added to. It is automatically passed + * by [option()]{@link LuCI.form.AbstractSection#option} or + * [taboption()]{@link LuCI.form.AbstractSection#taboption} when adding the + * option to the section. + * + * @param {LuCI.form.AbstractSection} section + * The configuration section this option is added to. It is automatically passed + * by [option()]{@link LuCI.form.AbstractSection#option} or + * [taboption()]{@link LuCI.form.AbstractSection#taboption} when adding the + * option to the section. + * + * @param {string} option + * The name of the UCI option to map. + * + * @param {string} [title] + * The title caption of the option element. + * + * @param {string} [description] + * The description text of the option element. + */ +var CBIFlagValue = CBIValue.extend(/** @lends LuCI.form.FlagValue.prototype */ { __name__: 'CBI.FlagValue', __init__: function() { @@ -1763,6 +3305,23 @@ var CBIFlagValue = CBIValue.extend({ this.default = this.disabled; }, + /** + * Sets the input value to use for the checkbox checked state. + * + * @name LuCI.form.FlagValue.prototype#enabled + * @type number + * @default 1 + */ + + /** + * Sets the input value to use for the checkbox unchecked state. + * + * @name LuCI.form.FlagValue.prototype#disabled + * @type number + * @default 0 + */ + + /** @private */ renderWidget: function(section_id, option_index, cfgvalue) { var widget = new ui.Checkbox((cfgvalue != null) ? cfgvalue : this.default, { id: this.cbid(section_id), @@ -1774,12 +3333,25 @@ var CBIFlagValue = CBIValue.extend({ return widget.render(); }, + /** + * Query the checked state of the underlying checkbox widget and return + * either the `enabled` or the `disabled` property value, depending on + * the checked state. + * + * @override + */ formvalue: function(section_id) { var elem = this.getUIElement(section_id), checked = elem ? elem.isChecked() : false; return checked ? this.enabled : this.disabled; }, + /** + * Query the checked state of the underlying checkbox widget and return + * either a localized `Yes` or `No` string, depending on the checked state. + * + * @override + */ textvalue: function(section_id) { var cval = this.cfgvalue(section_id); @@ -1789,6 +3361,7 @@ var CBIFlagValue = CBIValue.extend({ return (cval == this.enabled) ? _('Yes') : _('No'); }, + /** @override */ parse: function(section_id) { if (this.isActive(section_id)) { var fval = this.formvalue(section_id); @@ -1807,7 +3380,39 @@ var CBIFlagValue = CBIValue.extend({ }, }); -var CBIMultiValue = CBIDynamicList.extend({ +/** + * @class MultiValue + * @memberof LuCI.form + * @augments LuCI.form.DynamicList + * @hideconstructor + * @classdesc + * + * The `MultiValue` class is a modified variant of the `DynamicList` element + * which leverages the {@link LuCI.ui.Dropdown} widget to implement a multi + * select dropdown element. + * + * @param {LuCI.form.Map|LuCI.form.JSONMap} form + * The configuration form this section is added to. It is automatically passed + * by [option()]{@link LuCI.form.AbstractSection#option} or + * [taboption()]{@link LuCI.form.AbstractSection#taboption} when adding the + * option to the section. + * + * @param {LuCI.form.AbstractSection} section + * The configuration section this option is added to. It is automatically passed + * by [option()]{@link LuCI.form.AbstractSection#option} or + * [taboption()]{@link LuCI.form.AbstractSection#taboption} when adding the + * option to the section. + * + * @param {string} option + * The name of the UCI option to map. + * + * @param {string} [title] + * The title caption of the option element. + * + * @param {string} [description] + * The description text of the option element. + */ +var CBIMultiValue = CBIDynamicList.extend(/** @lends LuCI.form.MultiValue.prototype */ { __name__: 'CBI.MultiValue', __init__: function() { @@ -1815,6 +3420,27 @@ var CBIMultiValue = CBIDynamicList.extend({ this.placeholder = _('-- Please choose --'); }, + /** + * Allows to specify the [display_items]{@link LuCI.ui.Dropdown.InitOptions} + * property of the underlying dropdown widget. If omitted, the value of + * the `size` property is used or `3` when `size` is unspecified as well. + * + * @name LuCI.form.MultiValue.prototype#display_size + * @type number + * @default null + */ + + /** + * Allows to specify the [dropdown_items]{@link LuCI.ui.Dropdown.InitOptions} + * property of the underlying dropdown widget. If omitted, the value of + * the `size` property is used or `-1` when `size` is unspecified as well. + * + * @name LuCI.form.MultiValue.prototype#dropdown_size + * @type number + * @default null + */ + + /** @private */ renderWidget: function(section_id, option_index, cfgvalue) { var value = (cfgvalue != null) ? cfgvalue : this.default, choices = this.transformChoices(); @@ -1834,11 +3460,80 @@ var CBIMultiValue = CBIDynamicList.extend({ }, }); -var CBITextValue = CBIValue.extend({ +/** + * @class TextValue + * @memberof LuCI.form + * @augments LuCI.form.Value + * @hideconstructor + * @classdesc + * + * The `TextValue` class implements a multi-line textarea input using + * {@link LuCI.ui.Textarea}. + * + * @param {LuCI.form.Map|LuCI.form.JSONMap} form + * The configuration form this section is added to. It is automatically passed + * by [option()]{@link LuCI.form.AbstractSection#option} or + * [taboption()]{@link LuCI.form.AbstractSection#taboption} when adding the + * option to the section. + * + * @param {LuCI.form.AbstractSection} section + * The configuration section this option is added to. It is automatically passed + * by [option()]{@link LuCI.form.AbstractSection#option} or + * [taboption()]{@link LuCI.form.AbstractSection#taboption} when adding the + * option to the section. + * + * @param {string} option + * The name of the UCI option to map. + * + * @param {string} [title] + * The title caption of the option element. + * + * @param {string} [description] + * The description text of the option element. + */ +var CBITextValue = CBIValue.extend(/** @lends LuCI.form.TextValue.prototype */ { __name__: 'CBI.TextValue', + /** @ignore */ value: null, + /** + * Enforces the use of a monospace font for the textarea contents when set + * to `true`. + * + * @name LuCI.form.TextValue.prototype#monospace + * @type boolean + * @default false + */ + + /** + * Allows to specify the [cols]{@link LuCI.ui.Textarea.InitOptions} + * property of the underlying textarea widget. + * + * @name LuCI.form.TextValue.prototype#cols + * @type number + * @default null + */ + + /** + * Allows to specify the [rows]{@link LuCI.ui.Textarea.InitOptions} + * property of the underlying textarea widget. + * + * @name LuCI.form.TextValue.prototype#rows + * @type number + * @default null + */ + + /** + * Allows to specify the [wrap]{@link LuCI.ui.Textarea.InitOptions} + * property of the underlying textarea widget. + * + * @name LuCI.form.TextValue.prototype#wrap + * @type number + * @default null + */ + + /** @private */ renderWidget: function(section_id, option_index, cfgvalue) { var value = (cfgvalue != null) ? cfgvalue : this.default; @@ -1857,9 +3552,64 @@ var CBITextValue = CBIValue.extend({ } }); -var CBIDummyValue = CBIValue.extend({ +/** + * @class DummyValue + * @memberof LuCI.form + * @augments LuCI.form.Value + * @hideconstructor + * @classdesc + * + * The `DummyValue` element wraps an {@link LuCI.ui.Hiddenfield} widget and + * renders the underlying UCI option or default value as readonly text. + * + * @param {LuCI.form.Map|LuCI.form.JSONMap} form + * The configuration form this section is added to. It is automatically passed + * by [option()]{@link LuCI.form.AbstractSection#option} or + * [taboption()]{@link LuCI.form.AbstractSection#taboption} when adding the + * option to the section. + * + * @param {LuCI.form.AbstractSection} section + * The configuration section this option is added to. It is automatically passed + * by [option()]{@link LuCI.form.AbstractSection#option} or + * [taboption()]{@link LuCI.form.AbstractSection#taboption} when adding the + * option to the section. + * + * @param {string} option + * The name of the UCI option to map. + * + * @param {string} [title] + * The title caption of the option element. + * + * @param {string} [description] + * The description text of the option element. + */ +var CBIDummyValue = CBIValue.extend(/** @lends LuCI.form.DummyValue.prototype */ { __name__: 'CBI.DummyValue', + /** + * Set an URL which is opened when clicking on the dummy value text. + * + * By setting this property, the dummy value text is wrapped in an `<a>` + * element with the property value used as `href` attribute. + * + * @name LuCI.form.DummyValue.prototype#href + * @type string + * @default null + */ + + /** + * Treat the UCI option value (or the `default` property value) as HTML. + * + * By default, the value text is HTML escaped before being rendered as + * text. In some cases it may be needed to actually interpret and render + * HTML contents as-is. When set to `true`, HTML escaping is disabled. + * + * @name LuCI.form.DummyValue.prototype#rawhtml + * @type boolean + * @default null + */ + + /** @private */ renderWidget: function(section_id, option_index, cfgvalue) { var value = (cfgvalue != null) ? cfgvalue : this.default, hiddenEl = new ui.Hiddenfield(value, { id: this.cbid(section_id) }), @@ -1868,7 +3618,7 @@ var CBIDummyValue = CBIValue.extend({ if (this.href) outputEl.appendChild(E('a', { 'href': this.href })); - L.dom.append(outputEl.lastChild || outputEl, + dom.append(outputEl.lastChild || outputEl, this.rawhtml ? value : [ value ]); return E([ @@ -1877,13 +3627,99 @@ var CBIDummyValue = CBIValue.extend({ ]); }, + /** @override */ remove: function() {}, + + /** @override */ write: function() {} }); -var CBIButtonValue = CBIValue.extend({ +/** + * @class ButtonValue + * @memberof LuCI.form + * @augments LuCI.form.Value + * @hideconstructor + * @classdesc + * + * The `DummyValue` element wraps an {@link LuCI.ui.Hiddenfield} widget and + * renders the underlying UCI option or default value as readonly text. + * + * @param {LuCI.form.Map|LuCI.form.JSONMap} form + * The configuration form this section is added to. It is automatically passed + * by [option()]{@link LuCI.form.AbstractSection#option} or + * [taboption()]{@link LuCI.form.AbstractSection#taboption} when adding the + * option to the section. + * + * @param {LuCI.form.AbstractSection} section + * The configuration section this option is added to. It is automatically passed + * by [option()]{@link LuCI.form.AbstractSection#option} or + * [taboption()]{@link LuCI.form.AbstractSection#taboption} when adding the + * option to the section. + * + * @param {string} option + * The name of the UCI option to map. + * + * @param {string} [title] + * The title caption of the option element. + * + * @param {string} [description] + * The description text of the option element. + */ +var CBIButtonValue = CBIValue.extend(/** @lends LuCI.form.ButtonValue.prototype */ { __name__: 'CBI.ButtonValue', + /** + * Override the rendered button caption. + * + * By default, the option title - which is passed as fourth argument to the + * constructor - is used as caption for the button element. When setting + * this property to a string, it is used as `String.format()` pattern with + * the underlying UCI section name passed as first format argument. When + * set to a function, it is invoked passing the section ID as sole argument + * and the resulting return value is converted to a string before being + * used as button caption. + * + * The default is `null`, means the option title is used as caption. + * + * @name LuCI.form.ButtonValue.prototype#inputtitle + * @type string|function + * @default null + */ + + /** + * Override the button style class. + * + * By setting this property, a specific `cbi-button-*` CSS class can be + * selected to influence the style of the resulting button. + * + * Suitable values which are implemented by most themes are `positive`, + * `negative` and `primary`. + * + * The default is `null`, means a neutral button styling is used. + * + * @name LuCI.form.ButtonValue.prototype#inputstyle + * @type string + * @default null + */ + + /** + * Override the button click action. + * + * By default, the underlying UCI option (or default property) value is + * copied into a hidden field tied to the button element and the save + * action is triggered on the parent form element. + * + * When this property is set to a function, it is invoked instead of + * performing the default actions. The handler function will receive the + * DOM click element as first and the underlying configuration section ID + * as second argument. + * + * @name LuCI.form.ButtonValue.prototype#onclick + * @type function + * @default null + */ + + /** @private */ renderWidget: function(section_id, option_index, cfgvalue) { var value = (cfgvalue != null) ? cfgvalue : this.default, hiddenEl = new ui.Hiddenfield(value, { id: this.cbid(section_id) }), @@ -1891,10 +3727,10 @@ var CBIButtonValue = CBIValue.extend({ btn_title = this.titleFn('inputtitle', section_id) || this.titleFn('title', section_id); if (value !== false) - L.dom.content(outputEl, [ + dom.content(outputEl, [ E('button', { 'class': 'cbi-button cbi-button-%s'.format(this.inputstyle || 'button'), - 'click': L.ui.createHandlerFn(this, function(section_id, ev) { + 'click': ui.createHandlerFn(this, function(section_id, ev) { if (this.onclick) return this.onclick(ev, section_id); @@ -1904,7 +3740,7 @@ var CBIButtonValue = CBIValue.extend({ }, [ btn_title ]) ]); else - L.dom.content(outputEl, ' - '); + dom.content(outputEl, ' - '); return E([ outputEl, @@ -1913,9 +3749,48 @@ var CBIButtonValue = CBIValue.extend({ } }); -var CBIHiddenValue = CBIValue.extend({ +/** + * @class HiddenValue + * @memberof LuCI.form + * @augments LuCI.form.Value + * @hideconstructor + * @classdesc + * + * The `HiddenValue` element wraps an {@link LuCI.ui.Hiddenfield} widget. + * + * Hidden value widgets used to be necessary in legacy code which actually + * submitted the underlying HTML form the server. With client side handling of + * forms, there are more efficient ways to store hidden state data. + * + * Since this widget has no visible content, the title and description values + * of this form element should be set to `null` as well to avoid a broken or + * distorted form layout when rendering the option element. + * + * @param {LuCI.form.Map|LuCI.form.JSONMap} form + * The configuration form this section is added to. It is automatically passed + * by [option()]{@link LuCI.form.AbstractSection#option} or + * [taboption()]{@link LuCI.form.AbstractSection#taboption} when adding the + * option to the section. + * + * @param {LuCI.form.AbstractSection} section + * The configuration section this option is added to. It is automatically passed + * by [option()]{@link LuCI.form.AbstractSection#option} or + * [taboption()]{@link LuCI.form.AbstractSection#taboption} when adding the + * option to the section. + * + * @param {string} option + * The name of the UCI option to map. + * + * @param {string} [title] + * The title caption of the option element. + * + * @param {string} [description] + * The description text of the option element. + */ +var CBIHiddenValue = CBIValue.extend(/** @lends LuCI.form.HiddenValue.prototype */ { __name__: 'CBI.HiddenValue', + /** @private */ renderWidget: function(section_id, option_index, cfgvalue) { var widget = new ui.Hiddenfield((cfgvalue != null) ? cfgvalue : this.default, { id: this.cbid(section_id) @@ -1925,7 +3800,38 @@ var CBIHiddenValue = CBIValue.extend({ } }); -var CBIFileUpload = CBIValue.extend({ +/** + * @class FileUpload + * @memberof LuCI.form + * @augments LuCI.form.Value + * @hideconstructor + * @classdesc + * + * The `FileUpload` element wraps an {@link LuCI.ui.FileUpload} widget and + * offers the ability to browse, upload and select remote files. + * + * @param {LuCI.form.Map|LuCI.form.JSONMap} form + * The configuration form this section is added to. It is automatically passed + * by [option()]{@link LuCI.form.AbstractSection#option} or + * [taboption()]{@link LuCI.form.AbstractSection#taboption} when adding the + * option to the section. + * + * @param {LuCI.form.AbstractSection} section + * The configuration section this option is added to. It is automatically passed + * by [option()]{@link LuCI.form.AbstractSection#option} or + * [taboption()]{@link LuCI.form.AbstractSection#taboption} when adding the + * option to the section. + * + * @param {string} option + * The name of the UCI option to map. + * + * @param {string} [title] + * The title caption of the option element. + * + * @param {string} [description] + * The description text of the option element. + */ +var CBIFileUpload = CBIValue.extend(/** @lends LuCI.form.FileUpload.prototype */ { __name__: 'CBI.FileSelect', __init__: function(/* ... */) { @@ -1937,6 +3843,67 @@ var CBIFileUpload = CBIValue.extend({ this.root_directory = '/etc/luci-uploads'; }, + /** + * Toggle display of hidden files. + * + * Display hidden files when rendering the remote directory listing. + * Note that this is merely a cosmetic feature, hidden files are always + * included in received remote file listings. + * + * The default is `false`, means hidden files are not displayed. + * + * @name LuCI.form.FileUpload.prototype#show_hidden + * @type boolean + * @default false + */ + + /** + * Toggle file upload functionality. + * + * When set to `true`, the underlying widget provides a button which lets + * the user select and upload local files to the remote system. + * Note that this is merely a cosmetic feature, remote upload access is + * controlled by the session ACL rules. + * + * The default is `true`, means file upload functionality is displayed. + * + * @name LuCI.form.FileUpload.prototype#enable_upload + * @type boolean + * @default true + */ + + /** + * Toggle remote file delete functionality. + * + * When set to `true`, the underlying widget provides a buttons which let + * the user delete files from remote directories. Note that this is merely + * a cosmetic feature, remote delete permissions are controlled by the + * session ACL rules. + * + * The default is `true`, means file removal buttons are displayed. + * + * @name LuCI.form.FileUpload.prototype#enable_remove + * @type boolean + * @default true + */ + + /** + * Specify the root directory for file browsing. + * + * This property defines the topmost directory the file browser widget may + * navigate to, the UI will not allow browsing directories outside this + * prefix. Note that this is merely a cosmetic feature, remote file access + * and directory listing permissions are controlled by the session ACL + * rules. + * + * The default is `/etc/luci-uploads`. + * + * @name LuCI.form.FileUpload.prototype#root_directory + * @type string + * @default /etc/luci-uploads + */ + + /** @private */ renderWidget: function(section_id, option_index, cfgvalue) { var browserEl = new ui.FileUpload((cfgvalue != null) ? cfgvalue : this.default, { id: this.cbid(section_id), @@ -1951,7 +3918,45 @@ var CBIFileUpload = CBIValue.extend({ } }); -var CBISectionValue = CBIValue.extend({ +/** + * @class SectionValue + * @memberof LuCI.form + * @augments LuCI.form.Value + * @hideconstructor + * @classdesc + * + * The `SectionValue` widget embeds a form section element within an option + * element container, allowing to nest form sections into other sections. + * + * @param {LuCI.form.Map|LuCI.form.JSONMap} form + * The configuration form this section is added to. It is automatically passed + * by [option()]{@link LuCI.form.AbstractSection#option} or + * [taboption()]{@link LuCI.form.AbstractSection#taboption} when adding the + * option to the section. + * + * @param {LuCI.form.AbstractSection} section + * The configuration section this option is added to. It is automatically passed + * by [option()]{@link LuCI.form.AbstractSection#option} or + * [taboption()]{@link LuCI.form.AbstractSection#taboption} when adding the + * option to the section. + * + * @param {string} option + * The internal name of the option element holding the section. Since a section + * container element does not read or write any configuration itself, the name + * is only used internally and does not need to relate to any underlying UCI + * option name. + * + * @param {LuCI.form.AbstractSection} subsection_class + * The class to use for instantiating the nested section element. Note that + * the class value itself is expected here, not a class instance obtained by + * calling `new`. The given class argument must be a subclass of the + * `AbstractSection` class. + * + * @param {...*} [class_args] + * All further arguments are passed as-is to the subclass constructor. Refer + * to the corresponding class constructor documentations for details. + */ +var CBISectionValue = CBIValue.extend(/** @lends LuCI.form.SectionValue.prototype */ { __name__: 'CBI.ContainerValue', __init__: function(map, section, option, cbiClass /*, ... */) { this.super('__init__', [map, section, option]); @@ -1963,30 +3968,124 @@ var CBISectionValue = CBIValue.extend({ this.subsection.parentoption = this; }, + /** + * Access the embedded section instance. + * + * This property holds a reference to the instantiated nested section. + * + * @name LuCI.form.SectionValue.prototype#subsection + * @type LuCI.form.AbstractSection + * @readonly + */ + + /** @override */ load: function(section_id) { return this.subsection.load(); }, + /** @override */ parse: function(section_id) { return this.subsection.parse(); }, + /** @private */ renderWidget: function(section_id, option_index, cfgvalue) { return this.subsection.render(); }, + /** @private */ checkDepends: function(section_id) { this.subsection.checkDepends(); return CBIValue.prototype.checkDepends.apply(this, [ section_id ]); }, + /** + * Since the section container is not rendering an own widget, + * its `value()` implementation is a no-op. + * + * @override + */ + value: function() {}, + + /** + * Since the section container is not tied to any UCI configuration, + * its `write()` implementation is a no-op. + * + * @override + */ write: function() {}, + + /** + * Since the section container is not tied to any UCI configuration, + * its `remove()` implementation is a no-op. + * + * @override + */ remove: function() {}, + + /** + * Since the section container is not tied to any UCI configuration, + * its `cfgvalue()` implementation will always return `null`. + * + * @override + * @returns {null} + */ cfgvalue: function() { return null }, + + /** + * Since the section container is not tied to any UCI configuration, + * its `formvalue()` implementation will always return `null`. + * + * @override + * @returns {null} + */ formvalue: function() { return null } }); -return L.Class.extend({ +/** + * @class form + * @memberof LuCI + * @hideconstructor + * @classdesc + * + * The LuCI form class provides high level abstractions for creating creating + * UCI- or JSON backed configurations forms. + * + * To import the class in views, use `'require form'`, to import it in + * external JavaScript, use `L.require("form").then(...)`. + * + * A typical form is created by first constructing a + * {@link LuCI.form.Map} or {@link LuCI.form.JSONMap} instance using `new` and + * by subsequently adding sections and options to it. Finally + * [render()]{@link LuCI.form.Map#render} is invoked on the instance to + * assemble the HTML markup and insert it into the DOM. + * + * Example: + * + * <pre> + * 'use strict'; + * 'require form'; + * + * var m, s, o; + * + * m = new form.Map('example', 'Example form', + * 'This is an example form mapping the contents of /etc/config/example'); + * + * s = m.section(form.NamedSection, 'first_section', 'example', 'The first section', + * 'This sections maps "config example first_section" of /etc/config/example'); + * + * o = s.option(form.Flag, 'some_bool', 'A checkbox option'); + * + * o = s.option(form.ListValue, 'some_choice', 'A select element'); + * o.value('choice1', 'The first choice'); + * o.value('choice2', 'The second choice'); + * + * m.render().then(function(node) { + * document.body.appendChild(node); + * }); + * </pre> + */ +return baseclass.extend(/** @lends LuCI.form.prototype */ { Map: CBIMap, JSONMap: CBIJSONMap, AbstractSection: CBIAbstractSection, diff --git a/modules/luci-base/htdocs/luci-static/resources/fs.js b/modules/luci-base/htdocs/luci-static/resources/fs.js index 8d2760dd5e..99defb76c5 100644 --- a/modules/luci-base/htdocs/luci-static/resources/fs.js +++ b/modules/luci-base/htdocs/luci-static/resources/fs.js @@ -1,5 +1,7 @@ 'use strict'; 'require rpc'; +'require request'; +'require baseclass'; /** * @typedef {Object} FileStatEntry @@ -152,7 +154,7 @@ function handleCgiIoReply(res) { * To import the class in views, use `'require fs'`, to import it in * external JavaScript, use `L.require("fs").then(...)`. */ -var FileSystem = L.Class.extend(/** @lends LuCI.fs.prototype */ { +var FileSystem = baseclass.extend(/** @lends LuCI.fs.prototype */ { /** * Obtains a listing of the specified directory. * @@ -357,7 +359,7 @@ var FileSystem = L.Class.extend(/** @lends LuCI.fs.prototype */ { var postdata = 'sessionid=%s&path=%s' .format(encodeURIComponent(L.env.sessionid), encodeURIComponent(path)); - return L.Request.post(L.env.cgi_base + '/cgi-download', postdata, { + return request.post(L.env.cgi_base + '/cgi-download', postdata, { headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, responseType: (type == 'blob') ? 'blob' : 'text' }).then(handleCgiIoReply.bind({ type: type })); @@ -417,7 +419,7 @@ var FileSystem = L.Class.extend(/** @lends LuCI.fs.prototype */ { var postdata = 'sessionid=%s&command=%s' .format(encodeURIComponent(L.env.sessionid), cmdstr); - return L.Request.post(L.env.cgi_base + '/cgi-exec', postdata, { + return request.post(L.env.cgi_base + '/cgi-exec', postdata, { headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, responseType: (type == 'blob') ? 'blob' : 'text' }).then(handleCgiIoReply.bind({ type: type })); diff --git a/modules/luci-base/htdocs/luci-static/resources/luci.js b/modules/luci-base/htdocs/luci-static/resources/luci.js index 8d056ec03d..5984ad184a 100644 --- a/modules/luci-base/htdocs/luci-static/resources/luci.js +++ b/modules/luci-base/htdocs/luci-static/resources/luci.js @@ -57,12 +57,12 @@ }; /** - * @class Class + * @class baseclass * @hideconstructor * @memberof LuCI * @classdesc * - * `LuCI.Class` is the abstract base class all LuCI classes inherit from. + * `LuCI.baseclass` is the abstract base class all LuCI classes inherit from. * * It provides simple means to create subclasses of given classes and * implements prototypal inheritance. @@ -72,14 +72,14 @@ * Extends this base class with the properties described in * `properties` and returns a new subclassed Class instance * - * @memberof LuCI.Class + * @memberof LuCI.baseclass * * @param {Object<string, *>} properties * An object describing the properties to add to the new * subclass. * - * @returns {LuCI.Class} - * Returns a new LuCI.Class sublassed from this class, extended + * @returns {LuCI.baseclass} + * Returns a new LuCI.baseclass sublassed from this class, extended * by the given properties and with its prototype set to this base * class to enable inheritance. The resulting value represents a * class constructor and can be instantiated with `new`. @@ -125,10 +125,10 @@ * and returns the resulting subclassed Class instance. * * This function serves as a convenience shortcut for - * {@link LuCI.Class.extend Class.extend()} and subsequent + * {@link LuCI.baseclass.extend Class.extend()} and subsequent * `new`. * - * @memberof LuCI.Class + * @memberof LuCI.baseclass * * @param {Object<string, *>} properties * An object describing the properties to add to the new @@ -138,8 +138,8 @@ * Specifies arguments to be passed to the subclass constructor * as-is in order to instantiate the new subclass. * - * @returns {LuCI.Class} - * Returns a new LuCI.Class instance extended by the given + * @returns {LuCI.baseclass} + * Returns a new LuCI.baseclass instance extended by the given * properties with its prototype set to this base class to * enable inheritance. */ @@ -152,7 +152,7 @@ * Calls the class constructor using `new` with the given argument * array being passed as variadic parameters to the constructor. * - * @memberof LuCI.Class + * @memberof LuCI.baseclass * * @param {Array<*>} params * An array of arbitrary values which will be passed as arguments @@ -162,8 +162,8 @@ * Specifies arguments to be passed to the subclass constructor * as-is in order to instantiate the new subclass. * - * @returns {LuCI.Class} - * Returns a new LuCI.Class instance extended by the given + * @returns {LuCI.baseclass} + * Returns a new LuCI.baseclass instance extended by the given * properties with its prototype set to this base class to * enable inheritance. */ @@ -183,9 +183,9 @@ /** * Checks whether the given class value is a subclass of this class. * - * @memberof LuCI.Class + * @memberof LuCI.baseclass * - * @param {LuCI.Class} classValue + * @param {LuCI.baseclass} classValue * The class object to test. * * @returns {boolean} @@ -205,7 +205,7 @@ * `offset` and prepend any further given optional parameters to * the beginning of the resulting array copy. * - * @memberof LuCI.Class + * @memberof LuCI.baseclass * @instance * * @param {Array<*>} args @@ -244,7 +244,7 @@ * Calls the `key()` method with parameters `arg1` and `arg2` * when found within one of the parent classes. * - * @memberof LuCI.Class + * @memberof LuCI.baseclass * @instance * * @param {string} key @@ -328,7 +328,7 @@ /** - * @class + * @class headers * @memberof LuCI * @hideconstructor * @classdesc @@ -336,8 +336,8 @@ * The `Headers` class is an internal utility class exposed in HTTP * response objects using the `response.headers` property. */ - var Headers = Class.extend(/** @lends LuCI.Headers.prototype */ { - __name__: 'LuCI.XHR.Headers', + var Headers = Class.extend(/** @lends LuCI.headers.prototype */ { + __name__: 'LuCI.headers', __init__: function(xhr) { var hdrs = this.headers = {}; xhr.getAllResponseHeaders().split(/\r\n/).forEach(function(line) { @@ -352,7 +352,7 @@ * Note: Header-Names are case-insensitive. * * @instance - * @memberof LuCI.Headers + * @memberof LuCI.headers * @param {string} name * The header name to check * @@ -368,7 +368,7 @@ * Note: Header-Names are case-insensitive. * * @instance - * @memberof LuCI.Headers + * @memberof LuCI.headers * @param {string} name * The header name to read * @@ -382,7 +382,7 @@ }); /** - * @class + * @class response * @memberof LuCI * @hideconstructor * @classdesc @@ -390,12 +390,12 @@ * The `Response` class is an internal utility class representing HTTP responses. */ var Response = Class.extend({ - __name__: 'LuCI.XHR.Response', + __name__: 'LuCI.response', __init__: function(xhr, url, duration, headers, content) { /** * Describes whether the response is successful (status codes `200..299`) or not * @instance - * @memberof LuCI.Response + * @memberof LuCI.response * @name ok * @type {boolean} */ @@ -404,7 +404,7 @@ /** * The numeric HTTP status code of the response * @instance - * @memberof LuCI.Response + * @memberof LuCI.response * @name status * @type {number} */ @@ -413,7 +413,7 @@ /** * The HTTP status description message of the response * @instance - * @memberof LuCI.Response + * @memberof LuCI.response * @name statusText * @type {string} */ @@ -422,16 +422,16 @@ /** * The HTTP headers of the response * @instance - * @memberof LuCI.Response + * @memberof LuCI.response * @name headers - * @type {LuCI.Headers} + * @type {LuCI.headers} */ this.headers = (headers != null) ? headers : new Headers(xhr); /** * The total duration of the HTTP request in milliseconds * @instance - * @memberof LuCI.Response + * @memberof LuCI.response * @name duration * @type {number} */ @@ -440,7 +440,7 @@ /** * The final URL of the request, i.e. after following redirects. * @instance - * @memberof LuCI.Response + * @memberof LuCI.response * @name url * @type {string} */ @@ -483,13 +483,13 @@ * of the cloned instance. * * @instance - * @memberof LuCI.Response + * @memberof LuCI.response * @param {*} [content] * Override the content of the cloned response. Object values will be * treated as JSON response data, all other types will be converted * using `String()` and treated as response text. * - * @returns {LuCI.Response} + * @returns {LuCI.response} * The cloned `Response` instance. */ clone: function(content) { @@ -506,7 +506,7 @@ * Access the response content as JSON data. * * @instance - * @memberof LuCI.Response + * @memberof LuCI.response * @throws {SyntaxError} * Throws `SyntaxError` if the content isn't valid JSON. * @@ -524,7 +524,7 @@ * Access the response content as string. * * @instance - * @memberof LuCI.Response + * @memberof LuCI.response * @returns {string} * The response content. */ @@ -539,7 +539,7 @@ * Access the response content as blob. * * @instance - * @memberof LuCI.Response + * @memberof LuCI.response * @returns {Blob} * The response content as blob. */ @@ -600,7 +600,7 @@ } /** - * @class + * @class request * @memberof LuCI * @hideconstructor * @classdesc @@ -608,8 +608,8 @@ * The `Request` class allows initiating HTTP requests and provides utilities * for dealing with responses. */ - var Request = Class.singleton(/** @lends LuCI.Request.prototype */ { - __name__: 'LuCI.Request', + var Request = Class.singleton(/** @lends LuCI.request.prototype */ { + __name__: 'LuCI.request', interceptors: [], @@ -617,7 +617,7 @@ * Turn the given relative URL into an absolute URL if necessary. * * @instance - * @memberof LuCI.Request + * @memberof LuCI.request * @param {string} url * The URL to convert. * @@ -634,7 +634,7 @@ /** * @typedef {Object} RequestOptions - * @memberof LuCI.Request + * @memberof LuCI.request * * @property {string} [method=GET] * The HTTP method to use, e.g. `GET` or `POST`. @@ -682,14 +682,14 @@ * Initiate an HTTP request to the given target. * * @instance - * @memberof LuCI.Request + * @memberof LuCI.request * @param {string} target * The URL to request. * - * @param {LuCI.Request.RequestOptions} [options] + * @param {LuCI.request.RequestOptions} [options] * Additional options to configure the request. * - * @returns {Promise<LuCI.Response>} + * @returns {Promise<LuCI.response>} * The resulting HTTP response. */ request: function(target, options) { @@ -831,14 +831,14 @@ * Initiate an HTTP GET request to the given target. * * @instance - * @memberof LuCI.Request + * @memberof LuCI.request * @param {string} target * The URL to request. * - * @param {LuCI.Request.RequestOptions} [options] + * @param {LuCI.request.RequestOptions} [options] * Additional options to configure the request. * - * @returns {Promise<LuCI.Response>} + * @returns {Promise<LuCI.response>} * The resulting HTTP response. */ get: function(url, options) { @@ -849,17 +849,17 @@ * Initiate an HTTP POST request to the given target. * * @instance - * @memberof LuCI.Request + * @memberof LuCI.request * @param {string} target * The URL to request. * * @param {*} [data] - * The request data to send, see {@link LuCI.Request.RequestOptions} for details. + * The request data to send, see {@link LuCI.request.RequestOptions} for details. * - * @param {LuCI.Request.RequestOptions} [options] + * @param {LuCI.request.RequestOptions} [options] * Additional options to configure the request. * - * @returns {Promise<LuCI.Response>} + * @returns {Promise<LuCI.response>} * The resulting HTTP response. */ post: function(url, data, options) { @@ -869,8 +869,8 @@ /** * Interceptor functions are invoked whenever an HTTP reply is received, in the order * these functions have been registered. - * @callback LuCI.Request.interceptorFn - * @param {LuCI.Response} res + * @callback LuCI.request.interceptorFn + * @param {LuCI.response} res * The HTTP response object */ @@ -881,11 +881,11 @@ * implementing request retries before returning a failure. * * @instance - * @memberof LuCI.Request - * @param {LuCI.Request.interceptorFn} interceptorFn + * @memberof LuCI.request + * @param {LuCI.request.interceptorFn} interceptorFn * The interceptor function to register. * - * @returns {LuCI.Request.interceptorFn} + * @returns {LuCI.request.interceptorFn} * The registered function. */ addInterceptor: function(interceptorFn) { @@ -900,8 +900,8 @@ * function. * * @instance - * @memberof LuCI.Request - * @param {LuCI.Request.interceptorFn} interceptorFn + * @memberof LuCI.request + * @param {LuCI.request.interceptorFn} interceptorFn * The interceptor function to remove. * * @returns {boolean} @@ -917,12 +917,12 @@ /** * @class - * @memberof LuCI.Request + * @memberof LuCI.request * @hideconstructor * @classdesc * * The `Request.poll` class provides some convience wrappers around - * {@link LuCI.Poll} mainly to simplify registering repeating HTTP + * {@link LuCI.poll} mainly to simplify registering repeating HTTP * request calls as polling functions. */ poll: { @@ -931,8 +931,8 @@ * polled request is received or when the polled request timed * out. * - * @callback LuCI.Request.poll~callbackFn - * @param {LuCI.Response} res + * @callback LuCI.request.poll~callbackFn + * @param {LuCI.response} res * The HTTP response object. * * @param {*} data @@ -948,18 +948,18 @@ * to invoke whenever a response for the request is received. * * @instance - * @memberof LuCI.Request.poll + * @memberof LuCI.request.poll * @param {number} interval * The poll interval in seconds. * * @param {string} url * The URL to request on each poll. * - * @param {LuCI.Request.RequestOptions} [options] + * @param {LuCI.request.RequestOptions} [options] * Additional options to configure the request. * - * @param {LuCI.Request.poll~callbackFn} [callback] - * {@link LuCI.Request.poll~callbackFn Callback} function to + * @param {LuCI.request.poll~callbackFn} [callback] + * {@link LuCI.request.poll~callbackFn Callback} function to * invoke for each HTTP reply. * * @throws {TypeError} @@ -995,12 +995,12 @@ /** * Remove a polling request that has been previously added using `add()`. * This function is essentially a wrapper around - * {@link LuCI.Poll.remove LuCI.Poll.remove()}. + * {@link LuCI.poll.remove LuCI.poll.remove()}. * * @instance - * @memberof LuCI.Request.poll + * @memberof LuCI.request.poll * @param {function} entry - * The poll function returned by {@link LuCI.Request.poll#add add()}. + * The poll function returned by {@link LuCI.request.poll#add add()}. * * @returns {boolean} * Returns `true` if any function has been removed, else `false`. @@ -1008,33 +1008,33 @@ remove: function(entry) { return Poll.remove(entry) }, /** - * Alias for {@link LuCI.Poll.start LuCI.Poll.start()}. + * Alias for {@link LuCI.poll.start LuCI.poll.start()}. * * @instance - * @memberof LuCI.Request.poll + * @memberof LuCI.request.poll */ start: function() { return Poll.start() }, /** - * Alias for {@link LuCI.Poll.stop LuCI.Poll.stop()}. + * Alias for {@link LuCI.poll.stop LuCI.poll.stop()}. * * @instance - * @memberof LuCI.Request.poll + * @memberof LuCI.request.poll */ stop: function() { return Poll.stop() }, /** - * Alias for {@link LuCI.Poll.active LuCI.Poll.active()}. + * Alias for {@link LuCI.poll.active LuCI.poll.active()}. * * @instance - * @memberof LuCI.Request.poll + * @memberof LuCI.request.poll */ active: function() { return Poll.active() } } }); /** - * @class + * @class poll * @memberof LuCI * @hideconstructor * @classdesc @@ -1043,8 +1043,8 @@ * as well as starting, stopping and querying the state of the polling * loop. */ - var Poll = Class.singleton(/** @lends LuCI.Poll.prototype */ { - __name__: 'LuCI.Poll', + var Poll = Class.singleton(/** @lends LuCI.poll.prototype */ { + __name__: 'LuCI.poll', queue: [], @@ -1053,7 +1053,7 @@ * already started at this point, it will be implicitely started. * * @instance - * @memberof LuCI.Poll + * @memberof LuCI.poll * @param {function} fn * The function to invoke on each poll interval. * @@ -1072,7 +1072,7 @@ interval = window.L ? window.L.env.pollinterval : null; if (isNaN(interval) || typeof(fn) != 'function') - throw new TypeError('Invalid argument to LuCI.Poll.add()'); + throw new TypeError('Invalid argument to LuCI.poll.add()'); for (var i = 0; i < this.queue.length; i++) if (this.queue[i].fn === fn) @@ -1097,7 +1097,7 @@ * are registered, the polling loop is implicitely stopped. * * @instance - * @memberof LuCI.Poll + * @memberof LuCI.poll * @param {function} fn * The function to remove. * @@ -1110,7 +1110,7 @@ */ remove: function(fn) { if (typeof(fn) != 'function') - throw new TypeError('Invalid argument to LuCI.Poll.remove()'); + throw new TypeError('Invalid argument to LuCI.poll.remove()'); var len = this.queue.length; @@ -1129,7 +1129,7 @@ * to the `document` object upon successful start. * * @instance - * @memberof LuCI.Poll + * @memberof LuCI.poll * @returns {boolean} * Returns `true` if polling has been started (or if no functions * where registered) or `false` when the polling loop already runs. @@ -1154,7 +1154,7 @@ * to the `document` object upon successful stop. * * @instance - * @memberof LuCI.Poll + * @memberof LuCI.poll * @returns {boolean} * Returns `true` if polling has been stopped or `false` if it din't * run to begin with. @@ -1191,7 +1191,7 @@ * Test whether the polling loop is running. * * @instance - * @memberof LuCI.Poll + * @memberof LuCI.poll * @returns {boolean} - Returns `true` if polling is active, else `false`. */ active: function() { @@ -1199,13 +1199,904 @@ } }); + /** + * @class dom + * @memberof LuCI + * @hideconstructor + * @classdesc + * + * The `dom` class provides convenience method for creating and + * manipulating DOM elements. + * + * To import the class in views, use `'require dom'`, to import it in + * external JavaScript, use `L.require("dom").then(...)`. + */ + var DOM = Class.singleton(/* @lends LuCI.dom.prototype */ { + __name__: 'LuCI.dom', + + /** + * Tests whether the given argument is a valid DOM `Node`. + * + * @instance + * @memberof LuCI.dom + * @param {*} e + * The value to test. + * + * @returns {boolean} + * Returns `true` if the value is a DOM `Node`, else `false`. + */ + elem: function(e) { + return (e != null && typeof(e) == 'object' && 'nodeType' in e); + }, + + /** + * Parses a given string as HTML and returns the first child node. + * + * @instance + * @memberof LuCI.dom + * @param {string} s + * A string containing an HTML fragment to parse. Note that only + * the first result of the resulting structure is returned, so an + * input value of `<div>foo</div> <div>bar</div>` will only return + * the first `div` element node. + * + * @returns {Node} + * Returns the first DOM `Node` extracted from the HTML fragment or + * `null` on parsing failures or if no element could be found. + */ + parse: function(s) { + var elem; + + try { + domParser = domParser || new DOMParser(); + elem = domParser.parseFromString(s, 'text/html').body.firstChild; + } + catch(e) {} + + if (!elem) { + try { + dummyElem = dummyElem || document.createElement('div'); + dummyElem.innerHTML = s; + elem = dummyElem.firstChild; + } + catch (e) {} + } + + return elem || null; + }, + + /** + * Tests whether a given `Node` matches the given query selector. + * + * This function is a convenience wrapper around the standard + * `Node.matches("selector")` function with the added benefit that + * the `node` argument may be a non-`Node` value, in which case + * this function simply returns `false`. + * + * @instance + * @memberof LuCI.dom + * @param {*} node + * The `Node` argument to test the selector against. + * + * @param {string} [selector] + * The query selector expression to test against the given node. + * + * @returns {boolean} + * Returns `true` if the given node matches the specified selector + * or `false` when the node argument is no valid DOM `Node` or the + * selector didn't match. + */ + matches: function(node, selector) { + var m = this.elem(node) ? node.matches || node.msMatchesSelector : null; + return m ? m.call(node, selector) : false; + }, + + /** + * Returns the closest parent node that matches the given query + * selector expression. + * + * This function is a convenience wrapper around the standard + * `Node.closest("selector")` function with the added benefit that + * the `node` argument may be a non-`Node` value, in which case + * this function simply returns `null`. + * + * @instance + * @memberof LuCI.dom + * @param {*} node + * The `Node` argument to find the closest parent for. + * + * @param {string} [selector] + * The query selector expression to test against each parent. + * + * @returns {Node|null} + * Returns the closest parent node matching the selector or + * `null` when the node argument is no valid DOM `Node` or the + * selector didn't match any parent. + */ + parent: function(node, selector) { + if (this.elem(node) && node.closest) + return node.closest(selector); + + while (this.elem(node)) + if (this.matches(node, selector)) + return node; + else + node = node.parentNode; + + return null; + }, + + /** + * Appends the given children data to the given node. + * + * @instance + * @memberof LuCI.dom + * @param {*} node + * The `Node` argument to append the children to. + * + * @param {*} [children] + * The childrens to append to the given node. + * + * When `children` is an array, then each item of the array + * will be either appended as child element or text node, + * depending on whether the item is a DOM `Node` instance or + * some other non-`null` value. Non-`Node`, non-`null` values + * will be converted to strings first before being passed as + * argument to `createTextNode()`. + * + * When `children` is a function, it will be invoked with + * the passed `node` argument as sole parameter and the `append` + * function will be invoked again, with the given `node` argument + * as first and the return value of the `children` function as + * second parameter. + * + * When `children` is is a DOM `Node` instance, it will be + * appended to the given `node`. + * + * When `children` is any other non-`null` value, it will be + * converted to a string and appened to the `innerHTML` property + * of the given `node`. + * + * @returns {Node|null} + * Returns the last children `Node` appended to the node or `null` + * if either the `node` argument was no valid DOM `node` or if the + * `children` was `null` or didn't result in further DOM nodes. + */ + append: function(node, children) { + if (!this.elem(node)) + return null; + + if (Array.isArray(children)) { + for (var i = 0; i < children.length; i++) + if (this.elem(children[i])) + node.appendChild(children[i]); + else if (children !== null && children !== undefined) + node.appendChild(document.createTextNode('' + children[i])); + + return node.lastChild; + } + else if (typeof(children) === 'function') { + return this.append(node, children(node)); + } + else if (this.elem(children)) { + return node.appendChild(children); + } + else if (children !== null && children !== undefined) { + node.innerHTML = '' + children; + return node.lastChild; + } + + return null; + }, + + /** + * Replaces the content of the given node with the given children. + * + * This function first removes any children of the given DOM + * `Node` and then adds the given given children following the + * rules outlined below. + * + * @instance + * @memberof LuCI.dom + * @param {*} node + * The `Node` argument to replace the children of. + * + * @param {*} [children] + * The childrens to replace into the given node. + * + * When `children` is an array, then each item of the array + * will be either appended as child element or text node, + * depending on whether the item is a DOM `Node` instance or + * some other non-`null` value. Non-`Node`, non-`null` values + * will be converted to strings first before being passed as + * argument to `createTextNode()`. + * + * When `children` is a function, it will be invoked with + * the passed `node` argument as sole parameter and the `append` + * function will be invoked again, with the given `node` argument + * as first and the return value of the `children` function as + * second parameter. + * + * When `children` is is a DOM `Node` instance, it will be + * appended to the given `node`. + * + * When `children` is any other non-`null` value, it will be + * converted to a string and appened to the `innerHTML` property + * of the given `node`. + * + * @returns {Node|null} + * Returns the last children `Node` appended to the node or `null` + * if either the `node` argument was no valid DOM `node` or if the + * `children` was `null` or didn't result in further DOM nodes. + */ + content: function(node, children) { + if (!this.elem(node)) + return null; + + var dataNodes = node.querySelectorAll('[data-idref]'); + + for (var i = 0; i < dataNodes.length; i++) + delete this.registry[dataNodes[i].getAttribute('data-idref')]; + + while (node.firstChild) + node.removeChild(node.firstChild); + + return this.append(node, children); + }, + + /** + * Sets attributes or registers event listeners on element nodes. + * + * @instance + * @memberof LuCI.dom + * @param {*} node + * The `Node` argument to set the attributes or add the event + * listeners for. When the given `node` value is not a valid + * DOM `Node`, the function returns and does nothing. + * + * @param {string|Object<string, *>} key + * Specifies either the attribute or event handler name to use, + * or an object containing multiple key, value pairs which are + * each added to the node as either attribute or event handler, + * depending on the respective value. + * + * @param {*} [val] + * Specifies the attribute value or event handler function to add. + * If the `key` parameter is an `Object`, this parameter will be + * ignored. + * + * When `val` is of type function, it will be registered as event + * handler on the given `node` with the `key` parameter being the + * event name. + * + * When `val` is of type object, it will be serialized as JSON and + * added as attribute to the given `node`, using the given `key` + * as attribute name. + * + * When `val` is of any other type, it will be added as attribute + * to the given `node` as-is, with the underlying `setAttribute()` + * call implicitely turning it into a string. + */ + attr: function(node, key, val) { + if (!this.elem(node)) + return null; + + var attr = null; + + if (typeof(key) === 'object' && key !== null) + attr = key; + else if (typeof(key) === 'string') + attr = {}, attr[key] = val; + + for (key in attr) { + if (!attr.hasOwnProperty(key) || attr[key] == null) + continue; + + switch (typeof(attr[key])) { + case 'function': + node.addEventListener(key, attr[key]); + break; + + case 'object': + node.setAttribute(key, JSON.stringify(attr[key])); + break; + + default: + node.setAttribute(key, attr[key]); + } + } + }, + + /** + * Creates a new DOM `Node` from the given `html`, `attr` and + * `data` parameters. + * + * This function has multiple signatures, it can be either invoked + * in the form `create(html[, attr[, data]])` or in the form + * `create(html[, data])`. The used variant is determined from the + * type of the second argument. + * + * @instance + * @memberof LuCI.dom + * @param {*} html + * Describes the node to create. + * + * When the value of `html` is of type array, a `DocumentFragment` + * node is created and each item of the array is first converted + * to a DOM `Node` by passing it through `create()` and then added + * as child to the fragment. + * + * When the value of `html` is a DOM `Node` instance, no new + * element will be created but the node will be used as-is. + * + * When the value of `html` is a string starting with `<`, it will + * be passed to `dom.parse()` and the resulting value is used. + * + * When the value of `html` is any other string, it will be passed + * to `document.createElement()` for creating a new DOM `Node` of + * the given name. + * + * @param {Object<string, *>} [attr] + * Specifies an Object of key, value pairs to set as attributes + * or event handlers on the created node. Refer to + * {@link LuCI.dom#attr dom.attr()} for details. + * + * @param {*} [data] + * Specifies children to append to the newly created element. + * Refer to {@link LuCI.dom#append dom.append()} for details. + * + * @throws {InvalidCharacterError} + * Throws an `InvalidCharacterError` when the given `html` + * argument contained malformed markup (such as not escaped + * `&` characters in XHTML mode) or when the given node name + * in `html` contains characters which are not legal in DOM + * element names, such as spaces. + * + * @returns {Node} + * Returns the newly created `Node`. + */ + create: function() { + var html = arguments[0], + attr = arguments[1], + data = arguments[2], + elem; + + if (!(attr instanceof Object) || Array.isArray(attr)) + data = attr, attr = null; + + if (Array.isArray(html)) { + elem = document.createDocumentFragment(); + for (var i = 0; i < html.length; i++) + elem.appendChild(this.create(html[i])); + } + else if (this.elem(html)) { + elem = html; + } + else if (html.charCodeAt(0) === 60) { + elem = this.parse(html); + } + else { + elem = document.createElement(html); + } + + if (!elem) + return null; + + this.attr(elem, attr); + this.append(elem, data); + + return elem; + }, + + registry: {}, + + /** + * Attaches or detaches arbitrary data to and from a DOM `Node`. + * + * This function is useful to attach non-string values or runtime + * data that is not serializable to DOM nodes. To decouple data + * from the DOM, values are not added directly to nodes, but + * inserted into a registry instead which is then referenced by a + * string key stored as `data-idref` attribute in the node. + * + * This function has multiple signatures and is sensitive to the + * number of arguments passed to it. + * + * - `dom.data(node)` - + * Fetches all data associated with the given node. + * - `dom.data(node, key)` - + * Fetches a specific key associated with the given node. + * - `dom.data(node, key, val)` - + * Sets a specific key to the given value associated with the + * given node. + * - `dom.data(node, null)` - + * Clears any data associated with the node. + * - `dom.data(node, key, null)` - + * Clears the given key associated with the node. + * + * @instance + * @memberof LuCI.dom + * @param {Node} node + * The DOM `Node` instance to set or retrieve the data for. + * + * @param {string|null} [key] + * This is either a string specifying the key to retrieve, or + * `null` to unset the entire node data. + * + * @param {*|null} [val] + * This is either a non-`null` value to set for a given key or + * `null` to remove the given `key` from the specified node. + * + * @returns {*} + * Returns the get or set value, or `null` when no value could + * be found. + */ + data: function(node, key, val) { + if (!node || !node.getAttribute) + return null; + + var id = node.getAttribute('data-idref'); + + /* clear all data */ + if (arguments.length > 1 && key == null) { + if (id != null) { + node.removeAttribute('data-idref'); + val = this.registry[id] + delete this.registry[id]; + return val; + } + + return null; + } + + /* clear a key */ + else if (arguments.length > 2 && key != null && val == null) { + if (id != null) { + val = this.registry[id][key]; + delete this.registry[id][key]; + return val; + } + + return null; + } + + /* set a key */ + else if (arguments.length > 2 && key != null && val != null) { + if (id == null) { + do { id = Math.floor(Math.random() * 0xffffffff).toString(16) } + while (this.registry.hasOwnProperty(id)); + + node.setAttribute('data-idref', id); + this.registry[id] = {}; + } + + return (this.registry[id][key] = val); + } + + /* get all data */ + else if (arguments.length == 1) { + if (id != null) + return this.registry[id]; + + return null; + } + + /* get a key */ + else if (arguments.length == 2) { + if (id != null) + return this.registry[id][key]; + } + + return null; + }, + + /** + * Binds the given class instance ot the specified DOM `Node`. + * + * This function uses the `dom.data()` facility to attach the + * passed instance of a Class to a node. This is needed for + * complex widget elements or similar where the corresponding + * class instance responsible for the element must be retrieved + * from DOM nodes obtained by `querySelector()` or similar means. + * + * @instance + * @memberof LuCI.dom + * @param {Node} node + * The DOM `Node` instance to bind the class to. + * + * @param {Class} inst + * The Class instance to bind to the node. + * + * @throws {TypeError} + * Throws a `TypeError` when the given instance argument isn't + * a valid Class instance. + * + * @returns {Class} + * Returns the bound class instance. + */ + bindClassInstance: function(node, inst) { + if (!(inst instanceof Class)) + L.error('TypeError', 'Argument must be a class instance'); + + return this.data(node, '_class', inst); + }, + + /** + * Finds a bound class instance on the given node itself or the + * first bound instance on its closest parent node. + * + * @instance + * @memberof LuCI.dom + * @param {Node} node + * The DOM `Node` instance to start from. + * + * @returns {Class|null} + * Returns the founds class instance if any or `null` if no bound + * class could be found on the node itself or any of its parents. + */ + findClassInstance: function(node) { + var inst = null; + + do { + inst = this.data(node, '_class'); + node = node.parentNode; + } + while (!(inst instanceof Class) && node != null); + + return inst; + }, + + /** + * Finds a bound class instance on the given node itself or the + * first bound instance on its closest parent node and invokes + * the specified method name on the found class instance. + * + * @instance + * @memberof LuCI.dom + * @param {Node} node + * The DOM `Node` instance to start from. + * + * @param {string} method + * The name of the method to invoke on the found class instance. + * + * @param {...*} params + * Additional arguments to pass to the invoked method as-is. + * + * @returns {*|null} + * Returns the return value of the invoked method if a class + * instance and method has been found. Returns `null` if either + * no bound class instance could be found, or if the found + * instance didn't have the requested `method`. + */ + callClassMethod: function(node, method /*, ... */) { + var inst = this.findClassInstance(node); + + if (inst == null || typeof(inst[method]) != 'function') + return null; + + return inst[method].apply(inst, inst.varargs(arguments, 2)); + }, + + /** + * The ignore callback function is invoked by `isEmpty()` for each + * child node to decide whether to ignore a child node or not. + * + * When this function returns `false`, the node passed to it is + * ignored, else not. + * + * @callback LuCI.dom~ignoreCallbackFn + * @param {Node} node + * The child node to test. + * + * @returns {boolean} + * Boolean indicating whether to ignore the node or not. + */ + + /** + * Tests whether a given DOM `Node` instance is empty or appears + * empty. + * + * Any element child nodes which have the CSS class `hidden` set + * or for which the optionally passed `ignoreFn` callback function + * returns `false` are ignored. + * + * @instance + * @memberof LuCI.dom + * @param {Node} node + * The DOM `Node` instance to test. + * + * @param {LuCI.dom~ignoreCallbackFn} [ignoreFn] + * Specifies an optional function which is invoked for each child + * node to decide whether the child node should be ignored or not. + * + * @returns {boolean} + * Returns `true` if the node does not have any children or if + * any children node either has a `hidden` CSS class or a `false` + * result when testing it using the given `ignoreFn`. + */ + isEmpty: function(node, ignoreFn) { + for (var child = node.firstElementChild; child != null; child = child.nextElementSibling) + if (!child.classList.contains('hidden') && (!ignoreFn || !ignoreFn(child))) + return false; + + return true; + } + }); + + /** + * @class view + * @memberof LuCI + * @hideconstructor + * @classdesc + * + * The `view` class forms the basis of views and provides a standard + * set of methods to inherit from. + */ + var View = Class.extend(/* @lends LuCI.view.prototype */ { + __name__: 'LuCI.view', + + __init__: function() { + var vp = document.getElementById('view'); + + DOM.content(vp, E('div', { 'class': 'spinning' }, _('Loading view…'))); + + return Promise.resolve(this.load()) + .then(L.bind(this.render, this)) + .then(L.bind(function(nodes) { + var vp = document.getElementById('view'); + + DOM.content(vp, nodes); + DOM.append(vp, this.addFooter()); + }, this)).catch(L.error); + }, + + /** + * The load function is invoked before the view is rendered. + * + * The invocation of this function is wrapped by + * `Promise.resolve()` so it may return Promises if needed. + * + * The return value of the function (or the resolved values + * of the promise returned by it) will be passed as first + * argument to `render()`. + * + * This function is supposed to be overwritten by subclasses, + * the default implementation does nothing. + * + * @instance + * @abstract + * @memberof LuCI.view + * + * @returns {*|Promise<*>} + * May return any value or a Promise resolving to any value. + */ + load: function() {}, + + /** + * The render function is invoked after the + * {@link LuCI.view#load load()} function and responsible + * for setting up the view contents. It must return a DOM + * `Node` or `DocumentFragment` holding the contents to + * insert into the view area. + * + * The invocation of this function is wrapped by + * `Promise.resolve()` so it may return Promises if needed. + * + * The return value of the function (or the resolved values + * of the promise returned by it) will be inserted into the + * main content area using + * {@link LuCI.dom#append dom.append()}. + * + * This function is supposed to be overwritten by subclasses, + * the default implementation does nothing. + * + * @instance + * @abstract + * @memberof LuCI.view + * @param {*|null} load_results + * This function will receive the return value of the + * {@link LuCI.view#load view.load()} function as first + * argument. + * + * @returns {Node|Promise<Node>} + * Should return a DOM `Node` value or a `Promise` resolving + * to a `Node` value. + */ + render: function() {}, + + /** + * The handleSave function is invoked when the user clicks + * the `Save` button in the page action footer. + * + * The default implementation should be sufficient for most + * views using {@link form#Map form.Map()} based forms - it + * will iterate all forms present in the view and invoke + * the {@link form#Map#save Map.save()} method on each form. + * + * Views not using `Map` instances or requiring other special + * logic should overwrite `handleSave()` with a custom + * implementation. + * + * To disable the `Save` page footer button, views extending + * this base class should overwrite the `handleSave` function + * with `null`. + * + * The invocation of this function is wrapped by + * `Promise.resolve()` so it may return Promises if needed. + * + * @instance + * @memberof LuCI.view + * @param {Event} ev + * The DOM event that triggered the function. + * + * @returns {*|Promise<*>} + * Any return values of this function are discarded, but + * passed through `Promise.resolve()` to ensure that any + * returned promise runs to completion before the button + * is reenabled. + */ + handleSave: function(ev) { + var tasks = []; + + document.getElementById('maincontent') + .querySelectorAll('.cbi-map').forEach(function(map) { + tasks.push(DOM.callClassMethod(map, 'save')); + }); + + return Promise.all(tasks); + }, + + /** + * The handleSaveApply function is invoked when the user clicks + * the `Save & Apply` button in the page action footer. + * + * The default implementation should be sufficient for most + * views using {@link form#Map form.Map()} based forms - it + * will first invoke + * {@link LuCI.view.handleSave view.handleSave()} and then + * call {@link ui#changes#apply ui.changes.apply()} to start the + * modal config apply and page reload flow. + * + * Views not using `Map` instances or requiring other special + * logic should overwrite `handleSaveApply()` with a custom + * implementation. + * + * To disable the `Save & Apply` page footer button, views + * extending this base class should overwrite the + * `handleSaveApply` function with `null`. + * + * The invocation of this function is wrapped by + * `Promise.resolve()` so it may return Promises if needed. + * + * @instance + * @memberof LuCI.view + * @param {Event} ev + * The DOM event that triggered the function. + * + * @returns {*|Promise<*>} + * Any return values of this function are discarded, but + * passed through `Promise.resolve()` to ensure that any + * returned promise runs to completion before the button + * is reenabled. + */ + handleSaveApply: function(ev, mode) { + return this.handleSave(ev).then(function() { + L.ui.changes.apply(mode == '0'); + }); + }, + + /** + * The handleReset function is invoked when the user clicks + * the `Reset` button in the page action footer. + * + * The default implementation should be sufficient for most + * views using {@link form#Map form.Map()} based forms - it + * will iterate all forms present in the view and invoke + * the {@link form#Map#save Map.reset()} method on each form. + * + * Views not using `Map` instances or requiring other special + * logic should overwrite `handleReset()` with a custom + * implementation. + * + * To disable the `Reset` page footer button, views extending + * this base class should overwrite the `handleReset` function + * with `null`. + * + * The invocation of this function is wrapped by + * `Promise.resolve()` so it may return Promises if needed. + * + * @instance + * @memberof LuCI.view + * @param {Event} ev + * The DOM event that triggered the function. + * + * @returns {*|Promise<*>} + * Any return values of this function are discarded, but + * passed through `Promise.resolve()` to ensure that any + * returned promise runs to completion before the button + * is reenabled. + */ + handleReset: function(ev) { + var tasks = []; + + document.getElementById('maincontent') + .querySelectorAll('.cbi-map').forEach(function(map) { + tasks.push(DOM.callClassMethod(map, 'reset')); + }); + + return Promise.all(tasks); + }, + + /** + * Renders a standard page action footer if any of the + * `handleSave()`, `handleSaveApply()` or `handleReset()` + * functions are defined. + * + * The default implementation should be sufficient for most + * views - it will render a standard page footer with action + * buttons labeled `Save`, `Save & Apply` and `Reset` + * triggering the `handleSave()`, `handleSaveApply()` and + * `handleReset()` functions respectively. + * + * When any of these `handle*()` functions is overwritten + * with `null` by a view extending this class, the + * corresponding button will not be rendered. + * + * @instance + * @memberof LuCI.view + * @returns {DocumentFragment} + * Returns a `DocumentFragment` containing the footer bar + * with buttons for each corresponding `handle*()` action + * or an empty `DocumentFragment` if all three `handle*()` + * methods are overwritten with `null`. + */ + addFooter: function() { + var footer = E([]); + + var saveApplyBtn = this.handleSaveApply ? new L.ui.ComboButton('0', { + 0: [ _('Save & Apply') ], + 1: [ _('Apply unchecked') ] + }, { + classes: { + 0: 'btn cbi-button cbi-button-apply important', + 1: 'btn cbi-button cbi-button-negative important' + }, + click: L.ui.createHandlerFn(this, 'handleSaveApply') + }).render() : E([]); + + if (this.handleSaveApply || this.handleSave || this.handleReset) { + footer.appendChild(E('div', { 'class': 'cbi-page-actions control-group' }, [ + saveApplyBtn, ' ', + this.handleSave ? E('button', { + 'class': 'cbi-button cbi-button-save', + 'click': L.ui.createHandlerFn(this, 'handleSave') + }, [ _('Save') ]) : '', ' ', + this.handleReset ? E('button', { + 'class': 'cbi-button cbi-button-reset', + 'click': L.ui.createHandlerFn(this, 'handleReset') + }, [ _('Reset') ]) : '' + ])); + } + + return footer; + } + }); + var dummyElem = null, domParser = null, originalCBIInit = null, rpcBaseURL = null, - sysFeatures = null, - classes = {}; + sysFeatures = null; + + /* "preload" builtin classes to make the available via require */ + var classes = { + baseclass: Class, + dom: DOM, + poll: Poll, + request: Request, + view: View + }; var LuCI = Class.extend(/** @lends LuCI.prototype */ { __name__: 'LuCI', @@ -1356,7 +2247,7 @@ L.ui.addNotification(e.name || _('Runtime error'), E('pre', {}, e.message), 'danger'); else - L.dom.content(document.querySelector('#maincontent'), + DOM.content(document.querySelector('#maincontent'), E('pre', { 'class': 'alert-message error' }, e.message)); e.reported = true; @@ -1410,7 +2301,7 @@ * circular dependencies. * * @throws {NetworkError} - * Throws `NetworkError` when the underlying {@link LuCI.Request} + * Throws `NetworkError` when the underlying {@link LuCI.request} * call failed. * * @throws {SyntaxError} @@ -1422,7 +2313,7 @@ * interpreted, but when invoking its code did not yield a valid * class instance. * - * @returns {Promise<LuCI#Class>} + * @returns {Promise<LuCI.baseclass>} * Returns the instantiated class. */ require: function(name, from) { @@ -1968,7 +2859,7 @@ /** * Issues a GET request to the given url and invokes the specified * callback function. The function is a wrapper around - * {@link LuCI.Request#request Request.request()}. + * {@link LuCI.request#request Request.request()}. * * @deprecated * @instance @@ -1993,7 +2884,7 @@ /** * Issues a POST request to the given url and invokes the specified * callback function. The function is a wrapper around - * {@link LuCI.Request#request Request.request()}. The request is + * {@link LuCI.request#request Request.request()}. The request is * sent using `application/x-www-form-urlencoded` encoding and will * contain a field `token` with the current value of `LuCI.env.token` * by default. @@ -2021,7 +2912,7 @@ /** * Register a polling HTTP request that invokes the specified * callback function. The function is a wrapper around - * {@link LuCI.Request.poll#add Request.poll.add()}. + * {@link LuCI.request.poll#add Request.poll.add()}. * * @deprecated * @instance @@ -2052,8 +2943,8 @@ * * @return {function} * Returns the internally created function that has been passed to - * {@link LuCI.Request.poll#add Request.poll.add()}. This value can - * be passed to {@link LuCI.Poll.remove Poll.remove()} to remove the + * {@link LuCI.request.poll#add Request.poll.add()}. This value can + * be passed to {@link LuCI.poll.remove Poll.remove()} to remove the * polling request. */ poll: function(interval, url, args, cb, post) { @@ -2082,7 +2973,7 @@ }, /** - * Deprecated wrapper around {@link LuCI.Poll.remove Poll.remove()}. + * Deprecated wrapper around {@link LuCI.poll.remove Poll.remove()}. * * @deprecated * @instance @@ -2098,7 +2989,7 @@ stop: function(entry) { return Poll.remove(entry) }, /** - * Deprecated wrapper around {@link LuCI.Poll.stop Poll.stop()}. + * Deprecated wrapper around {@link LuCI.poll.stop Poll.stop()}. * * @deprecated * @instance @@ -2111,7 +3002,7 @@ halt: function() { return Poll.stop() }, /** - * Deprecated wrapper around {@link LuCI.Poll.start Poll.start()}. + * Deprecated wrapper around {@link LuCI.poll.start Poll.start()}. * * @deprecated * @instance @@ -2123,907 +3014,72 @@ */ run: function() { return Poll.start() }, - /** - * @class - * @memberof LuCI - * @hideconstructor - * @classdesc + * Legacy `L.dom` class alias. New view code should use `'require dom';` + * to request the `LuCI.dom` class. * - * The `dom` class provides convenience method for creating and - * manipulating DOM elements. + * @instance + * @memberof LuCI + * @deprecated */ - dom: Class.singleton(/* @lends LuCI.dom.prototype */ { - __name__: 'LuCI.DOM', - - /** - * Tests whether the given argument is a valid DOM `Node`. - * - * @instance - * @memberof LuCI.dom - * @param {*} e - * The value to test. - * - * @returns {boolean} - * Returns `true` if the value is a DOM `Node`, else `false`. - */ - elem: function(e) { - return (e != null && typeof(e) == 'object' && 'nodeType' in e); - }, - - /** - * Parses a given string as HTML and returns the first child node. - * - * @instance - * @memberof LuCI.dom - * @param {string} s - * A string containing an HTML fragment to parse. Note that only - * the first result of the resulting structure is returned, so an - * input value of `<div>foo</div> <div>bar</div>` will only return - * the first `div` element node. - * - * @returns {Node} - * Returns the first DOM `Node` extracted from the HTML fragment or - * `null` on parsing failures or if no element could be found. - */ - parse: function(s) { - var elem; - - try { - domParser = domParser || new DOMParser(); - elem = domParser.parseFromString(s, 'text/html').body.firstChild; - } - catch(e) {} - - if (!elem) { - try { - dummyElem = dummyElem || document.createElement('div'); - dummyElem.innerHTML = s; - elem = dummyElem.firstChild; - } - catch (e) {} - } - - return elem || null; - }, - - /** - * Tests whether a given `Node` matches the given query selector. - * - * This function is a convenience wrapper around the standard - * `Node.matches("selector")` function with the added benefit that - * the `node` argument may be a non-`Node` value, in which case - * this function simply returns `false`. - * - * @instance - * @memberof LuCI.dom - * @param {*} node - * The `Node` argument to test the selector against. - * - * @param {string} [selector] - * The query selector expression to test against the given node. - * - * @returns {boolean} - * Returns `true` if the given node matches the specified selector - * or `false` when the node argument is no valid DOM `Node` or the - * selector didn't match. - */ - matches: function(node, selector) { - var m = this.elem(node) ? node.matches || node.msMatchesSelector : null; - return m ? m.call(node, selector) : false; - }, - - /** - * Returns the closest parent node that matches the given query - * selector expression. - * - * This function is a convenience wrapper around the standard - * `Node.closest("selector")` function with the added benefit that - * the `node` argument may be a non-`Node` value, in which case - * this function simply returns `null`. - * - * @instance - * @memberof LuCI.dom - * @param {*} node - * The `Node` argument to find the closest parent for. - * - * @param {string} [selector] - * The query selector expression to test against each parent. - * - * @returns {Node|null} - * Returns the closest parent node matching the selector or - * `null` when the node argument is no valid DOM `Node` or the - * selector didn't match any parent. - */ - parent: function(node, selector) { - if (this.elem(node) && node.closest) - return node.closest(selector); - - while (this.elem(node)) - if (this.matches(node, selector)) - return node; - else - node = node.parentNode; - - return null; - }, - - /** - * Appends the given children data to the given node. - * - * @instance - * @memberof LuCI.dom - * @param {*} node - * The `Node` argument to append the children to. - * - * @param {*} [children] - * The childrens to append to the given node. - * - * When `children` is an array, then each item of the array - * will be either appended as child element or text node, - * depending on whether the item is a DOM `Node` instance or - * some other non-`null` value. Non-`Node`, non-`null` values - * will be converted to strings first before being passed as - * argument to `createTextNode()`. - * - * When `children` is a function, it will be invoked with - * the passed `node` argument as sole parameter and the `append` - * function will be invoked again, with the given `node` argument - * as first and the return value of the `children` function as - * second parameter. - * - * When `children` is is a DOM `Node` instance, it will be - * appended to the given `node`. - * - * When `children` is any other non-`null` value, it will be - * converted to a string and appened to the `innerHTML` property - * of the given `node`. - * - * @returns {Node|null} - * Returns the last children `Node` appended to the node or `null` - * if either the `node` argument was no valid DOM `node` or if the - * `children` was `null` or didn't result in further DOM nodes. - */ - append: function(node, children) { - if (!this.elem(node)) - return null; - - if (Array.isArray(children)) { - for (var i = 0; i < children.length; i++) - if (this.elem(children[i])) - node.appendChild(children[i]); - else if (children !== null && children !== undefined) - node.appendChild(document.createTextNode('' + children[i])); - - return node.lastChild; - } - else if (typeof(children) === 'function') { - return this.append(node, children(node)); - } - else if (this.elem(children)) { - return node.appendChild(children); - } - else if (children !== null && children !== undefined) { - node.innerHTML = '' + children; - return node.lastChild; - } - - return null; - }, - - /** - * Replaces the content of the given node with the given children. - * - * This function first removes any children of the given DOM - * `Node` and then adds the given given children following the - * rules outlined below. - * - * @instance - * @memberof LuCI.dom - * @param {*} node - * The `Node` argument to replace the children of. - * - * @param {*} [children] - * The childrens to replace into the given node. - * - * When `children` is an array, then each item of the array - * will be either appended as child element or text node, - * depending on whether the item is a DOM `Node` instance or - * some other non-`null` value. Non-`Node`, non-`null` values - * will be converted to strings first before being passed as - * argument to `createTextNode()`. - * - * When `children` is a function, it will be invoked with - * the passed `node` argument as sole parameter and the `append` - * function will be invoked again, with the given `node` argument - * as first and the return value of the `children` function as - * second parameter. - * - * When `children` is is a DOM `Node` instance, it will be - * appended to the given `node`. - * - * When `children` is any other non-`null` value, it will be - * converted to a string and appened to the `innerHTML` property - * of the given `node`. - * - * @returns {Node|null} - * Returns the last children `Node` appended to the node or `null` - * if either the `node` argument was no valid DOM `node` or if the - * `children` was `null` or didn't result in further DOM nodes. - */ - content: function(node, children) { - if (!this.elem(node)) - return null; - - var dataNodes = node.querySelectorAll('[data-idref]'); - - for (var i = 0; i < dataNodes.length; i++) - delete this.registry[dataNodes[i].getAttribute('data-idref')]; - - while (node.firstChild) - node.removeChild(node.firstChild); - - return this.append(node, children); - }, - - /** - * Sets attributes or registers event listeners on element nodes. - * - * @instance - * @memberof LuCI.dom - * @param {*} node - * The `Node` argument to set the attributes or add the event - * listeners for. When the given `node` value is not a valid - * DOM `Node`, the function returns and does nothing. - * - * @param {string|Object<string, *>} key - * Specifies either the attribute or event handler name to use, - * or an object containing multiple key, value pairs which are - * each added to the node as either attribute or event handler, - * depending on the respective value. - * - * @param {*} [val] - * Specifies the attribute value or event handler function to add. - * If the `key` parameter is an `Object`, this parameter will be - * ignored. - * - * When `val` is of type function, it will be registered as event - * handler on the given `node` with the `key` parameter being the - * event name. - * - * When `val` is of type object, it will be serialized as JSON and - * added as attribute to the given `node`, using the given `key` - * as attribute name. - * - * When `val` is of any other type, it will be added as attribute - * to the given `node` as-is, with the underlying `setAttribute()` - * call implicitely turning it into a string. - */ - attr: function(node, key, val) { - if (!this.elem(node)) - return null; - - var attr = null; - - if (typeof(key) === 'object' && key !== null) - attr = key; - else if (typeof(key) === 'string') - attr = {}, attr[key] = val; - - for (key in attr) { - if (!attr.hasOwnProperty(key) || attr[key] == null) - continue; - - switch (typeof(attr[key])) { - case 'function': - node.addEventListener(key, attr[key]); - break; - - case 'object': - node.setAttribute(key, JSON.stringify(attr[key])); - break; - - default: - node.setAttribute(key, attr[key]); - } - } - }, - - /** - * Creates a new DOM `Node` from the given `html`, `attr` and - * `data` parameters. - * - * This function has multiple signatures, it can be either invoked - * in the form `create(html[, attr[, data]])` or in the form - * `create(html[, data])`. The used variant is determined from the - * type of the second argument. - * - * @instance - * @memberof LuCI.dom - * @param {*} html - * Describes the node to create. - * - * When the value of `html` is of type array, a `DocumentFragment` - * node is created and each item of the array is first converted - * to a DOM `Node` by passing it through `create()` and then added - * as child to the fragment. - * - * When the value of `html` is a DOM `Node` instance, no new - * element will be created but the node will be used as-is. - * - * When the value of `html` is a string starting with `<`, it will - * be passed to `dom.parse()` and the resulting value is used. - * - * When the value of `html` is any other string, it will be passed - * to `document.createElement()` for creating a new DOM `Node` of - * the given name. - * - * @param {Object<string, *>} [attr] - * Specifies an Object of key, value pairs to set as attributes - * or event handlers on the created node. Refer to - * {@link LuCI.dom#attr dom.attr()} for details. - * - * @param {*} [data] - * Specifies children to append to the newly created element. - * Refer to {@link LuCI.dom#append dom.append()} for details. - * - * @throws {InvalidCharacterError} - * Throws an `InvalidCharacterError` when the given `html` - * argument contained malformed markup (such as not escaped - * `&` characters in XHTML mode) or when the given node name - * in `html` contains characters which are not legal in DOM - * element names, such as spaces. - * - * @returns {Node} - * Returns the newly created `Node`. - */ - create: function() { - var html = arguments[0], - attr = arguments[1], - data = arguments[2], - elem; - - if (!(attr instanceof Object) || Array.isArray(attr)) - data = attr, attr = null; - - if (Array.isArray(html)) { - elem = document.createDocumentFragment(); - for (var i = 0; i < html.length; i++) - elem.appendChild(this.create(html[i])); - } - else if (this.elem(html)) { - elem = html; - } - else if (html.charCodeAt(0) === 60) { - elem = this.parse(html); - } - else { - elem = document.createElement(html); - } - - if (!elem) - return null; - - this.attr(elem, attr); - this.append(elem, data); - - return elem; - }, - - registry: {}, - - /** - * Attaches or detaches arbitrary data to and from a DOM `Node`. - * - * This function is useful to attach non-string values or runtime - * data that is not serializable to DOM nodes. To decouple data - * from the DOM, values are not added directly to nodes, but - * inserted into a registry instead which is then referenced by a - * string key stored as `data-idref` attribute in the node. - * - * This function has multiple signatures and is sensitive to the - * number of arguments passed to it. - * - * - `dom.data(node)` - - * Fetches all data associated with the given node. - * - `dom.data(node, key)` - - * Fetches a specific key associated with the given node. - * - `dom.data(node, key, val)` - - * Sets a specific key to the given value associated with the - * given node. - * - `dom.data(node, null)` - - * Clears any data associated with the node. - * - `dom.data(node, key, null)` - - * Clears the given key associated with the node. - * - * @instance - * @memberof LuCI.dom - * @param {Node} node - * The DOM `Node` instance to set or retrieve the data for. - * - * @param {string|null} [key] - * This is either a string specifying the key to retrieve, or - * `null` to unset the entire node data. - * - * @param {*|null} [val] - * This is either a non-`null` value to set for a given key or - * `null` to remove the given `key` from the specified node. - * - * @returns {*} - * Returns the get or set value, or `null` when no value could - * be found. - */ - data: function(node, key, val) { - if (!node || !node.getAttribute) - return null; - - var id = node.getAttribute('data-idref'); - - /* clear all data */ - if (arguments.length > 1 && key == null) { - if (id != null) { - node.removeAttribute('data-idref'); - val = this.registry[id] - delete this.registry[id]; - return val; - } - - return null; - } - - /* clear a key */ - else if (arguments.length > 2 && key != null && val == null) { - if (id != null) { - val = this.registry[id][key]; - delete this.registry[id][key]; - return val; - } - - return null; - } - - /* set a key */ - else if (arguments.length > 2 && key != null && val != null) { - if (id == null) { - do { id = Math.floor(Math.random() * 0xffffffff).toString(16) } - while (this.registry.hasOwnProperty(id)); - - node.setAttribute('data-idref', id); - this.registry[id] = {}; - } - - return (this.registry[id][key] = val); - } - - /* get all data */ - else if (arguments.length == 1) { - if (id != null) - return this.registry[id]; - - return null; - } + dom: DOM, - /* get a key */ - else if (arguments.length == 2) { - if (id != null) - return this.registry[id][key]; - } - - return null; - }, - - /** - * Binds the given class instance ot the specified DOM `Node`. - * - * This function uses the `dom.data()` facility to attach the - * passed instance of a Class to a node. This is needed for - * complex widget elements or similar where the corresponding - * class instance responsible for the element must be retrieved - * from DOM nodes obtained by `querySelector()` or similar means. - * - * @instance - * @memberof LuCI.dom - * @param {Node} node - * The DOM `Node` instance to bind the class to. - * - * @param {Class} inst - * The Class instance to bind to the node. - * - * @throws {TypeError} - * Throws a `TypeError` when the given instance argument isn't - * a valid Class instance. - * - * @returns {Class} - * Returns the bound class instance. - */ - bindClassInstance: function(node, inst) { - if (!(inst instanceof Class)) - L.error('TypeError', 'Argument must be a class instance'); - - return this.data(node, '_class', inst); - }, - - /** - * Finds a bound class instance on the given node itself or the - * first bound instance on its closest parent node. - * - * @instance - * @memberof LuCI.dom - * @param {Node} node - * The DOM `Node` instance to start from. - * - * @returns {Class|null} - * Returns the founds class instance if any or `null` if no bound - * class could be found on the node itself or any of its parents. - */ - findClassInstance: function(node) { - var inst = null; - - do { - inst = this.data(node, '_class'); - node = node.parentNode; - } - while (!(inst instanceof Class) && node != null); - - return inst; - }, - - /** - * Finds a bound class instance on the given node itself or the - * first bound instance on its closest parent node and invokes - * the specified method name on the found class instance. - * - * @instance - * @memberof LuCI.dom - * @param {Node} node - * The DOM `Node` instance to start from. - * - * @param {string} method - * The name of the method to invoke on the found class instance. - * - * @param {...*} params - * Additional arguments to pass to the invoked method as-is. - * - * @returns {*|null} - * Returns the return value of the invoked method if a class - * instance and method has been found. Returns `null` if either - * no bound class instance could be found, or if the found - * instance didn't have the requested `method`. - */ - callClassMethod: function(node, method /*, ... */) { - var inst = this.findClassInstance(node); - - if (inst == null || typeof(inst[method]) != 'function') - return null; - - return inst[method].apply(inst, inst.varargs(arguments, 2)); - }, - - /** - * The ignore callback function is invoked by `isEmpty()` for each - * child node to decide whether to ignore a child node or not. - * - * When this function returns `false`, the node passed to it is - * ignored, else not. - * - * @callback LuCI.dom~ignoreCallbackFn - * @param {Node} node - * The child node to test. - * - * @returns {boolean} - * Boolean indicating whether to ignore the node or not. - */ - - /** - * Tests whether a given DOM `Node` instance is empty or appears - * empty. - * - * Any element child nodes which have the CSS class `hidden` set - * or for which the optionally passed `ignoreFn` callback function - * returns `false` are ignored. - * - * @instance - * @memberof LuCI.dom - * @param {Node} node - * The DOM `Node` instance to test. - * - * @param {LuCI.dom~ignoreCallbackFn} [ignoreFn] - * Specifies an optional function which is invoked for each child - * node to decide whether the child node should be ignored or not. - * - * @returns {boolean} - * Returns `true` if the node does not have any children or if - * any children node either has a `hidden` CSS class or a `false` - * result when testing it using the given `ignoreFn`. - */ - isEmpty: function(node, ignoreFn) { - for (var child = node.firstElementChild; child != null; child = child.nextElementSibling) - if (!child.classList.contains('hidden') && (!ignoreFn || !ignoreFn(child))) - return false; - - return true; - } - }), + /** + * Legacy `L.view` class alias. New view code should use `'require view';` + * to request the `LuCI.view` class. + * + * @instance + * @memberof LuCI + * @deprecated + */ + view: View, + /** + * Legacy `L.Poll` class alias. New view code should use `'require poll';` + * to request the `LuCI.poll` class. + * + * @instance + * @memberof LuCI + * @deprecated + */ Poll: Poll, - Class: Class, - Request: Request, /** - * @class - * @memberof LuCI - * @hideconstructor - * @classdesc + * Legacy `L.Request` class alias. New view code should use `'require request';` + * to request the `LuCI.request` class. * - * The `view` class forms the basis of views and provides a standard - * set of methods to inherit from. + * @instance + * @memberof LuCI + * @deprecated */ - view: Class.extend(/* @lends LuCI.view.prototype */ { - __name__: 'LuCI.View', - - __init__: function() { - var vp = document.getElementById('view'); - - L.dom.content(vp, E('div', { 'class': 'spinning' }, _('Loading view…'))); - - return Promise.resolve(this.load()) - .then(L.bind(this.render, this)) - .then(L.bind(function(nodes) { - var vp = document.getElementById('view'); - - L.dom.content(vp, nodes); - L.dom.append(vp, this.addFooter()); - }, this)).catch(L.error); - }, - - /** - * The load function is invoked before the view is rendered. - * - * The invocation of this function is wrapped by - * `Promise.resolve()` so it may return Promises if needed. - * - * The return value of the function (or the resolved values - * of the promise returned by it) will be passed as first - * argument to `render()`. - * - * This function is supposed to be overwritten by subclasses, - * the default implementation does nothing. - * - * @instance - * @abstract - * @memberof LuCI.view - * - * @returns {*|Promise<*>} - * May return any value or a Promise resolving to any value. - */ - load: function() {}, - - /** - * The render function is invoked after the - * {@link LuCI.view#load load()} function and responsible - * for setting up the view contents. It must return a DOM - * `Node` or `DocumentFragment` holding the contents to - * insert into the view area. - * - * The invocation of this function is wrapped by - * `Promise.resolve()` so it may return Promises if needed. - * - * The return value of the function (or the resolved values - * of the promise returned by it) will be inserted into the - * main content area using - * {@link LuCI.dom#append dom.append()}. - * - * This function is supposed to be overwritten by subclasses, - * the default implementation does nothing. - * - * @instance - * @abstract - * @memberof LuCI.view - * @param {*|null} load_results - * This function will receive the return value of the - * {@link LuCI.view#load view.load()} function as first - * argument. - * - * @returns {Node|Promise<Node>} - * Should return a DOM `Node` value or a `Promise` resolving - * to a `Node` value. - */ - render: function() {}, - - /** - * The handleSave function is invoked when the user clicks - * the `Save` button in the page action footer. - * - * The default implementation should be sufficient for most - * views using {@link form#Map form.Map()} based forms - it - * will iterate all forms present in the view and invoke - * the {@link form#Map#save Map.save()} method on each form. - * - * Views not using `Map` instances or requiring other special - * logic should overwrite `handleSave()` with a custom - * implementation. - * - * To disable the `Save` page footer button, views extending - * this base class should overwrite the `handleSave` function - * with `null`. - * - * The invocation of this function is wrapped by - * `Promise.resolve()` so it may return Promises if needed. - * - * @instance - * @memberof LuCI.view - * @param {Event} ev - * The DOM event that triggered the function. - * - * @returns {*|Promise<*>} - * Any return values of this function are discarded, but - * passed through `Promise.resolve()` to ensure that any - * returned promise runs to completion before the button - * is reenabled. - */ - handleSave: function(ev) { - var tasks = []; - - document.getElementById('maincontent') - .querySelectorAll('.cbi-map').forEach(function(map) { - tasks.push(L.dom.callClassMethod(map, 'save')); - }); - - return Promise.all(tasks); - }, - - /** - * The handleSaveApply function is invoked when the user clicks - * the `Save & Apply` button in the page action footer. - * - * The default implementation should be sufficient for most - * views using {@link form#Map form.Map()} based forms - it - * will first invoke - * {@link LuCI.view.handleSave view.handleSave()} and then - * call {@link ui#changes#apply ui.changes.apply()} to start the - * modal config apply and page reload flow. - * - * Views not using `Map` instances or requiring other special - * logic should overwrite `handleSaveApply()` with a custom - * implementation. - * - * To disable the `Save & Apply` page footer button, views - * extending this base class should overwrite the - * `handleSaveApply` function with `null`. - * - * The invocation of this function is wrapped by - * `Promise.resolve()` so it may return Promises if needed. - * - * @instance - * @memberof LuCI.view - * @param {Event} ev - * The DOM event that triggered the function. - * - * @returns {*|Promise<*>} - * Any return values of this function are discarded, but - * passed through `Promise.resolve()` to ensure that any - * returned promise runs to completion before the button - * is reenabled. - */ - handleSaveApply: function(ev, mode) { - return this.handleSave(ev).then(function() { - L.ui.changes.apply(mode == '0'); - }); - }, - - /** - * The handleReset function is invoked when the user clicks - * the `Reset` button in the page action footer. - * - * The default implementation should be sufficient for most - * views using {@link form#Map form.Map()} based forms - it - * will iterate all forms present in the view and invoke - * the {@link form#Map#save Map.reset()} method on each form. - * - * Views not using `Map` instances or requiring other special - * logic should overwrite `handleReset()` with a custom - * implementation. - * - * To disable the `Reset` page footer button, views extending - * this base class should overwrite the `handleReset` function - * with `null`. - * - * The invocation of this function is wrapped by - * `Promise.resolve()` so it may return Promises if needed. - * - * @instance - * @memberof LuCI.view - * @param {Event} ev - * The DOM event that triggered the function. - * - * @returns {*|Promise<*>} - * Any return values of this function are discarded, but - * passed through `Promise.resolve()` to ensure that any - * returned promise runs to completion before the button - * is reenabled. - */ - handleReset: function(ev) { - var tasks = []; - - document.getElementById('maincontent') - .querySelectorAll('.cbi-map').forEach(function(map) { - tasks.push(L.dom.callClassMethod(map, 'reset')); - }); - - return Promise.all(tasks); - }, - - /** - * Renders a standard page action footer if any of the - * `handleSave()`, `handleSaveApply()` or `handleReset()` - * functions are defined. - * - * The default implementation should be sufficient for most - * views - it will render a standard page footer with action - * buttons labeled `Save`, `Save & Apply` and `Reset` - * triggering the `handleSave()`, `handleSaveApply()` and - * `handleReset()` functions respectively. - * - * When any of these `handle*()` functions is overwritten - * with `null` by a view extending this class, the - * corresponding button will not be rendered. - * - * @instance - * @memberof LuCI.view - * @returns {DocumentFragment} - * Returns a `DocumentFragment` containing the footer bar - * with buttons for each corresponding `handle*()` action - * or an empty `DocumentFragment` if all three `handle*()` - * methods are overwritten with `null`. - */ - addFooter: function() { - var footer = E([]); - - var saveApplyBtn = this.handleSaveApply ? new L.ui.ComboButton('0', { - 0: [ _('Save & Apply') ], - 1: [ _('Apply unchecked') ] - }, { - classes: { - 0: 'cbi-button cbi-button-apply important', - 1: 'cbi-button cbi-button-negative important' - }, - click: L.ui.createHandlerFn(this, 'handleSaveApply') - }).render() : E([]); - - if (this.handleSaveApply || this.handleSave || this.handleReset) { - footer.appendChild(E('div', { 'class': 'cbi-page-actions' }, [ - saveApplyBtn, ' ', - this.handleSave ? E('button', { - 'class': 'cbi-button cbi-button-save', - 'click': L.ui.createHandlerFn(this, 'handleSave') - }, [ _('Save') ]) : '', ' ', - this.handleReset ? E('button', { - 'class': 'cbi-button cbi-button-reset', - 'click': L.ui.createHandlerFn(this, 'handleReset') - }, [ _('Reset') ]) : '' - ])); - } + Request: Request, - return footer; - } - }) + /** + * Legacy `L.Class` class alias. New view code should use `'require baseclass';` + * to request the `LuCI.baseclass` class. + * + * @instance + * @memberof LuCI + * @deprecated + */ + Class: Class }); /** - * @class + * @class xhr * @memberof LuCI * @deprecated * @classdesc * - * The `LuCI.XHR` class is a legacy compatibility shim for the + * The `LuCI.xhr` class is a legacy compatibility shim for the * functionality formerly provided by `xhr.js`. It is registered as global * `window.XHR` symbol for compatibility with legacy code. * - * New code should use {@link LuCI.Request} instead to implement HTTP + * New code should use {@link LuCI.request} instead to implement HTTP * request handling. */ - var XHR = Class.extend(/** @lends LuCI.XHR.prototype */ { - __name__: 'LuCI.XHR', + var XHR = Class.extend(/** @lends LuCI.xhr.prototype */ { + __name__: 'LuCI.xhr', __init__: function() { if (window.console && console.debug) console.debug('Direct use XHR() is deprecated, please use L.Request instead'); @@ -3041,7 +3097,7 @@ * * @instance * @deprecated - * @memberof LuCI.XHR + * @memberof LuCI.xhr * * @param {string} url * The URL to request @@ -3068,7 +3124,7 @@ * * @instance * @deprecated - * @memberof LuCI.XHR + * @memberof LuCI.xhr * * @param {string} url * The URL to request @@ -3099,7 +3155,7 @@ * * @instance * @deprecated - * @memberof LuCI.XHR + * @memberof LuCI.xhr */ cancel: function() { delete this.active }, @@ -3108,7 +3164,7 @@ * * @instance * @deprecated - * @memberof LuCI.XHR + * @memberof LuCI.xhr * * @returns {boolean} * Returns `true` if the request is still running or `false` if it @@ -3123,7 +3179,7 @@ * * @instance * @deprecated - * @memberof LuCI.XHR + * @memberof LuCI.xhr */ abort: function() {}, @@ -3134,7 +3190,7 @@ * * @instance * @deprecated - * @memberof LuCI.XHR + * @memberof LuCI.xhr * * @throws {InternalError} * Throws an `InternalError` with the message `Not implemented` diff --git a/modules/luci-base/htdocs/luci-static/resources/network.js b/modules/luci-base/htdocs/luci-static/resources/network.js index 43ae076a0a..bca67849b4 100644 --- a/modules/luci-base/htdocs/luci-static/resources/network.js +++ b/modules/luci-base/htdocs/luci-static/resources/network.js @@ -2,6 +2,8 @@ 'require uci'; 'require rpc'; 'require validation'; +'require baseclass'; +'require firewall'; var proto_errors = { CONNECT_FAILED: _('Connection attempt failed'), @@ -632,18 +634,18 @@ function enumerateNetworks() { var Hosts, Network, Protocol, Device, WifiDevice, WifiNetwork; /** - * @class + * @class network * @memberof LuCI * @hideconstructor * @classdesc * - * The `LuCI.Network` class combines data from multiple `ubus` apis to + * The `LuCI.network` class combines data from multiple `ubus` apis to * provide an abstraction of the current network configuration state. * * It provides methods to enumerate interfaces and devices, to query * current configuration details and to manipulate settings. */ -Network = L.Class.extend(/** @lends LuCI.Network.prototype */ { +Network = baseclass.extend(/** @lends LuCI.network.prototype */ { /** * Converts the given prefix size in bits to a netmask. * @@ -686,8 +688,8 @@ Network = L.Class.extend(/** @lends LuCI.Network.prototype */ { * such as the used key management protocols, active ciphers and * protocol versions. * - * @typedef {Object<string, boolean|Array<number|string>>} LuCI.Network.WifiEncryption - * @memberof LuCI.Network + * @typedef {Object<string, boolean|Array<number|string>>} LuCI.network.WifiEncryption + * @memberof LuCI.network * * @property {boolean} enabled * Specifies whether any kind of encryption, such as `WEP` or `WPA` is @@ -721,13 +723,13 @@ Network = L.Class.extend(/** @lends LuCI.Network.prototype */ { */ /** - * Converts a given {@link LuCI.Network.WifiEncryption encryption entry} + * Converts a given {@link LuCI.network.WifiEncryption encryption entry} * into a human readable string such as `mixed WPA/WPA2 PSK (TKIP, CCMP)` * or `WPA3 SAE (CCMP)`. * * @method * - * @param {LuCI.Network.WifiEncryption} encryption + * @param {LuCI.network.WifiEncryption} encryption * The wireless encryption entry to convert. * * @returns {null|string} @@ -749,7 +751,7 @@ Network = L.Class.extend(/** @lends LuCI.Network.prototype */ { }, /** - * Instantiates the given {@link LuCI.Network.Protocol Protocol} backend, + * Instantiates the given {@link LuCI.network.Protocol Protocol} backend, * optionally using the given network name. * * @param {string} protoname @@ -761,7 +763,7 @@ Network = L.Class.extend(/** @lends LuCI.Network.prototype */ { * but it is allowed to omit it, e.g. to query protocol capabilities * without the need for an existing interface. * - * @returns {null|LuCI.Network.Protocol} + * @returns {null|LuCI.network.Protocol} * Returns the instantiated protocol backend class or `null` if the given * protocol isn't known. */ @@ -774,10 +776,10 @@ Network = L.Class.extend(/** @lends LuCI.Network.prototype */ { }, /** - * Obtains instances of all known {@link LuCI.Network.Protocol Protocol} + * Obtains instances of all known {@link LuCI.network.Protocol Protocol} * backend classes. * - * @returns {Array<LuCI.Network.Protocol>} + * @returns {Array<LuCI.network.Protocol>} * Returns an array of protocol class instances. */ getProtocols: function() { @@ -790,7 +792,7 @@ Network = L.Class.extend(/** @lends LuCI.Network.prototype */ { }, /** - * Registers a new {@link LuCI.Network.Protocol Protocol} subclass + * Registers a new {@link LuCI.network.Protocol Protocol} subclass * with the given methods and returns the resulting subclass value. * * This functions internally calls @@ -804,7 +806,7 @@ Network = L.Class.extend(/** @lends LuCI.Network.prototype */ { * The member methods and values of the new `Protocol` subclass to * be passed to {@link LuCI.Class.extend Class.extend()}. * - * @returns {LuCI.Network.Protocol} + * @returns {LuCI.network.Protocol} * Returns the new `Protocol` subclass. */ registerProtocol: function(protoname, methods) { @@ -893,7 +895,7 @@ Network = L.Class.extend(/** @lends LuCI.Network.prototype */ { * An object of uci option values to set on the new network or to * update in an existing, empty network. * - * @returns {Promise<null|LuCI.Network.Protocol>} + * @returns {Promise<null|LuCI.network.Protocol>} * Returns a promise resolving to the `Protocol` subclass instance * describing the added network or resolving to `null` if the name * was invalid or if a non-empty network of the given name already @@ -925,15 +927,15 @@ Network = L.Class.extend(/** @lends LuCI.Network.prototype */ { }, /** - * Get a {@link LuCI.Network.Protocol Protocol} instance describing + * Get a {@link LuCI.network.Protocol Protocol} instance describing * the network with the given name. * * @param {string} name * The logical interface name of the network get, e.g. `lan` or `wan`. * - * @returns {Promise<null|LuCI.Network.Protocol>} + * @returns {Promise<null|LuCI.network.Protocol>} * Returns a promise resolving to a - * {@link LuCI.Network.Protocol Protocol} subclass instance describing + * {@link LuCI.network.Protocol Protocol} subclass instance describing * the network or `null` if the network did not exist. */ getNetwork: function(name) { @@ -956,9 +958,9 @@ Network = L.Class.extend(/** @lends LuCI.Network.prototype */ { /** * Gets an array containing all known networks. * - * @returns {Promise<Array<LuCI.Network.Protocol>>} + * @returns {Promise<Array<LuCI.network.Protocol>>} * Returns a promise resolving to a name-sorted array of - * {@link LuCI.Network.Protocol Protocol} subclass instances + * {@link LuCI.network.Protocol Protocol} subclass instances * describing all known networks. */ getNetworks: function() { @@ -981,8 +983,9 @@ Network = L.Class.extend(/** @lends LuCI.Network.prototype */ { var requireFirewall = Promise.resolve(L.require('firewall')).catch(function() {}), network = this.instantiateNetwork(name); - return Promise.all([ requireFirewall, initNetworkState() ]).then(function() { - var uciInterface = uci.get('network', name); + return Promise.all([ requireFirewall, initNetworkState() ]).then(function(res) { + var uciInterface = uci.get('network', name), + firewall = res[0]; if (uciInterface != null && uciInterface['.type'] == 'interface') { return Promise.resolve(network ? network.deleteConfiguration() : null).then(function() { @@ -1017,8 +1020,8 @@ Network = L.Class.extend(/** @lends LuCI.Network.prototype */ { uci.unset('wireless', s['.name'], 'network'); }); - if (L.firewall) - return L.firewall.deleteNetwork(name).then(function() { return true }); + if (firewall) + return firewall.deleteNetwork(name).then(function() { return true }); return true; }).catch(function() { @@ -1096,13 +1099,13 @@ Network = L.Class.extend(/** @lends LuCI.Network.prototype */ { }, /** - * Get a {@link LuCI.Network.Device Device} instance describing the + * Get a {@link LuCI.network.Device Device} instance describing the * given network device. * * @param {string} name * The name of the network device to get, e.g. `eth0` or `br-lan`. * - * @returns {Promise<null|LuCI.Network.Device>} + * @returns {Promise<null|LuCI.network.Device>} * Returns a promise resolving to the `Device` instance describing * the network device or `null` if the given device name could not * be found. @@ -1126,7 +1129,7 @@ Network = L.Class.extend(/** @lends LuCI.Network.prototype */ { /** * Get a sorted list of all found network devices. * - * @returns {Promise<Array<LuCI.Network.Device>>} + * @returns {Promise<Array<LuCI.network.Device>>} * Returns a promise resolving to a sorted array of `Device` class * instances describing the network devices found on the system. */ @@ -1251,14 +1254,14 @@ Network = L.Class.extend(/** @lends LuCI.Network.prototype */ { }, /** - * Get a {@link LuCI.Network.WifiDevice WifiDevice} instance describing + * Get a {@link LuCI.network.WifiDevice WifiDevice} instance describing * the given wireless radio. * * @param {string} devname * The configuration name of the wireless radio to lookup, e.g. `radio0` * for the first mac80211 phy on the system. * - * @returns {Promise<null|LuCI.Network.WifiDevice>} + * @returns {Promise<null|LuCI.network.WifiDevice>} * Returns a promise resolving to the `WifiDevice` instance describing * the underlying radio device or `null` if the wireless radio could not * be found. @@ -1277,7 +1280,7 @@ Network = L.Class.extend(/** @lends LuCI.Network.prototype */ { /** * Obtain a list of all configured radio devices. * - * @returns {Promise<Array<LuCI.Network.WifiDevice>>} + * @returns {Promise<Array<LuCI.network.WifiDevice>>} * Returns a promise resolving to an array of `WifiDevice` instances * describing the wireless radios configured in the system. * The order of the array corresponds to the order of the radios in @@ -1298,7 +1301,7 @@ Network = L.Class.extend(/** @lends LuCI.Network.prototype */ { }, /** - * Get a {@link LuCI.Network.WifiNetwork WifiNetwork} instance describing + * Get a {@link LuCI.network.WifiNetwork WifiNetwork} instance describing * the given wireless network. * * @param {string} netname @@ -1307,7 +1310,7 @@ Network = L.Class.extend(/** @lends LuCI.Network.prototype */ { * or a Linux network device name like `wlan0` which is resolved to the * corresponding configuration section through `ubus` runtime information. * - * @returns {Promise<null|LuCI.Network.WifiNetwork>} + * @returns {Promise<null|LuCI.network.WifiNetwork>} * Returns a promise resolving to the `WifiNetwork` instance describing * the wireless network or `null` if the corresponding network could not * be found. @@ -1318,10 +1321,10 @@ Network = L.Class.extend(/** @lends LuCI.Network.prototype */ { }, /** - * Get an array of all {@link LuCI.Network.WifiNetwork WifiNetwork} + * Get an array of all {@link LuCI.network.WifiNetwork WifiNetwork} * instances describing the wireless networks present on the system. * - * @returns {Promise<Array<LuCI.Network.WifiNetwork>>} + * @returns {Promise<Array<LuCI.network.WifiNetwork>>} * Returns a promise resolving to an array of `WifiNetwork` instances * describing the wireless networks. The array will be empty if no networks * are found. @@ -1351,7 +1354,7 @@ Network = L.Class.extend(/** @lends LuCI.Network.prototype */ { * must at least contain a `device` property which is set to the radio * name the new network belongs to. * - * @returns {Promise<null|LuCI.Network.WifiNetwork>} + * @returns {Promise<null|LuCI.network.WifiNetwork>} * Returns a promise resolving to a `WifiNetwork` instance describing * the newly added wireless network or `null` if the given options * were invalid or if the associated radio device could not be found. @@ -1472,7 +1475,7 @@ Network = L.Class.extend(/** @lends LuCI.Network.prototype */ { * This function looks up all networks having a default `0.0.0.0/0` route * and returns them as array. * - * @returns {Promise<Array<LuCI.Network.Protocol>>} + * @returns {Promise<Array<LuCI.network.Protocol>>} * Returns a promise resolving to an array of `Protocol` subclass * instances describing the found default route interfaces. */ @@ -1497,7 +1500,7 @@ Network = L.Class.extend(/** @lends LuCI.Network.prototype */ { * This function looks up all networks having a default `::/0` route * and returns them as array. * - * @returns {Promise<Array<LuCI.Network.Protocol>>} + * @returns {Promise<Array<LuCI.network.Protocol>>} * Returns a promise resolving to an array of `Protocol` subclass * instances describing the found IPv6 default route interfaces. */ @@ -1521,7 +1524,7 @@ Network = L.Class.extend(/** @lends LuCI.Network.prototype */ { * connections and external port labels of a switch. * * @typedef {Object<string, Object|Array>} SwitchTopology - * @memberof LuCI.Network + * @memberof LuCI.network * * @property {Object<number, string>} netdevs * The `netdevs` property points to an object describing the CPU port @@ -1543,11 +1546,11 @@ Network = L.Class.extend(/** @lends LuCI.Network.prototype */ { /** * Returns the topologies of all swconfig switches found on the system. * - * @returns {Promise<Object<string, LuCI.Network.SwitchTopology>>} + * @returns {Promise<Object<string, LuCI.network.SwitchTopology>>} * Returns a promise resolving to an object containing the topologies * of each switch. The object keys correspond to the name of the switches * such as `switch0`, the values are - * {@link LuCI.Network.SwitchTopology SwitchTopology} objects describing + * {@link LuCI.network.SwitchTopology SwitchTopology} objects describing * the layout. */ getSwitchTopologies: function() { @@ -1638,7 +1641,7 @@ Network = L.Class.extend(/** @lends LuCI.Network.prototype */ { /** * Obtains the the network device name of the given object. * - * @param {LuCI.Network.Protocol|LuCI.Network.Device|LuCI.Network.WifiDevice|LuCI.Network.WifiNetwork|string} obj + * @param {LuCI.network.Protocol|LuCI.network.Device|LuCI.network.WifiDevice|LuCI.network.WifiNetwork|string} obj * The object to get the device name from. * * @returns {null|string} @@ -1667,10 +1670,10 @@ Network = L.Class.extend(/** @lends LuCI.Network.prototype */ { * * This function aggregates information from various sources such as * DHCP lease databases, ARP and IPv6 neighbour entries, wireless - * association list etc. and returns a {@link LuCI.Network.Hosts Hosts} + * association list etc. and returns a {@link LuCI.network.Hosts Hosts} * class instance describing the found hosts. * - * @returns {Promise<LuCI.Network.Hosts>} + * @returns {Promise<LuCI.network.Hosts>} * Returns a `Hosts` instance describing host known on the system. */ getHostHints: function() { @@ -1682,15 +1685,15 @@ Network = L.Class.extend(/** @lends LuCI.Network.prototype */ { /** * @class - * @memberof LuCI.Network + * @memberof LuCI.network * @hideconstructor * @classdesc * - * The `LuCI.Network.Hosts` class encapsulates host information aggregated + * The `LuCI.network.Hosts` class encapsulates host information aggregated * from multiple sources and provides convenience functions to access the * host information by different criteria. */ -Hosts = L.Class.extend(/** @lends LuCI.Network.Hosts.prototype */ { +Hosts = baseclass.extend(/** @lends LuCI.network.Hosts.prototype */ { __init__: function(hosts) { this.hosts = hosts; }, @@ -1848,7 +1851,7 @@ Hosts = L.Class.extend(/** @lends LuCI.Network.Hosts.prototype */ { /** * @class - * @memberof LuCI.Network + * @memberof LuCI.network * @hideconstructor * @classdesc * @@ -1856,7 +1859,7 @@ Hosts = L.Class.extend(/** @lends LuCI.Network.Hosts.prototype */ { * subclasses which describe logical UCI networks defined by `config * interface` sections in `/etc/config/network`. */ -Protocol = L.Class.extend(/** @lends LuCI.Network.Protocol.prototype */ { +Protocol = baseclass.extend(/** @lends LuCI.network.Protocol.prototype */ { __init__: function(name) { this.sid = name; }, @@ -1933,7 +1936,7 @@ Protocol = L.Class.extend(/** @lends LuCI.Network.Protocol.prototype */ { * Get the name of this network protocol class. * * This function will be overwritten by subclasses created by - * {@link LuCI.Network#registerProtocol Network.registerProtocol()}. + * {@link LuCI.network#registerProtocol Network.registerProtocol()}. * * @abstract * @returns {string} @@ -1967,7 +1970,7 @@ Protocol = L.Class.extend(/** @lends LuCI.Network.Protocol.prototype */ { * Get the type of the underlying interface. * * This function actually is a convenience wrapper around - * `proto.get("type")` and is mainly used by other `LuCI.Network` code + * `proto.get("type")` and is mainly used by other `LuCI.network` code * to check whether the interface is declared as bridge in UCI. * * @returns {null|string} @@ -2251,7 +2254,7 @@ Protocol = L.Class.extend(/** @lends LuCI.Network.Protocol.prototype */ { * * This function will translate the found error codes to human readable * messages using the descriptions registered by - * {@link LuCI.Network#registerErrorCode Network.registerErrorCode()} + * {@link LuCI.network#registerErrorCode Network.registerErrorCode()} * and fall back to `"Unknown error (%s)"` where `%s` is replaced by the * error code in case no translation can be found. * @@ -2312,8 +2315,9 @@ Protocol = L.Class.extend(/** @lends LuCI.Network.Protocol.prototype */ { * @param {string} ifname * The name of the interface to be created. * - * @returns {Promise<null|error message>} - * Returns `null` if new interface is createable, else returns (error) message. + * @returns {Promise<void>} + * Returns a promise resolving if new interface is createable, else + * rejects with an error message string. */ isCreateable: function(ifname) { return Promise.resolve(null); @@ -2452,10 +2456,10 @@ Protocol = L.Class.extend(/** @lends LuCI.Network.Protocol.prototype */ { /** * Add the given network device to the logical interface. * - * @param {LuCI.Network.Protocol|LuCI.Network.Device|LuCI.Network.WifiDevice|LuCI.Network.WifiNetwork|string} device + * @param {LuCI.network.Protocol|LuCI.network.Device|LuCI.network.WifiDevice|LuCI.network.WifiNetwork|string} device * The object or device name to add to the logical interface. In case the * given argument is not a string, it is resolved though the - * {@link LuCI.Network#getIfnameOf Network.getIfnameOf()} function. + * {@link LuCI.network#getIfnameOf Network.getIfnameOf()} function. * * @returns {boolean} * Returns `true` if the device name has been added or `false` if any @@ -2479,10 +2483,10 @@ Protocol = L.Class.extend(/** @lends LuCI.Network.Protocol.prototype */ { /** * Remove the given network device from the logical interface. * - * @param {LuCI.Network.Protocol|LuCI.Network.Device|LuCI.Network.WifiDevice|LuCI.Network.WifiNetwork|string} device + * @param {LuCI.network.Protocol|LuCI.network.Device|LuCI.network.WifiDevice|LuCI.network.WifiNetwork|string} device * The object or device name to remove from the logical interface. In case * the given argument is not a string, it is resolved though the - * {@link LuCI.Network#getIfnameOf Network.getIfnameOf()} function. + * {@link LuCI.network#getIfnameOf Network.getIfnameOf()} function. * * @returns {boolean} * Returns `true` if the device name has been added or `false` if any @@ -2512,7 +2516,7 @@ Protocol = L.Class.extend(/** @lends LuCI.Network.Protocol.prototype */ { * Returns the Linux network device associated with this logical * interface. * - * @returns {LuCI.Network.Device} + * @returns {LuCI.network.Device} * Returns a `Network.Device` class instance representing the * expected Linux network device according to the configuration. */ @@ -2520,7 +2524,7 @@ Protocol = L.Class.extend(/** @lends LuCI.Network.Protocol.prototype */ { if (this.isVirtual()) { var ifname = '%s-%s'.format(this.getProtocol(), this.sid); _state.isTunnel[this.getProtocol() + '-' + this.sid] = true; - return L.network.instantiateDevice(ifname, this); + return Network.prototype.instantiateDevice(ifname, this); } else if (this.isBridge()) { var ifname = 'br-%s'.format(this.sid); @@ -2532,12 +2536,12 @@ Protocol = L.Class.extend(/** @lends LuCI.Network.Protocol.prototype */ { for (var i = 0; i < ifnames.length; i++) { var m = ifnames[i].match(/^([^:/]+)/); - return ((m && m[1]) ? L.network.instantiateDevice(m[1], this) : null); + return ((m && m[1]) ? Network.prototype.instantiateDevice(m[1], this) : null); } ifname = getWifiNetidByNetname(this.sid); - return (ifname != null ? L.network.instantiateDevice(ifname[0], this) : null); + return (ifname != null ? Network.prototype.instantiateDevice(ifname[0], this) : null); } }, @@ -2545,33 +2549,33 @@ Protocol = L.Class.extend(/** @lends LuCI.Network.Protocol.prototype */ { * Returns the layer 2 linux network device currently associated * with this logical interface. * - * @returns {LuCI.Network.Device} + * @returns {LuCI.network.Device} * Returns a `Network.Device` class instance representing the Linux * network device currently associated with the logical interface. */ getL2Device: function() { var ifname = this._ubus('device'); - return (ifname != null ? L.network.instantiateDevice(ifname, this) : null); + return (ifname != null ? Network.prototype.instantiateDevice(ifname, this) : null); }, /** * Returns the layer 3 linux network device currently associated * with this logical interface. * - * @returns {LuCI.Network.Device} + * @returns {LuCI.network.Device} * Returns a `Network.Device` class instance representing the Linux * network device currently associated with the logical interface. */ getL3Device: function() { var ifname = this._ubus('l3_device'); - return (ifname != null ? L.network.instantiateDevice(ifname, this) : null); + return (ifname != null ? Network.prototype.instantiateDevice(ifname, this) : null); }, /** * Returns a list of network sub-devices associated with this logical * interface. * - * @returns {null|Array<LuCI.Network.Device>} + * @returns {null|Array<LuCI.network.Device>} * Returns an array of of `Network.Device` class instances representing * the sub-devices attached to this logical interface or `null` if the * logical interface does not support sub-devices, e.g. because it is @@ -2591,7 +2595,7 @@ Protocol = L.Class.extend(/** @lends LuCI.Network.Protocol.prototype */ { var m = ifnames[i].match(/^([^:/]+)/); if (m != null) - rv.push(L.network.instantiateDevice(m[1], this)); + rv.push(Network.prototype.instantiateDevice(m[1], this)); } var uciWifiIfaces = uci.sections('wireless', 'wifi-iface'); @@ -2609,7 +2613,7 @@ Protocol = L.Class.extend(/** @lends LuCI.Network.Protocol.prototype */ { var netid = getWifiNetidBySid(uciWifiIfaces[i]['.name']); if (netid != null) - rv.push(L.network.instantiateDevice(netid[0], this)); + rv.push(Network.prototype.instantiateDevice(netid[0], this)); } } @@ -2622,10 +2626,10 @@ Protocol = L.Class.extend(/** @lends LuCI.Network.Protocol.prototype */ { * Checks whether this logical interface contains the given device * object. * - * @param {LuCI.Network.Protocol|LuCI.Network.Device|LuCI.Network.WifiDevice|LuCI.Network.WifiNetwork|string} device + * @param {LuCI.network.Protocol|LuCI.network.Device|LuCI.network.WifiDevice|LuCI.network.WifiNetwork|string} device * The object or device name to check. In case the given argument is not * a string, it is resolved though the - * {@link LuCI.Network#getIfnameOf Network.getIfnameOf()} function. + * {@link LuCI.network#getIfnameOf Network.getIfnameOf()} function. * * @returns {boolean} * Returns `true` when this logical interface contains the given network @@ -2684,14 +2688,14 @@ Protocol = L.Class.extend(/** @lends LuCI.Network.Protocol.prototype */ { /** * @class - * @memberof LuCI.Network + * @memberof LuCI.network * @hideconstructor * @classdesc * * A `Network.Device` class instance represents an underlying Linux network * device and allows querying device details such as packet statistics or MTU. */ -Device = L.Class.extend(/** @lends LuCI.Network.Device.prototype */ { +Device = baseclass.extend(/** @lends LuCI.network.Device.prototype */ { __init__: function(ifname, network) { var wif = getWifiSidByIfname(ifname); @@ -2871,7 +2875,7 @@ Device = L.Class.extend(/** @lends LuCI.Network.Device.prototype */ { /** * Get the associated bridge ports of the device. * - * @returns {null|Array<LuCI.Network.Device>} + * @returns {null|Array<LuCI.network.Device>} * Returns an array of `Network.Device` instances representing the ports * (slave interfaces) of the bridge or `null` when this device isn't * a Linux bridge. @@ -2884,7 +2888,7 @@ Device = L.Class.extend(/** @lends LuCI.Network.Device.prototype */ { return null; for (var i = 0; i < br.ifnames.length; i++) - rv.push(L.network.instantiateDevice(br.ifnames[i].name)); + rv.push(Network.prototype.instantiateDevice(br.ifnames[i].name)); rv.sort(deviceSort); @@ -3000,7 +3004,7 @@ Device = L.Class.extend(/** @lends LuCI.Network.Device.prototype */ { /** * Get the primary logical interface this device is assigned to. * - * @returns {null|LuCI.Network.Protocol} + * @returns {null|LuCI.network.Protocol} * Returns a `Network.Protocol` instance representing the logical * interface this device is attached to or `null` if it is not * assigned to any logical interface. @@ -3012,7 +3016,7 @@ Device = L.Class.extend(/** @lends LuCI.Network.Device.prototype */ { /** * Get the logical interfaces this device is assigned to. * - * @returns {Array<LuCI.Network.Protocol>} + * @returns {Array<LuCI.network.Protocol>} * Returns an array of `Network.Protocol` instances representing the * logical interfaces this device is assigned to. */ @@ -3035,7 +3039,7 @@ Device = L.Class.extend(/** @lends LuCI.Network.Device.prototype */ { /** * Get the related wireless network this device is related to. * - * @returns {null|LuCI.Network.WifiNetwork} + * @returns {null|LuCI.network.WifiNetwork} * Returns a `Network.WifiNetwork` instance representing the wireless * network corresponding to this network device or `null` if this device * is not a wireless device. @@ -3047,7 +3051,7 @@ Device = L.Class.extend(/** @lends LuCI.Network.Device.prototype */ { /** * @class - * @memberof LuCI.Network + * @memberof LuCI.network * @hideconstructor * @classdesc * @@ -3055,7 +3059,7 @@ Device = L.Class.extend(/** @lends LuCI.Network.Device.prototype */ { * present on the system and provides wireless capability information as * well as methods for enumerating related wireless networks. */ -WifiDevice = L.Class.extend(/** @lends LuCI.Network.WifiDevice.prototype */ { +WifiDevice = baseclass.extend(/** @lends LuCI.network.WifiDevice.prototype */ { __init__: function(name, radiostate) { var uciWifiDevice = uci.get('wireless', name); @@ -3207,8 +3211,8 @@ WifiDevice = L.Class.extend(/** @lends LuCI.Network.WifiDevice.prototype */ { * A wireless scan result object describes a neighbouring wireless * network found in the vincinity. * - * @typedef {Object<string, number|string|LuCI.Network.WifiEncryption>} WifiScanResult - * @memberof LuCI.Network + * @typedef {Object<string, number|string|LuCI.network.WifiEncryption>} WifiScanResult + * @memberof LuCI.network * * @property {string} ssid * The SSID / Mesh ID of the network. @@ -3233,7 +3237,7 @@ WifiDevice = L.Class.extend(/** @lends LuCI.Network.WifiDevice.prototype */ { * The maximum possible quality level of the signal, can be used in * conjunction with `quality` to calculate a quality percentage. * - * @property {LuCI.Network.WifiEncryption} encryption + * @property {LuCI.network.WifiEncryption} encryption * The encryption used by the wireless network. */ @@ -3241,7 +3245,7 @@ WifiDevice = L.Class.extend(/** @lends LuCI.Network.WifiDevice.prototype */ { * Trigger a wireless scan on this radio device and obtain a list of * nearby networks. * - * @returns {Promise<Array<LuCI.Network.WifiScanResult>>} + * @returns {Promise<Array<LuCI.network.WifiScanResult>>} * Returns a promise resolving to an array of scan result objects * describing the networks found in the vincinity. */ @@ -3272,14 +3276,14 @@ WifiDevice = L.Class.extend(/** @lends LuCI.Network.WifiDevice.prototype */ { * or a Linux network device name like `wlan0` which is resolved to the * corresponding configuration section through `ubus` runtime information. * - * @returns {Promise<LuCI.Network.WifiNetwork>} + * @returns {Promise<LuCI.network.WifiNetwork>} * Returns a promise resolving to a `Network.WifiNetwork` instance * representing the wireless network and rejecting with `null` if * the given network could not be found or is not associated with * this radio device. */ getWifiNetwork: function(network) { - return L.network.getWifiNetwork(network).then(L.bind(function(networkInstance) { + return Network.prototype.getWifiNetwork(network).then(L.bind(function(networkInstance) { var uciWifiIface = (networkInstance.sid ? uci.get('wireless', networkInstance.sid) : null); if (uciWifiIface == null || uciWifiIface['.type'] != 'wifi-iface' || uciWifiIface.device != this.sid) @@ -3292,13 +3296,13 @@ WifiDevice = L.Class.extend(/** @lends LuCI.Network.WifiDevice.prototype */ { /** * Get all wireless networks associated with this wireless radio device. * - * @returns {Promise<Array<LuCI.Network.WifiNetwork>>} + * @returns {Promise<Array<LuCI.network.WifiNetwork>>} * Returns a promise resolving to an array of `Network.WifiNetwork` * instances respresenting the wireless networks associated with this * radio device. */ getWifiNetworks: function() { - return L.network.getWifiNetworks().then(L.bind(function(networks) { + return Network.prototype.getWifiNetworks().then(L.bind(function(networks) { var rv = []; for (var i = 0; i < networks.length; i++) @@ -3316,7 +3320,7 @@ WifiDevice = L.Class.extend(/** @lends LuCI.Network.WifiDevice.prototype */ { * @param {Object<string, string|string[]>} [options] * The options to set for the newly added wireless network. * - * @returns {Promise<null|LuCI.Network.WifiNetwork>} + * @returns {Promise<null|LuCI.network.WifiNetwork>} * Returns a promise resolving to a `WifiNetwork` instance describing * the newly added wireless network or `null` if the given options * were invalid. @@ -3327,7 +3331,7 @@ WifiDevice = L.Class.extend(/** @lends LuCI.Network.WifiDevice.prototype */ { options.device = this.sid; - return L.network.addWifiNetwork(options); + return Network.prototype.addWifiNetwork(options); }, /** @@ -3370,7 +3374,7 @@ WifiDevice = L.Class.extend(/** @lends LuCI.Network.WifiDevice.prototype */ { /** * @class - * @memberof LuCI.Network + * @memberof LuCI.network * @hideconstructor * @classdesc * @@ -3379,7 +3383,7 @@ WifiDevice = L.Class.extend(/** @lends LuCI.Network.WifiDevice.prototype */ { * the runtime state of the network. Most radio devices support multiple * such networks in parallel. */ -WifiNetwork = L.Class.extend(/** @lends LuCI.Network.WifiNetwork.prototype */ { +WifiNetwork = baseclass.extend(/** @lends LuCI.network.WifiNetwork.prototype */ { __init__: function(sid, radioname, radiostate, netid, netstate, hostapd) { this.sid = sid; this.netid = netid; @@ -3562,7 +3566,7 @@ WifiNetwork = L.Class.extend(/** @lends LuCI.Network.WifiNetwork.prototype */ { /** * Get the corresponding wifi radio device. * - * @returns {null|LuCI.Network.WifiDevice} + * @returns {null|LuCI.network.WifiDevice} * Returns a `Network.WifiDevice` instance representing the corresponding * wifi radio device or `null` if the related radio device could not be * found. @@ -3573,7 +3577,7 @@ WifiNetwork = L.Class.extend(/** @lends LuCI.Network.WifiNetwork.prototype */ { if (radioname == null) return Promise.reject(); - return L.network.getWifiDevice(radioname); + return Network.prototype.getWifiDevice(radioname); }, /** @@ -3685,8 +3689,8 @@ WifiNetwork = L.Class.extend(/** @lends LuCI.Network.WifiNetwork.prototype */ { * A wireless peer entry describes the properties of a remote wireless * peer associated with a local network. * - * @typedef {Object<string, boolean|number|string|LuCI.Network.WifiRateEntry>} WifiPeerEntry - * @memberof LuCI.Network + * @typedef {Object<string, boolean|number|string|LuCI.network.WifiRateEntry>} WifiPeerEntry + * @memberof LuCI.network * * @property {string} mac * The MAC address (BSSID). @@ -3784,10 +3788,10 @@ WifiNetwork = L.Class.extend(/** @lends LuCI.Network.WifiNetwork.prototype */ { * - `DEEP SLEEP` * - `UNKNOWN` * - * @property {LuCI.Network.WifiRateEntry} rx + * @property {LuCI.network.WifiRateEntry} rx * Describes the receiving wireless rate from the peer. * - * @property {LuCI.Network.WifiRateEntry} tx + * @property {LuCI.network.WifiRateEntry} tx * Describes the transmitting wireless rate to the peer. */ @@ -3796,7 +3800,7 @@ WifiNetwork = L.Class.extend(/** @lends LuCI.Network.WifiNetwork.prototype */ { * transmission rate to or from a peer. * * @typedef {Object<string, boolean|number>} WifiRateEntry - * @memberof LuCI.Network + * @memberof LuCI.network * * @property {number} [drop_misc] * The amount of received misc. packages that have been dropped, e.g. @@ -3853,7 +3857,7 @@ WifiNetwork = L.Class.extend(/** @lends LuCI.Network.WifiNetwork.prototype */ { /** * Fetch the list of associated peers. * - * @returns {Promise<Array<LuCI.Network.WifiPeerEntry>>} + * @returns {Promise<Array<LuCI.network.WifiPeerEntry>>} * Returns a promise resolving to an array of wireless peers associated * with this network. */ @@ -4041,7 +4045,7 @@ WifiNetwork = L.Class.extend(/** @lends LuCI.Network.WifiNetwork.prototype */ { /** * Get the primary logical interface this wireless network is attached to. * - * @returns {null|LuCI.Network.Protocol} + * @returns {null|LuCI.network.Protocol} * Returns a `Network.Protocol` instance representing the logical * interface or `null` if this network is not attached to any logical * interface. @@ -4053,7 +4057,7 @@ WifiNetwork = L.Class.extend(/** @lends LuCI.Network.WifiNetwork.prototype */ { /** * Get the logical interfaces this wireless network is attached to. * - * @returns {Array<LuCI.Network.Protocol>} + * @returns {Array<LuCI.network.Protocol>} * Returns an array of `Network.Protocol` instances representing the * logical interfaces this wireless network is attached to. */ @@ -4067,7 +4071,7 @@ WifiNetwork = L.Class.extend(/** @lends LuCI.Network.WifiNetwork.prototype */ { if (uciInterface == null || uciInterface['.type'] != 'interface') continue; - networks.push(L.network.instantiateNetwork(networkNames[i])); + networks.push(Network.prototype.instantiateNetwork(networkNames[i])); } networks.sort(networkSort); @@ -4078,12 +4082,12 @@ WifiNetwork = L.Class.extend(/** @lends LuCI.Network.WifiNetwork.prototype */ { /** * Get the associated Linux network device. * - * @returns {LuCI.Network.Device} + * @returns {LuCI.network.Device} * Returns a `Network.Device` instance representing the Linux network * device associted with this wireless network. */ getDevice: function() { - return L.network.instantiateDevice(this.getIfname()); + return Network.prototype.instantiateDevice(this.getIfname()); }, /** diff --git a/modules/luci-base/htdocs/luci-static/resources/rpc.js b/modules/luci-base/htdocs/luci-static/resources/rpc.js index 9b642444fa..20b77c18fc 100644 --- a/modules/luci-base/htdocs/luci-static/resources/rpc.js +++ b/modules/luci-base/htdocs/luci-static/resources/rpc.js @@ -1,4 +1,6 @@ 'use strict'; +'require baseclass'; +'require request'; var rpcRequestID = 1, rpcSessionID = L.env.sessionid || '00000000000000000000000000000000', @@ -14,7 +16,7 @@ var rpcRequestID = 1, * The `LuCI.rpc` class provides high level ubus JSON-RPC abstractions * and means for listing and invoking remove RPC methods. */ -return L.Class.extend(/** @lends LuCI.rpc.prototype */ { +return baseclass.extend(/** @lends LuCI.rpc.prototype */ { /* privates */ call: function(req, cb, nobatch) { var q = ''; @@ -35,7 +37,7 @@ return L.Class.extend(/** @lends LuCI.rpc.prototype */ { q += '/%s.%s'.format(req.params[1], req.params[2]); } - return L.Request.post(rpcBaseURL + q, req, { + return request.post(rpcBaseURL + q, req, { timeout: (L.env.rpctimeout || 20) * 1000, nobatch: nobatch, credentials: true diff --git a/modules/luci-base/htdocs/luci-static/resources/uci.js b/modules/luci-base/htdocs/luci-static/resources/uci.js index 677edf6add..f381e0b649 100644 --- a/modules/luci-base/htdocs/luci-static/resources/uci.js +++ b/modules/luci-base/htdocs/luci-static/resources/uci.js @@ -1,5 +1,6 @@ 'use strict'; 'require rpc'; +'require baseclass'; /** * @class uci @@ -12,7 +13,7 @@ * manipulation layer on top to allow for synchroneous operations on * UCI configuration data. */ -return L.Class.extend(/** @lends LuCI.uci.prototype */ { +return baseclass.extend(/** @lends LuCI.uci.prototype */ { __init__: function() { this.state = { newidx: 0, diff --git a/modules/luci-base/htdocs/luci-static/resources/ui.js b/modules/luci-base/htdocs/luci-static/resources/ui.js index 4458573601..61ae69f1cb 100644 --- a/modules/luci-base/htdocs/luci-static/resources/ui.js +++ b/modules/luci-base/htdocs/luci-static/resources/ui.js @@ -1,30 +1,123 @@ 'use strict'; +'require validation'; +'require baseclass'; +'require request'; +'require poll'; +'require dom'; 'require rpc'; 'require uci'; -'require validation'; 'require fs'; var modalDiv = null, tooltipDiv = null, + indicatorDiv = null, tooltipTimeout = null; -var UIElement = L.Class.extend({ +/** + * @class AbstractElement + * @memberof LuCI.ui + * @hideconstructor + * @classdesc + * + * The `AbstractElement` class serves as abstract base for the different widgets + * implemented by `LuCI.ui`. It provides the common logic for getting and + * setting values, for checking the validity state and for wiring up required + * events. + * + * UI widget instances are usually not supposed to be created by view code + * directly, instead they're implicitely created by `LuCI.form` when + * instantiating CBI forms. + * + * This class is automatically instantiated as part of `LuCI.ui`. To use it + * in views, use `'require ui'` and refer to `ui.AbstractElement`. To import + * it in external JavaScript, use `L.require("ui").then(...)` and access the + * `AbstractElement` property of the class instance value. + */ +var UIElement = baseclass.extend(/** @lends LuCI.ui.AbstractElement.prototype */ { + /** + * @typedef {Object} InitOptions + * @memberof LuCI.ui.AbstractElement + * + * @property {string} [id] + * Specifies the widget ID to use. It will be used as HTML `id` attribute + * on the toplevel widget DOM node. + * + * @property {string} [name] + * Specifies the widget name which is set as HTML `name` attribute on the + * corresponding `<input>` element. + * + * @property {boolean} [optional=true] + * Specifies whether the input field allows empty values. + * + * @property {string} [datatype=string] + * An expression describing the input data validation constraints. + * It defaults to `string` which will allow any value. + * See {@link LuCI.validation} for details on the expression format. + * + * @property {function} [validator] + * Specifies a custom validator function which is invoked after the + * standard validation constraints are checked. The function should return + * `true` to accept the given input value. Any other return value type is + * converted to a string and treated as validation error message. + */ + + /** + * Read the current value of the input widget. + * + * @instance + * @memberof LuCI.ui.AbstractElement + * @returns {string|string[]|null} + * The current value of the input element. For simple inputs like text + * fields or selects, the return value type will be a - possibly empty - + * string. Complex widgets such as `DynamicList` instances may result in + * an array of strings or `null` for unset values. + */ getValue: function() { - if (L.dom.matches(this.node, 'select') || L.dom.matches(this.node, 'input')) + if (dom.matches(this.node, 'select') || dom.matches(this.node, 'input')) return this.node.value; return null; }, + /** + * Set the current value of the input widget. + * + * @instance + * @memberof LuCI.ui.AbstractElement + * @param {string|string[]|null} value + * The value to set the input element to. For simple inputs like text + * fields or selects, the value should be a - possibly empty - string. + * Complex widgets such as `DynamicList` instances may accept string array + * or `null` values. + */ setValue: function(value) { - if (L.dom.matches(this.node, 'select') || L.dom.matches(this.node, 'input')) + if (dom.matches(this.node, 'select') || dom.matches(this.node, 'input')) this.node.value = value; }, + /** + * Check whether the current input value is valid. + * + * @instance + * @memberof LuCI.ui.AbstractElement + * @returns {boolean} + * Returns `true` if the current input value is valid or `false` if it does + * not meet the validation constraints. + */ isValid: function() { return (this.validState !== false); }, + /** + * Force validation of the current input value. + * + * Usually input validation is automatically triggered by various DOM events + * bound to the input widget. In some cases it is required though to manually + * trigger validation runs, e.g. when programmatically altering values. + * + * @instance + * @memberof LuCI.ui.AbstractElement + */ triggerValidation: function() { if (typeof(this.vfunc) != 'function') return false; @@ -36,6 +129,30 @@ var UIElement = L.Class.extend({ return (wasValid != this.isValid()); }, + /** + * Dispatch a custom (synthetic) event in response to received events. + * + * Sets up event handlers on the given target DOM node for the given event + * names that dispatch a custom event of the given type to the widget root + * DOM node. + * + * The primary purpose of this function is to set up a series of custom + * uniform standard events such as `widget-update`, `validation-success`, + * `validation-failure` etc. which are triggered by various different + * widget specific native DOM events. + * + * @instance + * @memberof LuCI.ui.AbstractElement + * @param {Node} targetNode + * Specifies the DOM node on which the native event listeners should be + * registered. + * + * @param {string} synevent + * The name of the custom event to dispatch to the widget root DOM node. + * + * @param {string[]} events + * The native DOM events for which event handlers should be registered. + */ registerEvents: function(targetNode, synevent, events) { var dispatchFn = L.bind(function(ev) { this.node.dispatchEvent(new CustomEvent(synevent, { bubbles: true })); @@ -45,6 +162,22 @@ var UIElement = L.Class.extend({ targetNode.addEventListener(events[i], dispatchFn); }, + /** + * Setup listeners for native DOM events that may update the widget value. + * + * Sets up event handlers on the given target DOM node for the given event + * names which may cause the input value to update, such as `keyup` or + * `onclick` events. In contrast to change events, such update events will + * trigger input value validation. + * + * @instance + * @memberof LuCI.ui.AbstractElement + * @param {Node} targetNode + * Specifies the DOM node on which the event listeners should be registered. + * + * @param {...string} events + * The DOM events for which event handlers should be registered. + */ setUpdateEvents: function(targetNode /*, ... */) { var datatype = this.options.datatype, optional = this.options.hasOwnProperty('optional') ? this.options.optional : true, @@ -56,7 +189,7 @@ var UIElement = L.Class.extend({ if (!datatype && !validate) return; - this.vfunc = L.ui.addValidator.apply(L.ui, [ + this.vfunc = UI.prototype.addValidator.apply(UI.prototype, [ targetNode, datatype || 'string', optional, validate ].concat(events)); @@ -70,6 +203,24 @@ var UIElement = L.Class.extend({ }, this)); }, + /** + * Setup listeners for native DOM events that may change the widget value. + * + * Sets up event handlers on the given target DOM node for the given event + * names which may cause the input value to change completely, such as + * `change` events in a select menu. In contrast to update events, such + * change events will not trigger input value validation but they may cause + * field dependencies to get re-evaluated and will mark the input widget + * as dirty. + * + * @instance + * @memberof LuCI.ui.AbstractElement + * @param {Node} targetNode + * Specifies the DOM node on which the event listeners should be registered. + * + * @param {...string} events + * The DOM events for which event handlers should be registered. + */ setChangeEvents: function(targetNode /*, ... */) { var tag_changed = L.bind(function(ev) { this.setAttribute('data-changed', true) }, this.node); @@ -77,10 +228,71 @@ var UIElement = L.Class.extend({ targetNode.addEventListener(arguments[i], tag_changed); this.registerEvents(targetNode, 'widget-change', this.varargs(arguments, 1)); - } + }, + + /** + * Render the widget, setup event listeners and return resulting markup. + * + * @instance + * @memberof LuCI.ui.AbstractElement + * + * @returns {Node} + * Returns a DOM Node or DocumentFragment containing the rendered + * widget markup. + */ + render: function() {} }); -var UITextfield = UIElement.extend({ +/** + * Instantiate a text input widget. + * + * @constructor Textfield + * @memberof LuCI.ui + * @augments LuCI.ui.AbstractElement + * + * @classdesc + * + * The `Textfield` class implements a standard single line text input field. + * + * UI widget instances are usually not supposed to be created by view code + * directly, instead they're implicitely created by `LuCI.form` when + * instantiating CBI forms. + * + * This class is automatically instantiated as part of `LuCI.ui`. To use it + * in views, use `'require ui'` and refer to `ui.Textfield`. To import it in + * external JavaScript, use `L.require("ui").then(...)` and access the + * `Textfield` property of the class instance value. + * + * @param {string} [value=null] + * The initial input value. + * + * @param {LuCI.ui.Textfield.InitOptions} [options] + * Object describing the widget specific options to initialize the input. + */ +var UITextfield = UIElement.extend(/** @lends LuCI.ui.Textfield.prototype */ { + /** + * In addition to the [AbstractElement.InitOptions]{@link LuCI.ui.AbstractElement.InitOptions} + * the following properties are recognized: + * + * @typedef {LuCI.ui.AbstractElement.InitOptions} InitOptions + * @memberof LuCI.ui.Textfield + * + * @property {boolean} [password=false] + * Specifies whether the input should be rendered as concealed password field. + * + * @property {boolean} [readonly=false] + * Specifies whether the input widget should be rendered readonly. + * + * @property {number} [maxlength] + * Specifies the HTML `maxlength` attribute to set on the corresponding + * `<input>` element. Note that this a legacy property that exists for + * compatibility reasons. It is usually better to `maxlength(N)` validation + * expression. + * + * @property {string} [placeholder] + * Specifies the HTML `placeholder` attribute which is displayed when the + * corresponding `<input>` element is empty. + */ __init__: function(value, options) { this.value = value; this.options = Object.assign({ @@ -89,6 +301,7 @@ var UITextfield = UIElement.extend({ }, options); }, + /** @override */ render: function() { var frameEl = E('div', { 'id': this.options.id }); @@ -129,6 +342,7 @@ var UITextfield = UIElement.extend({ return this.bind(frameEl); }, + /** @private */ bind: function(frameEl) { var inputEl = frameEl.childNodes[+!!this.options.password]; @@ -137,23 +351,80 @@ var UITextfield = UIElement.extend({ this.setUpdateEvents(inputEl, 'keyup', 'blur'); this.setChangeEvents(inputEl, 'change'); - L.dom.bindClassInstance(frameEl, this); + dom.bindClassInstance(frameEl, this); return frameEl; }, + /** @override */ getValue: function() { var inputEl = this.node.childNodes[+!!this.options.password]; return inputEl.value; }, + /** @override */ setValue: function(value) { var inputEl = this.node.childNodes[+!!this.options.password]; inputEl.value = value; } }); -var UITextarea = UIElement.extend({ +/** + * Instantiate a textarea widget. + * + * @constructor Textarea + * @memberof LuCI.ui + * @augments LuCI.ui.AbstractElement + * + * @classdesc + * + * The `Textarea` class implements a multiline text area input field. + * + * UI widget instances are usually not supposed to be created by view code + * directly, instead they're implicitely created by `LuCI.form` when + * instantiating CBI forms. + * + * This class is automatically instantiated as part of `LuCI.ui`. To use it + * in views, use `'require ui'` and refer to `ui.Textarea`. To import it in + * external JavaScript, use `L.require("ui").then(...)` and access the + * `Textarea` property of the class instance value. + * + * @param {string} [value=null] + * The initial input value. + * + * @param {LuCI.ui.Textarea.InitOptions} [options] + * Object describing the widget specific options to initialize the input. + */ +var UITextarea = UIElement.extend(/** @lends LuCI.ui.Textarea.prototype */ { + /** + * In addition to the [AbstractElement.InitOptions]{@link LuCI.ui.AbstractElement.InitOptions} + * the following properties are recognized: + * + * @typedef {LuCI.ui.AbstractElement.InitOptions} InitOptions + * @memberof LuCI.ui.Textarea + * + * @property {boolean} [readonly=false] + * Specifies whether the input widget should be rendered readonly. + * + * @property {string} [placeholder] + * Specifies the HTML `placeholder` attribute which is displayed when the + * corresponding `<textarea>` element is empty. + * + * @property {boolean} [monospace=false] + * Specifies whether a monospace font should be forced for the textarea + * contents. + * + * @property {number} [cols] + * Specifies the HTML `cols` attribute to set on the corresponding + * `<textarea>` element. + * + * @property {number} [rows] + * Specifies the HTML `rows` attribute to set on the corresponding + * `<textarea>` element. + * + * @property {boolean} [wrap=false] + * Specifies whether the HTML `wrap` attribute should be set. + */ __init__: function(value, options) { this.value = value; this.options = Object.assign({ @@ -164,6 +435,7 @@ var UITextarea = UIElement.extend({ }, options); }, + /** @override */ render: function() { var frameEl = E('div', { 'id': this.options.id }), value = (this.value != null) ? String(this.value) : ''; @@ -186,6 +458,7 @@ var UITextarea = UIElement.extend({ return this.bind(frameEl); }, + /** @private */ bind: function(frameEl) { var inputEl = frameEl.firstElementChild; @@ -194,21 +467,67 @@ var UITextarea = UIElement.extend({ this.setUpdateEvents(inputEl, 'keyup', 'blur'); this.setChangeEvents(inputEl, 'change'); - L.dom.bindClassInstance(frameEl, this); + dom.bindClassInstance(frameEl, this); return frameEl; }, + /** @override */ getValue: function() { return this.node.firstElementChild.value; }, + /** @override */ setValue: function(value) { this.node.firstElementChild.value = value; } }); -var UICheckbox = UIElement.extend({ +/** + * Instantiate a checkbox widget. + * + * @constructor Checkbox + * @memberof LuCI.ui + * @augments LuCI.ui.AbstractElement + * + * @classdesc + * + * The `Checkbox` class implements a simple checkbox input field. + * + * UI widget instances are usually not supposed to be created by view code + * directly, instead they're implicitely created by `LuCI.form` when + * instantiating CBI forms. + * + * This class is automatically instantiated as part of `LuCI.ui`. To use it + * in views, use `'require ui'` and refer to `ui.Checkbox`. To import it in + * external JavaScript, use `L.require("ui").then(...)` and access the + * `Checkbox` property of the class instance value. + * + * @param {string} [value=null] + * The initial input value. + * + * @param {LuCI.ui.Checkbox.InitOptions} [options] + * Object describing the widget specific options to initialize the input. + */ +var UICheckbox = UIElement.extend(/** @lends LuCI.ui.Checkbox.prototype */ { + /** + * In addition to the [AbstractElement.InitOptions]{@link LuCI.ui.AbstractElement.InitOptions} + * the following properties are recognized: + * + * @typedef {LuCI.ui.AbstractElement.InitOptions} InitOptions + * @memberof LuCI.ui.Checkbox + * + * @property {string} [value_enabled=1] + * Specifies the value corresponding to a checked checkbox. + * + * @property {string} [value_disabled=0] + * Specifies the value corresponding to an unchecked checkbox. + * + * @property {string} [hiddenname] + * Specifies the HTML `name` attribute of the hidden input backing the + * checkbox. This is a legacy property existing for compatibility reasons, + * it is required for HTML based form submissions. + */ __init__: function(value, options) { this.value = value; this.options = Object.assign({ @@ -217,7 +536,9 @@ var UICheckbox = UIElement.extend({ }, options); }, + /** @override */ render: function() { + var id = 'cb%08x'.format(Math.random() * 0xffffffff); var frameEl = E('div', { 'id': this.options.id, 'class': 'cbi-checkbox' @@ -231,43 +552,124 @@ var UICheckbox = UIElement.extend({ })); frameEl.appendChild(E('input', { - 'id': this.options.id ? 'widget.' + this.options.id : null, + 'id': id, 'name': this.options.name, 'type': 'checkbox', 'value': this.options.value_enabled, - 'checked': (this.value == this.options.value_enabled) ? '' : null + 'checked': (this.value == this.options.value_enabled) ? '' : null, + 'data-widget-id': this.options.id ? 'widget.' + this.options.id : null })); + frameEl.appendChild(E('label', { 'for': id })); + return this.bind(frameEl); }, + /** @private */ bind: function(frameEl) { this.node = frameEl; - this.setUpdateEvents(frameEl.lastElementChild, 'click', 'blur'); - this.setChangeEvents(frameEl.lastElementChild, 'change'); + this.setUpdateEvents(frameEl.lastElementChild.previousElementSibling, 'click', 'blur'); + this.setChangeEvents(frameEl.lastElementChild.previousElementSibling, 'change'); - L.dom.bindClassInstance(frameEl, this); + dom.bindClassInstance(frameEl, this); return frameEl; }, + /** + * Test whether the checkbox is currently checked. + * + * @instance + * @memberof LuCI.ui.Checkbox + * @returns {boolean} + * Returns `true` when the checkbox is currently checked, otherwise `false`. + */ isChecked: function() { - return this.node.lastElementChild.checked; + return this.node.lastElementChild.previousElementSibling.checked; }, + /** @override */ getValue: function() { return this.isChecked() ? this.options.value_enabled : this.options.value_disabled; }, + /** @override */ setValue: function(value) { - this.node.lastElementChild.checked = (value == this.options.value_enabled); + this.node.lastElementChild.previousElementSibling.checked = (value == this.options.value_enabled); } }); -var UISelect = UIElement.extend({ +/** + * Instantiate a select dropdown or checkbox/radiobutton group. + * + * @constructor Select + * @memberof LuCI.ui + * @augments LuCI.ui.AbstractElement + * + * @classdesc + * + * The `Select` class implements either a traditional HTML `<select>` element + * or a group of checkboxes or radio buttons, depending on whether multiple + * values are enabled or not. + * + * UI widget instances are usually not supposed to be created by view code + * directly, instead they're implicitely created by `LuCI.form` when + * instantiating CBI forms. + * + * This class is automatically instantiated as part of `LuCI.ui`. To use it + * in views, use `'require ui'` and refer to `ui.Select`. To import it in + * external JavaScript, use `L.require("ui").then(...)` and access the + * `Select` property of the class instance value. + * + * @param {string|string[]} [value=null] + * The initial input value(s). + * + * @param {Object<string, string>} choices + * Object containing the selectable choices of the widget. The object keys + * serve as values for the different choices while the values are used as + * choice labels. + * + * @param {LuCI.ui.Select.InitOptions} [options] + * Object describing the widget specific options to initialize the inputs. + */ +var UISelect = UIElement.extend(/** @lends LuCI.ui.Select.prototype */ { + /** + * In addition to the [AbstractElement.InitOptions]{@link LuCI.ui.AbstractElement.InitOptions} + * the following properties are recognized: + * + * @typedef {LuCI.ui.AbstractElement.InitOptions} InitOptions + * @memberof LuCI.ui.Select + * + * @property {boolean} [multiple=false] + * Specifies whether multiple choice values may be selected. + * + * @property {string} [widget=select] + * Specifies the kind of widget to render. May be either `select` or + * `individual`. When set to `select` an HTML `<select>` element will be + * used, otherwise a group of checkbox or radio button elements is created, + * depending on the value of the `multiple` option. + * + * @property {string} [orientation=horizontal] + * Specifies whether checkbox / radio button groups should be rendered + * in a `horizontal` or `vertical` manner. Does not apply to the `select` + * widget type. + * + * @property {boolean|string[]} [sort=false] + * Specifies if and how to sort choice values. If set to `true`, the choice + * values will be sorted alphabetically. If set to an array of strings, the + * choice sort order is derived from the array. + * + * @property {number} [size] + * Specifies the HTML `size` attribute to set on the `<select>` element. + * Only applicable to the `select` widget type. + * + * @property {string} [placeholder=-- Please choose --] + * Specifies a placeholder text which is displayed when no choice is + * selected yet. Only applicable to the `select` widget type. + */ __init__: function(value, choices, options) { if (!L.isObject(choices)) choices = {}; @@ -290,6 +692,7 @@ var UISelect = UIElement.extend({ this.options.optional = true; }, + /** @override */ render: function() { var frameEl = E('div', { 'id': this.options.id }), keys = Object.keys(this.choices); @@ -348,6 +751,7 @@ var UISelect = UIElement.extend({ return this.bind(frameEl); }, + /** @private */ bind: function(frameEl) { this.node = frameEl; @@ -363,11 +767,12 @@ var UISelect = UIElement.extend({ } } - L.dom.bindClassInstance(frameEl, this); + dom.bindClassInstance(frameEl, this); return frameEl; }, + /** @override */ getValue: function() { if (this.options.widget == 'select') return this.node.firstChild.value; @@ -380,6 +785,7 @@ var UISelect = UIElement.extend({ return null; }, + /** @override */ setValue: function(value) { if (this.options.widget == 'select') { if (value == null) @@ -397,7 +803,135 @@ var UISelect = UIElement.extend({ } }); -var UIDropdown = UIElement.extend({ +/** + * Instantiate a rich dropdown choice widget. + * + * @constructor Dropdown + * @memberof LuCI.ui + * @augments LuCI.ui.AbstractElement + * + * @classdesc + * + * The `Dropdown` class implements a rich, stylable dropdown menu which + * supports non-text choice labels. + * + * UI widget instances are usually not supposed to be created by view code + * directly, instead they're implicitely created by `LuCI.form` when + * instantiating CBI forms. + * + * This class is automatically instantiated as part of `LuCI.ui`. To use it + * in views, use `'require ui'` and refer to `ui.Dropdown`. To import it in + * external JavaScript, use `L.require("ui").then(...)` and access the + * `Dropdown` property of the class instance value. + * + * @param {string|string[]} [value=null] + * The initial input value(s). + * + * @param {Object<string, *>} choices + * Object containing the selectable choices of the widget. The object keys + * serve as values for the different choices while the values are used as + * choice labels. + * + * @param {LuCI.ui.Dropdown.InitOptions} [options] + * Object describing the widget specific options to initialize the dropdown. + */ +var UIDropdown = UIElement.extend(/** @lends LuCI.ui.Dropdown.prototype */ { + /** + * In addition to the [AbstractElement.InitOptions]{@link LuCI.ui.AbstractElement.InitOptions} + * the following properties are recognized: + * + * @typedef {LuCI.ui.AbstractElement.InitOptions} InitOptions + * @memberof LuCI.ui.Dropdown + * + * @property {boolean} [optional=true] + * Specifies whether the dropdown selection is optional. In contrast to + * other widgets, the `optional` constraint of dropdowns works differently; + * instead of marking the widget invalid on empty values when set to `false`, + * the user is not allowed to deselect all choices. + * + * For single value dropdowns that means that no empty "please select" + * choice is offered and for multi value dropdowns, the last selected choice + * may not be deselected without selecting another choice first. + * + * @property {boolean} [multiple] + * Specifies whether multiple choice values may be selected. It defaults + * to `true` when an array is passed as input value to the constructor. + * + * @property {boolean|string[]} [sort=false] + * Specifies if and how to sort choice values. If set to `true`, the choice + * values will be sorted alphabetically. If set to an array of strings, the + * choice sort order is derived from the array. + * + * @property {string} [select_placeholder=-- Please choose --] + * Specifies a placeholder text which is displayed when no choice is + * selected yet. + * + * @property {string} [custom_placeholder=-- custom --] + * Specifies a placeholder text which is displayed in the text input + * field allowing to enter custom choice values. Only applicable if the + * `create` option is set to `true`. + * + * @property {boolean} [create=false] + * Specifies whether custom choices may be entered into the dropdown + * widget. + * + * @property {string} [create_query=.create-item-input] + * Specifies a CSS selector expression used to find the input element + * which is used to enter custom choice values. This should not normally + * be used except by widgets derived from the Dropdown class. + * + * @property {string} [create_template=script[type="item-template"]] + * Specifies a CSS selector expression used to find an HTML element + * serving as template for newly added custom choice values. + * + * Any `{{value}}` placeholder string within the template elements text + * content will be replaced by the user supplied choice value, the + * resulting string is parsed as HTML and appended to the end of the + * choice list. The template markup may specify one HTML element with a + * `data-label-placeholder` attribute which is replaced by a matching + * label value from the `choices` object or with the user supplied value + * itself in case `choices` contains no matching choice label. + * + * If the template element is not found or if no `create_template` selector + * expression is specified, the default markup for newly created elements is + * `<li data-value="{{value}}"><span data-label-placeholder="true" /></li>`. + * + * @property {string} [create_markup] + * This property allows specifying the markup for custom choices directly + * instead of referring to a template element through CSS selectors. + * + * Apart from that it works exactly like `create_template`. + * + * @property {number} [display_items=3] + * Specifies the maximum amount of choice labels that should be shown in + * collapsed dropdown state before further selected choices are cut off. + * + * Only applicable when `multiple` is `true`. + * + * @property {number} [dropdown_items=-1] + * Specifies the maximum amount of choices that should be shown when the + * dropdown is open. If the amount of available choices exceeds this number, + * the dropdown area must be scrolled to reach further items. + * + * If set to `-1`, the dropdown menu will attempt to show all choice values + * and only resort to scrolling if the amount of choices exceeds the available + * screen space above and below the dropdown widget. + * + * @property {string} [placeholder] + * This property serves as a shortcut to set both `select_placeholder` and + * `custom_placeholder`. Either of these properties will fallback to + * `placeholder` if not specified. + * + * @property {boolean} [readonly=false] + * Specifies whether the custom choice input field should be rendered + * readonly. Only applicable when `create` is `true`. + * + * @property {number} [maxlength] + * Specifies the HTML `maxlength` attribute to set on the custom choice + * `<input>` element. Note that this a legacy property that exists for + * compatibility reasons. It is usually better to `maxlength(N)` validation + * expression. Only applicable when `create` is `true`. + */ __init__: function(value, choices, options) { if (typeof(choices) != 'object') choices = {}; @@ -422,6 +956,7 @@ var UIDropdown = UIElement.extend({ }, options); }, + /** @override */ render: function() { var sb = E('div', { 'id': this.options.id, @@ -445,7 +980,7 @@ var UIDropdown = UIElement.extend({ for (var i = 0; i < keys.length; i++) { var label = this.choices[keys[i]]; - if (L.dom.elem(label)) + if (dom.elem(label)) label = label.cloneNode(true); sb.lastElementChild.appendChild(E('li', { @@ -464,8 +999,8 @@ var UIDropdown = UIElement.extend({ }); if (this.options.datatype || this.options.validate) - L.ui.addValidator(createEl, this.options.datatype || 'string', - true, this.options.validate, 'blur', 'keyup'); + UI.prototype.addValidator(createEl, this.options.datatype || 'string', + true, this.options.validate, 'blur', 'keyup'); sb.lastElementChild.appendChild(E('li', { 'data-value': '-' }, createEl)); } @@ -477,6 +1012,7 @@ var UIDropdown = UIElement.extend({ return this.bind(sb); }, + /** @private */ bind: function(sb) { var o = this.options; @@ -547,7 +1083,7 @@ var UIDropdown = UIElement.extend({ else sb.removeAttribute('empty'); - L.dom.content(more, (ndisplay == this.options.display_items) + dom.content(more, (ndisplay == this.options.display_items) ? (this.options.select_placeholder || this.options.placeholder) : '···'); @@ -586,11 +1122,12 @@ var UIDropdown = UIElement.extend({ this.setUpdateEvents(sb, 'cbi-dropdown-open', 'cbi-dropdown-close'); this.setChangeEvents(sb, 'cbi-dropdown-change', 'cbi-dropdown-close'); - L.dom.bindClassInstance(sb, this); + dom.bindClassInstance(sb, this); return sb; }, + /** @private */ openDropdown: function(sb) { var st = window.getComputedStyle(sb, null), ul = sb.querySelector('ul'), @@ -615,8 +1152,6 @@ var UIDropdown = UIElement.extend({ if ('ontouchstart' in window) { var vpWidth = Math.max(document.documentElement.clientWidth, window.innerWidth || 0), vpHeight = Math.max(document.documentElement.clientHeight, window.innerHeight || 0), - scrollFrom = window.pageYOffset, - scrollTo = scrollFrom + rect.top - vpHeight * 0.5, start = null; ul.style.top = sb.offsetHeight + 'px'; @@ -625,6 +1160,31 @@ var UIDropdown = UIElement.extend({ ul.style.maxHeight = (vpHeight * 0.5) + 'px'; ul.style.WebkitOverflowScrolling = 'touch'; + function getScrollParent(element) { + var parent = element, + style = getComputedStyle(element), + excludeStaticParent = (style.position === 'absolute'); + + if (style.position === 'fixed') + return document.body; + + while ((parent = parent.parentElement) != null) { + style = getComputedStyle(parent); + + if (excludeStaticParent && style.position === 'static') + continue; + + if (/(auto|scroll)/.test(style.overflow + style.overflowY + style.overflowX)) + return parent; + } + + return document.body; + } + + var scrollParent = getScrollParent(sb), + scrollFrom = scrollParent.scrollTop, + scrollTo = scrollFrom + rect.top - vpHeight * 0.5; + var scrollStep = function(timestamp) { if (!start) { start = timestamp; @@ -633,11 +1193,11 @@ var UIDropdown = UIElement.extend({ var duration = Math.max(timestamp - start, 1); if (duration < 100) { - document.body.scrollTop = scrollFrom + (scrollTo - scrollFrom) * (duration / 100); + scrollParent.scrollTop = scrollFrom + (scrollTo - scrollFrom) * (duration / 100); window.requestAnimationFrame(scrollStep); } else { - document.body.scrollTop = scrollTo; + scrollParent.scrollTop = scrollTo; } }; @@ -696,6 +1256,7 @@ var UIDropdown = UIElement.extend({ this.setFocus(sb, sel || li[0], true); }, + /** @private */ closeDropdown: function(sb, no_focus) { if (!sb.hasAttribute('open')) return; @@ -724,6 +1285,7 @@ var UIDropdown = UIElement.extend({ this.saveValues(sb, ul); }, + /** @private */ toggleItem: function(sb, li, force_state) { if (li.hasAttribute('unselectable')) return; @@ -785,7 +1347,7 @@ var UIDropdown = UIElement.extend({ else sb.removeAttribute('empty'); - L.dom.content(more, (ndisplay === this.options.display_items) + dom.content(more, (ndisplay === this.options.display_items) ? (this.options.select_placeholder || this.options.placeholder) : '···'); } else { @@ -804,6 +1366,7 @@ var UIDropdown = UIElement.extend({ this.saveValues(sb, li.parentNode); }, + /** @private */ transformItem: function(sb, li) { var cbox = E('form', {}, E('input', { type: 'checkbox', tabindex: -1, onclick: 'event.preventDefault()' })), label = E('label'); @@ -815,6 +1378,7 @@ var UIDropdown = UIElement.extend({ li.appendChild(label); }, + /** @private */ saveValues: function(sb, ul) { var sel = ul.querySelectorAll('li[selected]'), div = sb.lastElementChild, @@ -864,6 +1428,7 @@ var UIDropdown = UIElement.extend({ })); }, + /** @private */ setValues: function(sb, values) { var ul = sb.querySelector('ul'); @@ -900,6 +1465,7 @@ var UIDropdown = UIElement.extend({ } }, + /** @private */ setFocus: function(sb, elem, scroll) { if (sb && sb.hasAttribute && sb.hasAttribute('locked-in')) return; @@ -923,6 +1489,7 @@ var UIDropdown = UIElement.extend({ } }, + /** @private */ createChoiceElement: function(sb, value, label) { var tpl = sb.querySelector(this.options.create_template), markup = null; @@ -950,6 +1517,7 @@ var UIDropdown = UIElement.extend({ return new_item; }, + /** @private */ createItems: function(sb, value) { var sbox = this, val = (value || '').trim(), @@ -987,6 +1555,19 @@ var UIDropdown = UIElement.extend({ }); }, + /** + * Remove all existing choices from the dropdown menu. + * + * This function removes all preexisting dropdown choices from the widget, + * keeping only choices currently being selected unless `reset_values` is + * given, in which case all choices and deselected and removed. + * + * @instance + * @memberof LuCI.ui.Dropdown + * @param {boolean} [reset_value=false] + * If set to `true`, deselect and remove selected choices as well instead + * of keeping them. + */ clearChoices: function(reset_value) { var ul = this.node.querySelector('ul'), lis = ul ? ul.querySelectorAll('li[data-value]') : [], @@ -1005,6 +1586,23 @@ var UIDropdown = UIElement.extend({ this.setValues(this.node, {}); }, + /** + * Add new choices to the dropdown menu. + * + * This function adds further choices to an existing dropdown menu, + * ignoring choice values which are already present. + * + * @instance + * @memberof LuCI.ui.Dropdown + * @param {string[]} values + * The choice values to add to the dropdown widget. + * + * @param {Object<string, *>} labels + * The choice label values to use when adding dropdown choices. If no + * label is found for a particular choice value, the value itself is used + * as label text. Choice labels may be any valid value accepted by + * {@link LuCI.dom#content}. + */ addChoices: function(values, labels) { var sb = this.node, ul = sb.querySelector('ul'), @@ -1035,12 +1633,16 @@ var UIDropdown = UIElement.extend({ } }, + /** + * Close all open dropdown widgets in the current document. + */ closeAllDropdowns: function() { document.querySelectorAll('.cbi-dropdown[open]').forEach(function(s) { s.dispatchEvent(new CustomEvent('cbi-dropdown-close', {})); }); }, + /** @private */ handleClick: function(ev) { var sb = ev.currentTarget; @@ -1062,6 +1664,7 @@ var UIDropdown = UIElement.extend({ ev.stopPropagation(); }, + /** @private */ handleKeydown: function(ev) { var sb = ev.currentTarget; @@ -1119,12 +1722,14 @@ var UIDropdown = UIElement.extend({ } }, + /** @private */ handleDropdownClose: function(ev) { var sb = ev.currentTarget; this.closeDropdown(sb, true); }, + /** @private */ handleDropdownSelect: function(ev) { var sb = ev.currentTarget, li = findParent(ev.target, 'li'); @@ -1136,6 +1741,7 @@ var UIDropdown = UIElement.extend({ this.closeDropdown(sb, true); }, + /** @private */ handleMouseover: function(ev) { var sb = ev.currentTarget; @@ -1148,6 +1754,7 @@ var UIDropdown = UIElement.extend({ this.setFocus(sb, li); }, + /** @private */ handleFocus: function(ev) { var sb = ev.currentTarget; @@ -1157,10 +1764,12 @@ var UIDropdown = UIElement.extend({ }); }, + /** @private */ handleCanaryFocus: function(ev) { this.closeDropdown(ev.currentTarget.parentNode); }, + /** @private */ handleCreateKeydown: function(ev) { var input = ev.currentTarget, sb = findParent(input, '.cbi-dropdown'); @@ -1179,6 +1788,7 @@ var UIDropdown = UIElement.extend({ } }, + /** @private */ handleCreateFocus: function(ev) { var input = ev.currentTarget, cbox = findParent(input, 'li').querySelector('input[type="checkbox"]'), @@ -1190,6 +1800,7 @@ var UIDropdown = UIElement.extend({ sb.setAttribute('locked-in', ''); }, + /** @private */ handleCreateBlur: function(ev) { var input = ev.currentTarget, cbox = findParent(input, 'li').querySelector('input[type="checkbox"]'), @@ -1201,10 +1812,12 @@ var UIDropdown = UIElement.extend({ sb.removeAttribute('locked-in'); }, + /** @private */ handleCreateClick: function(ev) { ev.currentTarget.querySelector(this.options.create_query).focus(); }, + /** @override */ setValue: function(values) { if (this.options.multiple) { if (!Array.isArray(values)) @@ -1231,6 +1844,7 @@ var UIDropdown = UIElement.extend({ } }, + /** @override */ getValue: function() { var div = this.node.lastElementChild, h = div.querySelectorAll('input[type="hidden"]'), @@ -1243,7 +1857,61 @@ var UIDropdown = UIElement.extend({ } }); -var UICombobox = UIDropdown.extend({ +/** + * Instantiate a rich dropdown choice widget allowing custom values. + * + * @constructor Combobox + * @memberof LuCI.ui + * @augments LuCI.ui.Dropdown + * + * @classdesc + * + * The `Combobox` class implements a rich, stylable dropdown menu which allows + * to enter custom values. Historically, comboboxes used to be a dedicated + * widget type in LuCI but nowadays they are direct aliases of dropdown widgets + * with a set of enforced default properties for easier instantiation. + * + * UI widget instances are usually not supposed to be created by view code + * directly, instead they're implicitely created by `LuCI.form` when + * instantiating CBI forms. + * + * This class is automatically instantiated as part of `LuCI.ui`. To use it + * in views, use `'require ui'` and refer to `ui.Combobox`. To import it in + * external JavaScript, use `L.require("ui").then(...)` and access the + * `Combobox` property of the class instance value. + * + * @param {string|string[]} [value=null] + * The initial input value(s). + * + * @param {Object<string, *>} choices + * Object containing the selectable choices of the widget. The object keys + * serve as values for the different choices while the values are used as + * choice labels. + * + * @param {LuCI.ui.Combobox.InitOptions} [options] + * Object describing the widget specific options to initialize the dropdown. + */ +var UICombobox = UIDropdown.extend(/** @lends LuCI.ui.Combobox.prototype */ { + /** + * Comboboxes support the same properties as + * [Dropdown.InitOptions]{@link LuCI.ui.Dropdown.InitOptions} but enforce + * specific values for the following properties: + * + * @typedef {LuCI.ui.Dropdown.InitOptions} InitOptions + * @memberof LuCI.ui.Combobox + * + * @property {boolean} multiple=false + * Since Comboboxes never allow selecting multiple values, this property + * is forcibly set to `false`. + * + * @property {boolean} create=true + * Since Comboboxes always allow custom choice values, this property is + * forcibly set to `true`. + * + * @property {boolean} optional=true + * Since Comboboxes are always optional, this property is forcibly set to + * `true`. + */ __init__: function(value, choices, options) { this.super('__init__', [ value, choices, Object.assign({ select_placeholder: _('-- Please choose --'), @@ -1258,7 +1926,75 @@ var UICombobox = UIDropdown.extend({ } }); -var UIComboButton = UIDropdown.extend({ +/** + * Instantiate a combo button widget offering multiple action choices. + * + * @constructor ComboButton + * @memberof LuCI.ui + * @augments LuCI.ui.Dropdown + * + * @classdesc + * + * The `ComboButton` class implements a button element which can be expanded + * into a dropdown to chose from a set of different action choices. + * + * UI widget instances are usually not supposed to be created by view code + * directly, instead they're implicitely created by `LuCI.form` when + * instantiating CBI forms. + * + * This class is automatically instantiated as part of `LuCI.ui`. To use it + * in views, use `'require ui'` and refer to `ui.ComboButton`. To import it in + * external JavaScript, use `L.require("ui").then(...)` and access the + * `ComboButton` property of the class instance value. + * + * @param {string|string[]} [value=null] + * The initial input value(s). + * + * @param {Object<string, *>} choices + * Object containing the selectable choices of the widget. The object keys + * serve as values for the different choices while the values are used as + * choice labels. + * + * @param {LuCI.ui.ComboButton.InitOptions} [options] + * Object describing the widget specific options to initialize the button. + */ +var UIComboButton = UIDropdown.extend(/** @lends LuCI.ui.ComboButton.prototype */ { + /** + * ComboButtons support the same properties as + * [Dropdown.InitOptions]{@link LuCI.ui.Dropdown.InitOptions} but enforce + * specific values for some properties and add aditional button specific + * properties. + * + * @typedef {LuCI.ui.Dropdown.InitOptions} InitOptions + * @memberof LuCI.ui.ComboButton + * + * @property {boolean} multiple=false + * Since ComboButtons never allow selecting multiple actions, this property + * is forcibly set to `false`. + * + * @property {boolean} create=false + * Since ComboButtons never allow creating custom choices, this property + * is forcibly set to `false`. + * + * @property {boolean} optional=false + * Since ComboButtons must always select one action, this property is + * forcibly set to `false`. + * + * @property {Object<string, string>} [classes] + * Specifies a mapping of choice values to CSS class names. If an action + * choice is selected by the user and if a corresponding entry exists in + * the `classes` object, the class names corresponding to the selected + * value are set on the button element. + * + * This is useful to apply different button styles, such as colors, to the + * combined button depending on the selected action. + * + * @property {function} [click] + * Specifies a handler function to invoke when the user clicks the button. + * This function will be called with the button DOM node as `this` context + * and receive the DOM click event as first as well as the selected action + * choice value as second argument. + */ __init__: function(value, choices, options) { this.super('__init__', [ value, choices, Object.assign({ sort: true @@ -1269,6 +2005,7 @@ var UIComboButton = UIDropdown.extend({ }) ]); }, + /** @override */ render: function(/* ... */) { var node = UIDropdown.prototype.render.apply(this, arguments), val = this.getValue(); @@ -1279,17 +2016,19 @@ var UIComboButton = UIDropdown.extend({ return node; }, + /** @private */ handleClick: function(ev) { var sb = ev.currentTarget, t = ev.target; - if (sb.hasAttribute('open') || L.dom.matches(t, '.cbi-dropdown > span.open')) + if (sb.hasAttribute('open') || dom.matches(t, '.cbi-dropdown > span.open')) return UIDropdown.prototype.handleClick.apply(this, arguments); if (this.options.click) return this.options.click.call(sb, ev, this.getValue()); }, + /** @private */ toggleItem: function(sb /*, ... */) { var rv = UIDropdown.prototype.toggleItem.apply(this, arguments), val = this.getValue(); @@ -1303,7 +2042,59 @@ var UIComboButton = UIDropdown.extend({ } }); -var UIDynamicList = UIElement.extend({ +/** + * Instantiate a dynamic list widget. + * + * @constructor DynamicList + * @memberof LuCI.ui + * @augments LuCI.ui.AbstractElement + * + * @classdesc + * + * The `DynamicList` class implements a widget which allows the user to specify + * an arbitrary amount of input values, either from free formed text input or + * from a set of predefined choices. + * + * UI widget instances are usually not supposed to be created by view code + * directly, instead they're implicitely created by `LuCI.form` when + * instantiating CBI forms. + * + * This class is automatically instantiated as part of `LuCI.ui`. To use it + * in views, use `'require ui'` and refer to `ui.DynamicList`. To import it in + * external JavaScript, use `L.require("ui").then(...)` and access the + * `DynamicList` property of the class instance value. + * + * @param {string|string[]} [value=null] + * The initial input value(s). + * + * @param {Object<string, *>} [choices] + * Object containing the selectable choices of the widget. The object keys + * serve as values for the different choices while the values are used as + * choice labels. If omitted, no default choices are presented to the user, + * instead a plain text input field is rendered allowing the user to add + * arbitrary values to the dynamic list. + * + * @param {LuCI.ui.DynamicList.InitOptions} [options] + * Object describing the widget specific options to initialize the dynamic list. + */ +var UIDynamicList = UIElement.extend(/** @lends LuCI.ui.DynamicList.prototype */ { + /** + * In case choices are passed to the dynamic list contructor, the widget + * supports the same properties as [Dropdown.InitOptions]{@link LuCI.ui.Dropdown.InitOptions} + * but enforces specific values for some dropdown properties. + * + * @typedef {LuCI.ui.Dropdown.InitOptions} InitOptions + * @memberof LuCI.ui.DynamicList + * + * @property {boolean} multiple=false + * Since dynamic lists never allow selecting multiple choices when adding + * another list item, this property is forcibly set to `false`. + * + * @property {boolean} optional=true + * Since dynamic lists use an embedded dropdown to present a list of + * predefined choice values, the dropdown must be made optional to allow + * it to remain unselected. + */ __init__: function(values, choices, options) { if (!Array.isArray(values)) values = (values != null && values != '') ? [ values ] : []; @@ -1319,6 +2110,7 @@ var UIDynamicList = UIElement.extend({ }); }, + /** @override */ render: function() { var dl = E('div', { 'id': this.options.id, @@ -1342,17 +2134,17 @@ var UIDynamicList = UIElement.extend({ }); dl.lastElementChild.appendChild(inputEl); - dl.lastElementChild.appendChild(E('div', { 'class': 'cbi-button cbi-button-add' }, '+')); + dl.lastElementChild.appendChild(E('div', { 'class': 'btn cbi-button cbi-button-add' }, '+')); if (this.options.datatype || this.options.validate) - L.ui.addValidator(inputEl, this.options.datatype || 'string', - true, this.options.validate, 'blur', 'keyup'); + UI.prototype.addValidator(inputEl, this.options.datatype || 'string', + true, this.options.validate, 'blur', 'keyup'); } for (var i = 0; i < this.values.length; i++) { var label = this.choices ? this.choices[this.values[i]] : null; - if (L.dom.elem(label)) + if (dom.elem(label)) label = label.cloneNode(true); this.addItem(dl, this.values[i], label); @@ -1361,6 +2153,7 @@ var UIDynamicList = UIElement.extend({ return this.bind(dl); }, + /** @private */ bind: function(dl) { dl.addEventListener('click', L.bind(this.handleClick, this)); dl.addEventListener('keydown', L.bind(this.handleKeydown, this)); @@ -1371,11 +2164,12 @@ var UIDynamicList = UIElement.extend({ this.setUpdateEvents(dl, 'cbi-dynlist-change'); this.setChangeEvents(dl, 'cbi-dynlist-change'); - L.dom.bindClassInstance(dl, this); + dom.bindClassInstance(dl, this); return dl; }, + /** @private */ addItem: function(dl, value, text, flash) { var exists = false, new_item = E('div', { 'class': flash ? 'item flash' : 'item', 'tabindex': 0 }, [ @@ -1414,6 +2208,7 @@ var UIDynamicList = UIElement.extend({ })); }, + /** @private */ removeItem: function(dl, item) { var value = item.querySelector('input[type="hidden"]').value; var sb = dl.querySelector('.cbi-dropdown'); @@ -1440,6 +2235,7 @@ var UIDynamicList = UIElement.extend({ })); }, + /** @private */ handleClick: function(ev) { var dl = ev.currentTarget, item = findParent(ev.target, '.item'); @@ -1456,6 +2252,7 @@ var UIDynamicList = UIElement.extend({ } }, + /** @private */ handleDropdownChange: function(ev) { var dl = ev.currentTarget, sbIn = ev.detail.instance, @@ -1485,6 +2282,7 @@ var UIDynamicList = UIElement.extend({ this.addItem(dl, sbVal.value, label, true); }, + /** @private */ handleKeydown: function(ev) { var dl = ev.currentTarget, item = findParent(ev.target, '.item'); @@ -1526,6 +2324,7 @@ var UIDynamicList = UIElement.extend({ } }, + /** @override */ getValue: function() { var items = this.node.querySelectorAll('.item > input[type="hidden"]'), input = this.node.querySelector('.add-item > input[type="text"]'), @@ -1542,6 +2341,7 @@ var UIDynamicList = UIElement.extend({ return v; }, + /** @override */ setValue: function(values) { if (!Array.isArray(values)) values = (values != null && values != '') ? [ values ] : []; @@ -1557,18 +2357,70 @@ var UIDynamicList = UIElement.extend({ this.choices ? this.choices[values[i]] : null); }, + /** + * Add new suggested choices to the dynamic list. + * + * This function adds further choices to an existing dynamic list, + * ignoring choice values which are already present. + * + * @instance + * @memberof LuCI.ui.DynamicList + * @param {string[]} values + * The choice values to add to the dynamic lists suggestion dropdown. + * + * @param {Object<string, *>} labels + * The choice label values to use when adding suggested choices. If no + * label is found for a particular choice value, the value itself is used + * as label text. Choice labels may be any valid value accepted by + * {@link LuCI.dom#content}. + */ addChoices: function(values, labels) { var dl = this.node.lastElementChild.firstElementChild; - L.dom.callClassMethod(dl, 'addChoices', values, labels); + dom.callClassMethod(dl, 'addChoices', values, labels); }, + /** + * Remove all existing choices from the dynamic list. + * + * This function removes all preexisting suggested choices from the widget. + * + * @instance + * @memberof LuCI.ui.DynamicList + */ clearChoices: function() { var dl = this.node.lastElementChild.firstElementChild; - L.dom.callClassMethod(dl, 'clearChoices'); + dom.callClassMethod(dl, 'clearChoices'); } }); -var UIHiddenfield = UIElement.extend({ +/** + * Instantiate a hidden input field widget. + * + * @constructor Hiddenfield + * @memberof LuCI.ui + * @augments LuCI.ui.AbstractElement + * + * @classdesc + * + * The `Hiddenfield` class implements an HTML `<input type="hidden">` field + * which allows to store form data without exposing it to the user. + * + * UI widget instances are usually not supposed to be created by view code + * directly, instead they're implicitely created by `LuCI.form` when + * instantiating CBI forms. + * + * This class is automatically instantiated as part of `LuCI.ui`. To use it + * in views, use `'require ui'` and refer to `ui.Hiddenfield`. To import it in + * external JavaScript, use `L.require("ui").then(...)` and access the + * `Hiddenfield` property of the class instance value. + * + * @param {string|string[]} [value=null] + * The initial input value. + * + * @param {LuCI.ui.AbstractElement.InitOptions} [options] + * Object describing the widget specific options to initialize the hidden input. + */ +var UIHiddenfield = UIElement.extend(/** @lends LuCI.ui.Hiddenfield.prototype */ { __init__: function(value, options) { this.value = value; this.options = Object.assign({ @@ -1576,6 +2428,7 @@ var UIHiddenfield = UIElement.extend({ }, options); }, + /** @override */ render: function() { var hiddenEl = E('input', { 'id': this.options.id, @@ -1586,24 +2439,89 @@ var UIHiddenfield = UIElement.extend({ return this.bind(hiddenEl); }, + /** @private */ bind: function(hiddenEl) { this.node = hiddenEl; - L.dom.bindClassInstance(hiddenEl, this); + dom.bindClassInstance(hiddenEl, this); return hiddenEl; }, + /** @override */ getValue: function() { return this.node.value; }, + /** @override */ setValue: function(value) { this.node.value = value; } }); -var UIFileUpload = UIElement.extend({ +/** + * Instantiate a file upload widget. + * + * @constructor FileUpload + * @memberof LuCI.ui + * @augments LuCI.ui.AbstractElement + * + * @classdesc + * + * The `FileUpload` class implements a widget which allows the user to upload, + * browse, select and delete files beneath a predefined remote directory. + * + * UI widget instances are usually not supposed to be created by view code + * directly, instead they're implicitely created by `LuCI.form` when + * instantiating CBI forms. + * + * This class is automatically instantiated as part of `LuCI.ui`. To use it + * in views, use `'require ui'` and refer to `ui.FileUpload`. To import it in + * external JavaScript, use `L.require("ui").then(...)` and access the + * `FileUpload` property of the class instance value. + * + * @param {string|string[]} [value=null] + * The initial input value. + * + * @param {LuCI.ui.DynamicList.InitOptions} [options] + * Object describing the widget specific options to initialize the file + * upload control. + */ +var UIFileUpload = UIElement.extend(/** @lends LuCI.ui.FileUpload.prototype */ { + /** + * In addition to the [AbstractElement.InitOptions]{@link LuCI.ui.AbstractElement.InitOptions} + * the following properties are recognized: + * + * @typedef {LuCI.ui.AbstractElement.InitOptions} InitOptions + * @memberof LuCI.ui.FileUpload + * + * @property {boolean} [show_hidden=false] + * Specifies whether hidden files should be displayed when browsing remote + * files. Note that this is not a security feature, hidden files are always + * present in the remote file listings received, this option merely controls + * whether they're displayed or not. + * + * @property {boolean} [enable_upload=true] + * Specifies whether the widget allows the user to upload files. If set to + * `false`, only existing files may be selected. Note that this is not a + * security feature. Whether file upload requests are accepted remotely + * depends on the ACL setup for the current session. This option merely + * controls whether the upload controls are rendered or not. + * + * @property {boolean} [enable_remove=true] + * Specifies whether the widget allows the user to delete remove files. + * If set to `false`, existing files may not be removed. Note that this is + * not a security feature. Whether file delete requests are accepted + * remotely depends on the ACL setup for the current session. This option + * merely controls whether the file remove controls are rendered or not. + * + * @property {string} [root_directory=/etc/luci-uploads] + * Specifies the remote directory the upload and file browsing actions take + * place in. Browsing to directories outside of the root directory is + * prevented by the widget. Note that this is not a security feature. + * Whether remote directories are browseable or not solely depends on the + * ACL setup for the current session. + */ __init__: function(value, options) { this.value = value; this.options = Object.assign({ @@ -1614,17 +2532,19 @@ var UIFileUpload = UIElement.extend({ }, options); }, + /** @private */ bind: function(browserEl) { this.node = browserEl; this.setUpdateEvents(browserEl, 'cbi-fileupload-select', 'cbi-fileupload-cancel'); this.setChangeEvents(browserEl, 'cbi-fileupload-select', 'cbi-fileupload-cancel'); - L.dom.bindClassInstance(browserEl, this); + dom.bindClassInstance(browserEl, this); return browserEl; }, + /** @override */ render: function() { return L.resolveDefault(this.value != null ? fs.stat(this.value) : null).then(L.bind(function(stat) { var label; @@ -1642,7 +2562,7 @@ var UIFileUpload = UIElement.extend({ return this.bind(E('div', { 'id': this.options.id }, [ E('button', { 'class': 'btn', - 'click': L.ui.createHandlerFn(this, 'handleFileBrowser') + 'click': UI.prototype.createHandlerFn(this, 'handleFileBrowser') }, label), E('div', { 'class': 'cbi-filebrowser' @@ -1656,6 +2576,7 @@ var UIFileUpload = UIElement.extend({ }, this)); }, + /** @private */ truncatePath: function(path) { if (path.length > 50) path = path.substring(0, 25) + '…' + path.substring(path.length - 25); @@ -1663,6 +2584,7 @@ var UIFileUpload = UIElement.extend({ return path; }, + /** @private */ iconForType: function(type) { switch (type) { case 'symlink': @@ -1688,6 +2610,7 @@ var UIFileUpload = UIElement.extend({ } }, + /** @private */ canonicalizePath: function(path) { return path.replace(/\/{2,}/, '/') .replace(/\/\.(\/|$)/g, '/') @@ -1695,6 +2618,7 @@ var UIFileUpload = UIElement.extend({ .replace(/\/$/, ''); }, + /** @private */ splitPath: function(path) { var croot = this.canonicalizePath(this.options.root_directory || '/'), cpath = this.canonicalizePath(path || '/'); @@ -1712,6 +2636,7 @@ var UIFileUpload = UIElement.extend({ return parts; }, + /** @private */ handleUpload: function(path, list, ev) { var form = ev.target.parentNode, fileinput = form.querySelector('input[type="file"]'), @@ -1736,7 +2661,7 @@ var UIFileUpload = UIElement.extend({ data.append('filename', path + '/' + filename); data.append('filedata', fileinput.files[0]); - return L.Request.post(L.env.cgi_base + '/cgi-upload', data, { + return request.post(L.env.cgi_base + '/cgi-upload', data, { progress: L.bind(function(btn, ev) { btn.firstChild.data = '%.2f%%'.format((ev.loaded / ev.total) * 100); }, this, ev.target) @@ -1750,6 +2675,7 @@ var UIFileUpload = UIElement.extend({ }, this, path, ev)); }, + /** @private */ handleDelete: function(path, fileStat, ev) { var parent = path.replace(/\/[^\/]+$/, '') || '/', name = path.replace(/^.+\//, ''), @@ -1767,7 +2693,7 @@ var UIFileUpload = UIElement.extend({ hidden = this.node.lastElementChild; if (path == hidden.value) { - L.dom.content(button, _('Select file…')); + dom.content(button, _('Select file…')); hidden.value = ''; } @@ -1779,6 +2705,7 @@ var UIFileUpload = UIElement.extend({ } }, + /** @private */ renderUpload: function(path, list) { if (!this.options.enable_upload) return E([]); @@ -1818,13 +2745,14 @@ var UIFileUpload = UIElement.extend({ E('div', {}, E('input', { 'type': 'text', 'placeholder': _('Filename') })), E('button', { 'class': 'btn cbi-button-save', - 'click': L.ui.createHandlerFn(this, 'handleUpload', path, list), + 'click': UI.prototype.createHandlerFn(this, 'handleUpload', path, list), 'disabled': true }, [ _('Upload file') ]) ]) ]); }, + /** @private */ renderListing: function(container, path, list) { var breadcrumb = E('p'), rows = E('ul'); @@ -1854,7 +2782,7 @@ var UIFileUpload = UIElement.extend({ E('a', { 'href': '#', 'style': selected ? 'font-weight:bold' : null, - 'click': L.ui.createHandlerFn(this, 'handleSelect', + 'click': UI.prototype.createHandlerFn(this, 'handleSelect', entrypath, list[i].type != 'directory' ? list[i] : null) }, '%h'.format(list[i].name)) ]), @@ -1870,11 +2798,11 @@ var UIFileUpload = UIElement.extend({ E('div', [ selected ? E('button', { 'class': 'btn', - 'click': L.ui.createHandlerFn(this, 'handleReset') + 'click': UI.prototype.createHandlerFn(this, 'handleReset') }, [ _('Deselect') ]) : '', this.options.enable_remove ? E('button', { 'class': 'btn cbi-button-negative', - 'click': L.ui.createHandlerFn(this, 'handleDelete', entrypath, list[i]) + 'click': UI.prototype.createHandlerFn(this, 'handleDelete', entrypath, list[i]) }, [ _('Delete') ]) : '' ]) ])); @@ -1888,16 +2816,16 @@ var UIFileUpload = UIElement.extend({ for (var i = 0; i < dirs.length; i++) { cur = cur ? cur + '/' + dirs[i] : dirs[i]; - L.dom.append(breadcrumb, [ + dom.append(breadcrumb, [ i ? ' » ' : '', E('a', { 'href': '#', - 'click': L.ui.createHandlerFn(this, 'handleSelect', cur || '/', null) + 'click': UI.prototype.createHandlerFn(this, 'handleSelect', cur || '/', null) }, dirs[i] != '' ? '%h'.format(dirs[i]) : E('em', '(root)')), ]); } - L.dom.content(container, [ + dom.content(container, [ breadcrumb, rows, E('div', { 'class': 'right' }, [ @@ -1905,12 +2833,13 @@ var UIFileUpload = UIElement.extend({ E('a', { 'href': '#', 'class': 'btn', - 'click': L.ui.createHandlerFn(this, 'handleCancel') + 'click': UI.prototype.createHandlerFn(this, 'handleCancel') }, _('Cancel')) ]), ]); }, + /** @private */ handleCancel: function(ev) { var button = this.node.firstElementChild, browser = button.nextElementSibling; @@ -1923,22 +2852,24 @@ var UIFileUpload = UIElement.extend({ ev.preventDefault(); }, + /** @private */ handleReset: function(ev) { var button = this.node.firstElementChild, hidden = this.node.lastElementChild; hidden.value = ''; - L.dom.content(button, _('Select file…')); + dom.content(button, _('Select file…')); this.handleCancel(ev); }, + /** @private */ handleSelect: function(path, fileStat, ev) { - var browser = L.dom.parent(ev.target, '.cbi-filebrowser'), + var browser = dom.parent(ev.target, '.cbi-filebrowser'), ul = browser.querySelector('ul'); if (fileStat == null) { - L.dom.content(ul, E('em', { 'class': 'spinning' }, _('Loading directory contents…'))); + dom.content(ul, E('em', { 'class': 'spinning' }, _('Loading directory contents…'))); L.resolveDefault(fs.list(path), []).then(L.bind(this.renderListing, this, browser, path)); } else { @@ -1947,7 +2878,7 @@ var UIFileUpload = UIElement.extend({ path = this.canonicalizePath(path); - L.dom.content(button, [ + dom.content(button, [ this.iconForType(fileStat.type), ' %s (%1000mB)'.format(this.truncatePath(path), fileStat.size) ]); @@ -1961,6 +2892,7 @@ var UIFileUpload = UIElement.extend({ } }, + /** @private */ handleFileBrowser: function(ev) { var button = ev.target, browser = button.nextElementSibling, @@ -1973,7 +2905,7 @@ var UIFileUpload = UIElement.extend({ return L.resolveDefault(fs.list(path), []).then(L.bind(function(button, browser, path, list) { document.querySelectorAll('.cbi-filebrowser.open').forEach(function(browserEl) { - L.dom.findClassInstance(browserEl).handleCancel(ev); + dom.findClassInstance(browserEl).handleCancel(ev); }); button.style.display = 'none'; @@ -1983,24 +2915,35 @@ var UIFileUpload = UIElement.extend({ }, this, button, browser, path)); }, + /** @override */ getValue: function() { return this.node.lastElementChild.value; }, + /** @override */ setValue: function(value) { this.node.lastElementChild.value = value; } }); - -return L.Class.extend({ +/** + * @class ui + * @memberof LuCI + * @hideconstructor + * @classdesc + * + * Provides high level UI helper functionality. + * To import the class in views, use `'require ui'`, to import it in + * external JavaScript, use `L.require("ui").then(...)`. + */ +var UI = baseclass.extend(/** @lends LuCI.ui.prototype */ { __init__: function() { modalDiv = document.body.appendChild( - L.dom.create('div', { id: 'modal_overlay' }, - L.dom.create('div', { class: 'modal', role: 'dialog', 'aria-modal': true }))); + dom.create('div', { id: 'modal_overlay' }, + dom.create('div', { class: 'modal', role: 'dialog', 'aria-modal': true }))); tooltipDiv = document.body.appendChild( - L.dom.create('div', { class: 'cbi-tooltip' })); + dom.create('div', { class: 'cbi-tooltip' })); /* setup old aliases */ L.showModal = this.showModal; @@ -2019,7 +2962,35 @@ return L.Class.extend({ document.addEventListener('uci-loaded', this.changes.init.bind(this.changes)); }, - /* Modal dialog */ + /** + * Display a modal overlay dialog with the specified contents. + * + * The modal overlay dialog covers the current view preventing interaction + * with the underlying view contents. Only one modal dialog instance can + * be opened. Invoking showModal() while a modal dialog is already open will + * replace the open dialog with a new one having the specified contents. + * + * Additional CSS class names may be passed to influence the appearence of + * the dialog. Valid values for the classes depend on the underlying theme. + * + * @see LuCI.dom.content + * + * @param {string} [title] + * The title of the dialog. If `null`, no title element will be rendered. + * + * @param {*} contents + * The contents to add to the modal dialog. This should be a DOM node or + * a document fragment in most cases. The value is passed as-is to the + * `dom.content()` function - refer to its documentation for applicable + * values. + * + * @param {...string} [classes] + * A number of extra CSS class names which are set on the modal dialog + * element. + * + * @returns {Node} + * Returns a DOM Node representing the modal dialog element. + */ showModal: function(title, children /* , ... */) { var dlg = modalDiv.firstElementChild; @@ -2028,19 +2999,29 @@ return L.Class.extend({ for (var i = 2; i < arguments.length; i++) dlg.classList.add(arguments[i]); - L.dom.content(dlg, L.dom.create('h4', {}, title)); - L.dom.append(dlg, children); + dom.content(dlg, dom.create('h4', {}, title)); + dom.append(dlg, children); document.body.classList.add('modal-overlay-active'); return dlg; }, + /** + * Close the open modal overlay dialog. + * + * This function will close an open modal dialog and restore the normal view + * behaviour. It has no effect if no modal dialog is currently open. + * + * Note that this function is stand-alone, it does not rely on `this` and + * will not invoke other class functions so it suitable to be used as event + * handler as-is without the need to bind it first. + */ hideModal: function() { document.body.classList.remove('modal-overlay-active'); }, - /* Tooltip */ + /** @private */ showTooltip: function(ev) { var target = findParent(ev.target, '[data-tooltip]'); @@ -2078,6 +3059,7 @@ return L.Class.extend({ })); }, + /** @private */ hideTooltip: function(ev) { if (ev.target === tooltipDiv || ev.relatedTarget === tooltipDiv || tooltipDiv.contains(ev.target) || tooltipDiv.contains(ev.relatedTarget)) @@ -2094,6 +3076,36 @@ return L.Class.extend({ tooltipDiv.dispatchEvent(new CustomEvent('tooltip-close', { bubbles: true })); }, + /** + * Add a notification banner at the top of the current view. + * + * A notification banner is an alert message usually displayed at the + * top of the current view, spanning the entire availibe width. + * Notification banners will stay in place until dismissed by the user. + * Multiple banners may be shown at the same time. + * + * Additional CSS class names may be passed to influence the appearence of + * the banner. Valid values for the classes depend on the underlying theme. + * + * @see LuCI.dom.content + * + * @param {string} [title] + * The title of the notification banner. If `null`, no title element + * will be rendered. + * + * @param {*} contents + * The contents to add to the notification banner. This should be a DOM + * node or a document fragment in most cases. The value is passed as-is + * to the `dom.content()` function - refer to its documentation for + * applicable values. + * + * @param {...string} [classes] + * A number of extra CSS class names which are set on the notification + * banner element. + * + * @returns {Node} + * Returns a DOM Node representing the notification banner element. + */ addNotification: function(title, children /*, ... */) { var mc = document.querySelector('#maincontent') || document.body; var msg = E('div', { @@ -2111,7 +3123,7 @@ return L.Class.extend({ 'class': 'btn', 'style': 'margin-left:auto; margin-top:auto', 'click': function(ev) { - L.dom.parent(ev.target, '.alert-message').classList.add('fade-out'); + dom.parent(ev.target, '.alert-message').classList.add('fade-out'); }, }, [ _('Dismiss') ]) @@ -2119,9 +3131,9 @@ return L.Class.extend({ ]); if (title != null) - L.dom.append(msg.firstElementChild, E('h4', {}, title)); + dom.append(msg.firstElementChild, E('h4', {}, title)); - L.dom.append(msg.firstElementChild, children); + dom.append(msg.firstElementChild, children); for (var i = 2; i < arguments.length; i++) msg.classList.add(arguments[i]); @@ -2131,7 +3143,121 @@ return L.Class.extend({ return msg; }, - /* Widget helper */ + /** + * Display or update an header area indicator. + * + * An indicator is a small label displayed in the header area of the screen + * providing few amounts of status information such as item counts or state + * toggle indicators. + * + * Multiple indicators may be shown at the same time and indicator labels + * may be made clickable to display extended information or to initiate + * further actions. + * + * Indicators can either use a default `active` or a less accented `inactive` + * style which is useful for indicators representing state toggles. + * + * @param {string} id + * The ID of the indicator. If an indicator with the given ID already exists, + * it is updated with the given label and style. + * + * @param {string} label + * The text to display in the indicator label. + * + * @param {function} [handler] + * A handler function to invoke when the indicator label is clicked/touched + * by the user. If omitted, the indicator is not clickable/touchable. + * + * Note that this parameter only applies to new indicators, when updating + * existing labels it is ignored. + * + * @param {string} [style=active] + * The indicator style to use. May be either `active` or `inactive`. + * + * @returns {boolean} + * Returns `true` when the indicator has been updated or `false` when no + * changes were made. + */ + showIndicator: function(id, label, handler, style) { + if (indicatorDiv == null) { + indicatorDiv = document.body.querySelector('#indicators'); + + if (indicatorDiv == null) + return false; + } + + var handlerFn = (typeof(handler) == 'function') ? handler : null, + indicatorElem = indicatorDiv.querySelector('span[data-indicator="%s"]'.format(id)) || + indicatorDiv.appendChild(E('span', { + 'data-indicator': id, + 'data-clickable': handlerFn ? true : null, + 'click': handlerFn + }, [''])); + + if (label == indicatorElem.firstChild.data && style == indicatorElem.getAttribute('data-style')) + return false; + + indicatorElem.firstChild.data = label; + indicatorElem.setAttribute('data-style', (style == 'inactive') ? 'inactive' : 'active'); + return true; + }, + + /** + * Remove an header area indicator. + * + * This function removes the given indicator label from the header indicator + * area. When the given indicator is not found, this function does nothing. + * + * @param {string} id + * The ID of the indicator to remove. + * + * @returns {boolean} + * Returns `true` when the indicator has been removed or `false` when the + * requested indicator was not found. + */ + hideIndicator: function(id) { + var indicatorElem = indicatorDiv ? indicatorDiv.querySelector('span[data-indicator="%s"]'.format(id)) : null; + + if (indicatorElem == null) + return false; + + indicatorDiv.removeChild(indicatorElem); + return true; + }, + + /** + * Formats a series of label/value pairs into list-like markup. + * + * This function transforms a flat array of alternating label and value + * elements into a list-like markup, using the values in `separators` as + * separators and appends the resulting nodes to the given parent DOM node. + * + * Each label is suffixed with `: ` and wrapped into a `<strong>` tag, the + * `<strong>` element and the value corresponding to the label are + * subsequently wrapped into a `<span class="nowrap">` element. + * + * The resulting `<span>` element tuples are joined by the given separators + * to form the final markup which is appened to the given parent DOM node. + * + * @param {Node} node + * The parent DOM node to append the markup to. Any previous child elements + * will be removed. + * + * @param {Array<*>} items + * An alternating array of labels and values. The label values will be + * converted to plain strings, the values are used as-is and may be of + * any type accepted by `LuCI.dom.content()`. + * + * @param {*|Array<*>} [separators=[E('br')]] + * A single value or an array of separator values to separate each + * label/value pair with. The function will cycle through the separators + * when joining the pairs. If omitted, the default separator is a sole HTML + * `<br>` element. Separator values are used as-is and may be of any type + * accepted by `LuCI.dom.content()`. + * + * @returns {Node} + * Returns the parent DOM node the formatted markup has been added to. + */ itemlist: function(node, items, separators) { var children = []; @@ -2149,24 +3275,39 @@ return L.Class.extend({ ])); if ((i+2) < items.length) - children.push(L.dom.elem(sep) ? sep.cloneNode(true) : sep); + children.push(dom.elem(sep) ? sep.cloneNode(true) : sep); } } - L.dom.content(node, children); + dom.content(node, children); return node; }, - /* Tabs */ - tabs: L.Class.singleton({ + /** + * @class + * @memberof LuCI.ui + * @hideconstructor + * @classdesc + * + * The `tabs` class handles tab menu groups used throughout the view area. + * It takes care of setting up tab groups, tracking their state and handling + * related events. + * + * This class is automatically instantiated as part of `LuCI.ui`. To use it + * in views, use `'require ui'` and refer to `ui.tabs`. To import it in + * external JavaScript, use `L.require("ui").then(...)` and access the + * `tabs` property of the class instance value. + */ + tabs: baseclass.singleton(/* @lends LuCI.ui.tabs.prototype */ { + /** @private */ init: function() { var groups = [], prevGroup = null, currGroup = null; document.querySelectorAll('[data-tab]').forEach(function(tab) { var parent = tab.parentNode; - if (L.dom.matches(tab, 'li') && L.dom.matches(parent, 'ul.cbi-tabmenu')) + if (dom.matches(tab, 'li') && dom.matches(parent, 'ul.cbi-tabmenu')) return; if (!parent.hasAttribute('data-tab-group')) @@ -2192,6 +3333,27 @@ return L.Class.extend({ this.updateTabs(); }, + /** + * Initializes a new tab group from the given tab pane collection. + * + * This function cycles through the given tab pane DOM nodes, extracts + * their tab IDs, titles and active states, renders a corresponding + * tab menu and prepends it to the tab panes common parent DOM node. + * + * The tab menu labels will be set to the value of the `data-tab-title` + * attribute of each corresponding pane. The last pane with the + * `data-tab-active` attribute set to `true` will be selected by default. + * + * If no pane is marked as active, the first one will be preselected. + * + * @instance + * @memberof LuCI.ui.tabs + * @param {Array<Node>|NodeList} panes + * A collection of tab panes to build a tab group menu for. May be a + * plain array of DOM nodes or a NodeList collection, such as the result + * of a `querySelectorAll()` call or the `.childNodes` property of a + * DOM node. + */ initTabGroup: function(panes) { if (typeof(panes) != 'object' || !('length' in panes) || panes.length === 0) return; @@ -2251,10 +3413,22 @@ return L.Class.extend({ this.updateTabs(group); }, + /** + * Checks whether the given tab pane node is empty. + * + * @instance + * @memberof LuCI.ui.tabs + * @param {Node} pane + * The tab pane to check. + * + * @returns {boolean} + * Returns `true` if the pane is empty, else `false`. + */ isEmptyPane: function(pane) { - return L.dom.isEmpty(pane, function(n) { return n.classList.contains('cbi-tab-descr') }); + return dom.isEmpty(pane, function(n) { return n.classList.contains('cbi-tab-descr') }); }, + /** @private */ getPathForPane: function(pane) { var path = [], node = null; @@ -2271,6 +3445,7 @@ return L.Class.extend({ return path.join('/'); }, + /** @private */ getActiveTabState: function() { var page = document.body.getAttribute('data-page'); @@ -2285,11 +3460,13 @@ return L.Class.extend({ return { page: page, paths: {} }; }, + /** @private */ getActiveTabId: function(pane) { var path = this.getPathForPane(pane); return +this.getActiveTabState().paths[path] || 0; }, + /** @private */ setActiveTabId: function(pane, tabIndex) { var path = this.getPathForPane(pane); @@ -2304,6 +3481,7 @@ return L.Class.extend({ return true; }, + /** @private */ updateTabs: function(ev, root) { (root || document).querySelectorAll('[data-tab-title]').forEach(L.bind(function(pane) { var menu = pane.parentNode.previousElementSibling, @@ -2334,6 +3512,7 @@ return L.Class.extend({ }, this)); }, + /** @private */ switchTab: function(ev) { var tab = ev.target.parentNode, name = tab.getAttribute('data-tab'), @@ -2355,11 +3534,11 @@ return L.Class.extend({ }); group.childNodes.forEach(function(pane) { - if (L.dom.matches(pane, '[data-tab]')) { + if (dom.matches(pane, '[data-tab]')) { if (pane.getAttribute('data-tab') === name) { pane.setAttribute('data-tab-active', 'true'); pane.dispatchEvent(new CustomEvent('cbi-tab-active', { detail: { tab: name } })); - L.ui.tabs.setActiveTabId(pane, index); + UI.prototype.tabs.setActiveTabId(pane, index); } else { pane.setAttribute('data-tab-active', 'false'); @@ -2371,10 +3550,37 @@ return L.Class.extend({ } }), - /* File uploading */ + /** + * @typedef {Object} FileUploadReply + * @memberof LuCI.ui + + * @property {string} name - Name of the uploaded file without directory components + * @property {number} size - Size of the uploaded file in bytes + * @property {string} checksum - The MD5 checksum of the received file data + * @property {string} sha256sum - The SHA256 checksum of the received file data + */ + + /** + * Display a modal file upload prompt. + * + * This function opens a modal dialog prompting the user to select and + * upload a file to a predefined remote destination path. + * + * @param {string} path + * The remote file path to upload the local file to. + * + * @param {Node} [progessStatusNode] + * An optional DOM text node whose content text is set to the progress + * percentage value during file upload. + * + * @returns {Promise<LuCI.ui.FileUploadReply>} + * Returns a promise resolving to a file upload status object on success + * or rejecting with an error in case the upload failed or has been + * cancelled by the user. + */ uploadFile: function(path, progressStatusNode) { return new Promise(function(resolveFn, rejectFn) { - L.ui.showModal(_('Uploading file…'), [ + UI.prototype.showModal(_('Uploading file…'), [ E('p', _('Please select the file to upload.')), E('div', { 'style': 'display:flex' }, [ E('div', { 'class': 'left', 'style': 'flex:1' }, [ @@ -2382,7 +3588,7 @@ return L.Class.extend({ type: 'file', style: 'display:none', change: function(ev) { - var modal = L.dom.parent(ev.target, '.modal'), + var modal = dom.parent(ev.target, '.modal'), body = modal.querySelector('p'), upload = modal.querySelector('.cbi-button-action.important'), file = ev.currentTarget.files[0]; @@ -2390,7 +3596,7 @@ return L.Class.extend({ if (file == null) return; - L.dom.content(body, [ + dom.content(body, [ E('ul', {}, [ E('li', {}, [ '%s: %s'.format(_('Name'), file.name.replace(/^.*[\\\/]/, '')) ]), E('li', {}, [ '%s: %1024mB'.format(_('Size'), file.size) ]) @@ -2412,7 +3618,7 @@ return L.Class.extend({ E('button', { 'class': 'btn', 'click': function() { - L.ui.hideModal(); + UI.prototype.hideModal(); rejectFn(new Error('Upload has been cancelled')); } }, [ _('Cancel') ]), @@ -2421,14 +3627,14 @@ return L.Class.extend({ 'class': 'btn cbi-button-action important', 'disabled': true, 'click': function(ev) { - var input = L.dom.parent(ev.target, '.modal').querySelector('input[type="file"]'); + var input = dom.parent(ev.target, '.modal').querySelector('input[type="file"]'); if (!input.files[0]) return; var progress = E('div', { 'class': 'cbi-progressbar', 'title': '0%' }, E('div', { 'style': 'width:0' })); - L.ui.showModal(_('Uploading file…'), [ progress ]); + UI.prototype.showModal(_('Uploading file…'), [ progress ]); var data = new FormData(); @@ -2438,7 +3644,7 @@ return L.Class.extend({ var filename = input.files[0].name; - L.Request.post(L.env.cgi_base + '/cgi-upload', data, { + request.post(L.env.cgi_base + '/cgi-upload', data, { timeout: 0, progress: function(pev) { var percent = (pev.loaded / pev.total) * 100; @@ -2452,10 +3658,10 @@ return L.Class.extend({ }).then(function(res) { var reply = res.json(); - L.ui.hideModal(); + UI.prototype.hideModal(); if (L.isObject(reply) && reply.failure) { - L.ui.addNotification(null, E('p', _('Upload request failed: %s').format(reply.message))); + UI.prototype.addNotification(null, E('p', _('Upload request failed: %s').format(reply.message))); rejectFn(new Error(reply.failure)); } else { @@ -2463,7 +3669,7 @@ return L.Class.extend({ resolveFn(reply); } }, function(err) { - L.ui.hideModal(); + UI.prototype.hideModal(); rejectFn(err); }); } @@ -2474,7 +3680,26 @@ return L.Class.extend({ }); }, - /* Reconnect handling */ + /** + * Perform a device connectivity test. + * + * Attempt to fetch a well known ressource from the remote device via HTTP + * in order to test connectivity. This function is mainly useful to wait + * for the router to come back online after a reboot or reconfiguration. + * + * @param {string} [proto=http] + * The protocol to use for fetching the resource. May be either `http` + * (the default) or `https`. + * + * @param {string} [host=window.location.host] + * Override the host address to probe. By default the current host as seen + * in the address bar is probed. + * + * @returns {Promise<Event>} + * Returns a promise resolving to a `load` event in case the device is + * reachable or rejecting with an `error` event in case it is not reachable + * or rejecting with `null` when the connectivity check timed out. + */ pingDevice: function(proto, ipaddr) { var target = '%s://%s%s?%s'.format(proto || 'http', ipaddr || window.location.host, L.resource('icons/loading.gif'), Math.random()); @@ -2490,11 +3715,22 @@ return L.Class.extend({ }); }, + /** + * Wait for device to come back online and reconnect to it. + * + * Poll each given hostname or IP address and navigate to it as soon as + * one of the addresses becomes reachable. + * + * @param {...string} [hosts=[window.location.host]] + * The list of IP addresses and host names to check for reachability. + * If omitted, the current value of `window.location.host` is used by + * default. + */ awaitReconnect: function(/* ... */) { var ipaddrs = arguments.length ? arguments : [ window.location.host ]; window.setTimeout(L.bind(function() { - L.Poll.add(L.bind(function() { + poll.add(L.bind(function() { var tasks = [], reachable = false; for (var i = 0; i < 2; i++) @@ -2504,7 +3740,7 @@ return L.Class.extend({ return Promise.all(tasks).then(function() { if (reachable) { - L.Poll.stop(); + poll.stop(); window.location = reachable; } }); @@ -2512,8 +3748,21 @@ return L.Class.extend({ }, this), 5000); }, - /* UCI Changes */ - changes: L.Class.singleton({ + /** + * @class + * @memberof LuCI.ui + * @hideconstructor + * @classdesc + * + * The `changes` class encapsulates logic for visualizing, applying, + * confirming and reverting staged UCI changesets. + * + * This class is automatically instantiated as part of `LuCI.ui`. To use it + * in views, use `'require ui'` and refer to `ui.changes`. To import it in + * external JavaScript, use `L.require("ui").then(...)` and access the + * `changes` property of the class instance value. + */ + changes: baseclass.singleton(/* @lends LuCI.ui.changes.prototype */ { init: function() { if (!L.env.sessionid) return; @@ -2521,6 +3770,19 @@ return L.Class.extend({ return uci.changes().then(L.bind(this.renderChangeIndicator, this)); }, + /** + * Set the change count indicator. + * + * This function updates or hides the UCI change count indicator, + * depending on the passed change count. When the count is greater + * than 0, the change indicator is displayed or updated, otherwise it + * is removed. + * + * @instance + * @memberof LuCI.ui.changes + * @param {number} numChanges + * The number of changes to indicate. + */ setIndicator: function(n) { var i = document.querySelector('.uci_change_indicator'); if (i == null) { @@ -2533,16 +3795,29 @@ return L.Class.extend({ } if (n > 0) { - L.dom.content(i, [ _('Unsaved Changes'), ': ', n ]); + dom.content(i, [ _('Unsaved Changes'), ': ', n ]); i.classList.add('flash'); i.style.display = ''; + document.dispatchEvent(new CustomEvent('uci-new-changes')); } else { i.classList.remove('flash'); i.style.display = 'none'; + document.dispatchEvent(new CustomEvent('uci-clear-changes')); } }, + /** + * Update the change count indicator. + * + * This function updates the UCI change count indicator from the given + * UCI changeset structure. + * + * @instance + * @memberof LuCI.ui.changes + * @param {Object<string, Array<LuCI.uci.ChangeRecord>>} changes + * The UCI changeset to count. + */ renderChangeIndicator: function(changes) { var n_changes = 0; @@ -2554,6 +3829,7 @@ return L.Class.extend({ this.setIndicator(n_changes); }, + /** @private */ changeTemplates: { 'add-3': '<ins>uci add %0 <strong>%3</strong> # =%2</ins>', 'set-3': '<ins>uci set %0.<strong>%2</strong>=%3</ins>', @@ -2567,9 +3843,18 @@ return L.Class.extend({ 'rename-4': '<var>uci rename %0.%2.%3=<strong>%4</strong></var>' }, + /** + * Display the current changelog. + * + * Open a modal dialog visualizing the currently staged UCI changes + * and offer options to revert or apply the shown changes. + * + * @instance + * @memberof LuCI.ui.changes + */ displayChanges: function() { var list = E('div', { 'class': 'uci-change-list' }), - dlg = L.ui.showModal(_('Configuration') + ' / ' + _('Changes'), [ + dlg = UI.prototype.showModal(_('Configuration') + ' / ' + _('Changes'), [ E('div', { 'class': 'cbi-section' }, [ E('strong', _('Legend:')), E('div', { 'class': 'uci-change-legend' }, [ @@ -2585,7 +3870,7 @@ return L.Class.extend({ E('div', { 'class': 'right' }, [ E('button', { 'class': 'btn', - 'click': L.ui.hideModal + 'click': UI.prototype.hideModal }, [ _('Dismiss') ]), ' ', E('button', { 'class': 'cbi-button cbi-button-positive important', @@ -2635,29 +3920,31 @@ return L.Class.extend({ dlg.classList.add('uci-dialog'); }, + /** @private */ displayStatus: function(type, content) { if (type) { - var message = L.ui.showModal('', ''); + var message = UI.prototype.showModal('', ''); message.classList.add('alert-message'); DOMTokenList.prototype.add.apply(message.classList, type.split(/\s+/)); if (content) - L.dom.content(message, content); + dom.content(message, content); if (!this.was_polling) { - this.was_polling = L.Request.poll.active(); - L.Request.poll.stop(); + this.was_polling = request.poll.active(); + request.poll.stop(); } } else { - L.ui.hideModal(); + UI.prototype.hideModal(); if (this.was_polling) - L.Request.poll.start(); + request.poll.start(); } }, + /** @private */ rollback: function(checked) { if (checked) { this.displayStatus('warning spinning', @@ -2666,21 +3953,21 @@ return L.Class.extend({ var call = function(r, data, duration) { if (r.status === 204) { - L.ui.changes.displayStatus('warning', [ + UI.prototype.changes.displayStatus('warning', [ E('h4', _('Configuration changes have been rolled back!')), 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)), E('div', { 'class': 'right' }, [ E('button', { 'class': 'btn', - 'click': L.bind(L.ui.changes.displayStatus, L.ui.changes, false) + 'click': L.bind(UI.prototype.changes.displayStatus, UI.prototype.changes, false) }, [ _('Dismiss') ]), ' ', E('button', { 'class': 'btn cbi-button-action important', - 'click': L.bind(L.ui.changes.revert, L.ui.changes) + 'click': L.bind(UI.prototype.changes.revert, UI.prototype.changes) }, [ _('Revert changes') ]), ' ', E('button', { 'class': 'btn cbi-button-negative important', - 'click': L.bind(L.ui.changes.apply, L.ui.changes, false) + 'click': L.bind(UI.prototype.changes.apply, UI.prototype.changes, false) }, [ _('Apply unchecked') ]) ]) ]); @@ -2690,7 +3977,7 @@ return L.Class.extend({ var delay = isNaN(duration) ? 0 : Math.max(1000 - duration, 0); window.setTimeout(function() { - L.Request.request(L.url('admin/uci/confirm'), { + request.request(L.url('admin/uci/confirm'), { method: 'post', timeout: L.env.apply_timeout * 1000, query: { sid: L.env.sessionid, token: L.env.token } @@ -2708,6 +3995,7 @@ return L.Class.extend({ } }, + /** @private */ confirm: function(checked, deadline, override_token) { var tt; var ts = Date.now(); @@ -2720,19 +4008,19 @@ return L.Class.extend({ var call = function(r, data, duration) { if (Date.now() >= deadline) { window.clearTimeout(tt); - L.ui.changes.rollback(checked); + UI.prototype.changes.rollback(checked); return; } else if (r && (r.status === 200 || r.status === 204)) { document.dispatchEvent(new CustomEvent('uci-applied')); - L.ui.changes.setIndicator(0); - L.ui.changes.displayStatus('notice', + UI.prototype.changes.setIndicator(0); + UI.prototype.changes.displayStatus('notice', E('p', _('Configuration changes applied.'))); window.clearTimeout(tt); window.setTimeout(function() { - //L.ui.changes.displayStatus(false); + //UI.prototype.changes.displayStatus(false); window.location = window.location.href.split('#')[0]; }, L.env.apply_display * 1000); @@ -2741,10 +4029,10 @@ return L.Class.extend({ var delay = isNaN(duration) ? 0 : Math.max(1000 - duration, 0); window.setTimeout(function() { - L.Request.request(L.url('admin/uci/confirm'), { + request.request(L.url('admin/uci/confirm'), { method: 'post', timeout: L.env.apply_timeout * 1000, - query: L.ui.changes.confirm_auth + query: UI.prototype.changes.confirm_auth }).then(call, call); }, delay); }; @@ -2752,7 +4040,7 @@ return L.Class.extend({ var tick = function() { var now = Date.now(); - L.ui.changes.displayStatus('notice spinning', + UI.prototype.changes.displayStatus('notice spinning', E('p', _('Applying configuration changes… %ds') .format(Math.max(Math.floor((deadline - Date.now()) / 1000), 0)))); @@ -2769,74 +4057,141 @@ return L.Class.extend({ window.setTimeout(call, Math.max(L.env.apply_holdoff * 1000 - ((ts + L.env.apply_rollback * 1000) - deadline), 1)); }, + /** + * Apply the staged configuration changes. + * + * Start applying staged configuration changes and open a modal dialog + * with a progress indication to prevent interaction with the view + * during the apply process. The modal dialog will be automatically + * closed and the current view reloaded once the apply process is + * complete. + * + * @instance + * @memberof LuCI.ui.changes + * @param {boolean} [checked=false] + * Whether to perform a checked (`true`) configuration apply or an + * unchecked (`false`) one. + + * In case of a checked apply, the configuration changes must be + * confirmed within a specific time interval, otherwise the device + * will begin to roll back the changes in order to restore the previous + * settings. + */ apply: function(checked) { this.displayStatus('notice spinning', E('p', _('Starting configuration apply…'))); - L.Request.request(L.url('admin/uci', checked ? 'apply_rollback' : 'apply_unchecked'), { + request.request(L.url('admin/uci', checked ? 'apply_rollback' : 'apply_unchecked'), { method: 'post', query: { sid: L.env.sessionid, token: L.env.token } }).then(function(r) { if (r.status === (checked ? 200 : 204)) { var tok = null; try { tok = r.json(); } catch(e) {} if (checked && tok !== null && typeof(tok) === 'object' && typeof(tok.token) === 'string') - L.ui.changes.confirm_auth = tok; + UI.prototype.changes.confirm_auth = tok; - L.ui.changes.confirm(checked, Date.now() + L.env.apply_rollback * 1000); + UI.prototype.changes.confirm(checked, Date.now() + L.env.apply_rollback * 1000); } else if (checked && r.status === 204) { - L.ui.changes.displayStatus('notice', + UI.prototype.changes.displayStatus('notice', E('p', _('There are no changes to apply'))); window.setTimeout(function() { - L.ui.changes.displayStatus(false); + UI.prototype.changes.displayStatus(false); }, L.env.apply_display * 1000); } else { - L.ui.changes.displayStatus('warning', + UI.prototype.changes.displayStatus('warning', E('p', _('Apply request failed with status <code>%h</code>') .format(r.responseText || r.statusText || r.status))); window.setTimeout(function() { - L.ui.changes.displayStatus(false); + UI.prototype.changes.displayStatus(false); }, L.env.apply_display * 1000); } }); }, + /** + * Revert the staged configuration changes. + * + * Start reverting staged configuration changes and open a modal dialog + * with a progress indication to prevent interaction with the view + * during the revert process. The modal dialog will be automatically + * closed and the current view reloaded once the revert process is + * complete. + * + * @instance + * @memberof LuCI.ui.changes + */ revert: function() { this.displayStatus('notice spinning', E('p', _('Reverting configuration…'))); - L.Request.request(L.url('admin/uci/revert'), { + request.request(L.url('admin/uci/revert'), { method: 'post', query: { sid: L.env.sessionid, token: L.env.token } }).then(function(r) { if (r.status === 200) { document.dispatchEvent(new CustomEvent('uci-reverted')); - L.ui.changes.setIndicator(0); - L.ui.changes.displayStatus('notice', + UI.prototype.changes.setIndicator(0); + UI.prototype.changes.displayStatus('notice', E('p', _('Changes have been reverted.'))); window.setTimeout(function() { - //L.ui.changes.displayStatus(false); + //UI.prototype.changes.displayStatus(false); window.location = window.location.href.split('#')[0]; }, L.env.apply_display * 1000); } else { - L.ui.changes.displayStatus('warning', + UI.prototype.changes.displayStatus('warning', E('p', _('Revert request failed with status <code>%h</code>') .format(r.statusText || r.status))); window.setTimeout(function() { - L.ui.changes.displayStatus(false); + UI.prototype.changes.displayStatus(false); }, L.env.apply_display * 1000); } }); } }), + /** + * Add validation constraints to an input element. + * + * Compile the given type expression and optional validator function into + * a validation function and bind it to the specified input element events. + * + * @param {Node} field + * The DOM input element node to bind the validation constraints to. + * + * @param {string} type + * The datatype specification to describe validation constraints. + * Refer to the `LuCI.validation` class documentation for details. + * + * @param {boolean} [optional=false] + * Specifies whether empty values are allowed (`true`) or not (`false`). + * If an input element is not marked optional it must not be empty, + * otherwise it will be marked as invalid. + * + * @param {function} [vfunc] + * Specifies a custom validation function which is invoked after the + * other validation constraints are applied. The validation must return + * `true` to accept the passed value. Any other return type is converted + * to a string and treated as validation error message. + * + * @param {...string} [events=blur, keyup] + * The list of events to bind. Each received event will trigger a field + * validation. If omitted, the `keyup` and `blur` events are bound by + * default. + * + * @returns {function} + * Returns the compiled validator function which can be used to manually + * trigger field validation or to bind it to further events. + * + * @see LuCI.validation + */ addValidator: function(field, type, optional, vfunc /*, ... */) { if (type == null) return; @@ -2846,7 +4201,7 @@ return L.Class.extend({ events.push('blur', 'keyup'); try { - var cbiValidator = L.validation.create(field, type, optional, vfunc), + var cbiValidator = validation.create(field, type, optional, vfunc), validatorFn = cbiValidator.validate.bind(cbiValidator); for (var i = 0; i < events.length; i++) @@ -2859,6 +4214,35 @@ return L.Class.extend({ catch (e) { } }, + /** + * Create a pre-bound event handler function. + * + * Generate and bind a function suitable for use in event handlers. The + * generated function automatically disables the event source element + * and adds an active indication to it by adding appropriate CSS classes. + * + * It will also await any promises returned by the wrapped function and + * re-enable the source element after the promises ran to completion. + * + * @param {*} ctx + * The `this` context to use for the wrapped function. + * + * @param {function|string} fn + * Specifies the function to wrap. In case of a function value, the + * function is used as-is. If a string is specified instead, it is looked + * up in `ctx` to obtain the function to wrap. In both cases the bound + * function will be invoked with `ctx` as `this` context + * + * @param {...*} extra_args + * Any further parameter as passed as-is to the bound event handler + * function in the same order as passed to `createHandlerFn()`. + * + * @returns {function|null} + * Returns the pre-bound handler function which is suitable to be passed + * to `addEventListener()`. Returns `null` if the given `fn` argument is + * a string which could not be found in `ctx` or if `ctx[fn]` is not a + * valid function value. + */ createHandlerFn: function(ctx, fn /*, ... */) { if (typeof(fn) == 'string') fn = ctx[fn]; @@ -2869,7 +4253,7 @@ return L.Class.extend({ var arg_offset = arguments.length - 2; return Function.prototype.bind.apply(function() { - var t = arguments[arg_offset].target; + var t = arguments[arg_offset].currentTarget; t.classList.add('spinning'); t.disabled = true; @@ -2884,6 +4268,38 @@ return L.Class.extend({ }, this.varargs(arguments, 2, ctx)); }, + /** + * Load specified view class path and set it up. + * + * Transforms the given view path into a class name, requires it + * using [LuCI.require()]{@link LuCI#require} and asserts that the + * resulting class instance is a descendant of + * [LuCI.view]{@link LuCI.view}. + * + * By instantiating the view class, its corresponding contents are + * rendered and included into the view area. Any runtime errors are + * catched and rendered using [LuCI.error()]{@link LuCI#error}. + * + * @param {string} path + * The view path to render. + * + * @returns {Promise<LuCI.view>} + * Returns a promise resolving to the loaded view instance. + */ + instantiateView: function(path) { + var className = 'view.%s'.format(path.replace(/\//g, '.')); + + return L.require(className).then(function(view) { + if (!(view instanceof View)) + throw new TypeError('Loaded class %s is not a descendant of View'.format(className)); + + return view; + }).catch(function(err) { + dom.content(document.querySelector('#view'), null); + L.error(err); + }); + }, + AbstractElement: UIElement, /* Widgets */ @@ -2898,3 +4314,5 @@ return L.Class.extend({ Hiddenfield: UIHiddenfield, FileUpload: UIFileUpload }); + +return UI; diff --git a/modules/luci-base/htdocs/luci-static/resources/validation.js b/modules/luci-base/htdocs/luci-static/resources/validation.js index 0544e2f680..eea837d64e 100644 --- a/modules/luci-base/htdocs/luci-static/resources/validation.js +++ b/modules/luci-base/htdocs/luci-static/resources/validation.js @@ -1,6 +1,7 @@ 'use strict'; +'require baseclass'; -var Validator = L.Class.extend({ +var Validator = baseclass.extend({ __name__: 'Validation', __init__: function(field, type, optional, vfunc, validatorFactory) { @@ -81,7 +82,7 @@ var Validator = L.Class.extend({ }); -var ValidatorFactory = L.Class.extend({ +var ValidatorFactory = baseclass.extend({ __name__: 'ValidatorFactory', create: function(field, type, optional, vfunc) { |