Skip to content

Commit

Permalink
check tool: support for deep links
Browse files Browse the repository at this point in the history
- new route: '/check/link' which requires query params and
  redirects to the correct step in the wizard
- added DeepLinkController - to populate the session/history
  with passed query params
- new filter for generating a deep link (path+params)
- removed cookie parser (express docs say it's no longer needed and can
  actually cause issues)
- update check tool templates to display the dataset and organisation
  names when the wizard was entered via a deep link
- some utility functions/maps to simplify looking up datasets info
- moved a predicate out of DatasetController so it can be reused
  • Loading branch information
rosado committed Oct 11, 2024
1 parent a5b8886 commit aee164a
Show file tree
Hide file tree
Showing 25 changed files with 326 additions and 75 deletions.
10 changes: 2 additions & 8 deletions src/controllers/chooseDatasetController.js
Original file line number Diff line number Diff line change
@@ -1,16 +1,10 @@
import PageController from './pageController.js'

import { dataSubjects } from '../utils/utils.js'
import { dataSubjects, availableDatasets } from '../utils/utils.js'

class ChooseDatasetController extends PageController {
locals (req, res, next) {
const availableDataSubjects = Object.values(dataSubjects).filter(dataSubject => dataSubject.available)
const dataSets = Object.values(availableDataSubjects).map(dataSubject => dataSubject.dataSets).flat()
const availableDatasets = dataSets.filter(dataSet => dataSet.available)
availableDatasets.sort((a, b) => a.text.localeCompare(b.text))

req.form.options.datasetItems = availableDatasets

req.form.options.datasetItems = availableDatasets(dataSubjects)
super.locals(req, res, next)
}
}
Expand Down
41 changes: 13 additions & 28 deletions src/controllers/datasetController.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,46 +4,31 @@ import PageController from './pageController.js'

// ToDo: we shouldn't hardcode these values here, should we get them from the API
// maybe take from specification
import { dataSubjects } from '../utils/utils.js'
import { dataSubjects, datasets, availableDatasets } from '../utils/utils.js'

/**
* @param req
* @returns {boolean}
*/
export function requiresGeometryTypeToBeSelected (req) {
const dataset = req.body.dataset
const dataSet = datasets.get(dataset)
return dataSet?.requiresGeometryTypeSelection || false
}

class DatasetController extends PageController {
locals (req, res, next) {
const availableDataSubjects = Object.values(dataSubjects).filter(dataSubject => dataSubject.available)
const dataSets = Object.values(availableDataSubjects).map(dataSubject => dataSubject.dataSets).flat()
const availableDatasets = dataSets.filter(dataSet => dataSet.available)
availableDatasets.sort((a, b) => a.text.localeCompare(b.text))

req.form.options.datasetItems = availableDatasets

req.form.options.datasetItems = availableDatasets(dataSubjects)
super.locals(req, res, next)
}

// we shouldn't need this here but as we dont currently set the datasubject, we need to do so here
post (req, res, next) {
const dataset = req.body.dataset
// set the data-subject based on the dataset selected
let dataSubject = ''
for (const [key, value] of Object.entries(dataSubjects)) {
if (value.dataSets.find(dataSet => dataSet.value === dataset)) {
dataSubject = key
break
}
}
const { dataSubject } = datasets.get(dataset) || { dataSubject: '' }
req.body['data-subject'] = dataSubject
super.post(req, res, next)
}

requiresGeometryTypeToBeSelected (req) {
const dataset = req.body.dataset

if (!dataset) {
return false
}

const dataSubject = Object.values(dataSubjects).find(dataSubject => dataSubject.dataSets.find(dataSet => dataSet.value === dataset))
const dataSet = dataSubject.dataSets.find(dataSet => dataSet.value === dataset)
return dataSet.requiresGeometryTypeSelection || false
}
}

export default DatasetController
59 changes: 59 additions & 0 deletions src/controllers/deepLinkController.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import PageController from './pageController.js'

import { datasets } from '../utils/utils.js'
import logger from '../utils/logger.js'
import { types } from '../utils/logging.js'
import * as v from 'valibot'
import { NonEmptyString } from '../routes/schemas.js'

const QueryParams = v.object({
dataset: NonEmptyString,
orgName: NonEmptyString
})

/**
* Handles deep links in the Check Tool.
*
* It is meant to extract required params from query params
* and partially pre-populate the session with them,
* then redirect the user to the "next" page in the wizard
*/
class DeepLinkController extends PageController {
get (req, res, next) {
// if the query params don't contain what we need, redirect to the "get started" page,
// this way the user can still proceed (but need to fill the dataset+orgName themselves)
const { dataset, orgName } = req.query
const validationResult = v.safeParse(QueryParams, req.query)
if (!(validationResult.success && datasets.has(dataset))) {
logger.info('DeepLinkController.get(): invalid params for deep link, redirecting to start page',
{ type: types.App, query: req.query })
return res.redirect('/check')
}

req.sessionModel.set('dataset', dataset)
const datasetInfo = datasets.get(dataset) ?? { dataSubject: '', requiresGeometryTypeSelection: false }
req.sessionModel.set('data-subject', datasetInfo.dataSubject)
req.sessionModel.set(this.checkToolDeepLinkSessionKey,
{ 'data-subject': datasetInfo.dataSubject, orgName, dataset, datasetName: datasetInfo.text })

this.#addHistoryStep(req, '/check/dataset')

super.post(req, res, next)
}

#addHistoryStep (req, path, next) {
const newItem = {
path,
wizard: 'check-wizard',
fields: ['dataset', 'data-subject'],
skip: false,
continueOnEdit: false
}

const history = req.journeyModel.get('history') || []
history.push(newItem)
req.journeyModel.set('history', history)
}
}

export default DeepLinkController
17 changes: 12 additions & 5 deletions src/controllers/pageController.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,22 @@ import { logPageView } from '../utils/logging.js'
const { Controller } = hmpoFormWizard

class PageController extends Controller {
configure (req, res, callback) {
req.form.options.lastPage = this.options.backLink ? this.options.backLink : undefined
super.configure(req, res, callback)
}

checkToolDeepLinkSessionKey = 'check-tool-deep-link'

get (req, res, next) {
logPageView(this.options.route, req.sessionID, req.ip)
super.get(req, res, next)
}

locals (req, res, next) {
if (this.options.backLink) {
req.form.options.lastPage = this.options.backLink
}
if (req.sessionModel) {
req.form.options.deepLink = req.sessionModel.get(this.checkToolDeepLinkSessionKey)
}
super.locals(req, res, next)
}
}

export default PageController
11 changes: 11 additions & 0 deletions src/filters/checkToolDeepLink.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/**
* Returns the deep link to the check tool for a given dataset and organisation
*
* @param {{name:string}} organisation
* @param {{dataset:string, name:string}} dataset
*
* @return {string}
*/
export function checkToolDeepLink (organisation, dataset) {
return `/check/link?dataset=${encodeURIComponent(dataset.dataset)}&orgName=${encodeURIComponent(organisation.name)}`
}
2 changes: 2 additions & 0 deletions src/filters/filters.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import toErrorList from './toErrorList.js'
import prettifyColumnName from './prettifyColumnName.js'
import getFullServiceName from './getFullServiceName.js'
import { makeDatasetSlugToReadableNameFilter } from './makeDatasetSlugToReadableNameFilter.js'
import { checkToolDeepLink } from './checkToolDeepLink.js'
import pluralize from 'pluralize'

/** maps dataset status (as returned by `fetchLpaOverview` middleware to a
Expand Down Expand Up @@ -40,6 +41,7 @@ const addFilters = (nunjucksEnv, { datasetNameMapping }) => {
nunjucksEnv.addFilter('getFullServiceName', getFullServiceName)
nunjucksEnv.addFilter('statusToTagClass', statusToTagClass)
nunjucksEnv.addFilter('pluralise', pluralize)
nunjucksEnv.addFilter('checkToolDeepLink', checkToolDeepLink)
}

export default addFilters
11 changes: 10 additions & 1 deletion src/middleware/datasetTaskList.middleware.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { fetchDatasetInfo, isResourceAccessible, isResourceNotAccessible, fetchLatestResource, fetchEntityCount, logPageError, fetchLpaDatasetIssues } from './common.middleware.js'
import { fetchDatasetInfo, isResourceAccessible, isResourceNotAccessible, fetchLatestResource, fetchEntityCount, logPageError, fetchLpaDatasetIssues, validateQueryParams } from './common.middleware.js'
import { parallel, fetchOne, fetchIf, onlyIf, renderTemplate } from './middleware.builders.js'
import performanceDbApi from '../services/performanceDbApi.js'
import { statusToTagClass } from '../filters/filters.js'
import * as v from 'valibot'

/**
* Fetches the resource status
Expand Down Expand Up @@ -111,7 +112,15 @@ const getDatasetTaskListError = renderTemplate({
handlerName: 'getDatasetTaskListError'
})

const validateParams = validateQueryParams({
schema: v.object({
lpa: v.string(),
dataset: v.string()
})
})

export default [
validateParams,
fetchResourceStatus,
fetchOrgInfoWithStatGeo,
fetchDatasetInfo,
Expand Down
40 changes: 29 additions & 11 deletions src/routes/form-wizard/check/steps.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
// ToDo: Split this into two form wizards
import PageController from '../../../controllers/pageController.js'
import datasetController from '../../../controllers/datasetController.js'
import datasetController, { requiresGeometryTypeToBeSelected } from '../../../controllers/datasetController.js'
import uploadFileController from '../../../controllers/uploadFileController.js'
import submitUrlController from '../../../controllers/submitUrlController.js'
import statusController from '../../../controllers/statusController.js'
import resultsController from '../../../controllers/resultsController.js'
import deepLinkController from '../../../controllers/deepLinkController.js'

const baseSettings = {
controller: PageController,
Expand All @@ -20,17 +21,13 @@ export default {
template: 'check/start.html',
noPost: true
},
// '/data-subject': {
// ...baseSettings,
// fields: ['data-subject'],
// next: 'dataset'
// },
'/dataset': {
...baseSettings,
controller: datasetController,
fields: ['dataset', 'data-subject'],
checkJourney: false,
next: [
{ field: 'dataset', fn: 'requiresGeometryTypeToBeSelected', next: 'geometry-type' },
{ field: 'dataset', fn: requiresGeometryTypeToBeSelected, next: 'geometry-type' },
'upload-method'
],
backLink: './'
Expand All @@ -39,7 +36,8 @@ export default {
...baseSettings,
fields: ['geomType'],
next: 'upload-method',
backLink: './dataset'
backLink: './dataset',
checkJourney: false
},
'/upload-method': {
...baseSettings,
Expand All @@ -48,21 +46,24 @@ export default {
{ field: 'upload-method', op: '===', value: 'url', next: 'url' },
'upload'
],
backLink: './dataset'
backLink: './dataset',
checkJourney: false
},
'/url': {
...baseSettings,
controller: submitUrlController,
fields: ['url', 'request_id'],
next: (req, res) => `status/${req.sessionModel.get('request_id')}`,
backLink: './upload-method'
backLink: './upload-method',
checkJourney: false
},
'/upload': {
...baseSettings,
controller: uploadFileController,
fields: ['datafile', 'request_id'],
next: (req, res) => `status/${req.sessionModel.get('request_id')}`,
backLink: './upload-method'
backLink: './upload-method',
checkJourney: false
},
'/status/:id': {
...baseSettings,
Expand Down Expand Up @@ -90,5 +91,22 @@ export default {
noPost: true,
checkJourney: false, // ToDo: it would be useful here if we make sure they have selected if their results are ok from the previous step
template: 'check/confirmation.html'
},
// This step allows to fill in some of the required data via query params.
// This way we can link from a dataset issues page to the Check Tool without
// the user having to go through the whole process again.
// This means it doesn't render a page, but redirects the client to the next step
'/link': {
...baseSettings,
controller: deepLinkController,
next: [
{ field: 'dataset', fn: requiresGeometryTypeToBeSelected, next: 'geometry-type' },
'upload-method'
],
entryPoint: true,
resetJourney: true,
reset: true,
skip: true,
checkJourney: false
}
}
2 changes: 1 addition & 1 deletion src/routes/schemas.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export const ErrorParams = v.strictObject({
err: v.object({})
})

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

export const Base = v.object({
// serviceName: NonEmptyString,
Expand Down
2 changes: 0 additions & 2 deletions src/serverSetup/session.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
import session from 'express-session'
import { createClient } from 'redis'
import RedisStore from 'connect-redis'
import cookieParser from 'cookie-parser'
import config from '../../config/index.js'
import logger from '../utils/logger.js'
import { types } from '../utils/logging.js'

export async function setupSession (app) {
app.use(cookieParser())
let sessionStore
if ('redis' in config) {
const urlPrefix = `redis${config.redis.secure ? 's' : ''}`
Expand Down
29 changes: 29 additions & 0 deletions src/utils/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,35 @@ export const dataSubjects = {
}
}

export function makeDatasetsLookup (dataSubjects) {
const lookup = new Map()
for (const [key, dataSubject] of Object.entries(dataSubjects)) {
for (const dataSet of dataSubject.dataSets) {
lookup.set(dataSet.value, { ...dataSet, dataSubject: key })
}
}

return lookup
}

/**
* @type {Map<string, {value: string, text: string, available: boolean, dataSubject: string, requiresGeometryTypeSelection?: boolean}>}
*/
export const datasets = makeDatasetsLookup(dataSubjects)

/**
*
* @param dataSubjects
* @returns {FlatArray<*[], 1>[]} datasets sorted by 'text' property
*/
export function availableDatasets (dataSubjects) {
const availableDataSubjects = Object.values(dataSubjects).filter(dataSubject => dataSubject.available)
const dataSets = Object.values(availableDataSubjects).map(dataSubject => dataSubject.dataSets).flat()
const availableDatasets = dataSets.filter(dataSet => dataSet.available)
availableDatasets.sort((a, b) => a.text.localeCompare(b.text))
return availableDatasets
}

export const finishedProcessingStatuses = [
'COMPLETE',
'FAILED'
Expand Down
6 changes: 6 additions & 0 deletions src/views/check/geometry-type.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
{% from 'govuk/components/radios/macro.njk' import govukRadios %}
{% from 'govuk/components/error-message/macro.njk' import govukErrorMessage %}
{% from 'govuk/components/error-summary/macro.njk' import govukErrorSummary %}
{% from 'components/dataset-banner.html' import datasetBanner %}

{% extends "layouts/main.html" %}

Expand All @@ -23,6 +24,11 @@
{% endblock %}

{% block content %}

{% if options.deepLink %}
{{ datasetBanner(options.deepLink) }}
{% endif %}

<div class="govuk-grid-row">
<div class="govuk-grid-column-two-thirds">
{% if error %}
Expand Down
Loading

0 comments on commit aee164a

Please sign in to comment.