if (indexA != indexB)
return (indexA - indexB);
- return (a > b);
+ return L.naturalCompare(a, b);
}, this));
for (var i = 0; i < section_ids.length; i++)
/**
* Add another form element as children to this element.
*
- * @param {AbstractElement} element
+ * @param {AbstractElement} obj
* The form element to add.
*/
append: function(obj) {
* 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.
+ * Elements which are hidden due to unsatisfied dependencies are skipped.
*
* @returns {Promise<void>}
* Returns a promise resolving once this element's value and the values of
/**
* Strip any HTML tags from the given input string.
*
- * @param {string} input
+ * @param {string} s
* The input string to clean.
*
* @returns {string}
- * The cleaned input string with HTML removes removed.
+ * The cleaned input string with HTML tags removed.
*/
stripTags: function(s) {
if (typeof(s) == 'string' && !s.match(/[<>]/))
return s;
- var x = E('div', {}, s);
- return x.textContent || x.innerText || '';
+ var x = dom.elem(s) ? s : dom.parse('<div>' + s + '</div>');
+
+ x.querySelectorAll('br').forEach(function(br) {
+ x.replaceChild(document.createTextNode('\n'), br);
+ });
+
+ return (x.textContent || x.innerText || '').replace(/([ \t]*\n)+/g, '\n');
},
/**
* @classdesc
*
* The `Map` class represents one complete form. A form usually maps one UCI
- * configuraton file and is divided into multiple sections containing multiple
+ * configuration 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} [description]
* The description text of the form which is usually rendered as text
- * paragraph below the form title and before the actual form conents.
+ * paragraph below the form title and before the actual form contents.
* If omitted, the corresponding paragraph element will not be rendered.
*/
var CBIMap = CBIAbstractElement.extend(/** @lends LuCI.form.Map.prototype */ {
* @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
+ * obtained from calling `new`. It must also be a class derived from
* `LuCI.form.AbstractSection`.
*
* @param {...string} classargs
- * Additional arguments which are passed as-is to the contructor of the
+ * Additional arguments which are passed as-is to the constructor of the
* given section class. Refer to the class specific constructor
* documentation for details.
*
* 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.
+ * Elements which are hidden due to unsatisfied dependencies are skipped.
*
* @returns {Promise<void>}
* Returns a promise resolving once the entire form completed parsing all
*
* @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.
+ * the form data failures. Otherwise fail silently.
*
* @returns {Promise<void>}
* Returns a promise resolving once the entire save operation is complete.
if (!silent) {
ui.showModal(_('Save error'), [
E('p', {}, [ _('An error occurred while saving the form:') ]),
- E('p', {}, [ E('em', { 'style': 'white-space:pre' }, [ e.message ]) ]),
+ E('p', {}, [ E('em', { 'style': 'white-space:pre-wrap' }, [ e.message ]) ]),
E('div', { 'class': 'right' }, [
- E('button', { 'class': 'btn', 'click': ui.hideModal }, [ _('Dismiss') ])
+ E('button', { 'class': 'cbi-button', 'click': ui.hideModal }, [ _('Dismiss') ])
])
]);
}
/**
* Find a form option element instance.
*
- * @param {string} name_or_id
+ * @param {string} name
* 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.
+ * @param {string} [config_name]
+ * The name of the UCI configuration the option instance belongs to.
* Defaults to the main UCI configuration of the map if omitted.
*
* @returns {Array<LuCI.form.AbstractValue,string>|null}
*
* @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.
+ * paragraph below the form title and before the actual form contents.
* If omitted, the corresponding paragraph element will not be rendered.
*/
var CBIJSONMap = CBIMap.extend(/** @lends LuCI.form.JSONMap.prototype */ {
* triggers input value reading and validation for each encountered child
* option element.
*
- * Options which are hidden due to unsatisified dependencies are skipped.
+ * Options which are hidden due to unsatisfied dependencies are skipped.
*
* @returns {Promise<void>}
* Returns a promise resolving once the values of all child elements have
* contents. If omitted, no description will be rendered.
*
* @throws {Error}
- * Throws an exeption if a tab with the same `name` already exists.
+ * Throws an exception if a tab with the same `name` already exists.
*/
tab: function(name, title, description) {
if (this.tabs && this.tabs[name])
* @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
+ * from calling `new`. It must also be a class derived from
* [LuCI.form.AbstractSection]{@link LuCI.form.AbstractSection}.
*
* @param {...*} classargs
- * Additional arguments which are passed as-is to the contructor of the
+ * Additional arguments which are passed as-is to the constructor of the
* given option class. Refer to the class specific constructor
* documentation for details.
*
/**
* Add a configuration option widget to a tab of the section.
*
- * @param {string} tabname
+ * @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
+ * from calling `new`. It must also be a class derived from
* [LuCI.form.AbstractSection]{@link LuCI.form.AbstractSection}.
*
* @param {...*} classargs
- * Additional arguments which are passed as-is to the contructor of the
+ * Additional arguments which are passed as-is to the constructor of the
* given option class. Refer to the class specific constructor
* documentation for details.
*
this.default = null;
this.size = null;
this.optional = false;
+ this.retain = false;
},
/**
* @default false
*/
+ /**
+ * If set to `true`, the underlying ui input widget value is not cleared
+ * from the configuration on unsatisfied dependencies. The default behavior
+ * is to remove the values of all options whose dependencies are not
+ * fulfilled.
+ *
+ * @name LuCI.form.AbstractValue.prototype#retain
+ * @type boolean
+ * @default false
+ */
+
/**
* Sets a default value to use when the underlying UCI option is not set.
*
*/
/**
- * Add a dependency contraint to the option.
+ * Add a dependency constraint to the option.
*
* Dependency constraints allow making the presence of option elements
- * dependant on the current values of certain other options within the
+ * dependent 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.
*
* 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
+ * option elements outside 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.
* </li>
* </ul>
*
- * @param {string|Object<string, string|RegExp>} optionname_or_depends
+ * @param {string|Object<string, string|RegExp>} field
* The name of the option to depend on or an object describing multiple
- * dependencies which must be satified (a logical "and" expression).
+ * dependencies which must be satisfied (a logical "and" expression).
*
- * @param {string} optionvalue|RegExp
+ * @param {string|RegExp} value
* 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.
return elem ? elem.isValid() : true;
},
+ /**
+ * Returns the current validation error for this input.
+ *
+ * @param {string} section_id
+ * The configuration section ID
+ *
+ * @returns {string}
+ * The validation error at this time
+ */
+ getValidationError: function (section_id) {
+ var elem = this.getUIElement(section_id);
+ return elem ? elem.getValidationError() : '';
+ },
+
/**
* Test whether the option element is currently active.
*
* validation constraints.
*/
parse: function(section_id) {
- var active = this.isActive(section_id),
- cval = this.cfgvalue(section_id),
- fval = active ? this.formvalue(section_id) : null;
+ var active = this.isActive(section_id);
if (active && !this.isValid(section_id)) {
- var title = this.stripTags(this.title).trim();
- return Promise.reject(new TypeError(_('Option "%s" contains an invalid input value.').format(title || this.option)));
- }
+ var title = this.stripTags(this.title).trim(),
+ error = this.getValidationError(section_id);
- if (fval != '' && fval != null) {
- if (this.forcewrite || !isEqual(cval, fval))
- return Promise.resolve(this.write(section_id, fval));
+ return Promise.reject(new TypeError(
+ _('Option "%s" contains an invalid input value.').format(title || this.option) + ' ' + error));
}
- else {
- if (!active || this.rmempty || this.optional) {
- return Promise.resolve(this.remove(section_id));
+
+ if (active) {
+ var cval = this.cfgvalue(section_id),
+ fval = this.formvalue(section_id);
+
+ if (fval == null || fval == '') {
+ if (this.rmempty || this.optional) {
+ return Promise.resolve(this.remove(section_id));
+ }
+ else {
+ var title = this.stripTags(this.title).trim();
+
+ return Promise.reject(new TypeError(
+ _('Option "%s" must not be empty.').format(title || this.option)));
+ }
}
- else if (!isEqual(cval, fval)) {
- var title = this.stripTags(this.title).trim();
- return Promise.reject(new TypeError(_('Option "%s" must not be empty.').format(title || this.option)));
+ else if (this.forcewrite || !isEqual(cval, fval)) {
+ return Promise.resolve(this.write(section_id, fval));
}
}
+ else if (!this.retain) {
+ return Promise.resolve(this.remove(section_id));
+ }
return Promise.resolve();
},
dom.append(createEl, [
E('div', {}, nameEl),
- E('input', {
+ E('button', {
'class': 'cbi-button cbi-button-add',
- 'type': 'submit',
- 'value': btn_title || _('Add'),
'title': btn_title || _('Add'),
'click': ui.createHandlerFn(this, function(ev) {
if (nameEl.classList.contains('cbi-input-invalid'))
return this.handleAdd(ev, nameEl.value);
}),
- 'disabled': this.map.readonly || null
- })
+ 'disabled': this.map.readonly || true
+ }, [ btn_title || _('Add') ])
]);
- ui.addValidator(nameEl, 'uciname', true, 'blur', 'keyup');
+ if (this.map.readonly !== true) {
+ ui.addValidator(nameEl, 'uciname', true, function(v) {
+ var button = createEl.querySelector('.cbi-section-create > .cbi-button-add');
+ if (v !== '') {
+ button.disabled = null;
+ return true;
+ }
+ else {
+ button.disabled = true;
+ return _('Expecting: %s').format(_('non-empty value'));
+ }
+ }, 'blur', 'keyup');
+ }
}
return createEl;
/** @private */
renderSectionPlaceholder: function() {
- return E([
- E('em', _('This section contains no values yet')),
- E('br'), E('br')
- ]);
+ return E('em', _('This section contains no values yet'));
},
/** @private */
config_name = this.uciconfig || this.map.config,
max_cols = isNaN(this.max_cols) ? this.children.length : this.max_cols,
has_more = max_cols < this.children.length,
+ drag_sort = this.sortable && !('ontouchstart' in window),
+ touch_sort = this.sortable && ('ontouchstart' in window),
sectionEl = E('div', {
'id': 'cbi-%s-%s'.format(config_name, this.sectiontype),
'class': 'cbi-section cbi-tblsection',
'id': 'cbi-%s-%s'.format(config_name, cfgsections[i]),
'class': 'tr cbi-section-table-row',
'data-sid': cfgsections[i],
- 'draggable': this.sortable ? true : null,
- 'mousedown': this.sortable ? L.bind(this.handleDragInit, this) : null,
- 'dragstart': this.sortable ? L.bind(this.handleDragStart, this) : null,
- 'dragover': this.sortable ? L.bind(this.handleDragOver, this) : null,
- 'dragenter': this.sortable ? L.bind(this.handleDragEnter, this) : null,
- 'dragleave': this.sortable ? L.bind(this.handleDragLeave, this) : null,
- 'dragend': this.sortable ? L.bind(this.handleDragEnd, this) : null,
- 'drop': this.sortable ? L.bind(this.handleDrop, this) : null,
+ 'draggable': (drag_sort || touch_sort) ? true : null,
+ 'mousedown': drag_sort ? L.bind(this.handleDragInit, this) : null,
+ 'dragstart': drag_sort ? L.bind(this.handleDragStart, this) : null,
+ 'dragover': drag_sort ? L.bind(this.handleDragOver, this) : null,
+ 'dragenter': drag_sort ? L.bind(this.handleDragEnter, this) : null,
+ 'dragleave': drag_sort ? L.bind(this.handleDragLeave, this) : null,
+ 'dragend': drag_sort ? L.bind(this.handleDragEnd, this) : null,
+ 'drop': drag_sort ? L.bind(this.handleDrop, this) : null,
+ 'touchmove': touch_sort ? L.bind(this.handleTouchMove, this) : null,
+ 'touchend': touch_sort ? L.bind(this.handleTouchEnd, this) : null,
'data-title': (sectionname && (!this.anonymous || this.sectiontitle)) ? sectionname : null,
'data-section-id': cfgsections[i]
});
if (nodes.length == 0)
tableEl.appendChild(E('tr', { 'class': 'tr cbi-section-table-row placeholder' },
- E('td', { 'class': 'td' },
- E('em', {}, _('This section contains no values yet')))));
+ E('td', { 'class': 'td' }, this.renderSectionPlaceholder())));
sectionEl.appendChild(tableEl);
if (has_titles) {
var trEl = E('tr', {
'class': 'tr cbi-section-table-titles ' + anon_class,
- 'data-title': (!this.anonymous || this.sectiontitle) ? _('Name') : null
+ 'data-title': (!this.anonymous || this.sectiontitle) ? _('Name') : null,
+ 'click': this.sortable ? ui.createHandlerFn(this, 'handleSort') : null
});
for (var i = 0, opt; i < max_cols && (opt = this.children[i]) != null; i++) {
trEl.appendChild(E('th', {
'class': 'th cbi-section-table-cell',
- 'data-widget': opt.__name__
+ 'data-widget': opt.__name__,
+ 'data-sortable-row': this.sortable ? '' : null
}));
if (opt.width != null)
if (this.sortable) {
dom.append(tdEl.lastElementChild, [
- E('div', {
+ E('button', {
'title': _('Drag to reorder'),
- 'class': 'btn cbi-button drag-handle center',
+ 'class': 'cbi-button drag-handle center',
'style': 'cursor:move',
'disabled': this.map.readonly || null
}, 'โฐ')
return false;
},
+ /** @private */
+ determineBackgroundColor: function(node) {
+ var r = 255, g = 255, b = 255;
+
+ while (node) {
+ var s = window.getComputedStyle(node),
+ c = (s.getPropertyValue('background-color') || '').replace(/ /g, '');
+
+ if (c != '' && c != 'transparent' && c != 'rgba(0,0,0,0)') {
+ if (/^#([a-f0-9]{2})([a-f0-9]{2})([a-f0-9]{2})$/i.test(c)) {
+ r = parseInt(RegExp.$1, 16);
+ g = parseInt(RegExp.$2, 16);
+ b = parseInt(RegExp.$3, 16);
+ }
+ else if (/^rgba?\(([0-9]+),([0-9]+),([0-9]+)[,)]$/.test(c)) {
+ r = +RegExp.$1;
+ g = +RegExp.$2;
+ b = +RegExp.$3;
+ }
+
+ break;
+ }
+
+ node = node.parentNode;
+ }
+
+ return [ r, g, b ];
+ },
+
+ /** @private */
+ handleTouchMove: function(ev) {
+ if (!ev.target.classList.contains('drag-handle'))
+ return;
+
+ var touchLoc = ev.targetTouches[0],
+ rowBtn = ev.target,
+ rowElem = dom.parent(rowBtn, '.tr'),
+ htmlElem = document.querySelector('html'),
+ dragHandle = document.querySelector('.touchsort-element'),
+ viewportHeight = Math.max(document.documentElement.clientHeight, window.innerHeight || 0);
+
+ if (!dragHandle) {
+ var rowRect = rowElem.getBoundingClientRect(),
+ btnRect = rowBtn.getBoundingClientRect(),
+ paddingLeft = btnRect.left - rowRect.left,
+ paddingRight = rowRect.right - btnRect.right,
+ colorBg = this.determineBackgroundColor(rowElem),
+ colorFg = (colorBg[0] * 0.299 + colorBg[1] * 0.587 + colorBg[2] * 0.114) > 186 ? [ 0, 0, 0 ] : [ 255, 255, 255 ];
+
+ dragHandle = E('div', { 'class': 'touchsort-element' }, [
+ E('strong', [ rowElem.getAttribute('data-title') ]),
+ rowBtn.cloneNode(true)
+ ]);
+
+ Object.assign(dragHandle.style, {
+ position: 'absolute',
+ boxShadow: '0 0 3px rgba(%d, %d, %d, 1)'.format(colorFg[0], colorFg[1], colorFg[2]),
+ background: 'rgba(%d, %d, %d, 0.8)'.format(colorBg[0], colorBg[1], colorBg[2]),
+ top: rowRect.top + 'px',
+ left: rowRect.left + 'px',
+ width: rowRect.width + 'px',
+ height: (rowBtn.offsetHeight + 4) + 'px'
+ });
+
+ Object.assign(dragHandle.firstElementChild.style, {
+ position: 'absolute',
+ lineHeight: dragHandle.style.height,
+ whiteSpace: 'nowrap',
+ overflow: 'hidden',
+ textOverflow: 'ellipsis',
+ left: (paddingRight > paddingLeft) ? '' : '5px',
+ right: (paddingRight > paddingLeft) ? '5px' : '',
+ width: (Math.max(paddingLeft, paddingRight) - 10) + 'px'
+ });
+
+ Object.assign(dragHandle.lastElementChild.style, {
+ position: 'absolute',
+ top: '2px',
+ left: paddingLeft + 'px',
+ width: rowBtn.offsetWidth + 'px'
+ });
+
+ document.body.appendChild(dragHandle);
+
+ rowElem.classList.remove('flash');
+ rowBtn.blur();
+ }
+
+ dragHandle.style.top = (touchLoc.pageY - (parseInt(dragHandle.style.height) / 2)) + 'px';
+
+ rowElem.parentNode.querySelectorAll('[draggable]').forEach(function(tr, i, trs) {
+ var trRect = tr.getBoundingClientRect(),
+ yTop = trRect.top + window.scrollY,
+ yBottom = trRect.bottom + window.scrollY,
+ yMiddle = yTop + ((yBottom - yTop) / 2);
+
+ tr.classList.remove('drag-over-above', 'drag-over-below');
+
+ if ((i == 0 || touchLoc.pageY >= yTop) && touchLoc.pageY <= yMiddle)
+ tr.classList.add('drag-over-above');
+ else if ((i == (trs.length - 1) || touchLoc.pageY <= yBottom) && touchLoc.pageY > yMiddle)
+ tr.classList.add('drag-over-below');
+ });
+
+ /* prevent standard scrolling and scroll page when drag handle is
+ * moved very close (~30px) to the viewport edge */
+
+ ev.preventDefault();
+
+ if (touchLoc.clientY < 30)
+ window.requestAnimationFrame(function() { htmlElem.scrollTop -= 30 });
+ else if (touchLoc.clientY > viewportHeight - 30)
+ window.requestAnimationFrame(function() { htmlElem.scrollTop += 30 });
+ },
+
+ /** @private */
+ handleTouchEnd: function(ev) {
+ var rowElem = dom.parent(ev.target, '.tr'),
+ htmlElem = document.querySelector('html'),
+ dragHandle = document.querySelector('.touchsort-element'),
+ targetElem = rowElem.parentNode.querySelector('.drag-over-above, .drag-over-below'),
+ viewportHeight = Math.max(document.documentElement.clientHeight, window.innerHeight || 0);
+
+ if (!dragHandle)
+ return;
+
+ if (targetElem) {
+ var isBelow = targetElem.classList.contains('drag-over-below');
+
+ rowElem.parentNode.insertBefore(rowElem, isBelow ? targetElem.nextElementSibling : targetElem);
+
+ this.map.data.move(
+ this.uciconfig || this.map.config,
+ rowElem.getAttribute('data-sid'),
+ targetElem.getAttribute('data-sid'),
+ isBelow);
+
+ window.requestAnimationFrame(function() {
+ var rowRect = rowElem.getBoundingClientRect();
+
+ if (rowRect.top < 50)
+ htmlElem.scrollTop = (htmlElem.scrollTop + rowRect.top - 50);
+ else if (rowRect.bottom > viewportHeight - 50)
+ htmlElem.scrollTop = (htmlElem.scrollTop + viewportHeight - 50 - rowRect.height);
+
+ rowElem.classList.add('flash');
+ });
+
+ targetElem.classList.remove('drag-over-above', 'drag-over-below');
+ }
+
+ document.body.removeChild(dragHandle);
+ },
+
/** @private */
handleModalCancel: function(modalMap, ev) {
- return Promise.resolve(ui.hideModal());
+ var prevNode = this.getPreviousModalMap(),
+ resetTasks = Promise.resolve();
+
+ if (prevNode) {
+ var heading = prevNode.parentNode.querySelector('h4'),
+ prevMap = dom.findClassInstance(prevNode);
+
+ while (prevMap) {
+ resetTasks = resetTasks
+ .then(L.bind(prevMap.load, prevMap))
+ .then(L.bind(prevMap.reset, prevMap));
+
+ prevMap = prevMap.parent;
+ }
+
+ prevNode.classList.add('flash');
+ prevNode.classList.remove('hidden');
+ prevNode.parentNode.removeChild(prevNode.nextElementSibling);
+
+ heading.removeChild(heading.lastElementChild);
+
+ if (!this.getPreviousModalMap())
+ prevNode.parentNode
+ .querySelector('div.right > button')
+ .firstChild.data = _('Dismiss');
+ }
+ else {
+ ui.hideModal();
+ }
+
+ return resetTasks;
},
/** @private */
handleModalSave: function(modalMap, ev) {
- return modalMap.save(null, true)
- .then(L.bind(this.map.load, this.map))
- .then(L.bind(this.map.reset, this.map))
- .then(ui.hideModal)
+ var mapNode = this.getActiveModalMap(),
+ activeMap = dom.findClassInstance(mapNode),
+ saveTasks = activeMap.save(null, true);
+
+ while (activeMap.parent) {
+ activeMap = activeMap.parent;
+ saveTasks = saveTasks
+ .then(L.bind(activeMap.load, activeMap))
+ .then(L.bind(activeMap.reset, activeMap));
+ }
+
+ return saveTasks
+ .then(L.bind(this.handleModalCancel, this, modalMap, ev, true))
.catch(function() {});
},
+ /** @private */
+ handleSort: function(ev) {
+ if (!ev.target.matches('th[data-sortable-row]'))
+ return;
+
+ var th = ev.target,
+ descending = (th.getAttribute('data-sort-direction') == 'desc'),
+ config_name = this.uciconfig || this.map.config,
+ index = 0,
+ list = [];
+
+ ev.currentTarget.querySelectorAll('th').forEach(function(other_th, i) {
+ if (other_th !== th)
+ other_th.removeAttribute('data-sort-direction');
+ else
+ index = i;
+ });
+
+ ev.currentTarget.parentNode.querySelectorAll('tr.cbi-section-table-row').forEach(L.bind(function(tr, i) {
+ var sid = tr.getAttribute('data-sid'),
+ opt = tr.childNodes[index].getAttribute('data-name'),
+ val = this.cfgvalue(sid, opt);
+
+ tr.querySelectorAll('.flash').forEach(function(n) {
+ n.classList.remove('flash')
+ });
+
+ list.push([
+ ui.Table.prototype.deriveSortKey((val != null) ? val.trim() : ''),
+ tr
+ ]);
+ }, this));
+
+ list.sort(function(a, b) {
+ return descending
+ ? -L.naturalCompare(a[0], b[0])
+ : L.naturalCompare(a[0], b[0]);
+ });
+
+ window.requestAnimationFrame(L.bind(function() {
+ var ref_sid, cur_sid;
+
+ for (var i = 0; i < list.length; i++) {
+ list[i][1].childNodes[index].classList.add('flash');
+ th.parentNode.parentNode.appendChild(list[i][1]);
+
+ cur_sid = list[i][1].getAttribute('data-sid');
+
+ if (ref_sid)
+ this.map.data.move(config_name, cur_sid, ref_sid, true);
+
+ ref_sid = cur_sid;
+ }
+
+ th.setAttribute('data-sort-direction', descending ? 'asc' : 'desc');
+ }, this));
+ },
+
/**
* Add further options to the per-section instanced modal popup.
*
* @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
+ * custom logic to perform asynchronous 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,
- name = null,
- m = new CBIMap(this.map.config, null, null),
- s = m.section(CBINamedSection, section_id, this.sectiontype);
-
- m.parent = parent;
- m.readonly = parent.readonly;
+ getActiveModalMap: function() {
+ return document.querySelector('body.modal-overlay-active > #modal_overlay > .modal.cbi-modal > .cbi-map:not(.hidden)');
+ },
- s.tabs = this.tabs;
- s.tab_names = this.tab_names;
+ /** @private */
+ getPreviousModalMap: function() {
+ var mapNode = this.getActiveModalMap(),
+ prevNode = mapNode ? mapNode.previousElementSibling : null;
- if ((name = this.titleFn('modaltitle', section_id)) != null)
- title = name;
- else if ((name = this.titleFn('sectiontitle', section_id)) != null)
- title = '%s - %s'.format(parent.title, name);
- else if (!this.anonymous)
- title = '%s - %s'.format(parent.title, section_id);
+ return (prevNode && prevNode.matches('.cbi-map.hidden')) ? prevNode : null;
+ },
- for (var i = 0; i < this.children.length; i++) {
- var o1 = this.children[i];
+ /** @private */
+ cloneOptions: function(src_section, dest_section) {
+ for (var i = 0; i < src_section.children.length; i++) {
+ var o1 = src_section.children[i];
- if (o1.modalonly === false)
+ if (o1.modalonly === false && src_section === this)
continue;
- var o2 = s.option(o1.constructor, o1.option, o1.title, o1.description);
+ var o2;
+
+ if (o1.subsection) {
+ o2 = dest_section.option(o1.constructor, o1.option, o1.subsection.constructor, o1.subsection.sectiontype, o1.subsection.title, o1.subsection.description);
+
+ for (var k in o1.subsection) {
+ if (!o1.subsection.hasOwnProperty(k))
+ continue;
+
+ switch (k) {
+ case 'map':
+ case 'children':
+ case 'parentoption':
+ continue;
+
+ default:
+ o2.subsection[k] = o1.subsection[k];
+ }
+ }
+
+ this.cloneOptions(o1.subsection, o2.subsection);
+ }
+ else {
+ o2 = dest_section.option(o1.constructor, o1.option, o1.title, o1.description);
+ }
for (var k in o1) {
if (!o1.hasOwnProperty(k))
case 'option':
case 'title':
case 'description':
+ case 'subsection':
continue;
default:
}
}
}
+ },
+
+ /** @private */
+ renderMoreOptionsModal: function(section_id, ev) {
+ var parent = this.map,
+ sref = parent.data.get(parent.config, section_id),
+ mapNode = this.getActiveModalMap(),
+ activeMap = mapNode ? dom.findClassInstance(mapNode) : null,
+ stackedMap = activeMap && (activeMap.parent !== parent || activeMap.section !== section_id);
+
+ return (stackedMap ? activeMap.save(null, true) : Promise.resolve()).then(L.bind(function() {
+ section_id = sref['.name'];
+
+ var m;
+
+ if (parent instanceof CBIJSONMap) {
+ m = new CBIJSONMap(null, null, null);
+ m.data = parent.data;
+ }
+ else {
+ m = new CBIMap(parent.config, null, null);
+ }
+
+ var s = m.section(CBINamedSection, section_id, this.sectiontype);
+
+ m.parent = parent;
+ m.section = section_id;
+ m.readonly = parent.readonly;
+
+ s.tabs = this.tabs;
+ s.tab_names = this.tab_names;
+
+ this.cloneOptions(this, s);
+
+ return Promise.resolve(this.addModalOptions(s, section_id, ev)).then(function() {
+ return m.render();
+ }).then(L.bind(function(nodes) {
+ var title = parent.title,
+ name = null;
+
+ if ((name = this.titleFn('modaltitle', section_id)) != null)
+ title = name;
+ else if ((name = this.titleFn('sectiontitle', section_id)) != null)
+ title = '%s - %s'.format(parent.title, name);
+ else if (!this.anonymous)
+ title = '%s - %s'.format(parent.title, section_id);
+
+ if (stackedMap) {
+ mapNode.parentNode
+ .querySelector('h4')
+ .appendChild(E('span', title ? ' ยป ' + title : ''));
- return Promise.resolve(this.addModalOptions(s, section_id, ev)).then(L.bind(m.render, m)).then(L.bind(function(nodes) {
- ui.showModal(title, [
- nodes,
- E('div', { 'class': 'right' }, [
- E('button', {
- 'class': 'btn',
- 'click': ui.createHandlerFn(this, 'handleModalCancel', m)
- }, [ _('Dismiss') ]), ' ',
- E('button', {
- 'class': 'cbi-button cbi-button-positive important',
- 'click': ui.createHandlerFn(this, 'handleModalSave', m),
- 'disabled': m.readonly || null
- }, [ _('Save') ])
- ])
- ], 'cbi-modal');
+ mapNode.parentNode
+ .querySelector('div.right > button')
+ .firstChild.data = _('Back');
+
+ mapNode.classList.add('hidden');
+ mapNode.parentNode.insertBefore(nodes, mapNode.nextElementSibling);
+
+ nodes.classList.add('flash');
+ }
+ else {
+ ui.showModal(title, [
+ nodes,
+ E('div', { 'class': 'right' }, [
+ E('button', {
+ 'class': 'cbi-button',
+ 'click': ui.createHandlerFn(this, 'handleModalCancel', m)
+ }, [ _('Dismiss') ]), ' ',
+ E('button', {
+ 'class': 'cbi-button cbi-button-positive important',
+ 'click': ui.createHandlerFn(this, 'handleModalSave', m),
+ 'disabled': m.readonly || null
+ }, [ _('Save') ])
+ ])
+ ], 'cbi-modal');
+ }
+ }, this));
}, this)).catch(L.error);
}
});
*
* 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`
+ * option element is explicitly made writable by setting the `editable`
* property to `true`.
*
* Additionally, the grid section honours a `modalonly` property of child
* contents. If omitted, no description will be rendered.
*
* @throws {Error}
- * Throws an exeption if a tab with the same `name` already exists.
+ * Throws an exception 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);
+ section_id = this.map.data.add(config_name, this.sectiontype, name),
+ mapNode = this.getPreviousModalMap(),
+ prevMap = mapNode ? dom.findClassInstance(mapNode) : this.map;
+
+ prevMap.addedSection = section_id;
- this.addedSection = section_id;
return this.renderMoreOptionsModal(section_id);
},
/** @private */
handleModalSave: function(/* ... */) {
- return this.super('handleModalSave', arguments)
- .then(L.bind(function() { this.addedSection = null }, this));
+ var mapNode = this.getPreviousModalMap(),
+ prevMap = mapNode ? dom.findClassInstance(mapNode) : this.map;
+
+ return this.super('handleModalSave', arguments);
},
/** @private */
- handleModalCancel: function(/* ... */) {
- var config_name = this.uciconfig || this.map.config;
+ handleModalCancel: function(modalMap, ev, isSaving) {
+ var config_name = this.uciconfig || this.map.config,
+ mapNode = this.getPreviousModalMap(),
+ prevMap = mapNode ? dom.findClassInstance(mapNode) : this.map;
- if (this.addedSection != null) {
- this.map.data.remove(config_name, this.addedSection);
- this.addedSection = null;
- }
+ if (prevMap.addedSection != null && !isSaving)
+ this.map.data.remove(config_name, prevMap.addedSection);
+
+ delete prevMap.addedSection;
return this.super('handleModalCancel', arguments);
},
'data-title': (title != '') ? title : null,
'data-description': (descr != '') ? descr : null,
'data-name': opt.option,
- 'data-widget': opt.typename || opt.__name__
+ 'data-widget': 'CBI.DummyValue'
}, (value != null) ? value : E('em', _('none')));
},
* @param {string} key
* The choice value to add.
*
- * @param {Node|string} value
+ * @param {Node|string} val
* 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.
*/
if (!in_table && typeof(this.description) === 'string' && this.description !== '')
dom.append(optionEl.lastChild || optionEl,
- E('div', { 'class': 'cbi-value-description' }, this.description));
+ E('div', { 'class': 'cbi-value-description' }, this.description.trim()));
if (depend_list && depend_list.length)
optionEl.classList.add('hidden');
* @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.
+ * allowing the user 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
* Sets the input value to use for the checkbox checked state.
*
* @name LuCI.form.FlagValue.prototype#enabled
- * @type number
+ * @type string
* @default 1
*/
* Sets the input value to use for the checkbox unchecked state.
*
* @name LuCI.form.FlagValue.prototype#disabled
- * @type number
+ * @type string
* @default 0
*/
if (!this.isValid(section_id)) {
var title = this.stripTags(this.title).trim();
- return Promise.reject(new TypeError(_('Option "%s" contains an invalid input value.').format(title || this.option)));
+ var error = this.getValidationError(section_id);
+ return Promise.reject(new TypeError(
+ _('Option "%s" contains an invalid input value.').format(title || this.option) + ' ' + error));
}
if (fval == this.default && (this.optional || this.rmempty))
else
return Promise.resolve(this.write(section_id, fval));
}
- else {
+ else if (!this.retain) {
return Promise.resolve(this.remove(section_id));
}
},
__name__: 'CBI.DummyValue',
/**
- * Set an URL which is opened when clicking on the dummy value text.
+ * Set a 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.
* @default null
*/
+ /**
+ * Render the UCI option value as hidden using the HTML display: none style property.
+ *
+ * By default, the value is displayed
+ *
+ * @name LuCI.form.DummyValue.prototype#hidden
+ * @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) }),
- outputEl = E('div');
+ outputEl = E('div', { 'style': this.hidden ? 'display:none' : null });
if (this.href && !((this.readonly != null) ? this.readonly : this.map.readonly))
outputEl.appendChild(E('a', { 'href': this.href }));
* @hideconstructor
* @classdesc
*
- * The LuCI form class provides high level abstractions for creating creating
+ * The LuCI form class provides high level abstractions for creating
* UCI- or JSON backed configurations forms.
*
* To import the class in views, use `'require form'`, to import it in