TxVEMP Render Table
TxVEMP Render Table
txvemp-render-table.js
— 14.5 KB
File contents
const grantTableLabels = Object.freeze({
EquipmentType: "Old Vehicle/Equipment Type",
ApplicantType: "Applicant Type",
HPRange: "HP Range",
YearRange: "Year Range",
NewFuelPower: "New Fuel/Power Source",
GrantAmount: "Grant Amount",
MinimumYear: "Minimum Year",
MaximumYear: "Maximum Year",
MinimumHP: "Minimum HP",
MaximumHP: "Maximum HP",
});
const textAlignments = Object.freeze({
LEFT: "left",
CENTER: "center",
RIGHT: "right",
});
// One source for table columns and filter controls.
// If the HTML ID changes, update controlID here only.
const grantTableHeaders = Object.freeze([
{
columnHeader: grantTableLabels.EquipmentType,
textAlignment: textAlignments.LEFT,
isFilter: true,
filterKey: "equipment",
controlID: "#filterEquipmentType",
},
{
columnHeader: grantTableLabels.ApplicantType,
textAlignment: textAlignments.CENTER,
isFilter: true,
filterKey: "applicant",
controlID: "#filterApplicantType",
},
{
columnHeader: grantTableLabels.HPRange,
textAlignment: textAlignments.CENTER,
isFilter: true,
filterKey: "hpRange",
controlID: "#filterHpRange",
},
{
columnHeader: grantTableLabels.YearRange,
textAlignment: textAlignments.CENTER,
isFilter: true,
filterKey: "yearRange",
controlID: "#filterYearRange",
usesYearLookup: true,
},
{
columnHeader: grantTableLabels.NewFuelPower,
textAlignment: textAlignments.LEFT,
},
{
columnHeader: grantTableLabels.GrantAmount,
textAlignment: textAlignments.RIGHT,
},
]);
const filterColumns = grantTableHeaders.filter((col) => col.isFilter);
const filterSelectors = filterColumns.map((col) => col.controlID).join(", ");
let pageSize = 10;
let currentPage = 1;
function getGrantTablesData() {
if (typeof grantTables !== "undefined" && Array.isArray(grantTables)) {
return grantTables;
}
if (Array.isArray(window.grantTables)) {
return window.grantTables;
}
return [];
}
let filteredData = sortGrantTables([...getGrantTablesData()]);
// Tracks which equipment types are expanded in the mobile accordion.
let accordionState = {};
function getColumnByHeader(columnHeader) {
return grantTableHeaders.find((col) => col.columnHeader === columnHeader);
}
function getColumnByFilterKey(filterKey) {
return filterColumns.find((col) => col.filterKey === filterKey);
}
function getFilterState() {
return filterColumns.reduce((state, col) => {
state[col.filterKey] = $(col.controlID).val() || "";
return state;
}, {});
}
function rowMatchesFilters(row, filters) {
return filterColumns.every((col) => {
const selectedValue = filters[col.filterKey];
if (!selectedValue) return true;
if (col.usesYearLookup) {
const selectedYear = parseInt(selectedValue, 10);
const minYear = parseInt(row[grantTableLabels.MinimumYear], 10);
const maxYear = parseInt(row[grantTableLabels.MaximumYear], 10);
return (
!Number.isNaN(selectedYear) &&
!Number.isNaN(minYear) &&
!Number.isNaN(maxYear) &&
selectedYear >= minYear &&
selectedYear <= maxYear
);
}
return row[col.columnHeader] === selectedValue;
});
}
function filterRows(baseFilters) {
return getGrantTablesData().filter((row) => rowMatchesFilters(row, baseFilters));
}
function getFirstRangeNumber(value) {
return parseInt(String(value).split("-")[0].trim(), 10) || 0;
}
function sortEquipmentTypes(a, b) {
const startsWithClassA = String(a).startsWith("Class");
const startsWithClassB = String(b).startsWith("Class");
if (startsWithClassA && !startsWithClassB) return 1;
if (!startsWithClassA && startsWithClassB) return -1;
return String(a).localeCompare(String(b));
}
function sortGrantTables(data) {
return data.sort((a, b) => {
const equipmentCompare = sortEquipmentTypes(
a[grantTableLabels.EquipmentType] || "",
b[grantTableLabels.EquipmentType] || ""
);
if (equipmentCompare !== 0) return equipmentCompare;
const applicantCompare = String(a[grantTableLabels.ApplicantType] || "").localeCompare(
String(b[grantTableLabels.ApplicantType] || "")
);
if (applicantCompare !== 0) return applicantCompare;
const hpCompare =
(parseInt(a[grantTableLabels.MinimumHP], 10) || getFirstRangeNumber(a[grantTableLabels.HPRange])) -
(parseInt(b[grantTableLabels.MinimumHP], 10) || getFirstRangeNumber(b[grantTableLabels.HPRange]));
if (hpCompare !== 0) return hpCompare;
return (
(parseInt(a[grantTableLabels.MinimumYear], 10) || 0) -
(parseInt(b[grantTableLabels.MinimumYear], 10) || 0)
);
});
}
function renderGrantTableHeaders() {
const thead = $("#grantTable thead").empty();
const headerRow = $("<tr>");
grantTableHeaders.forEach((col) => {
headerRow.append(
$("<th>").text(col.columnHeader).css("text-align", textAlignments.CENTER)
);
});
thead.append(headerRow);
}
function renderGrantTableBody(page) {
const tbody = $("#grantTable tbody").empty();
if (!filteredData || filteredData.length === 0) {
tbody.append(
$("<tr>").append(
$("<td>")
.attr("colspan", grantTableHeaders.length)
.addClass("text-center")
.text("No data")
)
);
return;
}
const start = (page - 1) * pageSize;
const end = start + pageSize;
filteredData.slice(start, end).forEach((item) => {
const row = $("<tr>");
grantTableHeaders.forEach((col) => {
const value = item[col.columnHeader] || "—";
row.append($("<td>").text(value).css("text-align", col.textAlignment));
});
tbody.append(row);
});
}
function renderDesktopPagination(page) {
const totalItems = filteredData.length;
const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));
const pagination = $("#grantPagination").empty();
const windowSize = 7;
function addPageButton(label, targetPage, disabled, active) {
const pageItem = $("<li>").addClass(
"page-item" + (disabled ? " disabled" : "") + (active ? " active" : "")
);
const pageLink = $("<a>")
.addClass("page-link")
.attr("href", "#")
.text(label)
.on("click", function (e) {
e.preventDefault();
if (!disabled && currentPage !== targetPage) {
currentPage = targetPage;
renderGrantTable(currentPage);
}
});
pageItem.append(pageLink);
pagination.append(pageItem);
}
addPageButton("First", 1, page === 1, false);
addPageButton("Prev", Math.max(1, page - 1), page === 1, false);
if (totalPages <= windowSize) {
for (let i = 1; i <= totalPages; i++) {
addPageButton(String(i), i, false, i === page);
}
} else {
const half = Math.floor(windowSize / 2);
let startPage = Math.max(1, page - half);
let endPage = Math.min(totalPages, startPage + windowSize - 1);
if (endPage - startPage + 1 < windowSize) {
startPage = Math.max(1, endPage - windowSize + 1);
}
if (startPage > 1) addPageButton("…", Math.max(1, startPage - windowSize), false, false);
for (let i = startPage; i <= endPage; i++) {
addPageButton(String(i), i, false, i === page);
}
if (endPage < totalPages) addPageButton("…", Math.min(totalPages, endPage + 1), false, false);
}
addPageButton("Next", Math.min(totalPages, page + 1), page === totalPages, false);
addPageButton("Last", totalPages, page === totalPages, false);
}
function renderMobileAccordion() {
const table = $("#grantTable");
const container = table.closest(".table-responsive");
let accordion = $("#mobileAccordion");
if (!accordion.length) {
accordion = $('<div id="mobileAccordion" class="accordion mt-3"></div>');
container.append(accordion);
} else {
accordion.empty();
}
if (!filteredData || !filteredData.length) {
accordion.append('<div class="text-center text-muted small">No data</div>');
return;
}
if (!accordion.data("state-wired")) {
accordion
.on("shown.bs.collapse", ".accordion-collapse", function () {
const equipmentKey = $(this).data("equipment");
if (equipmentKey) accordionState[equipmentKey] = true;
})
.on("hidden.bs.collapse", ".accordion-collapse", function () {
const equipmentKey = $(this).data("equipment");
if (equipmentKey) accordionState[equipmentKey] = false;
});
accordion.data("state-wired", true);
}
const equipmentGroups = {};
filteredData.forEach((row) => {
const equipmentKey = row[grantTableLabels.EquipmentType] || "Other";
if (!equipmentGroups[equipmentKey]) equipmentGroups[equipmentKey] = [];
equipmentGroups[equipmentKey].push(row);
});
function buildInnerTable(rows) {
const tbl = $(
'<div class="table-responsive">' +
'<table class="table table-striped table-bordered table-sm align-middle mb-0">' +
'<thead></thead>' +
'<tbody></tbody>' +
'</table>' +
'</div>'
);
const thead = tbl.find("thead");
const tbody = tbl.find("tbody");
const headerRow = $("<tr>");
grantTableHeaders.forEach((col) => {
if (col.columnHeader === grantTableLabels.EquipmentType) return;
if (col.columnHeader === grantTableLabels.ApplicantType) return;
headerRow.append(
$("<th>")
.text(col.columnHeader)
.css("text-align", textAlignments.CENTER)
.addClass("fw-bold fs-6 text-body-secondary")
);
});
thead.append(headerRow);
rows.forEach((rowData) => {
const row = $("<tr>");
grantTableHeaders.forEach((col) => {
if (col.columnHeader === grantTableLabels.EquipmentType) return;
if (col.columnHeader === grantTableLabels.ApplicantType) return;
const value = rowData[col.columnHeader] || "—";
row.append($("<td>").text(value).css("text-align", col.textAlignment));
});
tbody.append(row);
});
return tbl;
}
Object.entries(equipmentGroups).forEach(([equipment, equipmentRows], index) => {
const safeIndex = `equipment-${index}`;
const collapseId = `collapse-${safeIndex}`;
const headingId = `heading-${safeIndex}`;
const isOpen = accordionState[equipment] === true;
const item = $(
'<div class="accordion-item">' +
`<h2 class="accordion-header" id="${headingId}">` +
`<button class="accordion-button ${isOpen ? "" : "collapsed"}" type="button" data-bs-toggle="collapse" data-bs-target="#${collapseId}" aria-expanded="${isOpen ? "true" : "false"}" aria-controls="${collapseId}"></button>` +
'</h2>' +
`<div id="${collapseId}" class="accordion-collapse collapse ${isOpen ? "show" : ""}" data-bs-parent="#mobileAccordion" data-equipment="${equipment}">` +
'<div class="accordion-body p-2">' +
'<div class="accordion-body-scroll" style="max-height: calc(100vh - 220px); overflow-y: auto;"></div>' +
'</div>' +
'</div>' +
'</div>'
);
item.find(".accordion-button").text(equipment);
const scrollBody = item.find(".accordion-body-scroll");
const applicantGroups = {};
equipmentRows.forEach((row) => {
const applicantKey = row[grantTableLabels.ApplicantType] || "—";
if (!applicantGroups[applicantKey]) applicantGroups[applicantKey] = [];
applicantGroups[applicantKey].push(row);
});
Object.entries(applicantGroups).forEach(([applicant, rows]) => {
scrollBody.append(
$("<div>")
.addClass("fw-bold fs-5 text-uppercase mt-4 mb-1")
.text(`${grantTableLabels.ApplicantType}: ${applicant}`)
);
scrollBody.append(buildInnerTable(rows));
});
accordion.append(item);
});
}
function updateFilterOptionsForField(column, currentFilters) {
const baseFilters = { ...currentFilters, [column.filterKey]: "" };
const rowsForField = filterRows(baseFilters);
const valueSet = new Set();
if (column.usesYearLookup) {
let minYear = Infinity;
let maxYear = -Infinity;
rowsForField.forEach((row) => {
const rowMin = parseInt(row[grantTableLabels.MinimumYear], 10);
const rowMax = parseInt(row[grantTableLabels.MaximumYear], 10);
if (!Number.isNaN(rowMin) && rowMin < minYear) minYear = rowMin;
if (!Number.isNaN(rowMax) && rowMax > maxYear) maxYear = rowMax;
});
if (minYear !== Infinity && maxYear !== -Infinity) {
for (let year = maxYear; year >= minYear; year--) {
valueSet.add(String(year));
}
}
} else {
rowsForField.forEach((row) => {
if (row[column.columnHeader]) valueSet.add(row[column.columnHeader]);
});
}
const select = $(column.controlID);
const previousValue = select.val();
select.empty();
select.append($("<option>").attr("value", "").text("All"));
const sortedValues = Array.from(valueSet).sort((a, b) => {
if (column.columnHeader === grantTableLabels.HPRange) {
return getFirstRangeNumber(a) - getFirstRangeNumber(b);
}
if (column.columnHeader === grantTableLabels.EquipmentType) {
return sortEquipmentTypes(a, b);
}
if (column.usesYearLookup) {
return parseInt(b, 10) - parseInt(a, 10);
}
return String(a).localeCompare(String(b));
});
sortedValues.forEach((value) => {
select.append($("<option>").attr("value", value).text(value));
});
select.val(previousValue && valueSet.has(previousValue) ? previousValue : "");
}
function updateAllFilterOptions(currentFilters) {
filterColumns.forEach((column) => updateFilterOptionsForField(column, currentFilters));
}
function applyFilters() {
const filters = getFilterState();
filteredData = sortGrantTables(filterRows(filters));
currentPage = 1;
updateAllFilterOptions(filters);
renderGrantTable(currentPage);
}
function initFilters() {
updateAllFilterOptions(getFilterState());
$(filterSelectors).on("change", applyFilters);
$("#resetFilters").on("click", function () {
filterColumns.forEach((col) => $(col.controlID).val(""));
applyFilters();
});
}
function renderGrantTable(page) {
const isMobile = window.innerWidth < 768;
if (isMobile) {
$("#grantTable").hide();
$("#grantPagination").closest("nav").hide();
renderMobileAccordion();
return;
}
$("#grantTable").show();
$("#grantPagination").closest("nav").show();
$("#mobileAccordion").remove();
renderGrantTableHeaders();
renderGrantTableBody(page);
renderDesktopPagination(page);
}
$(function () {
initFilters();
renderGrantTable(currentPage);
let resizeTimer = null;
$(window).on("resize", function () {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(function () {
renderGrantTable(currentPage);
}, 150);
});
});
