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.

310 lines (307 loc) 12.2 kB
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