@ministryofjustice/hmpps-digital-prison-reporting-frontend
Version:
The Digital Prison Reporting Frontend contains templates and code to help display data effectively in UI applications.
310 lines (307 loc) • 12.2 kB
JavaScript
import { clearFilterValue } from '../utils/urlHelper.js';
import ColumnUtils from '../components/_reports/report-heading/report-columns/report-columns-form/utils.js';
import { ReportType } from './UserReports.js';
import logger from '../utils/logger.js';
const DEFAULT_FILTERS_PREFIX = 'filters.';
class ReportQuery {
selectedPage;
pageSize;
sortColumn;
columns;
sortedAsc;
filters;
filtersPrefix;
dataProductDefinitionsPath;
constructor({ fields, template, queryParams, definitionsPath, filtersPrefix = DEFAULT_FILTERS_PREFIX, reportType, }) {
this.selectedPage = queryParams['selectedPage'] ? Number(queryParams['selectedPage']) : 1;
this.pageSize = this.getPageSize(queryParams, template, reportType);
this.sortColumn = queryParams['sortColumn']
? queryParams['sortColumn'].toString()
: this.getDefaultSortColumn(fields);
this.sortedAsc =
queryParams['sortedAsc'] !== undefined
? queryParams['sortedAsc'] !== 'false'
: this.getDefaultSortDirection(fields);
this.dataProductDefinitionsPath =
definitionsPath ??
(queryParams['dataProductDefinitionsPath'] ? queryParams['dataProductDefinitionsPath'].toString() : undefined);
this.filtersPrefix = filtersPrefix;
this.columns = this.setColumns(queryParams, fields);
this.filters = {};
this.setAndTrimFiltersFromQuery(queryParams, fields);
this.handleUnsetDateTypeDefaultsAndBounds(fields);
}
/**
* Set the filers from the query params
* - trims the start/end dates to min/max
* - removes no-filter options
*
* @private
* @param {ParsedQs} queryParams
* @param {string} [min]
* @param {string} [max]
* @memberof ReportQuery
*/
setAndTrimFiltersFromQuery(queryParams, fields) {
Object.keys(queryParams)
.filter(key => key.startsWith(this.filtersPrefix))
.filter(key => queryParams[key])
.forEach(key => {
const filterName = key.replace(this.filtersPrefix, '');
const p = queryParams[key];
let value = p ? p.toString() : '';
// Extract base field name
const baseFieldName = filterName.split('.')[0];
const field = fields.find(f => f.name === baseFieldName);
if (!field?.filter)
return;
const { type, staticOptions } = field.filter;
// Drop relative-duration for daterange types
if ((type === 'daterange' || type === 'granulardaterange') && filterName.endsWith('relative-duration')) {
return;
}
// Select / radio / multiselect validation
if (type.toLowerCase() === 'radio' || type.toLowerCase() === 'select' || type === 'multiselect') {
const validated = this.validateSelectFilterValue(p, type, staticOptions);
if (!validated)
return;
value = validated;
}
// Handle Date types
if (field.type === 'date') {
const { min, max } = field.filter;
if (type === 'daterange' || type === 'granulardaterange') {
if (min && filterName.endsWith('.start')) {
if (new Date(value) < new Date(min))
value = min;
}
if (max && filterName.endsWith('.end')) {
if (new Date(value) > new Date(max))
value = max;
}
}
if (type === 'date') {
if (min && new Date(value) < new Date(min)) {
value = min;
}
if (max && new Date(value) > new Date(max)) {
value = max;
}
}
}
// Handle no filter values (select, radio)
if (value !== 'no-filter') {
this.filters[filterName] = value;
}
});
}
/**
* Validates that all staticOption values are correct
*
* - checks/validates value against the staticOptions in the filter definition
* - if not present then it is dropped from the query,
* - and logged
*
* @private
* @param {unknown} rawValue
* @param {string} type
* @param {components['schemas']['FilterDefinition']['staticOptions']} [staticOptions]
* @param {string} [fieldName]
* @return {*} {(string | undefined)}
* @memberof ReportQuery
*/
validateSelectFilterValue(rawValue, type, staticOptions, fieldName) {
if (!staticOptions || !rawValue)
return undefined;
// Map lowercase -> original casing
const valueMap = new Map(staticOptions.map(o => [o.name.toLowerCase(), o.name]));
// Normalize input to lowercase strings
const values = Array.isArray(rawValue)
? rawValue.map(v => v.toString().toLowerCase())
: rawValue
.toString()
.split(',')
.map(v => v.trim().toLowerCase());
if (type === 'multiselect') {
const invalidValues = values.filter(v => !valueMap.has(v));
const filtered = [...new Set(values.map(v => valueMap.get(v)).filter((v) => v !== undefined))];
if (invalidValues.length > 0) {
logger.warn(`Invalid filter values removed for multiselect: ${fieldName}: [${invalidValues.join(', ')}]`);
}
if (filtered.length === 0)
return undefined;
return filtered.join(',');
}
// Single value (radio/select)
const value = values[0];
const mappedValue = valueMap.get(value);
if (!mappedValue) {
logger.warn(`Invalid filter value: ${fieldName}: ${value}`);
return undefined;
}
return mappedValue;
}
/**
* Handles the setting of default values for date type filters
* when no user input value has been applied
*
* - if min/max bounds in definition we ensure the date in within bounds
* before being sent to the API
*
* @private
* @param {components['schemas']['FieldDefinition'][]} fields
* @memberof ReportQuery
*/
handleUnsetDateTypeDefaultsAndBounds(fields) {
const dateFields = fields.filter(f => {
return ((f.type === 'date' && f.filter && f.filter.type === 'daterange') ||
(f.type === 'date' && f.filter && f.filter.type === 'date') ||
(f.type === 'date' && f.filter && f.filter.type === 'granulardaterange'));
});
dateFields.forEach(df => {
if (df.filter) {
const min = df.filter.min ?? undefined;
const max = df.filter.max ?? undefined;
this.setDefaultBoundsOnUnsetDatefields(df, df.filter, min, max);
}
});
}
/**
* Set the default value for date filters where min/max is provided
*
* - If min / max exist and the user hasn’t already provided date bounds in the request,
* - the code injects them as defaults so the filtering stays within those bounds.
*
* @private
* @memberof ReportQuery
*/
setDefaultBoundsOnUnsetDatefields(dateField, dateFieldFilter, min, max) {
const existingKeys = Object.keys(this.filters);
const baseKey = dateField.name;
const startKey = `${baseKey}.start`;
const endKey = `${baseKey}.end`;
if (dateFieldFilter.type === 'daterange' || dateFieldFilter.type === 'granulardaterange') {
const hasStart = existingKeys.some(key => key === startKey);
const hasEnd = existingKeys.some(key => key === endKey);
if (min && !hasStart) {
this.filters[startKey] = min;
}
if (max && !hasEnd) {
this.filters[endKey] = max;
}
}
if (dateFieldFilter.type === 'date') {
// Any value for this field counts (base or dotted)
const hasAnyValueForField = existingKeys.some(key => key === baseKey || key.startsWith(`${baseKey}.`));
if (!hasAnyValueForField) {
const value = min ?? max;
if (value) {
this.filters[baseKey] = value;
}
}
}
}
setColumns(queryParams, fields) {
if (queryParams['columns']) {
const columns = typeof queryParams['columns'] === 'string'
? queryParams['columns'].split(',')
: queryParams['columns'];
return ColumnUtils.ensureMandatoryColumns(fields, columns);
}
return fields.filter(f => f.visible).map(f => f.name);
}
/**
* Sets the default sort column
*
* @private
* @param {components['schemas']['FieldDefinition'][]} fields
* @return {*}
* @memberof ReportQuery
*/
getDefaultSortColumn(fields) {
const defaultSortColumn = fields.find(f => f.defaultsort);
return defaultSortColumn ? defaultSortColumn.name : fields.find(f => f.sortable)?.name;
}
/**
* Gets the default sort direction
* - Default if not set in DPD: ASC
*
* @private
* @param {components['schemas']['FieldDefinition'][]} fields
* @return {*}
* @memberof ReportQuery
*/
getDefaultSortDirection(fields) {
const field = fields.find(f => f.defaultsort);
if (field) {
return field.sortDirection ? field.sortDirection === 'asc' : true;
}
return true;
}
/**
* Get the pagesize for the request
* - from query, otherwise set meaningful default
*
* @param {ParsedQs} queryParams
* @param {Template} [template]
* @param {ReportType} [reportType]
* @return {*} {number}
* @memberof ReportQuery
*/
getPageSize(queryParams, template, reportType) {
let pageSize = 5000000;
if (!reportType || reportType === ReportType.REPORT) {
if (queryParams['pageSize']) {
pageSize = Number(queryParams['pageSize']);
}
else if (template) {
pageSize = this.getDefaultPageSize(template);
}
}
return pageSize;
}
toRecordWithFilterPrefix(removeClearedFilters = false) {
const record = {
selectedPage: this.selectedPage.toString(),
...(this.pageSize && { pageSize: this.pageSize.toString() }),
...(this.sortColumn && { sortColumn: this.sortColumn }),
sortedAsc: this.sortedAsc.toString(),
columns: this.columns,
};
if (this.dataProductDefinitionsPath) {
record['dataProductDefinitionsPath'] = this.dataProductDefinitionsPath;
}
Object.keys(this.filters).forEach(filterName => {
const value = this.filters[filterName];
if ((value && !removeClearedFilters) || value !== clearFilterValue) {
record[`${this.filtersPrefix}${filterName}`] = value;
}
});
return record;
}
/**
* Gets a meaningful default pagesize for a request
*
* @private
* @param {Template} template
* @return {*}
* @memberof ReportQuery
*/
getDefaultPageSize(template) {
const maxResultsSize = 500000; // catch all rows
const standardPage = 20; // smallest pagesize
switch (template) {
// These templates do no include pagination so return all rows
case 'list-section':
case 'summary-section':
case 'row-section-child':
case 'parent-child':
case 'parent-child-section':
return maxResultsSize;
default:
return standardPage;
}
}
}
export { DEFAULT_FILTERS_PREFIX, ReportQuery, ReportQuery as default };
//# sourceMappingURL=ReportQuery.js.map