@ministryofjustice/hmpps-digital-prison-reporting-frontend
Version:
The Digital Prison Reporting Frontend contains templates and code to help display data effectively in UI applications.
1,302 lines (1,276 loc) • 615 kB
JavaScript
import dayjs from 'dayjs';
import { MatrixController, MatrixElement } from 'chartjs-chart-matrix';
import ChartDataLabels from 'chartjs-plugin-datalabels';
function getDefaultExportFromCjs (x) {
return x && x.__esModule && Object.prototype.hasOwnProperty.call(x, 'default') ? x['default'] : x;
}
var customParseFormat$1 = {exports: {}};
var customParseFormat = customParseFormat$1.exports;
var hasRequiredCustomParseFormat;
function requireCustomParseFormat () {
if (hasRequiredCustomParseFormat) return customParseFormat$1.exports;
hasRequiredCustomParseFormat = 1;
(function (module, exports) {
!function(e,t){module.exports=t();}(customParseFormat,(function(){var e={LTS:"h:mm:ss A",LT:"h:mm A",L:"MM/DD/YYYY",LL:"MMMM D, YYYY",LLL:"MMMM D, YYYY h:mm A",LLLL:"dddd, MMMM D, YYYY h:mm A"},t=/(\[[^[]*\])|([-_:/.,()\s]+)|(A|a|Q|YYYY|YY?|ww?|MM?M?M?|Do|DD?|hh?|HH?|mm?|ss?|S{1,3}|z|ZZ?)/g,n=/\d/,r=/\d\d/,i=/\d\d?/,o=/\d*[^-_:/,()\s\d]+/,s={},a=function(e){return (e=+e)+(e>68?1900:2e3)};var f=function(e){return function(t){this[e]=+t;}},h=[/[+-]\d\d:?(\d\d)?|Z/,function(e){(this.zone||(this.zone={})).offset=function(e){if(!e)return 0;if("Z"===e)return 0;var t=e.match(/([+-]|\d\d)/g),n=60*t[1]+(+t[2]||0);return 0===n?0:"+"===t[0]?-n:n}(e);}],u=function(e){var t=s[e];return t&&(t.indexOf?t:t.s.concat(t.f))},d=function(e,t){var n,r=s.meridiem;if(r){for(var i=1;i<=24;i+=1)if(e.indexOf(r(i,0,t))>-1){n=i>12;break}}else n=e===(t?"pm":"PM");return n},c={A:[o,function(e){this.afternoon=d(e,false);}],a:[o,function(e){this.afternoon=d(e,true);}],Q:[n,function(e){this.month=3*(e-1)+1;}],S:[n,function(e){this.milliseconds=100*+e;}],SS:[r,function(e){this.milliseconds=10*+e;}],SSS:[/\d{3}/,function(e){this.milliseconds=+e;}],s:[i,f("seconds")],ss:[i,f("seconds")],m:[i,f("minutes")],mm:[i,f("minutes")],H:[i,f("hours")],h:[i,f("hours")],HH:[i,f("hours")],hh:[i,f("hours")],D:[i,f("day")],DD:[r,f("day")],Do:[o,function(e){var t=s.ordinal,n=e.match(/\d+/);if(this.day=n[0],t)for(var r=1;r<=31;r+=1)t(r).replace(/\[|\]/g,"")===e&&(this.day=r);}],w:[i,f("week")],ww:[r,f("week")],M:[i,f("month")],MM:[r,f("month")],MMM:[o,function(e){var t=u("months"),n=(u("monthsShort")||t.map((function(e){return e.slice(0,3)}))).indexOf(e)+1;if(n<1)throw new Error;this.month=n%12||n;}],MMMM:[o,function(e){var t=u("months").indexOf(e)+1;if(t<1)throw new Error;this.month=t%12||t;}],Y:[/[+-]?\d+/,f("year")],YY:[r,function(e){this.year=a(e);}],YYYY:[/\d{4}/,f("year")],Z:h,ZZ:h};function l(n){var r,i;r=n,i=s&&s.formats;for(var o=(n=r.replace(/(\[[^\]]+])|(LTS?|l{1,4}|L{1,4})/g,(function(t,n,r){var o=r&&r.toUpperCase();return n||i[r]||e[r]||i[o].replace(/(\[[^\]]+])|(MMMM|MM|DD|dddd)/g,(function(e,t,n){return t||n.slice(1)}))}))).match(t),a=o.length,f=0;f<a;f+=1){var h=o[f],u=c[h],d=u&&u[0],l=u&&u[1];o[f]=l?{regex:d,parser:l}:h.replace(/^\[|\]$/g,"");}return function(e){for(var t={},n=0,r=0;n<a;n+=1){var i=o[n];if("string"==typeof i)r+=i.length;else {var s=i.regex,f=i.parser,h=e.slice(r),u=s.exec(h)[0];f.call(t,u),e=e.replace(u,"");}}return function(e){var t=e.afternoon;if(void 0!==t){var n=e.hours;t?n<12&&(e.hours+=12):12===n&&(e.hours=0),delete e.afternoon;}}(t),t}}return function(e,t,n){n.p.customParseFormat=true,e&&e.parseTwoDigitYear&&(a=e.parseTwoDigitYear);var r=t.prototype,i=r.parse;r.parse=function(e){var t=e.date,r=e.utc,o=e.args;this.$u=r;var a=o[1];if("string"==typeof a){var f=true===o[2],h=true===o[3],u=f||h,d=o[2];h&&(d=o[2]),s=this.$locale(),!f&&d&&(s=n.Ls[d]),this.$d=function(e,t,n,r){try{if(["x","X"].indexOf(t)>-1)return new Date(("X"===t?1e3:1)*e);var i=l(t)(e),o=i.year,s=i.month,a=i.day,f=i.hours,h=i.minutes,u=i.seconds,d=i.milliseconds,c=i.zone,m=i.week,M=new Date,Y=a||(o||s?1:M.getDate()),p=o||M.getFullYear(),v=0;o&&!s||(v=s>0?s-1:M.getMonth());var D,w=f||0,g=h||0,y=u||0,L=d||0;return c?new Date(Date.UTC(p,v,Y,w,g,y,L+60*c.offset*1e3)):n?new Date(Date.UTC(p,v,Y,w,g,y,L)):(D=new Date(p,v,Y,w,g,y,L),m&&(D=r(D).week(m).toDate()),D)}catch(e){return new Date("")}}(t,a,r,n),this.init(),d&&true!==d&&(this.$L=this.locale(d).$L),u&&t!=this.format(a)&&(this.$d=new Date("")),s={};}else if(a instanceof Array)for(var c=a.length,m=1;m<=c;m+=1){o[1]=a[m-1];var M=n.apply(this,o);if(M.isValid()){this.$d=M.$d,this.$L=M.$L,this.init();break}m===c&&(this.$d=new Date(""));}else i.call(this,e);};}}));
} (customParseFormat$1));
return customParseFormat$1.exports;
}
var customParseFormatExports = requireCustomParseFormat();
var customParse = /*@__PURE__*/getDefaultExportFromCjs(customParseFormatExports);
class DprClientClass {
element;
constructor(element) {
this.element = element;
}
static getModuleName() {
throw new Error('Module name not set');
}
getElement() {
return this.element;
}
initialise() {
throw new Error('Initialisation not configured');
}
}
class CardGroup extends DprClientClass {
static getModuleName() {
return 'card-group';
}
initialise() {
const cards = this.getElement().querySelectorAll('[data-click-navigate-to]');
const wrapperClass = 'card-loading';
cards.forEach(card => {
card.addEventListener('click', () => {
card.classList.add(wrapperClass);
cards.forEach(c => {
if (!c.classList.contains('card-loading')) {
const disabledClass = 'card-disabled';
c.classList.add(disabledClass);
}
});
const url = card.dataset['clickNavigateTo'];
if (url) {
window.location.href = url;
}
});
});
}
}
var BookmarkAction;
(function (BookmarkAction) {
BookmarkAction["ADD"] = "add";
BookmarkAction["REMOVE"] = "remove";
})(BookmarkAction || (BookmarkAction = {}));
class BookmarkButton extends DprClientClass {
csrfToken;
reportId;
id;
linkType;
reportType;
endpoint;
baseUrl;
isRunning = false;
static getModuleName() {
return 'bookmark-button';
}
initialise() {
const element = this.getElement();
element.style.pointerEvents = '';
element.style.opacity = '1';
this.id = element.getAttribute('data-id');
this.reportId = element.getAttribute('data-report-id');
this.linkType = this.element.getAttribute('data-link-type') || BookmarkAction.ADD;
this.setReportType();
this.endpoint = element.getAttribute('data-endpoint') || '';
this.csrfToken = element.getAttribute('data-csrf-token') || '';
this.initEventListener();
}
/**
* Updates the bookmark button UI so that it shows:
* - the correct text
* - toggles the bookmark on and off correctly
*
* @memberof BookmarkButton
*/
updateUI() {
this.linkType = this.linkType === BookmarkAction.ADD ? BookmarkAction.REMOVE : BookmarkAction.ADD;
const textContent = this.linkType === BookmarkAction.ADD ? 'Add bookmark' : 'Remove bookmark';
const element = this.getElement();
element.setAttribute('data-link-type', this.linkType);
element.textContent = textContent;
}
/**
* Inits the bookmark button click event
*
* @memberof BookmarkButton
*/
initEventListener() {
this.getElement().addEventListener('click', (e) => this.activateBookmark(e));
this.getElement().addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
this.activateBookmark(e);
}
});
}
async activateBookmark(e) {
e.preventDefault();
if (this.isRunning)
return;
this.isRunning = true;
this.getElement().classList.add('bookmark-disabled');
try {
await fetch(this.endpoint, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'CSRF-Token': this.csrfToken,
},
body: JSON.stringify({
type: this.linkType,
id: this.id,
reportId: this.reportId,
reportType: this.reportType,
}),
})
.then(() => {
// The page should not reload if on the report/dashboard page so not to have to reload the data and cause a delay
// Should instead update the UI labels to signify the change has been made
if (!window.location.href.includes('/report') && !window.location.href.includes('/dashboard')) {
window.location.reload();
}
else {
this.updateUI();
}
})
.catch(error => console.error('Error:', error));
}
finally {
this.isRunning = false;
this.getElement().classList.remove('bookmark-disabled');
}
}
setReportType() {
const rawReportTypeValue = this.element.getAttribute('data-report-type') || '';
if (!['dashboard', 'report'].includes(rawReportTypeValue)) {
throw new Error(`Report type for bookmark setup was unexpected: ${rawReportTypeValue}`);
}
this.reportType = rawReportTypeValue;
}
}
class dprTruncate extends DprClientClass {
static getModuleName() {
return 'dpr-truncate';
}
initialise() {
this.initShowMore();
}
initShowMore() {
const container = this.getElement();
const content = container.querySelector('.js-content');
const button = container.querySelector('.js-show-more');
if (!content || !button) {
return;
}
const fullHtml = content.dataset['fullHtml'] ?? '';
const truncatedHtml = content.dataset['truncatedHtml'] ?? '';
button.addEventListener('click', () => {
const expanded = container.dataset['expanded'] === 'true';
if (expanded) {
content.innerHTML = truncatedHtml;
button.textContent = 'Show more';
button.setAttribute('aria-expanded', String(!expanded));
}
else {
content.innerHTML = fullHtml;
button.textContent = 'Show less';
button.setAttribute('aria-expanded', String(!expanded));
}
container.dataset['expanded'] = String(!expanded);
});
}
}
// @ts-nocheck
/* eslint-disable class-methods-use-this */
/* eslint-disable no-param-reassign */
class DprCatalogueFilters extends DprClientClass {
initTable() {
this.table = document.getElementById(this.tableId).querySelector('tbody');
this.classes = [
'dpr-search-option-hide',
'dpr-unauthorised-report-hide',
'dpr-live-report-hide',
'dpr-missing-report-hide',
'dpr-report-type-hide',
];
this.totalShown = document.getElementById('total-shown');
this.totalAmount = document.getElementById('total-amount');
this.totalRows = Array.from(this.table.rows).length;
this.shownRows = this.totalRows;
}
updateTotals() {
let count = 0;
this.table = document.getElementById(this.tableId).querySelector('tbody');
Array.from(this.table.rows).forEach(row => {
const hidden = [];
this.classes.forEach(className => {
hidden.push(Array.from(row.classList).includes(className));
});
if (hidden.every(h => h === false)) {
count += 1;
}
});
this.shownRows = count;
this.renderTotals();
}
renderTotals() {
if (this.totalShown) {
this.totalShown.innerText = this.shownRows;
}
if (this.totalAmount) {
this.totalAmount.innerText = this.totalRows;
}
}
}
// @ts-nocheck
/* eslint-disable class-methods-use-this */
/* eslint-disable no-param-reassign */
class DprCatalogueSearch extends DprCatalogueFilters {
static getModuleName() {
return 'dpr-catalogue-search';
}
initialise() {
this.searchBox = this.getElement().querySelector('.dpr-search-box');
this.hideClass = 'dpr-search-option-hide';
if (this.searchBox) {
this.tableId = this.searchBox.dataset.tableId;
this.initTable();
this.initSeachBoxEvents();
this.updateTableRows();
this.initSearchInputFromQueryParams();
this.initProductCollectionSelect();
}
}
initProductCollectionSelect() {
/**
* @type {HTMLSelectElement | undefined}
*/
const productCollections = this.getElement().querySelector('#productCollection');
if (productCollections) {
productCollections.addEventListener('change', e => {
e.preventDefault();
productCollections.closest('form').submit();
});
}
}
initSeachBoxEvents() {
this.searchBox.addEventListener('keyup', e => {
this.updateTableRows(e.target.value);
// Update Query Params
const queryParams = new URLSearchParams(window.location.search);
queryParams.set(this.searchBox.id, e.target.value);
window.history.replaceState(null, null, `?${queryParams.toString()}`);
});
}
updateTableRows(value) {
this.initSearchRows(value);
this.updateSearchListing(value);
this.updateTotals();
}
initSearchRows(value) {
const rows = Array.from(this.table.rows);
if (value) {
rows.forEach(row => row.classList.add(this.hideClass));
}
}
updateSearchListing(value) {
Array.from(this.table.rows)
.filter(row => {
return (!value ||
value.length === 0 ||
Array.from(row.cells).find(cell => {
const searchValue = value.toLowerCase();
return cell.innerText.toLowerCase().includes(searchValue.toLowerCase());
}));
})
.forEach(row => {
row.classList.remove(this.hideClass);
});
}
initSearchInputFromQueryParams() {
const urlParams = new URLSearchParams(window.location.search);
urlParams.forEach((value, key) => {
const element = document.getElementById(key);
if (element && element.classList.contains('dpr-search-box')) {
element.value = value;
this.updateTableRows(element.value);
}
});
}
}
// @ts-nocheck
/* eslint-disable class-methods-use-this */
/* eslint-disable no-param-reassign */
class DprCatalogueUnauthorisedFilter extends DprCatalogueFilters {
static getModuleName() {
return 'dpr-catalogue-show-unauthorised';
}
initialise() {
this.toggleFilters = this.getElement().querySelector('#dpr-toggle-filters');
this.hideClasses = ['dpr-unauthorised-report-hide', 'dpr-live-report-hide', 'dpr-missing-report-hide'];
if (this.toggleFilters) {
this.tableId = this.toggleFilters.dataset.tableId;
this.initTable();
this.initCheckboxToggleEvents();
this.initCheckboxesFromQueryParams();
this.updateTableRows();
}
}
initCheckboxToggleEvents() {
this.toggleFilters.addEventListener('change', e => {
const queryParams = new URLSearchParams(window.location.search);
if (e.target.checked) {
queryParams.set(e.target.id, e.target.value);
}
else {
queryParams.delete(e.target.id, e.target.value);
}
window.history.replaceState(null, null, `?${queryParams.toString()}`);
this.updateTableRows();
});
}
updateTableRows() {
const query = new URLSearchParams(window.location.search);
this.updateUnauthorisedRows(query);
this.updateMissingRows(query);
this.updateLiveRows(query);
this.updateTotals();
}
updateUnauthorisedRows(queryParams) {
const value = queryParams.get('show-unauthorised');
const hideClassName = this.hideClasses[0];
const tag = 'dpr-unauthorised-report';
this.updateRows(value, hideClassName, tag);
}
updateLiveRows(queryParams) {
const value = queryParams.get('hide-live');
const hideClassName = this.hideClasses[1];
const tag = 'dpr-live-report';
this.updateRowsLive(value, hideClassName, tag);
}
updateMissingRows(queryParams) {
const value = queryParams.get('show-missing');
const hideClassName = this.hideClasses[2];
const tag = 'dpr-missing-report';
this.updateRows(value, hideClassName, tag);
}
updateRowsLive(value, hideClassName, tag) {
Array.from(this.table.rows)
.filter(row => {
return Array.from(row.cells).find(cell => {
return cell.innerHTML.includes(tag);
});
})
.forEach(row => {
if (value) {
row.classList.add(hideClassName);
}
else {
row.classList.remove(hideClassName);
}
});
}
updateRows(value, hideClassName, tag) {
Array.from(this.table.rows)
.filter(row => {
return Array.from(row.cells).find(cell => {
return cell.innerHTML.includes(tag);
});
})
.forEach(row => {
if (value) {
row.classList.remove(hideClassName);
}
else if (!row.classList.contains(hideClassName)) {
row.classList.add(hideClassName);
}
});
}
initCheckboxesFromQueryParams() {
const urlParams = new URLSearchParams(window.location.search);
urlParams.forEach((value, key) => {
const element = document.getElementById(key);
if (element && element.id === 'show-unauthorised') {
element.setAttribute('checked', '');
}
if (element && element.id === 'show-missing') {
element.setAttribute('checked', '');
}
if (element && element.id === 'hide-live') {
element.setAttribute('checked', '');
}
});
}
}
// @ts-nocheck
/* eslint-disable class-methods-use-this */
/* eslint-disable no-param-reassign */
class DprCatalogueTypeFilter extends DprCatalogueFilters {
static getModuleName() {
return 'dpr-catalogue-type-filter';
}
initialise() {
this.reportTypeFilter = this.getElement().querySelector('#dpr-report-type-filters');
if (this.reportTypeFilter) {
this.tableId = this.reportTypeFilter.dataset.tableId;
this.initTable();
this.initRadioButtonEvent();
this.updateTableRows();
}
}
initRadioButtonEvent() {
this.reportTypeFilter.addEventListener('change', e => {
const queryParams = new URLSearchParams(window.location.search);
if (e.target.checked) {
queryParams.set(e.target.name, e.target.value);
}
else {
queryParams.delete(e.target.name, e.target.value);
}
window.history.replaceState(null, null, `?${queryParams.toString()}`);
this.updateTableRows();
});
}
updateTableRows() {
const query = new URLSearchParams(window.location.search);
this.updateReportTypeRowsRows(query);
this.updateTotals();
}
updateReportTypeRowsRows(queryParams) {
const value = queryParams.get('report-type');
const hideClassName = `dpr-report-type-hide`;
const tag = `dpr-type__${value}`;
this.updateRows(value, hideClassName, tag);
}
updateRows(value, hideClassName, tag) {
Array.from(this.table.rows).forEach(row => {
row.classList.remove(hideClassName);
});
Array.from(this.table.rows)
.filter(row => {
const isTypeRow = [];
Array.from(row.cells).forEach(cell => {
return isTypeRow.push(cell.innerHTML.includes(tag));
});
return isTypeRow.every(t => t === false);
})
.forEach(row => {
if (value && value !== 'all') {
row.classList.add(hideClassName);
}
});
}
}
let Pagination$1 = class Pagination {
pageSizeSelect;
static getModuleName() {
return 'pagination';
}
initialise() {
this.pageSizeSelect = document.getElementById('page-size-select');
this.initPageSizeSelectEvent();
}
initPageSizeSelectEvent() {
this.pageSizeSelect.addEventListener('change', () => {
const { name, value } = this.pageSizeSelect;
const queryParams = new URLSearchParams(window.location.search);
queryParams.set(name, value);
queryParams.set('selectedPage', '1');
window.history.replaceState(null, '', `?${queryParams.toString()}`);
window.location.reload();
});
}
};
class ReportActions extends DprClientClass {
refreshButton;
printButton;
shareButton;
copyButton;
static getModuleName() {
return 'report-actions';
}
initialise() {
this.refreshButton = document.getElementById('dpr-button-refresh');
this.printButton = document.getElementById('dpr-button-printable');
this.shareButton = document.getElementById('dpr-button-sharable');
this.copyButton = document.getElementById('dpr-button-copy');
this.initRefreshEvent();
this.initPrintButtonEvent();
this.initShareButtonEvent();
this.initCopyButtonEvent();
}
initPrintButtonEvent() {
if (this.printButton) {
const disabled = this.printButton.getAttribute('data-disabled');
this.printButton.addEventListener('click', () => {
if (disabled !== 'true') {
window.print();
}
});
this.printButton.addEventListener('keyup', e => {
if (e.key === 'Enter') {
if (disabled !== 'true') {
window.print();
}
}
});
}
}
initShareButtonEvent() {
if (this.shareButton) {
const href = this.shareButton.getAttribute('data-href');
if (href) {
this.shareButton.addEventListener('click', () => {
window.location.href = href;
});
this.shareButton.addEventListener('keyup', e => {
if (e.key === 'Enter') {
window.location.href = href;
}
});
}
}
}
initCopyButtonEvent() {
if (this.copyButton) {
const href = this.copyButton.getAttribute('data-href');
if (href) {
this.copyButton.addEventListener('click', () => {
navigator.clipboard.writeText(href);
});
this.copyButton.addEventListener('keyup', e => {
if (e.key === 'Enter') {
navigator.clipboard.writeText(href);
}
});
}
}
}
initRefreshEvent() {
if (this.refreshButton) {
const href = this.refreshButton.getAttribute('data-href');
if (href) {
this.refreshButton.addEventListener('click', () => {
window.location.href = href;
});
this.refreshButton.addEventListener('keyup', e => {
if (e.key === 'Enter') {
window.location.href = href;
}
});
}
}
}
}
// @ts-nocheck
class DataTable extends DprClientClass {
static getModuleName() {
return 'data-table';
}
initialise() {
this.element = this.getElement();
this.tableContainer = this.element.querySelector('.dpr-table-wrapper');
this.table = this.element.querySelector('.dpr-data-table');
this.gradient = this.element.querySelector('.dpr-overflow-gradient');
this.createTableHeaderAndFooter();
this.initTableScrollListener();
}
initTableScrollListener() {
this.checkOffsetWidths();
window.addEventListener('resize', () => {
this.checkOffsetWidths();
});
this.tableContainer.addEventListener('scroll', event => {
const endOfScroll = this.table.offsetWidth;
const currentScroll = event.target.offsetWidth + event.target.scrollLeft;
if (endOfScroll === currentScroll) {
this.removeGradient();
}
else {
this.addGradient();
}
});
}
removeGradient(gradient) {
this.gradient.style.display = 'none';
}
addGradient(gradient) {
this.gradient.style.display = 'block';
}
checkOffsetWidths() {
if (this.tableContainer.offsetWidth >= this.table.offsetWidth) {
this.removeGradient();
}
else {
this.addGradient();
}
}
createTableHeaderAndFooter() {
if (this.table) {
const classification = this.table.getAttribute('data-classification');
const headLength = Number(this.table.getAttribute('data-col-length'));
const classificationContent = `<b>${classification}</b>`;
// Headers
const header = this.table.createTHead();
const classificationHeaderRow = header.insertRow(0);
classificationHeaderRow.classList.add('print-header-footer');
const classificationHeaderCell = classificationHeaderRow.insertCell(0);
classificationHeaderCell.outerHTML = `<th class="govuk-table__header govuk-table__cell--content print-header-footer print-header-footer--header" colspan=${headLength}>${classificationContent}</th>`;
// Footers
const footer = this.table.createTFoot();
const classificationFooterRow = footer.insertRow(0);
classificationFooterRow.classList.add('print-header-footer');
const classificationFooterCell = classificationFooterRow.insertCell(0);
classificationFooterCell.outerHTML = `<td class="govuk-table__cell govuk-table__cell--content print-header-footer table-row--no-border print-header-footer--footer" colspan=${headLength}>${classificationContent}</td>`;
}
}
}
class DownloadMessage extends DprClientClass {
downloadMessage;
static getModuleName() {
return 'download-message';
}
initialise() {
this.downloadMessage = this.getElement();
if (window.location.href.includes('download-disabled')) {
this.downloadMessage.classList.remove('dpr-download-message--hidden');
}
}
}
class DprFiltersFormClass extends DprClientClass {
form;
static getModuleName() {
return 'filters-form';
}
initialise() {
if (!(this.element instanceof HTMLFormElement)) {
throw new Error('DprFormQuerySync must be initialised on a form element');
}
this.form = this.element;
this.bindInputEvents();
}
// ----------------------------------
// Events
// ----------------------------------
bindInputEvents() {
this.getAllInputs().forEach(input => {
input.addEventListener('change', () => {
const params = new URLSearchParams(window.location.search);
this.updateQueryFromInput(input, params);
this.markPreventDefault(params, input.name); // TODO: Check if this is needed ?
this.resetPagination(params); // TODO: Check if this is needed ?
this.replaceUrl(params);
});
});
}
// ----------------------------------
// Query updates
// ----------------------------------
updateQueryFromInput(input, params) {
const { name } = input;
if (!name)
return;
if (input instanceof HTMLInputElement) {
if (input.type === 'checkbox') {
this.updateCheckbox(params, input);
}
else if (input.type === 'radio') {
this.updateRadio(params, input);
}
else {
this.updateSingle(params, input);
}
}
else {
this.updateSingle(params, input);
}
}
updateCheckbox(params, input) {
const values = params.getAll(input.name);
// Remove all existing values for this key
params.delete(input.name);
// Re‑add all values except the unchecked one
values
.filter(value => value !== input.value || input.checked)
.forEach(value => {
params.append(input.name, value);
});
// Add the newly checked value
if (input.checked && !values.includes(input.value)) {
params.append(input.name, input.value);
}
}
updateRadio(params, input) {
if (!input.checked)
return;
const name = this.normaliseFilterName(input.name);
params.set(name, input.value);
}
updateSingle(params, input) {
const name = this.normaliseFilterName(input.name);
let value = input.value;
if (input instanceof HTMLInputElement && input.dataset['staticOptionNameValue']) {
value = input.dataset['staticOptionNameValue'];
}
const isDateInput = input instanceof HTMLInputElement && input.classList.contains('moj-js-datepicker-input');
if (isDateInput) {
const formatted = dayjs(value, 'D/M/YYYY').format('YYYY-MM-DD');
value = formatted !== 'Invalid Date' ? formatted : '';
}
const trimmed = value.trim();
if (trimmed) {
params.set(name, trimmed);
}
else {
params.delete(name);
}
}
// ----------------------------------
// Behaviour flags
// ----------------------------------
/**
* Signals to the server that defaults
*/
markPreventDefault(params, changedName) {
if (changedName !== 'columns') {
params.set('preventDefault', 'true');
}
}
/**
* Reset pagination when filters change
*/
resetPagination(params) {
if (params.has('selectedPage')) {
params.set('selectedPage', '1');
}
}
replaceUrl(params) {
const query = params.toString();
const url = query ? `?${query}` : window.location.pathname;
window.history.replaceState(null, '', url);
}
// ----------------------------------
// DOM helpers
// ----------------------------------
getAllInputs() {
return Array.from(this.form.querySelectorAll('input, select, textarea')).filter(el => el.name);
}
normaliseFilterName(name) {
return name.startsWith('label.') ? name.replace(/^label\./, '') : name;
}
}
dayjs.extend(customParse);
/**
* --------------------------------------------
* Date formats
* --------------------------------------------
*/
const UI_INPUT_FORMATS = ['D/M/YYYY', 'DD/MM/YYYY'];
const API_FORMAT = 'YYYY-MM-DD';
const UI_OUTPUT_FORMAT = 'DD/MM/YYYY';
/**
* --------------------------------------------
* Transform helpers
* --------------------------------------------
*/
/**
* DD/MM/YYYY -> YYYY-MM-DD
*
* @export
* @param {string} [value]
* @return {*} {(string | undefined)}
*/
function uiDateToApi(value) {
if (!value)
return undefined;
const parsed = dayjs(value, [...UI_INPUT_FORMATS], true);
return parsed.isValid() ? parsed.format(API_FORMAT) : undefined;
}
/**
* --------------------------------------------
* Display helpers
* --------------------------------------------
*/
function formatDateOrUnset(value) {
if (!value)
return 'unset';
const parsed = dayjs(value, [...UI_INPUT_FORMATS, API_FORMAT], true);
return parsed.isValid() ? parsed.format(UI_OUTPUT_FORMAT) : 'unset';
}
class DprSelectedAsyncFilters extends DprClientClass {
container;
static getModuleName() {
return 'dpr-selected-async-filters';
}
initialise() {
this.container = this.element;
this.renderFromInputs();
this.bindInputEvents();
}
// ----------------------------------
// Input observation
// ----------------------------------
bindInputEvents() {
this.getAllFilterControls().forEach(control => {
control.addEventListener('change', () => {
this.renderFromInputs();
});
});
}
// ----------------------------------
// Rendering
// ----------------------------------
renderFromInputs() {
this.container.innerHTML = '';
const selectedFilters = this.buildSelectedFilters();
if (!selectedFilters.length) {
this.renderEmptyState();
return;
}
selectedFilters.forEach(filter => {
this.container.appendChild(this.createFilterElement(filter));
});
}
renderEmptyState() {
const p = document.createElement('p');
p.className = 'govuk-body-s govuk-!-margin-top-1 govuk-!-margin-bottom-0';
p.textContent = 'No filters selected';
this.container.appendChild(p);
}
// ----------------------------------
// Build selected filters from inputs
// ----------------------------------
buildSelectedFilters() {
const grouped = this.groupControlsByField();
return Object.values(grouped)
.map(controls => this.buildFilterFromControls(controls))
.filter(Boolean);
}
groupControlsByField() {
return this.getAllFilterControls().reduce((acc, control) => {
const parts = control.name.split('.');
// label.filters.fieldX -> filters.fieldX
const fieldKey = parts[0] === 'label' ? parts.slice(1, 3).join('.') : parts.slice(0, 2).join('.');
acc[fieldKey] ??= [];
acc[fieldKey].push(control);
return acc;
}, {});
}
// ----------------------------------
// Orchestration
// ----------------------------------
buildFilterFromControls(controls) {
const activeControls = this.getActiveControls(controls);
if (!activeControls.length)
return null;
const displayName = this.getDisplayName(controls);
if (this.isGranularDateRange(controls)) {
return this.buildGranularDateRangeFilter(displayName, controls);
}
if (this.isDateRange(controls)) {
return this.buildDateRangeFilter(displayName, controls);
}
if (this.isMultiSelect(activeControls)) {
return this.buildMultiSelectFilter(displayName, activeControls, controls);
}
return this.buildSingleValueFilter(displayName, activeControls[0], controls);
}
// ----------------------------------
// Guards
// ----------------------------------
getActiveControls(controls) {
return controls.filter(control => {
if (control instanceof HTMLSelectElement) {
return Boolean(control.value);
}
if (control instanceof HTMLInputElement && (control.type === 'checkbox' || control.type === 'radio')) {
return control.checked;
}
return Boolean(control.value);
});
}
isGranularDateRange(controls) {
return controls.some(c => c.name.includes('granularity'));
}
isDateRange(controls) {
return controls.some(c => c.name.endsWith('.start')) || controls.some(c => c.name.endsWith('.end'));
}
isMultiSelect(active) {
return active.length > 1;
}
// ----------------------------------
// Builders
// ----------------------------------
buildDateRangeFilter(displayName, controls) {
const start = controls.find(c => c.name.endsWith('.start'));
const end = controls.find(c => c.name.endsWith('.end'));
const relativeDuration = controls.find(c => {
if (!c.name.endsWith('.relative-duration'))
return false;
const input = c;
return input.type === 'radio' && input.checked;
});
const rangeDisplay = `${formatDateOrUnset(start?.value)} - ${formatDateOrUnset(end?.value)}`;
const displayValue = relativeDuration && relativeDuration.value !== 'none'
? `${rangeDisplay} / ${this.getDisplayValue(relativeDuration)}`
: rangeDisplay;
return {
displayName,
displayValue,
inputs: controls,
};
}
buildGranularDateRangeFilter(displayName, controls) {
const quick = controls.find(c => c.name.includes('quick-filter') && c.value);
const granularity = controls.find(c => c.name.includes('granularity'));
const start = controls.find(c => c.name.endsWith('.start') && c.value);
const end = controls.find(c => c.name.endsWith('.end') && c.value);
let displayValue = '';
if (quick && quick.value !== 'None') {
displayValue = `${this.humanise(quick.value)} / ${granularity?.value ?? 'unset'}`;
}
else {
displayValue =
`${formatDateOrUnset(start?.value)} - ${formatDateOrUnset(end?.value)}` + ` / ${granularity?.value ?? 'unset'}`;
}
return {
displayName,
displayValue,
inputs: controls,
};
}
buildMultiSelectFilter(displayName, active, controls) {
const values = active.map(c => this.getDisplayValue(c));
const displayValue = values.length > 3 ? `${values.slice(0, 3).join(', ')} + ${values.length - 3} more` : values.join(', ');
return { displayName, displayValue, inputs: controls };
}
buildSingleValueFilter(displayName, control, controls) {
return {
displayName,
displayValue: this.getDisplayValue(control),
inputs: controls,
};
}
// ----------------------------------
// Display helpers
// ----------------------------------
getDisplayName(controls) {
const legend = controls[0]?.closest('fieldset')?.querySelector('legend')?.textContent;
if (legend) {
return legend.trim();
}
const isDateRange = controls.some(c => c.name.endsWith('.start')) && controls.some(c => c.name.endsWith('.end'));
if (isDateRange) {
const explicit = controls.map(c => c.getAttribute('display-name')).find(Boolean);
if (explicit) {
return explicit.replace(/\s+(start|end)$/i, '').trim();
}
}
const explicit = controls.map(c => c.getAttribute('display-name')).find(Boolean);
if (explicit) {
return explicit;
}
const label = controls
.map(c => (c instanceof HTMLInputElement ? c.labels?.[0]?.innerText : undefined))
.find(Boolean);
if (label) {
return label;
}
return controls[0].name;
}
getDisplayValue(control) {
if (control instanceof HTMLSelectElement) {
const option = control.options[control.selectedIndex];
return option?.text ?? '';
}
// Autocomplete & text inputs should use their value
if (control instanceof HTMLInputElement && (control.type === 'search' || control.type === 'text')) {
return control.value;
}
// Static option value for radios & checkboxes
if (control.dataset['staticOptionNameValue']) {
return control.dataset['staticOptionNameValue'];
}
if (control instanceof HTMLInputElement &&
(control.type === 'radio' || control.type === 'checkbox') &&
control.labels?.[0]?.innerText) {
return control.labels[0].innerText;
}
return control.value;
}
// ----------------------------------
// Chip removal
// ----------------------------------
createFilterElement(filter) {
const button = document.createElement('button');
button.type = 'button';
button.className = 'dpr-clear-button-styles govuk-body dpr-selected-filter';
const name = document.createElement('strong');
name.className = 'dpr-selected-filter__name';
name.textContent = filter.displayName;
const value = document.createTextNode(`: ${filter.displayValue}`);
button.append(name, value);
button.addEventListener('click', () => {
const params = new URLSearchParams(window.location.search);
// Remove ALL values for this filter from the query string
filter.inputs.forEach(control => {
params.delete(control.name);
if (control instanceof HTMLSelectElement) {
control.selectedIndex = 0;
}
else if (control instanceof HTMLInputElement) {
if (control.type === 'checkbox' || control.type === 'radio') {
control.checked = false;
}
else {
control.value = '';
}
}
this.removeAutocompleteHiddenInput(control.name, params);
control.dispatchEvent(new Event('change', { bubbles: true }));
});
const query = params.toString();
const url = query ? `?${query}` : window.location.pathname;
window.history.replaceState(null, '', url);
});
return button;
}
removeAutocompleteHiddenInput(name, params) {
if (!name.startsWith('label.')) {
return;
}
const realFilterName = name.replace(/^label\./, '');
const hidden = document.querySelectorAll(`input[type="hidden"][name="${CSS.escape(realFilterName)}"]`);
hidden.forEach(h => {
h.value = '';
params.delete(h.name);
});
}
// ----------------------------------
// Utilities
// ----------------------------------
getAllFilterControls() {
return Array.from(document.querySelectorAll('input[name^="filters."], select[name^="filters."], input[name^="label.filters."]')).filter(control => {
if (control instanceof HTMLInputElement && control.type === 'hidden') {
return false;
}
return true;
});
}
humanise(value) {
return value.replace(/-/g, ' ');
}
}
class DprAppliedFilters extends DprClientClass {
static getModuleName() {
return 'dpr-applied-filters';
}
initialise() {
this.bindRemoveEvents();
}
// ----------------------------------
// Event binding
// ----------------------------------
bindRemoveEvents() {
this.getFilterButtons().forEach(button => {
button.addEventListener('click', () => {
const reset = this.getResetConfig(button);
if (!reset)
return;
this.removeQueryParams(reset);
});
});
}
// ----------------------------------
// Query string manipulation
// ----------------------------------
removeQueryParams(reset) {
const url = new URL(window.location.href);
// Remove filter-related params
reset.keys.forEach(key => {
url.searchParams.delete(key);
});
// If no filters remain, prevent default search
if (!this.hasAnyFilterParams(url)) {
url.searchParams.set('preventDefault', 'true');
}
window.location.assign(url.toString());
}
hasAnyFilterParams(url) {
return Array.from(url.searchParams.keys()).some(key => key.startsWith('filters.'));
}
// ----------------------------------
// DOM helpers
// ----------------------------------
getFilterButtons() {
return Array.from(this.element.querySelectorAll('button[data-reset]'));
}
getResetConfig(button) {
const raw = button.dataset['reset'];
if (!raw)
return null;
try {
return JSON.parse(raw);
}
catch {
return null;
}
}
}
class PollingClientClass extends DprClientClass {
polling = false;
pollingTimer = null;
POLLING_INTERVAL = 3000;
csrfToken;
TERMINAL_STATUSES = ['FINISHED', 'EXPIRED', 'FAILED', 'ABORTED'];
startPolling(tick, shouldStop) {
const run = async () => {
await tick();
if (shouldStop()) {
this.stopPolling();
return;
}
this.pollingTimer = window.setTimeout(run, this.POLLING_INTERVAL);
};
run();
}
stopPolling() {
if (this.pollingTimer !== null) {
clearTimeout(this.pollingTimer);
this.pollingTimer = null;
}
}
isTerminalStatus(status) {
return status !== null && this.TERMINAL_STATUSES.includes(status);
}
getStatusFromElement(el) {
return el.getAttribute('data-status');
}
isTerminalElement(el) {
return this.isTerminalStatus(this.getStatusFromElement(el));
}
}
/**
* Lightweight HTTP utility for fetching server-rendered HTML fragments
* and handling common concerns like CSRF tokens and response parsing.
*/
class DprHtmlClient {
/**
* Fetches an HTML fragment from the server and parses it into a DocumentFragment.
* Returns null if the response indicates no content change (204).
*
* @static
* @param {string} url
* @param {string} [csrfToken]
* @return {*} {(Promise<DocumentFragment | null>)}
* @memberof DprHtmlClient
*/
static async fetchFragment(url, csrfToken) {
const res = await fetch(url, {
headers: {
'X-Requested-With': 'XMLHttpRequest',
Accept: 'text/html',
...(csrfToken ? { 'CSRF-Token': csrfToken } : {}),
},
});
if (res.status === 204)
return null;
if (!res.ok) {
throw new Error(`Fetch failed: ${res.status}`);
}
const html = await res.text();
const template = document.createElement('template');
template.innerHTML = html.trim();
return template.content;
}
/**
* Extracts the CSRF token from a DOM element's dataset.
* Throws an error if the token is not present.
*
* @static
* @param {HTMLElement} element
* @return {*} {string}
* @memberof DprHtmlClient
*/
static getCsrfToken(element) {
const token = element.dataset['csrfToken'];
if (!token) {
throw new Error('No csrf token provided');
}
return token;
}
}
class DprReportStatus extends PollingClientClass {
container;
path;
static getModuleName() {
return 'dpr-current-status-polling';
}
initialise() {
const element = this.getElement();
const { tableId, path } = this.element.dataset;
if (!tableId || !path) {
throw new Error(`Missing data required for polling: ${JSON.stringify({ tableId, path })}`);
}
this.path = path;
this.csrfToken = DprHtmlClient.getCsrfToken(element);
const container = element.querySelector('#dpr-current-status__content');
if (!container)
return;
this.container = element;
if (!this.isTerminal()) {
this.startPolling(() => this.pollReport(), () => this.isTerminal());
}
}
/**
* Poll the single report
*/
async pollReport() {
if (this.polling)
return;
this.polling = true;
try {
const fetchPath = `${this.path}/current-status`;
// get the updated current status fragment
const fragment = await DprHtmlClient.fetchFragment(fetchPath, this.csrfToken);
if (!fragment)
return;
// Get the redirect to the report, if present it means the status is finished
const redirectUrl = this.redirectToReport(fragment);
// Set the new container
const newContainer = fragment.querySelector('#dpr-current-status__content');
if (!newContainer)
return;
// Replace the old container
this.container.replaceWith(newContainer);
this.container = newContainer;
if (!!redirectUrl) {
setTimeout(() => {
window.location.replace(redirectUrl);
}, 1000);
}
}
catch (error) {
console.error('Polling error', error);
}
finally {
this.polling = false;
}
}
/**
* Check if report is in a terminal state
*/
isTerminal() {
return this.isTerminalElement(this.container);
}
redirectToReport(fragment) {