UNPKG

@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
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) {