/**
* @module helpers/jrh_grid
* @author jesse reichler <mouser@donationcoder.com>
* @copyright 6/21/19
* @description
* Collection of helper functions for creating general purpose html grids of data
*/
"use strict";
// our helper modules
const jrhMisc = require("./jrh_misc");
const jrhText = require("./jrh_text");
const jrequire = require("./jrequire");
/**
* Builds a grid table/list and form inputs for working with it
*
* @param {object} req - express request object
* @param {obj} listHelperData - data to put in the grid; from model_base_mongoose?
* @returns raw html string
*/
async function jrGridList(jrContext, listHelperData, csrfToken) {
//
let rethtml = "";
const flagShowDebugInfo = false;
const flagShowTopPager = false;
// destructure parts
const queryUrlData = listHelperData.queryUrlData;
const tableid = queryUrlData.tableId;
// form wrapper start
rethtml += `
<form id="jrGridList_${tableid}" action="#">
`;
// insert data object for the table
const gridListDataJson = JSON.stringify(queryUrlData);
// ATTN: 5/18/20 - IMPORTANT -- must use "var jrGridListData_" instead of "let jrGridListData_" or we get js error in browser
rethtml += `<script>
var jrGridListData_${tableid} = ${gridListDataJson};
</script>
`;
const pagerHtml = jrGridListPager(queryUrlData);
// link to ADD a new item
const addUrl = queryUrlData.baseUrl + "/add";
const addLabel = listHelperData.modelClass.getNiceName();
rethtml += `
<div class="float-right"><h4><a href="${addUrl}">Add ${addLabel}</a></h4></div>
`;
// add pager at top
if (flagShowTopPager) {
rethtml += pagerHtml;
}
// show result count
const resultCount = queryUrlData.resultCount;
rethtml += "<div><strong>Total items:" + resultCount.toString() + "</strong></div>";
// build table
rethtml += await jrGridListTable(jrContext, listHelperData, queryUrlData);
// show deleted/hidden options
rethtml += await jrGridListShowHiddenOptions(jrContext, listHelperData, queryUrlData);
// build "with all checked" input
rethtml += await jrGridListBulkActions(jrContext, listHelperData, queryUrlData, tableid);
// add pager at bottom
rethtml += pagerHtml;
// debug info
if (flagShowDebugInfo) {
rethtml += "<br/><hr/>";
const debugHtml = "<pre>listHelperData: " + JSON.stringify(listHelperData, null, " ") + "</pre>";
rethtml += debugHtml;
}
// add csrf
if (csrfToken) {
rethtml += `
<input type="hidden" name="_csrf" value="${csrfToken}">
`;
}
// form wrapper start
rethtml += `
</form>
`;
if (flagShowDebugInfo) {
// debug extra stuff
rethtml += "\n<br/><hr/>\n" + jrhText.jrBootstrapCollapseBox("Table Debug", listHelperData, "");
}
// return it as raw html
return rethtml;
}
//---------------------------------------------------------------------------
//---------------------------------------------------------------------------
// private helper functions
/**
* Build the html that represents the main area of the grid table
* @private
* @param {object} req
* @param {object} listHelperData
* @param {object} queryUrlData
* @returns raw html string
*/
async function jrGridListTable(jrContext, listHelperData, queryUrlData) {
let rethtml = "";
// beginning stuff
rethtml += `
<div class="table-responsive">
<table class="table table-striped w-auto table-bordered">
`;
// header
rethtml += jrGridListTableHeader(listHelperData, queryUrlData);
// data
rethtml += await jrGridListTableData(jrContext, listHelperData, queryUrlData);
// ending stuff
rethtml += `
</table>
</div>
`;
return rethtml;
}
/**
* Builds the core internal html table data
* @private
* @param {object} req
* @param {object} listHelperData
* @param {object} queryUrlData
* @returns raw html string
*/
async function jrGridListTableData(jrContext, listHelperData, queryUrlData) {
let rethtml = "";
// data body start
rethtml += `
<tbody>
`;
// data content
const gridSchema = listHelperData.gridSchema;
const headerKeys = calcHeaderKeysNicely(gridSchema);
const gridItems = listHelperData.gridItems;
//
const filterOptions = listHelperData.filterOptions;
const protectedFields = filterOptions.protectedFields;
const hiddenFields = filterOptions.hiddenFields;
// cache extra info for each header column
const extraInfo = {};
headerKeys.forEach((key) => {
extraInfo[key] = {
valformat: listHelperData.modelClass.getSchemaFieldVal(key, "format"),
valueFunction: listHelperData.modelClass.getSchemaFieldVal(key, "valueFunction"),
refModelClass: listHelperData.modelClass.getSchemaFieldVal(key, "refModelClass"),
valchoices: listHelperData.modelClass.getSchemaFieldVal(key, "choices"),
};
});
let val, valtype, valueFunction, valformat, valDisplay, valchoices, refModelClass;
let extraInfoKey;
let item;
let url;
const numItems = gridItems.length;
for (let i = 0; i < numItems; i += 1) {
item = gridItems[i];
// start
rethtml += `
<tr>
`;
// item row
await jrhMisc.asyncAwaitForEachFunctionCall(headerKeys, async (key) => {
if (jrhMisc.isInAnyArray(key, hiddenFields)) {
return;
}
// SPECIAL COLUMNS
if (key === "_batch_checkbox") {
// checkbox for batch actions
rethtml += `
<td scope="col"> <input type="checkbox" name="checkboxid_${item._id}"> </td>
`;
return;
}
if (key === "_actions") {
// action column
rethtml += "<td scope=\"col\">";
// edit
const urlEdit = queryUrlData.baseUrl + "/edit/" + item._id;
rethtml += `<a href="${urlEdit}" title="edit">✎</a> `;
// undelete
if (("disabled" in item) && item.disabled === 2) {
const urlUnDelete = queryUrlData.baseUrl + "/undelete/" + item._id;
rethtml += ` <a href="${urlUnDelete}" title="undelete">♼</a> `;
} else {
// delete
const urlDelete = queryUrlData.baseUrl + "/delete/" + item._id;
rethtml += ` <a href="${urlDelete}" title="delete">✘</a> `;
}
rethtml += "</td>";
return;
}
// ATTN: TODO -- split this off into a standalone function we can reuse elsewhere
// and perhaps reuse it for crudaid
// let valHtml = modelClass.renderFieldValueHtml(req, obj, reqbody, fieldName, crudSubType, helperData);
extraInfoKey = extraInfo[key];
val = item[key];
// put value as href link?
url = undefined;
// ids are internal special columns we know how to link
if (key === "_id" && val) {
url = queryUrlData.baseUrl + "/view/" + val;
}
// set url from schema info
if (!url && val) {
refModelClass = extraInfoKey.refModelClass;
if (refModelClass) {
url = refModelClass.getCrudUrlBase("view", val);
}
}
// extra scheme info
valformat = extraInfoKey.valformat;
valueFunction = extraInfoKey.valueFunction;
valchoices = extraInfoKey.valchoices;
// format it
if (valueFunction) {
// use custom value resolving callback function
valDisplay = await valueFunction(jrContext, "list", key, item, null, listHelperData);
} else {
if (valformat === "checkbox") {
if (val) {
valDisplay = "true";
} else {
valDisplay = "false";
}
} else if (valformat === "choices") {
// choices can be tricky.. because we'd like to show the nice choice option, but we also want to show underlying values so user can filter (esp. if it's numerical)
valDisplay = jrhText.jrHtmlNiceOptionFromList(valchoices, val);
} else if (valformat === "textarea") {
valDisplay = jrhText.sanitizeUnsafeText(val, true, true);
} else if (valformat === "date") {
// format as compact date
valDisplay = jrhText.formatDateNicely(val, true);
} else {
// default coerce to string
valDisplay = jrhText.sanitizeUnsafeText(jrhText.coerceToString(val, true), true, false);
}
}
// wrap in href url?
if (url) {
valDisplay = `<a href="${url}">${valDisplay}</a>`;
}
// add cell value
rethtml += `<td scope="col"> ${valDisplay} </td>`;
});
// end row
rethtml += `
</tr>
`;
}
// data body end
rethtml += `
</tbody>
`;
return rethtml;
}
//---------------------------------------------------------------------------
//---------------------------------------------------------------------------
/**
* Builds html for a with all checked drop down box and button
*
* @private
* @param {object} req
* @param {object} listHelperData
* @param {object} queryUrlData
*/
async function jrGridListBulkActions(jrContext, listHelperData, queryUrlData, tableid) {
let rethtml = "";
// beginning
rethtml += "<div>With all checked: ";
// drop down box of choices
rethtml += `<select name="bulkaction">
<option value=""></option>
`;
// enable and disable
rethtml += `
<option value="enable">Enable All</option>
<option value="disable">Disable All</option>
`;
// virtual delete/undelete
if (listHelperData.modelClass.supportsVirtualDelete()) {
rethtml += `
<option value="delete">Delete All (virtually)</option>
<option value="undelete">Un-delete All (virtually)</option>
`;
}
// permanently delete
rethtml += `
<option value="permdelete">Permanently Delete All (!)</option>
`;
// end of choices
rethtml += "</select>";
// go button
rethtml += "<input name=\"bulkactiongo\" type=\"button\" value =\"GO\" onclick=\"requestGridBulkAction('" + tableid + "'); return false;\"></input>";
// end
rethtml += "</div><br/>";
return rethtml;
}
//---------------------------------------------------------------------------
//---------------------------------------------------------------------------
/**
* Builds html for implementing a pager area for the grid
*
* @private
* @param {object} queryUrlData
* @returns raw html string
*/
function jrGridListPager(queryUrlData) {
// see https://www.tutorialrepublic.com/twitter-bootstrap-tutorial/bootstrap-pagination.php
let rethtml = "";
const pageNum = queryUrlData.pageNum;
const resultCount = queryUrlData.resultCount;
const pageSize = queryUrlData.pageSize;
//
const pageCount = Math.ceil(resultCount / pageSize);
const pageSurroundCount = 3;
//
const pageStart = Math.max(1, pageNum - pageSurroundCount);
const pageEnd = Math.min(pageCount, pageNum + pageSurroundCount);
const pageUp = Math.max(1, pageNum - ((pageSurroundCount * 2) + 1));
const pageDown = Math.min(pageCount, pageNum + ((pageSurroundCount * 2) + 1));
let flagActive;
//
// we want like: FIRST | PREVIOUS | ... | 4 | 5 | 6 | 7 | 8 | ... | NEXT | LAST
rethtml += `
<nav>
<ul class="pagination">
`;
// build the items before current pages
rethtml += jrGridListPagerItem("First", 1, (pageNum !== 1), false, queryUrlData);
rethtml += jrGridListPagerItem("Previous", pageNum - 1, (pageNum > 1), false, queryUrlData);
rethtml += jrGridListPagerItem("...", pageUp, (pageUp < pageNum), false, queryUrlData);
// surrounding numbers around current page
for (let i = pageStart; i <= pageEnd; i += 1) {
if (i === pageNum) {
flagActive = true;
} else {
flagActive = false;
}
rethtml += jrGridListPagerItem(i.toString(), i, true, flagActive, queryUrlData);
}
// after current pages
rethtml += jrGridListPagerItem("...", pageDown, (pageDown > pageNum), false, queryUrlData);
rethtml += jrGridListPagerItem("Next", pageNum + 1, (pageNum < pageCount), false, queryUrlData);
rethtml += jrGridListPagerItem("Last (" + pageCount + ")", pageCount, (pageNum < pageCount), false, queryUrlData);
rethtml += `
</ul>
</nav>
`;
// page size selector
rethtml += `
<nav>
<ul class="pagination">
`;
//
rethtml += jrGridListPagerItemPerPage("Items per page", 0, pageSize, false, queryUrlData);
rethtml += jrGridListPagerItemPerPage(null, 10, pageSize, true, queryUrlData);
rethtml += jrGridListPagerItemPerPage(null, 50, pageSize, true, queryUrlData);
rethtml += jrGridListPagerItemPerPage(null, 100, pageSize, true, queryUrlData);
rethtml += jrGridListPagerItemPerPage(null, 250, pageSize, true, queryUrlData);
rethtml += jrGridListPagerItemPerPage(null, 500, pageSize, true, queryUrlData);
rethtml += jrGridListPagerItemPerPage(null, 1000, pageSize, true, queryUrlData);
//
rethtml += `
</ul>
</nav>
`;
// return it
return rethtml;
}
/**
* Builds a single pager item.
*
* @private
* @param {string} label
* @param {int} pageIndex - current page
* @param {boolean} flagLink - whether pager item should be a working link
* @param {boolean} flagActive - whether this pager item represents current page
* @param {object} queryUrlData
* @returns raw html string
*/
function jrGridListPagerItem(label, pageIndex, flagLink, flagActive, queryUrlData) {
// make an item for pager
let liclass = "";
if (flagActive) {
liclass += " active";
}
if (!flagLink) {
liclass += " disabled";
}
const url = "#";
const updateObjString = "{pageNum: '" + pageIndex.toString() + "'}";
const onclick = "requestGridUpdate('" + queryUrlData.tableId + "', " + updateObjString + ", false);return false;";
const rethtml = `
<li class="page-item${liclass}">
<a href="${url}" onclick="${onclick}" class="page-link">${label}</a>
</li>
`;
return rethtml;
}
/**
* Helper function that creates the per-page dropdown option select
*
* @private
* @param {string} label
* @param {int} newPageSize - the new page size that this item selects
* @param {int} currentPageSize - the current page size of the currently viewed page
* @param {boolean} flagLink - whether to link it
* @param {object} queryUrlData
* @returns raw html string
*/
function jrGridListPagerItemPerPage(label, newPageSize, currentPageSize, flagLink, queryUrlData) {
// make an item for pager page size control
if (label === null) {
label = newPageSize.toString();
}
const flagActive = (newPageSize === currentPageSize);
let liclass = "";
if (flagActive) {
liclass += " active";
}
if (!flagLink) {
liclass += " disabled";
}
const url = "#"; // queryUrlData.baseUrl + "?pageSize=" + newPageSize.toString();
const updateObjString = "{pageSize: '" + newPageSize.toString() + "'}";
const onclick = "requestGridUpdate('" + queryUrlData.tableId + "', " + updateObjString + ", true);return false;";
const rethtml = `
<li class="page-item${liclass}">
<a href="${url}" onclick="${onclick}" class="page-link">${label}</a>
</li>
`;
return rethtml;
}
/**
* Generates the header for a table with sortable columns, and filters
* @private
* @param {object} listHelperData
* @param {object} queryUrlData
* @returns raw html string
*/
function jrGridListTableHeader(listHelperData, queryUrlData) {
// defaults
const defaultFilterInputSize = 20;
const flagUseLabel = true;
// header start
let rethtml = "";
rethtml += `
<thead>
`;
// main header content
rethtml += `
<tr>
`;
// data content
const gridSchema = listHelperData.gridSchema;
const headerKeys = calcHeaderKeysNicely(gridSchema);
const filterOptions = listHelperData.filterOptions;
const protectedFields = filterOptions.protectedFields;
const hiddenFields = filterOptions.hiddenFields;
let onclick;
headerKeys.forEach((key) => {
if (jrhMisc.isInAnyArray(key, hiddenFields)) {
return;
}
//
if (key === "_batch_checkbox") {
// toggle check all button
onclick = "jrGridToggleCheckboxes('" + queryUrlData.tableId + "');return false;";
rethtml += `
<th scope="col"> <input type="checkbox" name="_batch_checkbox_all" onclick="${onclick}" title="toggle all checkboxes"> </th>
`;
} else if (key === "_actions") {
// actions column for edit/delete buttons
rethtml += `
<th scope="col"> </th>
`;
} else if (false && jrhMisc.isInAnyArray(key, protectedFields)) {
rethtml += `
<th scope="col">${key}</th>
`;
} else {
const sortDir = jrGridListTableHeaderSortDir(key, queryUrlData);
let extraLabel = "";
let updateObjString;
let title;
let keyLabel;
if (sortDir) {
if (sortDir === "asc") {
extraLabel = " ▲";
updateObjString = "{sortDir: 'desc'}";
} else {
extraLabel = " ▼";
updateObjString = "{sortDir: 'asc'}";
}
title = "change sort direction";
} else {
updateObjString = "{sortField:'" + key + "', sortDir: 'asc'}";
title = "sort on this variable";
}
onclick = "requestGridUpdate('" + queryUrlData.tableId + "', " + updateObjString + ", true);return false;";
//
// use label?
if (flagUseLabel) {
keyLabel = listHelperData.modelClass.getSchemaFieldVal(key, "label", key);
} else {
if (key === "_id") {
// fixup for id
keyLabel = "id";
} else {
keyLabel = key;
}
}
rethtml += `
<th scope="col"> <a href="#" onclick="${onclick}" title="${title}"> ${keyLabel}${extraLabel} </a></th>
`;
}
});
//
rethtml += `
</tr>
`;
// filter row
rethtml += `
<tr>
`;
headerKeys.forEach((key) => {
if (jrhMisc.isInAnyArray(key, hiddenFields)) {
return;
}
if (key === "_batch_checkbox") {
onclick = "jrGridClearFilters('" + queryUrlData.tableId + "'); requestGridUpdate('" + queryUrlData.tableId + "', {}, true); return false;";
rethtml += `
<th scope="col"> <a href="#" onclick="${onclick}" title="clear all filters"> ✗ </a> </th>
`;
} else if (key === "_actions") {
// action column
rethtml += `
<th scope="col"> </th>
`;
} else if (jrhMisc.isInAnyArray(key, protectedFields)) {
rethtml += `
<th scope="col"> </th>
`;
} else {
let val = queryUrlData.fieldFilters[key];
if (val === undefined) {
val = "";
} else {
val = jrhMisc.makeSafeForFormInput(val);
}
const onkeydown = "jrGridGenericOnEnterRefresh(event, '" + queryUrlData.tableId + "', this, true)";
const size = listHelperData.modelClass.getSchemaFieldVal(key, "filterSize", defaultFilterInputSize);
if (!size) {
rethtml += `
<th scope="col"> </th>
`;
} else {
rethtml += `
<th scope="col"> <input type="text" name="filter_${key}" value="${val}" size="${size}" onkeydown="${onkeydown}" title="type search filter and hit Enter to refresh"> </th>
`;
}
}
});
//
rethtml += `
</tr>
`;
// header end
rethtml += `
</thead>
`;
return rethtml;
}
/**
* Returns the sort direction of a column key, based on previously parsed data
* @private
* @param {*} key
* @param {*} queryUrlData
* @returns string sort direction (asc|desc|null)
*/
function jrGridListTableHeaderSortDir(key, queryUrlData) {
if (queryUrlData.sortField === key) {
return queryUrlData.sortDir;
}
return null;
}
/**
* Get keys (columnns) for the header, adding our special ones for checkbox column and actions icons column
* @private
* @param {object} gridSchema
* @returns raw html string
*/
function calcHeaderKeysNicely(gridSchema) {
const headerKeys = Object.keys(gridSchema);
// add checkbox
headerKeys.unshift("_batch_checkbox");
// add column for actions at end
headerKeys.push("_actions");
// return it
return headerKeys;
}
//---------------------------------------------------------------------------
/**
* Add some hidden optinon to the grid form
*
* @param {*} req
* @param {*} listHelperData
* @param {*} queryUrlData
* @returns html to add to the input form
*/
function jrGridListShowHiddenOptions(jrContext, listHelperData, queryUrlData) {
// show a drop down with hidden options
// what we show may depend on user acl permissions
const tableid = queryUrlData.tableId;
const appdef = jrequire("appdef");
let rethtml = "";
if (false) {
// start stuff
rethtml += "<hr/><div>";
// build form input
const selectedid = "all";
rethtml += "Show: " + jrhText.jrHtmlFormOptionListSelect("showdisabled", appdef.DefShowStateModeLabels, selectedid, false);
}
// end stuff
rethtml += "</div><hr/>";
return rethtml;
}
//---------------------------------------------------------------------------
// export the class as the sole export
module.exports = {
jrGridList,
};