import { getVerboseColumns } from '../utils/getVerboseColumns.js'
import logger from '../utils/logger.js'
import { types } from '../utils/logging.js'
import { pagination } from '../utils/pagination.js'
/**
* @typedef {Object} PaginationOptions
* @property {string} [hash] - Hash option (should include the '#' character)
* @property {Function} [href] - Function to generate href (item: number) => string
*/
/**
* @typedef {Object} PaginationItem
* @property {string} href - Link URL
* @property {boolean} [ellipsis] - Whether this is an ellipsis item
*/
/**
* @typedef {Object} PaginationResult
* @property {number} totalResults - Total number of results
* @property {number} offset - Current offset
* @property {number} limit - Results per page limit
* @property {number} currentPage - Current page number
* @property {number|null} nextPage - Next page number or null
* @property {number|null} previousPage - Previous page number or null
* @property {number} totalPages - Total number of pages
* @property {Array<{href: string}>} items - Pagination items with hrefs
*/
/**
* Holds response data of 'http://ASYNC-REQUEST-API-HOST/requests/:result-id/response-details' endpoint.
*/
export default class ResponseDetails {
#cachedFields
constructor (id, response, pagination, columnFieldLog) {
this.id = id
this.response = response
this.pagination = pagination
this.columnFieldLog = columnFieldLog
}
getRows () {
if (!this.response) {
logger.warn('trying to get response details when there are none', {
requestId: this.id
})
return []
}
return this.response
}
/**
* @returns {Object[]}
*/
getColumnFieldLog () {
if (!this.columnFieldLog) {
logger.warn('trying to get column field log when there is none', {
requestId: this.id
})
return []
}
return this.columnFieldLog
}
/**
* Returns a collection of field names, where each name is either a column name from input data
* or field name from the column field log. The resulting collection includes fields that
* the submitted data might be missing.
*
* Note: fields are `converted_row.column | columnFieldLog.field`
*
* @returns {string[]}
*/
getFields () {
if (this.#cachedFields) {
return this.#cachedFields
}
const columnKeys = new Set()
const rows = this.getRows()
if (rows.length > 0) {
const keys = Object.keys(rows[0].converted_row)
for (const key of keys) {
columnKeys.add(key)
}
}
const columnFieldLog = this.getColumnFieldLog()
for (const [col, field] of columnFieldLog.map((field) => [field.column, field.field])) {
if (!columnKeys.has(col)) {
columnKeys.add(field)
}
}
this.#cachedFields = [...columnKeys]
return this.#cachedFields
}
/**
* Returns an array of rows with verbose columns, optionally filtering out rows without errors.
*
* @param {boolean} [filterNonErrors=false] - If true, only return rows that have at least one error.
* @returns {Array<object>} An array of rows with verbose columns, each containing:
* - `entryNumber`: the entry number of the row
* - `hasErrors`: a boolean indicating whether the row has any errors
* - `columns`: an array of verbose column details, each containing:
* - `key`: the column key
* - `value`: the column value
* - `column`: the column name
* - `field`: the field name
* - `error`: an error message if data was missing
*/
getRowsWithVerboseColumns (filterNonErrors = false) {
if (!this.response) {
logger.warn('trying to get response details when there are none', {
requestId: this.id
})
return []
}
let rows = this.response
if (filterNonErrors) {
rows = rows.filter(
(row) =>
row.issue_logs.filter((issue) => issue.severity === 'error').length >
0
)
}
// Map over the details in the response and return an array of rows with verbose columns
return rows.map((row) => ({
entryNumber: row.entry_number,
hasErrors:
row.issue_logs.filter((issue) => issue.severity === 'error').length > 0,
columns: getVerboseColumns(row, this.getColumnFieldLog())
}))
}
getGeometryKey () {
const columnFieldLog = this.getColumnFieldLog()
if (!columnFieldLog) {
return null
}
const columnFieldEntry =
columnFieldLog.find((column) => column.field === 'point') ||
columnFieldLog.find((column) => column.field === 'geometry')
if (!columnFieldEntry) {
return null
}
return columnFieldEntry.column
}
/**
* Returns array of geometries when available, `undefined` otherwise.
*
* @returns {any[] | undefined }
*/
getGeometries () {
const rows = this.getRows()
if (rows.length === 0) {
return undefined
}
const item = rows[0]
const getGeometryValue = this.#makeGeometryGetter(item)
if (!getGeometryValue) {
logger.debug('could not create geometry getter', {
type: types.App,
requestId: this.id
})
return undefined
}
const geometries = []
for (const item of rows) {
const geometry = getGeometryValue(item)
if (geometry && geometry.trim() !== '') {
geometries.push(geometry)
}
}
logger.debug('getGetometries()', {
type: types.App,
requestId: this.id,
geometryCount: geometries.length,
rowCount: rows.length
})
return geometries
}
/**
* Get pagination details for the current response
*
* @param {number} pageNumber
* @param {PaginationOptions} [opts] - Pagination options
* @returns {PaginationResult} Pagination result object
*/
getPagination (pageNumber, opts = {}) {
pageNumber = parseInt(pageNumber)
if (Number.isNaN(pageNumber)) pageNumber = 1
const totalPages = Math.ceil(
this.pagination.totalResults / this.pagination.limit
)
const hash = opts.hash ?? ''
const hrefFn =
opts.href ?? ((item) => `/check/results/${this.id}/${item}${hash}`)
const items = pagination(totalPages, pageNumber).map((item) => {
if (item === '...') {
return {
ellipsis: true,
href: '#'
}
} else {
return {
number: item,
href: hrefFn(item),
current: pageNumber === item
}
}
})
return {
totalResults: parseInt(this.pagination.totalResults),
offset: parseInt(this.pagination.offset),
limit: parseInt(this.pagination.limit),
currentPage: pageNumber,
nextPage: pageNumber < totalPages ? pageNumber + 1 : null,
previousPage: pageNumber > 1 ? pageNumber - 1 : null,
totalPages,
items
}
}
/**
* Detects where geometry is stored in the item and returns a function to extract geometry value.
* It's caller's responsibility to handle situations where the getter couldn't be returned.
* For most common use cases, we can omit displaying the map.
*
* @param {Object} item - Data item containing geometry information
* @returns {Function|undefined} Function that takes an item and returns a geometry string, or undefined if no geometry found
*/
#makeGeometryGetter (item) {
/*
The api seems to sometimes respond with weird casing, it can be camal case, all lower or all upper
I'll implement a fix here, but hopefully infa will be addressing it on the backend to
*/
const trow = item.transformed_row
if (trow) {
const key = trow.find(obj => obj.field === 'geometry' || obj.field === 'point')?.field
const getter = (row) => {
const geometry = row.transformed_row?.find(obj => obj.field === key)
return geometry?.value
}
return getter
}
return undefined
}
}