633 lines
22 KiB
JavaScript
633 lines
22 KiB
JavaScript
/// \brief Renders the specified \a value as text or a grey 'none' if the value is 'none' or empty.
|
|
function renderNoneInGrey(value, row, elementName, noneText)
|
|
{
|
|
const noValue = value === undefined || value === null || value === 'none' || value === 'None' ||
|
|
value === '' || value === 18446744073709552000;
|
|
const element = document.createElement((noValue || elementName === undefined) ? 'span' : elementName);
|
|
if (noValue) {
|
|
element.appendChild(document.createTextNode(noneText || 'none'));
|
|
element.style.color = 'grey';
|
|
element.dataset.isNone = true;
|
|
} else if (typeof value === 'boolean') {
|
|
element.appendChild(document.createTextNode(value ? 'yes' : 'no'));
|
|
} else {
|
|
element.appendChild(document.createTextNode(value));
|
|
}
|
|
return element;
|
|
}
|
|
|
|
/// \brief Renders a standard table cell.
|
|
/// \remarks This is the default renderer used by renderTableFromJsonArray() and renderTableFromJsonObject().
|
|
function renderStandardTableCell(data, allData, level)
|
|
{
|
|
const dataType = typeof data;
|
|
if (dataType !== 'object') {
|
|
return renderNoneInGrey(data);
|
|
}
|
|
if (!Array.isArray(data)) {
|
|
if (level !== undefined && level > 1) {
|
|
return renderNoneInGrey('', data, undefined, 'rendering stopped at this level')
|
|
}
|
|
return renderTableFromJsonObject({data: data, level: level !== undefined ? level + 1 : 1});
|
|
}
|
|
if (data.length === 0) {
|
|
return renderNoneInGrey('', data, undefined, 'empty array');
|
|
}
|
|
const ul = document.createElement('ul');
|
|
data.forEach(function(element) {
|
|
const li = document.createElement('li');
|
|
li.appendChild(renderStandardTableCell(element));
|
|
ul.appendChild(li);
|
|
});
|
|
return ul;
|
|
}
|
|
|
|
/// \brief Renders a custom list.
|
|
function renderCustomList(array, customRenderer, compareFunction)
|
|
{
|
|
if (!Array.isArray(array) || array.length < 1) {
|
|
return renderNoneInGrey();
|
|
}
|
|
if (compareFunction !== undefined) {
|
|
array.sort(compareFunction);
|
|
}
|
|
const ul = document.createElement('ul');
|
|
array.forEach(function(arrayElement) {
|
|
const li = document.createElement('li');
|
|
const renderedDomElements = customRenderer(arrayElement);
|
|
if (Array.isArray(renderedDomElements)) {
|
|
renderedDomElements.forEach(function(renderedDomElement) {
|
|
li.appendChild(renderedDomElement);
|
|
});
|
|
} else {
|
|
li.appendChild(renderedDomElements);
|
|
}
|
|
ul.appendChild(li);
|
|
});
|
|
return ul;
|
|
}
|
|
|
|
/// \brief Renders a list of links.
|
|
function renderLinkList(array, obj, handler)
|
|
{
|
|
return renderCustomList(array, function(arrayElement) {
|
|
return renderLink(array, obj, function() {
|
|
handler(arrayElement, array, obj);
|
|
});
|
|
});
|
|
}
|
|
|
|
/// \brief Returns a 'time ago' string used by the time stamp rendering functions.
|
|
function formatTimeAgoString(date)
|
|
{
|
|
const seconds = Math.floor((new Date() - date) / 1000);
|
|
let interval = Math.floor(seconds / 31536000);
|
|
if (interval > 1) {
|
|
return interval + ' years ago';
|
|
}
|
|
interval = Math.floor(seconds / 2592000);
|
|
if (interval > 1) {
|
|
return interval + ' months ago';
|
|
}
|
|
interval = Math.floor(seconds / 86400);
|
|
if (interval > 1) {
|
|
return interval + ' days ago';
|
|
}
|
|
interval = Math.floor(seconds / 3600);
|
|
if (interval > 1) {
|
|
return interval + ' hours ago';
|
|
}
|
|
interval = Math.floor(seconds / 60);
|
|
if (interval > 1) {
|
|
return interval + ' minutes ago';
|
|
}
|
|
return Math.floor(seconds) + ' seconds ago';
|
|
}
|
|
|
|
/// \brief Returns a Date object from the specified time stamp.
|
|
function dateFromTimeStamp(timeStamp)
|
|
{
|
|
return new Date(timeStamp + 'Z');
|
|
}
|
|
|
|
/// \brief Renders a short time stamp, e.g. "12 hours ago" with the exact date as tooltip.
|
|
function renderShortTimeStamp(timeStamp)
|
|
{
|
|
const date = dateFromTimeStamp(timeStamp);
|
|
if (date.getFullYear() === 1) {
|
|
return document.createTextNode('not yet');
|
|
}
|
|
const span = document.createElement('span');
|
|
span.appendChild(document.createTextNode(formatTimeAgoString(date)));
|
|
span.title = timeStamp;
|
|
return span;
|
|
}
|
|
|
|
/// \brief Renders a time stamp, e.g. "12 hours ago" with the exact date in brackets.
|
|
function renderTimeStamp(timeStamp)
|
|
{
|
|
const date = dateFromTimeStamp(timeStamp);
|
|
if (date.getFullYear() === 1) {
|
|
return document.createTextNode('not yet');
|
|
}
|
|
return document.createTextNode(formatTimeAgoString(date) + ' (' + timeStamp + ')');
|
|
}
|
|
|
|
/// \brief Renders a time delta from 2 time stamps.
|
|
function renderTimeSpan(startTimeStamp, endTimeStamp)
|
|
{
|
|
const startDate = dateFromTimeStamp(startTimeStamp);
|
|
if (startDate.getFullYear() === 1) {
|
|
return document.createTextNode('not started yet');
|
|
}
|
|
let endDate = dateFromTimeStamp(endTimeStamp);
|
|
if (endDate.getFullYear() === 1) {
|
|
endDate = Date.now();
|
|
}
|
|
const elapsedMilliseconds = endDate - startDate;
|
|
let text;
|
|
if (elapsedMilliseconds >= (1000 * 60 * 60)) {
|
|
text = Math.floor(elapsedMilliseconds / 1000 / 60 / 60) + ' h';
|
|
} else if (elapsedMilliseconds >= (1000 * 60)) {
|
|
text = Math.floor(elapsedMilliseconds / 1000 / 60) + ' min';
|
|
} else if (elapsedMilliseconds >= (1000)) {
|
|
text = Math.floor(elapsedMilliseconds / 1000) + ' s';
|
|
} else {
|
|
text = '< 1 s';
|
|
}
|
|
return document.createTextNode(text);
|
|
}
|
|
|
|
/// \brief Renders a link which will invoke the specified \a handler when clicked.
|
|
function renderLink(value, row, handler, tooltip, href, middleClickHref)
|
|
{
|
|
const linkElement = document.createElement('a');
|
|
const linkText = typeof value === 'object' ? value : document.createTextNode(value);
|
|
linkElement.appendChild(linkText);
|
|
linkElement.href = middleClickHref || href || '#';
|
|
if (tooltip !== undefined) {
|
|
linkElement.title = tooltip;
|
|
}
|
|
linkElement.onclick = function () {
|
|
handler(value, row);
|
|
return false;
|
|
};
|
|
linkElement.onmouseup = function (e) {
|
|
// treat middle-click as regular click
|
|
if (e.which !== 2) {
|
|
return true;
|
|
}
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (!middleClickHref) {
|
|
handler(value, row);
|
|
}
|
|
return false;
|
|
};
|
|
return linkElement;
|
|
}
|
|
|
|
/// \brief Renders the specified array as comma-separated string or 'none' if the array is empty.
|
|
function renderArrayAsCommaSeparatedString(value)
|
|
{
|
|
return renderNoneInGrey(!Array.isArray(value) || value.length <= 0 ? 'none' : value.join(', '));
|
|
}
|
|
|
|
/// \brief Renders the specified array as a possibly elided comma-separated string or 'none' if the array is empty.
|
|
function renderArrayElidedAsCommaSeparatedString(value)
|
|
{
|
|
if (!Array.isArray(value) || value.length <= 0) {
|
|
return renderNoneInGrey('none');
|
|
}
|
|
return renderTextPossiblyElidingTheEnd(value.join(', '));
|
|
}
|
|
|
|
/// \brief Renders the specified value, possibly eliding the end.
|
|
function renderTextPossiblyElidingTheEnd(value)
|
|
{
|
|
const limit = 50;
|
|
if (value.length < limit) {
|
|
return document.createTextNode(value);
|
|
}
|
|
const element = document.createElement('span');
|
|
const remainingText = document.createTextNode(value.substr(limit));
|
|
const elipses = document.createTextNode('…');
|
|
let expaned = false;
|
|
element.appendChild(document.createTextNode(value.substr(0, limit)));
|
|
element.appendChild(elipses);
|
|
element.onclick = function () {
|
|
element.removeChild(element.lastChild);
|
|
((expaned = !expaned)) ? element.appendChild(remainingText) : element.appendChild(elipses);
|
|
};
|
|
return element;
|
|
}
|
|
|
|
/// \brief Renders the specified \a sizeInByte using an appropriate unit.
|
|
function renderDataSize(sizeInByte, row, includeBytes)
|
|
{
|
|
if (typeof(sizeInByte) !== 'number') {
|
|
return renderNoneInGrey('none');
|
|
}
|
|
let res;
|
|
if (sizeInByte < 1024) {
|
|
res = sizeInByte << " bytes";
|
|
} else if (sizeInByte < 1048576) {
|
|
res = (sizeInByte / 1024.0) + " KiB";
|
|
} else if (sizeInByte < 1073741824) {
|
|
res = (sizeInByte / 1048576.0) + " MiB";
|
|
} else if (sizeInByte < 1099511627776) {
|
|
res = (sizeInByte / 1073741824.0) + " GiB";
|
|
} else {
|
|
res = (sizeInByte / 1099511627776.0) + " TiB";
|
|
}
|
|
if (includeBytes && sizeInByte > 1024) {
|
|
res += ' (' + sizeInByte + " byte)";
|
|
}
|
|
return document.createTextNode(res);
|
|
}
|
|
|
|
// \brief Accesses the property of the specified \a object denoted by \a accessor which is a string like 'foo.bar'.
|
|
// \returns Returns the propertie's value or undefined if it doesn't exist.
|
|
function accessProperty(object, accessor)
|
|
{
|
|
if (accessor === undefined) {
|
|
return;
|
|
}
|
|
const propertyNames = accessor.split(".");
|
|
for (let i = 0, count = propertyNames.length; i !== count; ++i) {
|
|
object = object[propertyNames[i]];
|
|
if (object === undefined || object === null) {
|
|
return;
|
|
}
|
|
}
|
|
return object;
|
|
}
|
|
|
|
/// \brief Renders a checkbox for selecting a table row.
|
|
function renderCheckBoxForTableRow(value, row, computeCheckBoxValue)
|
|
{
|
|
const checkbox = document.createElement('input');
|
|
checkbox.type = 'checkbox';
|
|
checkbox.checked = row.selected;
|
|
checkbox.value = computeCheckBoxValue(row);
|
|
checkbox.onchange = function () { row.selected = this.checked };
|
|
return checkbox;
|
|
}
|
|
|
|
/// \brief Returns a table for the specified JSON array.
|
|
function renderTableFromJsonArray(args)
|
|
{
|
|
// handle arguments
|
|
const rows = args.rows;
|
|
const columnHeaders = args.columnHeaders;
|
|
const columnAccessors = args.columnAccessors;
|
|
const columnSortAccessors = args.columnSortAccessors || [];
|
|
const defaultRenderer = args.defaultRenderer || renderStandardTableCell;
|
|
const customRenderer = args.customRenderer || {};
|
|
const rowHandler = args.rowHandler;
|
|
const maxPageButtons = args.maxPageButtons || 5;
|
|
|
|
const container = document.createElement("div");
|
|
|
|
// render note
|
|
let noteRenderer = customRenderer.note;
|
|
if (noteRenderer === undefined || typeof noteRenderer === 'string') {
|
|
const note = document.createElement("p");
|
|
note.appendChild(document.createTextNode(noteRenderer || "Showing " + rows.length + " results"));
|
|
container.appendChild(note);
|
|
} else {
|
|
const note = noteRenderer(rows);
|
|
if (note !== undefined) {
|
|
container.appendChild(note);
|
|
}
|
|
}
|
|
|
|
// add table
|
|
const rowsPerPage = args.rowsPerPage;
|
|
const table = document.createElement("table");
|
|
table.data = rows;
|
|
table.sortedData = rows;
|
|
table.className = "table-from-json table-from-json-array";
|
|
|
|
// define pagination stuff
|
|
if (rowsPerPage !== undefined && rowsPerPage > 0) {
|
|
table.hasPagination = true;
|
|
table.rowsPerPage = args.rowsPerPage;
|
|
table.currentPage = args.currentPage = 1;
|
|
table.pageCount = Math.ceil(rows.length / rowsPerPage);
|
|
}
|
|
table.pageInfo = function() {
|
|
if (!this.hasPagination) {
|
|
return {begin: 0, end: this.data.length}; // no pagination
|
|
}
|
|
if (this.currentPage === undefined || this.currentPage <= 0) {
|
|
return {begin: 0, end: 0}; // invalid page
|
|
}
|
|
return {
|
|
begin: Math.min((this.currentPage - 1) * this.rowsPerPage, this.data.length),
|
|
end: Math.min(this.currentPage * this.rowsPerPage, this.data.length),
|
|
};
|
|
};
|
|
table.forEachRowOnPage = function(callback) {
|
|
const pageInfo = this.pageInfo();
|
|
if (isNaN(pageInfo.begin) || isNaN(pageInfo.end)) {
|
|
return;
|
|
}
|
|
for (let i = pageInfo.begin, end = pageInfo.end; i != end; ++i) {
|
|
const row = this.data[i];
|
|
if (row === null || row === undefined) {
|
|
continue;
|
|
}
|
|
callback(row, i, pageInfo, this);
|
|
}
|
|
};
|
|
|
|
// render column header
|
|
const thead = document.createElement("thead");
|
|
const tr = document.createElement("tr");
|
|
columnHeaders.forEach(function (columnHeader) {
|
|
const th = document.createElement("th");
|
|
const columnIndex = tr.children.length;
|
|
th.columnAccessor = columnSortAccessors[columnIndex] || columnAccessors[columnIndex];
|
|
th.descending = true;
|
|
th.onclick = function () {
|
|
table.sort(this.columnAccessor, this.descending = !this.descending);
|
|
};
|
|
th.style.cursor = "pointer";
|
|
th.appendChild(document.createTextNode(columnHeader));
|
|
tr.appendChild(th);
|
|
});
|
|
thead.appendChild(tr);
|
|
table.appendChild(thead);
|
|
|
|
// add pagination
|
|
if (table.hasPagination) {
|
|
const td = document.createElement("td");
|
|
td.className = "pagination";
|
|
td.colSpan = columnAccessors.length;
|
|
const previousA = document.createElement("a");
|
|
previousA.appendChild(document.createTextNode("<"));
|
|
previousA.className = "prev";
|
|
previousA.onclick = function() {
|
|
const currentA = table.currentA;
|
|
if (currentA) {
|
|
const a = currentA.previousSibling;
|
|
if (a !== undefined && a !== previousA) {
|
|
a.onclick();
|
|
}
|
|
}
|
|
const pageNumInput = table.pageNumInput;
|
|
if (pageNumInput && table.currentPage > 1) {
|
|
pageNumInput.value = table.currentPage - 1;
|
|
pageNumInput.onchange();
|
|
}
|
|
};
|
|
const nextA = document.createElement("a");
|
|
nextA.appendChild(document.createTextNode(">"));
|
|
nextA.className = "next";
|
|
nextA.onclick = function() {
|
|
const currentA = table.currentA;
|
|
if (currentA) {
|
|
const a = currentA.nextSibling;
|
|
if (a !== undefined && a !== nextA) {
|
|
a.onclick();
|
|
}
|
|
}
|
|
const pageNumInput = table.pageNumInput;
|
|
if (pageNumInput && table.currentPage < table.pageCount) {
|
|
pageNumInput.value = table.currentPage + 1;
|
|
pageNumInput.onchange();
|
|
}
|
|
};
|
|
|
|
td.appendChild(previousA);
|
|
|
|
if (table.pageCount <= maxPageButtons) {
|
|
for (let pageNumber = 1; pageNumber <= table.pageCount; ++pageNumber) {
|
|
const a = document.createElement("a");
|
|
a.appendChild(document.createTextNode(pageNumber));
|
|
a.onclick = function () {
|
|
table.currentA.className = '';
|
|
table.currentA = this;
|
|
table.currentA.className = 'current';
|
|
table.currentPage = pageNumber;
|
|
table.rerender();
|
|
};
|
|
if (pageNumber === table.currentPage) {
|
|
table.currentA = a;
|
|
a.className = 'current';
|
|
}
|
|
td.appendChild(a);
|
|
}
|
|
} else {
|
|
const pageNumInput = document.createElement("input");
|
|
pageNumInput.type = "number";
|
|
pageNumInput.value = table.currentPage;
|
|
pageNumInput.min = 1;
|
|
pageNumInput.max = table.pageCount;
|
|
pageNumInput.onchange = function() {
|
|
const selectedPage = parseInt(this.value);
|
|
if (!isNaN(selectedPage) && selectedPage) {
|
|
table.currentPage = selectedPage;
|
|
table.rerender();
|
|
}
|
|
};
|
|
table.pageNumInput = pageNumInput;
|
|
td.appendChild(pageNumInput);
|
|
const totalSpan = document.createElement("span");
|
|
totalSpan.appendChild(document.createTextNode(" of " + table.pageCount));
|
|
td.appendChild(totalSpan);
|
|
}
|
|
|
|
td.appendChild(nextA);
|
|
|
|
const tr = document.createElement("tr");
|
|
const tfoot = document.createElement("tfoot");
|
|
tr.appendChild(td);
|
|
tfoot.appendChild(tr);
|
|
table.appendChild(tfoot);
|
|
}
|
|
|
|
// render table contents
|
|
const tbody = document.createElement("tbody");
|
|
const renderNewRow = function (row) {
|
|
const tr = document.createElement("tr");
|
|
columnAccessors.forEach(function (columnAccessor) {
|
|
const td = document.createElement("td");
|
|
const renderer = customRenderer[columnAccessor];
|
|
let data = accessProperty(row, columnAccessor);
|
|
if (data === undefined) {
|
|
data = "?";
|
|
}
|
|
const content = renderer ? renderer(data, row) : defaultRenderer(data, row);
|
|
td.appendChild(content);
|
|
tr.appendChild(td);
|
|
});
|
|
if (rowHandler) {
|
|
rowHandler(row, tr);
|
|
}
|
|
tbody.appendChild(tr);
|
|
};
|
|
table.forEachRowOnPage(renderNewRow);
|
|
table.appendChild(tbody);
|
|
|
|
// define function to re-render the table's contents
|
|
table.rerender = function() {
|
|
const sortedData = this.sortedData;
|
|
const trs = tbody.getElementsByTagName("tr");
|
|
const pageInfo = table.pageInfo();
|
|
let dataIndex = pageInfo.begin, dataEnd = pageInfo.end;
|
|
for (let tr = tbody.firstChild; tr; ++dataIndex) {
|
|
if (dataIndex >= dataEnd) {
|
|
const nextTr = tr.nextSibling;
|
|
tbody.removeChild(tr);
|
|
tr = nextTr;
|
|
continue;
|
|
}
|
|
const tds = tr.getElementsByTagName("td");
|
|
const row = sortedData[dataIndex];
|
|
for (let td = tr.firstChild, i = 0; td; td = td.nextSibling, ++i) {
|
|
const columnAccessor = columnAccessors[i];
|
|
while (td.firstChild) {
|
|
td.removeChild(td.firstChild);
|
|
}
|
|
const renderer = customRenderer[columnAccessor];
|
|
const data = accessProperty(row, columnAccessor);
|
|
const content = renderer !== undefined ? renderer(data, row) : defaultRenderer(data, row);
|
|
td.appendChild(content);
|
|
}
|
|
if (rowHandler) {
|
|
rowHandler(row, tr);
|
|
}
|
|
tr = tr.nextSibling;
|
|
}
|
|
for (; dataIndex < dataEnd; ++dataIndex) {
|
|
renderNewRow(sortedData[dataIndex]);
|
|
}
|
|
};
|
|
|
|
// define function to re-sort according to a specific column
|
|
table.sort = function(columnAccessor, descending) {
|
|
// sort the rows according to the column
|
|
table.sortedData = rows.sort(function (a, b) {
|
|
let aValue = accessProperty(a, columnAccessor);
|
|
let bValue = accessProperty(b, columnAccessor);
|
|
let aType = typeof aValue;
|
|
let bType = typeof bValue;
|
|
|
|
// handle undefined/null
|
|
if (aValue === undefined || aValue === null) {
|
|
return -1;
|
|
}
|
|
if (bValue === undefined || bValue === null) {
|
|
return 1;
|
|
}
|
|
|
|
// handle numbers
|
|
if (aType === "number" && bType === "number") {
|
|
if (aValue < bValue) {
|
|
return descending ? 1 : -1;
|
|
} else if (aValue > bValue) {
|
|
return descending ? -1 : 1;
|
|
} else {
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
// handle arrays (sort them by length)
|
|
if (aType === "array" && bType === "array") {
|
|
if (aValue.length < bValue.length) {
|
|
return descending ? 1 : -1;
|
|
} else if (aValue > bValue) {
|
|
return descending ? -1 : 1;
|
|
} else {
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
// handle non-strings
|
|
if (aType !== "string" || bType !== "string") {
|
|
aValue = aValue.toString();
|
|
bValue = bValue.toString();
|
|
aType = bType = "string";
|
|
}
|
|
|
|
// compare strings
|
|
return descending ? bValue.localeCompare(aValue) : aValue.localeCompare(bValue);
|
|
});
|
|
|
|
// re-render the table's contents
|
|
table.rerender();
|
|
};
|
|
|
|
// FIXME: implement filter
|
|
|
|
container.appendChild(table);
|
|
container.table = table;
|
|
return container;
|
|
}
|
|
|
|
/// \brief Returns a table for the specified JSON object.
|
|
function renderTableFromJsonObject(args)
|
|
{
|
|
// handle arguments
|
|
const data = args.data;
|
|
const relatedRow = args.relatedRow;
|
|
const displayLabels = args.displayLabels || [];
|
|
const fieldAccessors = args.fieldAccessors || Object.getOwnPropertyNames(data);
|
|
const level = args.level;
|
|
const defaultRenderer = args.defaultRenderer || renderStandardTableCell;
|
|
const customRenderer = args.customRenderer || {};
|
|
|
|
const container = document.createElement("div");
|
|
|
|
// render table
|
|
const table = document.createElement("table");
|
|
table.className = "table-from-json table-from-json-object";
|
|
|
|
// render table contents
|
|
const tbody = document.createElement("tbody");
|
|
fieldAccessors.forEach(function (fieldAccessor) {
|
|
const tr = document.createElement("tr");
|
|
const th = document.createElement("th");
|
|
const displayLabel = displayLabels[tbody.children.length] || fieldAccessor;
|
|
if (displayLabel !== undefined) {
|
|
th.appendChild(document.createTextNode(displayLabel));
|
|
}
|
|
tr.appendChild(th);
|
|
const td = document.createElement("td");
|
|
const renderer = customRenderer[fieldAccessor];
|
|
let fieldData = accessProperty(data, fieldAccessor);
|
|
if (fieldData === undefined) {
|
|
fieldData = "?";
|
|
}
|
|
const content = renderer ? renderer(fieldData, data, level, relatedRow) : defaultRenderer(fieldData, data, level, relatedRow);
|
|
if (Array.isArray(content)) {
|
|
content.forEach(function(contentElement) {
|
|
td.appendChild(contentElement);
|
|
});
|
|
} else {
|
|
td.appendChild(content);
|
|
}
|
|
tr.appendChild(td);
|
|
tbody.appendChild(tr);
|
|
});
|
|
table.appendChild(tbody);
|
|
|
|
container.appendChild(table);
|
|
return container;
|
|
}
|
|
|
|
/// \brief Returns a heading for each key and values via renderStandardTableCell().
|
|
function renderObjectWithHeadings(object, row, level)
|
|
{
|
|
const elements = [];
|
|
for (const [key, value] of Object.entries(object)) {
|
|
const heading = document.createElement('h4');
|
|
heading.className = 'compact-heading';
|
|
heading.appendChild(document.createTextNode(key));
|
|
elements.push(heading, renderStandardTableCell(value, object, level));
|
|
}
|
|
return elements;
|
|
}
|