Skip to content

Commit

Permalink
luci-base: introduce new LuCI.ui.Table class
Browse files Browse the repository at this point in the history
Introduce a new `LuCI.ui.Table` class to simplify generating HTML tables
for data output and turn the existing `cbi_update_table()` procedure into
a wrapper around this new facility.

Tables generated by the new class closely resemble the old markup but
provide additional sorting capabilities.

Signed-off-by: Jo-Philipp Wich <jo@mein.io>
  • Loading branch information
jow- committed Feb 24, 2022
1 parent 5be8c4b commit e0e6989
Show file tree
Hide file tree
Showing 2 changed files with 301 additions and 64 deletions.
70 changes: 6 additions & 64 deletions modules/luci-base/htdocs/luci-static/resources/cbi.js
Original file line number Diff line number Diff line change
Expand Up @@ -764,72 +764,14 @@ function cbi_update_table(table, data, placeholder) {
if (!isElem(target))
return;

target.querySelectorAll('tr.table-titles, .tr.table-titles, .cbi-section-table-titles').forEach(function(thead) {
var titles = [];
var t = L.dom.findClassInstance(target);

thead.querySelectorAll('th, .th').forEach(function(th) {
titles.push(th);
});

if (Array.isArray(data)) {
var n = 0, rows = target.querySelectorAll('tr, .tr'), trows = [];

data.forEach(function(row) {
var trow = E('tr', { 'class': 'tr' });

for (var i = 0; i < titles.length; i++) {
var text = (titles[i].innerText || '').trim();
var td = trow.appendChild(E('td', {
'class': titles[i].className,
'data-title': (text !== '') ? text : null
}, (row[i] != null) ? row[i] : ''));

td.classList.remove('th');
td.classList.add('td');
}

trow.classList.add('cbi-rowstyle-%d'.format((n++ % 2) ? 2 : 1));

trows[n] = trow;
});

for (var i = 1; i <= n; i++) {
if (rows[i])
target.replaceChild(trows[i], rows[i]);
else
target.appendChild(trows[i]);
}

while (rows[++n])
target.removeChild(rows[n]);

if (placeholder && target.firstElementChild === target.lastElementChild) {
var trow = target.appendChild(E('tr', { 'class': 'tr placeholder' }));
var td = trow.appendChild(E('td', { 'class': titles[0].className }, placeholder));

td.classList.remove('th');
td.classList.add('td');
}
}
else {
thead.parentNode.style.display = 'none';

thead.parentNode.querySelectorAll('tr, .tr, .cbi-section-table-row').forEach(function(trow) {
if (trow !== thead) {
var n = 0;
trow.querySelectorAll('th, td, .th, .td').forEach(function(td) {
if (n < titles.length) {
var text = (titles[n++].innerText || '').trim();
if (text !== '')
td.setAttribute('data-title', text);
}
});
}
});
if (!(t instanceof L.ui.Table)) {
t = new L.ui.Table(target);
L.dom.bindClassInstance(target, t);
}

thead.parentNode.style.display = '';
}
});
t.update(data, placeholder);
}

function showModal(title, children)
Expand Down
295 changes: 295 additions & 0 deletions modules/luci-base/htdocs/luci-static/resources/ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -3140,6 +3140,299 @@ var UIMenu = baseclass.singleton(/** @lends LuCI.ui.menu.prototype */ {
}
});

var UITable = baseclass.extend(/** @lends LuCI.ui.table.prototype */ {
__init__: function(captions, options, placeholder) {
if (!Array.isArray(captions)) {
this.initFromMarkup(captions);

return;
}

var id = options.id || 'table%08x'.format(Math.random() * 0xffffffff);

var table = E('table', { 'id': id, 'class': 'table' }, [
E('tr', { 'class': 'tr table-titles', 'click': UI.prototype.createHandlerFn(this, 'handleSort') })
]);

this.id = id;
this.node = table
this.options = options;

var sorting = this.getActiveSortState();

for (var i = 0; i < captions.length; i++) {
if (captions[i] == null)
continue;

var th = E('th', { 'class': 'th' }, [ captions[i] ]);

if (typeof(options.captionClasses) == 'object')
DOMTokenList.prototype.add.apply(th.classList, L.toArray(options.captionClasses[i]));

if (options.sortable !== false && (typeof(options.sortable) != 'object' || options.sortable[i] !== false)) {
th.setAttribute('data-sortable-row', true);

if (sorting && sorting[0] == i)
th.setAttribute('data-sort-direction', sorting[1] ? 'desc' : 'asc');
}

table.firstElementChild.appendChild(th);
}

if (placeholder) {
var trow = table.appendChild(E('tr', { 'class': 'tr placeholder' })),
td = trow.appendChild(E('td', { 'class': 'td' }, placeholder));

if (typeof(captionClasses) == 'object')
DOMTokenList.prototype.add.apply(td.classList, L.toArray(captionClasses[0]));
}

DOMTokenList.prototype.add.apply(table.classList, L.toArray(options.classes));
},

update: function(data, placeholder) {
var placeholder = placeholder || this.options.placeholder || _('No data', 'empty table placeholder'),
sorting = this.getActiveSortState();

if (!Array.isArray(data))
return;

if (sorting) {
var list = data.map(L.bind(function(row) {
return [ this.deriveSortKey(row[sorting[0]], sorting[0]), row ];
}, this));

list.sort(function(a, b) {
if (a[0] < b[0])
return sorting[1] ? 1 : -1;

if (a[0] > b[0])
return sorting[1] ? -1 : 1;

return 0;
});

data.length = 0;

list.forEach(function(item) {
data.push(item[1]);
});
}

this.data = data;
this.placeholder = placeholder;

var n = 0,
rows = this.node.querySelectorAll('tr'),
trows = [],
headings = [].slice.call(this.node.firstElementChild.querySelectorAll('th')),
captionClasses = this.options.captionClasses;

data.forEach(function(row) {
trows[n] = E('tr', { 'class': 'tr' });

for (var i = 0; i < headings.length; i++) {
var text = (headings[i].innerText || '').trim();
var td = trows[n].appendChild(E('td', {
'class': 'td',
'data-title': (text !== '') ? text : null
}, (row[i] != null) ? row[i] : ''));

if (typeof(captionClasses) == 'object')
DOMTokenList.prototype.add.apply(td.classList, L.toArray(captionClasses[i]));

if (!td.classList.contains('cbi-section-actions'))
headings[i].setAttribute('data-sortable-row', true);
}

trows[n].classList.add('cbi-rowstyle-%d'.format((n++ % 2) ? 2 : 1));
});

for (var i = 0; i < n; i++) {
if (rows[i+1])
this.node.replaceChild(trows[i], rows[i+1]);
else
this.node.appendChild(trows[i]);
}

while (rows[++n])
target.removeChild(rows[n]);

if (placeholder && this.node.firstElementChild === this.node.lastElementChild) {
var trow = this.node.appendChild(E('tr', { 'class': 'tr placeholder' })),
td = trow.appendChild(E('td', { 'class': 'td' }, placeholder));

if (typeof(captionClasses) == 'object')
DOMTokenList.prototype.add.apply(td.classList, L.toArray(captionClasses[0]));
}

return this.node;
},

render: function() {
return this.node;
},

/** @private */
initFromMarkup: function(node) {
if (!dom.elem(node))
node = document.querySelector(node);

if (!node)
throw 'Invalid table selector';

var options = {},
headrow = node.querySelector('tr, .tr');

if (!headrow)
return;

options.classes = [].slice.call(node.classList).filter(function(c) { return c != 'table' });
options.sortable = [];
options.captionClasses = [];

headrow.querySelectorAll('th, .th').forEach(function(th, i) {
options.sortable[i] = !th.classList.contains('cbi-section-actions');
options.captionClasses[i] = [].slice.call(th.classList).filter(function(c) { return c != 'th' });
});

headrow.addEventListener('click', UI.prototype.createHandlerFn(this, 'handleSort'));

this.id = node.id;
this.node = node;
this.options = options;
},

/** @private */
deriveSortKey: function(value, index) {
var opts = this.options || {},
hint, m;

if (opts.sortable == true || opts.sortable == null)
hint = 'auto';
else if (typeof( opts.sortable) == 'object')
hint = opts.sortable[index];

if (dom.elem(value))
value = value.innerText.trim();

switch (hint || 'auto') {
case true:
case 'auto':
m = /^([0-9a-fA-F:.]+)(?:\/([0-9a-fA-F:.]+))?$/.exec(value);

if (m) {
var addr, mask;

addr = validation.parseIPv6(m[1]);
mask = m[2] ? validation.parseIPv6(m[2]) : null;

if (addr && mask != null)
return '%04x%04x%04x%04x%04x%04x%04x%04x%04x%04x%04x%04x%04x%04x%04x%04x'.format(
addr[0], addr[1], addr[2], addr[3], addr[4], addr[5], addr[6], addr[7],
mask[0], mask[1], mask[2], mask[3], mask[4], mask[5], mask[6], mask[7]
);
else if (addr)
return '%04x%04x%04x%04x%04x%04x%04x%04x%02x'.format(
addr[0], addr[1], addr[2], addr[3], addr[4], addr[5], addr[6], addr[7],
m[2] ? +m[2] : 128
);

addr = validation.parseIPv4(m[1]);
mask = m[2] ? validation.parseIPv4(m[2]) : null;

if (addr && mask != null)
return '%03d%03d%03d%03d%03d%03d%03d%03d'.format(
addr[0], addr[1], addr[2], addr[3],
mask[0], mask[1], mask[2], mask[3]
);
else if (addr)
return '%03d%03d%03d%03d%02d'.format(
addr[0], addr[1], addr[2], addr[3],
m[2] ? +m[2] : 32
);
}

m = /^(?:(\d+)d )?(\d+)h (\d+)m (\d+)s$/.exec(value);

if (m)
return '%05d%02d%02d%02d'.format(+m[1], +m[2], +m[3], +m[4]);

m = /^(\d+)\b(\D*)$/.exec(value);

if (m)
return '%010d%s'.format(+m[1], m[2]);

return String(value);

case 'ignorecase':
return String(value).toLowerCase();

case 'numeric':
return +value;

default:
return String(value);
}
},

/** @private */
getActiveSortState: function() {
if (this.sortState)
return this.sortState;

var page = document.body.getAttribute('data-page'),
key = page + '.' + this.id,
state = session.getLocalData('tablesort');

if (L.isObject(state) && Array.isArray(state[key]))
return state[key];

return null;
},

/** @private */
setActiveSortState: function(index, descending) {
this.sortState = [ index, descending ];

if (!this.options.id)
return;

var page = document.body.getAttribute('data-page'),
key = page + '.' + this.id,
state = session.getLocalData('tablesort');

if (!L.isObject(state))
state = {};

state[key] = this.sortState;

session.setLocalData('tablesort', state);
},

/** @private */
handleSort: function(ev) {
if (!ev.target.matches('th[data-sortable-row]'))
return;

var th = ev.target,
direction = (th.getAttribute('data-sort-direction') == 'asc'),
index = 0;

this.node.firstElementChild.querySelectorAll('th').forEach(function(other_th, i) {
if (other_th !== th)
other_th.removeAttribute('data-sort-direction');
else
index = i;
});

th.setAttribute('data-sort-direction', direction ? 'desc' : 'asc');

this.setActiveSortState(index, direction);
this.update(this.data, this.placeholder);
}
});

/**
* @class ui
* @memberof LuCI
Expand Down Expand Up @@ -4519,6 +4812,8 @@ var UI = baseclass.extend(/** @lends LuCI.ui.prototype */ {

menu: UIMenu,

Table: UITable,

AbstractElement: UIElement,

/* Widgets */
Expand Down

0 comments on commit e0e6989

Please sign in to comment.