Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Out of bounds expectations: details page #958

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion config/default.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ features:
# enabling this feature will cause the results page to present
# links to individual issue pages
enabled: true
expectactionOutOfBoundsTask:
expectationOutOfBoundsTask:
# this feature includes violated 'out of bounds' expectations
# as 'tasks' on LPA overview pages etc.
# https://github.com/digital-land/submit/issues/921
Expand Down
2 changes: 1 addition & 1 deletion config/production.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -28,5 +28,5 @@ features:
# enabling this feature will cause the results page to present
# links to individual issue pages
enabled: false
expectactionOutOfBoundsTask:
expectationOutOfBoundsTask:
enabled: false
17 changes: 16 additions & 1 deletion config/util.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,18 @@ import * as v from 'valibot'

const NonEmptyString = v.pipe(v.string(), v.nonEmpty())

/**
* Informatino to allow us to display sanely worded messages about entities.
*
* - `base` is the part that doesn't change
* - `variable` should be specified in singular, and can be potentially turned into plural
* (by application code or template) when displayed.
*/
const EntityDisplay = v.object({
base: v.optional(NonEmptyString),
variable: NonEmptyString
})

export const ConfigSchema = v.object({
port: v.pipe(v.integer(), v.minValue(1)),
asyncRequestApi: v.object({
Expand Down Expand Up @@ -64,7 +76,10 @@ export const ConfigSchema = v.object({
'listed-building',
'listed-building-outline'
].reduce((acc, key) => {
acc[key] = v.object({ guidanceUrl: v.string() })
acc[key] = v.object({
guidanceUrl: v.string(),
entityDisplayName: EntityDisplay
})
return acc
}, {})
),
Expand Down
4 changes: 3 additions & 1 deletion src/controllers/OrganisationsController.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import getStartedMiddleware from '../middleware/getStarted.middleware.js'
import overviewMiddleware from '../middleware/lpa-overview.middleware.js'
import datasetDataviewMiddleware from '../middleware/dataview.middleware.js'
import datasetEndpointIssueMiddleware from '../middleware/datasetEndpointIssue.middleware.js'
import datasetFailedExpectationIssueMiddleware from '../middleware/dataset-failed-expectation-details.middleware.js'

const organisationsController = {
organisationsMiddleware,
Expand All @@ -19,7 +20,8 @@ const organisationsController = {
getStartedMiddleware,
overviewMiddleware,
datasetDataviewMiddleware,
datasetEndpointIssueMiddleware
datasetEndpointIssueMiddleware,
datasetFailedExpectationIssueMiddleware
}

export default organisationsController
76 changes: 17 additions & 59 deletions src/middleware/common.middleware.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
// @ts-check
/**
* @module middleware-common
*
*/
import logger from '../utils/logger.js'
import { types } from '../utils/logging.js'
import { dataSubjects, entryIssueGroups } from '../utils/utils.js'
import performanceDbApi from '../services/performanceDbApi.js'
import { fetchMany, fetchOne, FetchOneFallbackPolicy, FetchOptions, renderTemplate } from './middleware.builders.js'
import * as v from 'valibot'
import { pagination } from '../utils/pagination.js'
import { createPaginationTemplateParamsObject } from '../utils/pagination.js'
import datasette from '../services/datasette.js'
import { errorTemplateContext, MiddlewareError } from '../utils/errors.js'
import { dataRangeParams } from '../routes/schemas.js'
Expand Down Expand Up @@ -126,6 +130,14 @@ export const show404IfPageNumberNotInRange = (req, res, next) => {
next()
}

/**
* Potentially Updates `req` with `pagination`
*
* @param {Object} req request
* @param {Object} res response
* @param {Function} next next function
* @returns {undefined}
*/
export const createPaginationTemplateParams = (req, res, next) => {
const { pageNumber } = req.parsedParams
const { baseSubpath, dataRange } = req
Expand All @@ -139,62 +151,7 @@ export const createPaginationTemplateParams = (req, res, next) => {
return next()
}

/**
* @typedef {Object} PaginationItem
* @property {'ellipsis'|'number'} type - Type of pagination item
* @property {number} [number] - Page number (for number type)
* @property {string} href - Link URL
* @property {boolean} [ellipsis] - Whether this is an ellipsis item
* @property {boolean} [current] - Whether this is the current page
*/

/**
* @typedef {Object} PaginationNav
* @property {Object} [previous] - Previous page link
* @property {string} previous.href - Previous page URL
* @property {Object} [next] - Next page link
* @property {string} next.href - Next page URL
* @property {PaginationItem[]} items - Pagination items
*/

/** @type {PaginationNav} */
const paginationObj = {
previous: undefined,
next: undefined,
items: []
}

if (pageNumber > 1) {
paginationObj.previous = {
href: `${baseSubpath}/${pageNumber - 1}`
}
}
if (pageNumber < dataRange.maxPageNumber) {
paginationObj.next = {
href: `${baseSubpath}/${pageNumber + 1}`
}
}

for (const item of pagination(dataRange.maxPageNumber, Math.min(pageNumber, dataRange.maxPageNumber))) {
if (item === '...') {
paginationObj.items.push({
type: 'ellipsis',
ellipsis: true,
href: '#'
})
} else if (typeof item === 'number') {
paginationObj.items.push({
type: 'number',
number: item,
href: `${baseSubpath}/${item}`,
current: pageNumber === item
})
} else {
logger.warn('unexpected pagination item', { item, dataRange, types: types.App, endpoint: req.originalUrl })
}
}

req.pagination = paginationObj
req.pagination = createPaginationTemplateParamsObject({ pageNumber, dataRange, baseSubpath })

next()
}
Expand Down Expand Up @@ -853,7 +810,8 @@ export function noop (req, res, next) {

const expectationsOutOfBoundsDetailsSelectClause = () => {
return `CAST(JSON_EXTRACT(details, '$.actual') AS INTEGER) AS actual,
CAST(JSON_EXTRACT(details, '$.expected') AS INTEGER) AS expected`
CAST(JSON_EXTRACT(details, '$.expected') AS INTEGER) AS expected,
details as details`
}

const expectationsQuery = ({ lpa, dataset, expectation, includeDetails }) => {
Expand All @@ -875,7 +833,7 @@ const expectationsQuery = ({ lpa, dataset, expectation, includeDetails }) => {
* The `name` field is used in queries.
*/
export const expectations = {
entitiesOutOfBounds: { name: 'Check no entities are outside of the local planning authority boundary' }
entitiesOutOfBounds: { name: 'Check no entities are outside of the local planning authority boundary', slug: 'out-of-bounds' }
}

/**
Expand Down
159 changes: 159 additions & 0 deletions src/middleware/dataset-failed-expectation-details.middleware.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
/**
* @module middleware-dataset-failed-expectation-details
*
* @description Responsible for displaying issue page for a failed expectation of a dataset.
*
* See https://datasette.planning.data.gov.uk/digital-land/expectation for data.
*/

import * as v from 'valibot'
import {
validateOrgAndDatasetQueryParams,
expectationFetcher,
expectations,
validateQueryParams,
fetchDatasetInfo,
fetchOrgInfo
} from './common.middleware.js'
import { getIssueDetails } from './entityIssueDetails.middleware.js'
import { entityOutOfBoundsMessage } from './datasetTaskList.middleware.js'
import { MiddlewareError } from '../utils/errors.js'
import logger from '../utils/logger.js'
import { types } from '../utils/logging.js'
import { fetchOne, FetchOptions } from './middleware.builders.js'
import { createPaginationTemplateParamsObject } from '../utils/pagination.js'

const ExpectationPathParams = v.union(Object.values(expectations).map(exp => v.literal(exp.slug)))

const validateExpectationParams = validateQueryParams({
schema: v.object({
expectation: ExpectationPathParams,
pageNumber: v.optional(v.pipe(v.string(), v.transform(s => parseInt(s, 10)), v.minValue(1)), '1')
})
})

const fetchOutOfBoundsExpectations = expectationFetcher({
expectation: expectations.entitiesOutOfBounds,
includeDetails: true,
result: 'expectationOutOfBounds'
})

const fetchEntity = fetchOne({
query: ({ req }) => /* sql */ `select * --entity, name, geometry, point, entry_date
from entity
where entity = ${req.entityIds[req.parsedParams.pageNumber - 1]}`,
dataset: FetchOptions.fromParams,
result: 'entity'
})

const validateExpectationsFailed = (req, res, next) => {
if (req.expectationOutOfBounds.length === 0) {
next(new MiddlewareError('expectation query for out of bounds entities returned no results)', 404))
} else {
next()
}
}

/**
* @param {String} s JSON string
* @returns {Object|undefined}
*/
const safeParse = (s) => {
try {
return JSON.parse(s)
} catch (error) {
logger.info('safeParse() failed to parse', { type: types.App, string: s })
return undefined
}
}

const deserialiseEntities = (req, res, next) => {
const { expectationOutOfBounds } = req
req.entityIds = safeParse(expectationOutOfBounds[0].details)?.entities
next()
}

const preparePaginationInfo = (req, res, next) => {
const { orgInfo: organisation, dataset, entityIds = [] } = req
const { pageNumber } = req.parsedParams

req.dataRange = {
minRow: 1,
maxRow: 1,
totalRows: 1,
maxPageNumber: entityIds.length,
pageLength: 1,
offset: 0
}

const baseSubpath = `/organisations/${organisation.organisation}/${dataset.dataset}/expectation/out-of-bounds/entity`
req.pagination = createPaginationTemplateParamsObject({ pageNumber, baseSubpath, dataRange: req.dataRange })

next()
}

/**
*
* @param {Object} req The request object. It should contain the following properties:
* @param {Object} req.parsedParams An object containing the parameters of the request
* @param {Object} req.dataset dataset info
* @param {Object} req.orgInfo org info
* @param {Object[]} [req.expectationOutOfBounds]
* @param {string} req.expectationOutOfBounds[].dataset
* @param {boolean} req.expectationOutOfBounds[].passed did the exepectation pass
* @param {number} req.expectationOutOfBounds[].expected
* @param {number} req.expectationOutOfBounds[].actual
* @param {String} req.expectationOutOfBounds[].details JSON string
* @param {String[]} [req.entityIds] ids of entities out of bounds
* @param {Object} req.dataRange
* @param {Object} req.pagination pagination info
* @param {Object} [req.entity]
* @param {Object} req.templateParams OUT value
* @param {Object} res - The response object.
* @param {Function} next - The next middleware function.
* @returns {undefined}
*/
const prepareTemplateParams = (req, res, next) => {
const { orgInfo: organisation, dataset, expectationOutOfBounds, entity, dataRange, pagination } = req

req.templateParams = {
organisation,
dataset,
errorSummary: {
items: [
{ html: entityOutOfBoundsMessage(dataset.dataset, expectationOutOfBounds[0].actual), href: '' }
]
},
// we're hijacking isssueType & issueField here
issueType: 'expectation',
issueField: expectations.entitiesOutOfBounds.slug,
entry: {
title: `Entity: ${entity.entity}`,
fields: Object.entries(entity).map(([k, v]) => {
return {
key: { text: k },
value: { html: `${v}` },
classes: ''
}
})
},
dataRange,
pagination
}

next()
}

export default [
validateOrgAndDatasetQueryParams,
validateExpectationParams,
fetchOrgInfo,
fetchDatasetInfo,
fetchOutOfBoundsExpectations,
validateExpectationsFailed,
deserialiseEntities,
fetchEntity,
preparePaginationInfo,
prepareTemplateParams,
getIssueDetails
]
2 changes: 1 addition & 1 deletion src/middleware/datasetOverview.middleware.js
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,7 @@ export default [
fetchEntityIssueCounts,
fetchEntryIssueCounts,
fetchSpecification,
isFeatureEnabled('expectactionOutOfBoundsTask') ? fetchOutOfBoundsExpectations : noop,
isFeatureEnabled('expectationOutOfBoundsTask') ? fetchOutOfBoundsExpectations : noop,
pullOutDatasetSpecification,
// setNoticesFromSourceKey('resources'), // commented out as the logic is currently incorrect (https://github.com/digital-land/submit/issues/824)
fetchEntityCount,
Expand Down
5 changes: 2 additions & 3 deletions src/middleware/datasetTaskList.middleware.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,6 @@ const SPECIAL_ISSUE_TYPES = ['reference values are not unique']
*/
export function entityOutOfBoundsMessage (dataset, count) {
const displayNameConfig = config.datasetsConfig[dataset]?.entityDisplayName ?? { variable: 'entity', base: '' }
logger.info('about to pluralize: ', displayNameConfig)
// if count is missing for some reason, we don't display it and default to plural form
const displayName = `${displayNameConfig.base ?? ''} ${pluralize(displayNameConfig.variable, count ?? 2)}`.trim()
return `You have ${count ?? ''} ${displayName} outside of your boundary`.replace(/ {2}/, ' ')
Expand Down Expand Up @@ -170,7 +169,7 @@ export const prepareTasks = (req, res, next) => {
title: {
text: entityOutOfBoundsMessage(dataset, expectationOutOfBounds[0].actual)
},
href: '', // NOTE: design for 'expectation' task detail page not ready yet
href: `/organisations/${encodeURIComponent(lpa)}/${encodeURIComponent(dataset)}/expectation/${encodeURIComponent(expectations.entitiesOutOfBounds.slug)}`,
status: getStatusTag('Needs fixing')
})
}
Expand Down Expand Up @@ -218,7 +217,7 @@ export default [
fetchSources,
fetchDatasetInfo,
fetchResources,
isFeatureEnabled('expectactionOutOfBoundsTask') ? fetchOutOfBoundsExpectations : noop,
isFeatureEnabled('expectationOutOfBoundsTask') ? fetchOutOfBoundsExpectations : noop,
addEntityCountsToResources,
...processEntitiesMiddlewares,
fetchEntityIssueCounts,
Expand Down
2 changes: 1 addition & 1 deletion src/middleware/lpa-overview.middleware.js
Original file line number Diff line number Diff line change
Expand Up @@ -364,7 +364,7 @@ export default [
fetchEntryIssueCounts,
fetchEntityCounts,
setAvailableDatasets,
isFeatureEnabled('expectactionOutOfBoundsTask') ? fetchOutOfBoundsExpectations : noop,
isFeatureEnabled('expectationOutOfBoundsTask') ? fetchOutOfBoundsExpectations : noop,
groupResourcesByDataset,
groupIssuesCountsByDataset,
groupEndpointsByDataset,
Expand Down
2 changes: 2 additions & 0 deletions src/routes/organisations.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ const router = express.Router()
router.get('/:lpa/:dataset/get-started', OrganisationsController.getStartedMiddleware)
router.get('/:lpa/:dataset/overview', OrganisationsController.datasetOverviewMiddleware)
router.get('/:lpa/:dataset/endpoint-error/:endpoint', OrganisationsController.datasetEndpointIssueMiddleware)
router.get('/:lpa/:dataset/expectation/:expectation', OrganisationsController.datasetFailedExpectationIssueMiddleware)
router.get('/:lpa/:dataset/expectation/:expectation/entity/:pageNumber?', OrganisationsController.datasetFailedExpectationIssueMiddleware)
router.get('/:lpa/:dataset/data/:pageNumber', OrganisationsController.datasetDataviewMiddleware)
router.get('/:lpa/:dataset/data', OrganisationsController.datasetDataviewMiddleware)
router.get('/:lpa/:dataset/:issue_type/:issue_field/entity/:pageNumber', OrganisationsController.entityIssueDetailsMiddleware)
Expand Down
Loading