Skip to content

Commit

Permalink
PI-1504 Store search parameters in user session
Browse files Browse the repository at this point in the history
  • Loading branch information
marcus-bcl committed Oct 11, 2023
1 parent e1e16b0 commit 3aec169
Show file tree
Hide file tree
Showing 24 changed files with 453 additions and 146 deletions.
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,28 @@ Then, start the UI service:
npm run start:dev
```

## Development

### Running with HTTPS

This service also provides the Delius search screen (see [/delius/nationalSearch](https://probation-search-dev.hmpps.service.justice.gov.uk/delius/nationalSearch)),
which is loaded in an iframe in the Delius application.
For it to work in a cross-site iframe context, the Express session cookie must be served over HTTPS.

To enable HTTPS during local development, first create a self-signed certificate:
```shell
openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout key.pem -out cert.pem
```

Then, in the `.env` file, add the following:
```properties
INGRESS_URL=https://localhost:3000
HTTPS_KEY=key.pem
HTTPS_CERT=cert.pem
```

You will also need to add `https://localhost:3000/sign-in/callback` as a registered redirect URI for your auth client.

## Testing
### Run linter

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,28 +42,36 @@
{% if params.searchOnInput %}
<label for="{{ params.id }}" class="govuk-visually-hidden">Results will be updated as you type</label>
<script nonce="{{ params.results.cspNonce }}">
let timeoutId = null
document.getElementById("{{ params.id }}-form").addEventListener("submit", () => clearTimeout(timeoutId))
document.getElementById("{{ params.id }}").addEventListener('input', function() {
clearTimeout(timeoutId)
const url = new URL(location.href)
url.searchParams.delete("page")
url.searchParams.delete("providers[]")
url.searchParams.set('q', this.value)
timeoutId = setTimeout(() => fetch(url).then(async response => {
if (response.status === 200) {
const doc = new DOMParser().parseFromString(await response.text(), 'text/html')
document.getElementById("{{ params.id }}-results-container").innerHTML = doc.getElementById("{{ params.id }}-results-container").innerHTML
document.getElementById("{{ params.id }}-results-container").classList = doc.getElementById("{{ params.id }}-results-container").classList
document.getElementById("{{ params.id }}-suggestions").innerHTML = doc.getElementById("{{ params.id }}-suggestions").innerHTML
document.getElementsByName("_csrf")[0].value = doc.getElementsByName("_csrf")[0].value
history.pushState({}, '', url)
} else {
document.getElementById("{{ params.id }}-results-container").innerHTML = '<div class="govuk-error-summary"><h2 class="govuk-error-summary__title">Something went wrong</h2><div class="govuk-error-summary__body">The error has been logged. Please try again.</div></div>'
document.getElementById("{{ params.id }}-suggestions").innerHTML = ''
}
}), 250)
})
(() => {
let timeoutId = null;
function doSearch() {
clearTimeout(timeoutId);
const url = new URL(location.href);
url.searchParams.delete("page");
url.searchParams.delete("providers[]");
url.searchParams.set("q", document.getElementById("{{ params.id }}").value);
timeoutId = setTimeout(() => fetch(url).then(async response => {
if (response.status === 200) {
const doc = new DOMParser().parseFromString(await response.text(), "text/html");
document.getElementById("{{ params.id }}-results-container").innerHTML = doc.getElementById("{{ params.id }}-results-container").innerHTML;
document.getElementById("{{ params.id }}-results-container").classList = doc.getElementById("{{ params.id }}-results-container").classList;
document.getElementById("{{ params.id }}-suggestions").innerHTML = doc.getElementById("{{ params.id }}-suggestions").innerHTML;
document.getElementsByName("_csrf")[0].value = doc.getElementsByName("_csrf")[0].value;
history.pushState({}, "", url);
} else {
document.getElementById("{{ params.id }}-results-container").innerHTML = "<div class=\"govuk-error-summary\"><h2 class=\"govuk-error-summary__title\">Something went wrong</h2><div class=\"govuk-error-summary__body\">The error has been logged. Please try again.</div></div>";
document.getElementById("{{ params.id }}-suggestions").innerHTML = "";
}
}), 250);
}
document.getElementById("{{ params.id }}").addEventListener("input", doSearch);
document.getElementById("{{ params.id }}-form").addEventListener("submit", e => {
doSearch();
e.preventDefault();
});
})();
</script>
{% else %}
{{ govukButton({ text: "Search" }) }}
Expand Down
50 changes: 50 additions & 0 deletions packages/probation-search-frontend/data/searchParameters.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { Request } from 'express'
import SearchParameters, { ProbationSearchSession } from './searchParameters'

describe('SearchParameters', () => {
let req: Request
let session: ProbationSearchSession

beforeEach(() => {
session = {}
req = {
protocol: 'https',
get: () => 'localhost',
originalUrl: '/path',
session,
} as unknown as Request
})

it('should load parameters from the session', () => {
session.probationSearch = {
q: 'value1',
providers: ['value2', 'value3'],
}
const params = SearchParameters.loadFromSession(req)
expect(params).toBe('https://localhost/path?q=value1&providers=value2&providers=value3')
})

it('should save parameters to the session', () => {
req.query = {
q: 'value1',
providers: ['value2', 'value3'],
}
SearchParameters.saveToSession(req)
expect(session.probationSearch).toEqual({
q: 'value1',
providers: ['value2', 'value3'],
})
})

it('should check if parameters are in the session', () => {
expect(SearchParameters.inSession(req)).toBe(false)

req.query = {
param1: 'value1',
param2: ['value2', 'value3'],
}
SearchParameters.saveToSession(req)

expect(SearchParameters.inSession(req)).toBe(true)
})
})
36 changes: 36 additions & 0 deletions packages/probation-search-frontend/data/searchParameters.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { Request } from 'express'
import { addParameters } from '../utils/url'

export default class SearchParameters {
private static getSession(req: Request): ProbationSearchSession {
return req.session as unknown
}

static loadFromSession(req: Request): string {
return addParameters(req, SearchParameters.getSession(req).probationSearch)
}

static saveToSession(req: Request) {
SearchParameters.getSession(req).probationSearch = {
q: req.query.q as string,
matchAllTerms: req.query.matchAllTerms as string,
providers: req.query.providers as string[],
page: req.query.page as string,
}
}

static inSession(req: Request): boolean {
return 'probationSearch' in req.session
}
}

export interface ProbationSearchSession {
probationSearch?: ProbationSearchParameters
}

export interface ProbationSearchParameters extends Record<string, string | string[]> {
q?: string
page?: string
matchAllTerms?: string
providers?: string[]
}
47 changes: 28 additions & 19 deletions packages/probation-search-frontend/routes/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ import data from '../data/localData'
import getPaginationLinks, { Pagination } from '../utils/pagination'
import getSuggestionLinks, { SuggestionLink } from '../utils/suggestions'
import wrapAsync from '../utils/middleware'
import addParameters from '../utils/url'
import { addParameters } from '../utils/url'
import SearchParameters from '../data/searchParameters'

export default function probationSearchRoutes({
environment,
Expand All @@ -31,43 +32,51 @@ export default function probationSearchRoutes({
}: ProbationSearchRouteOptions): Router {
const client = new ProbationSearchClient(oauthClient, environment === 'local' ? localData : environment)

router.post(path, post({ allowEmptyQuery, template, templateFields }))
router.get(path, get(client, { pageSize, maxPagesToShow, resultsFormatter, template, templateFields }))
router.post(path, redirectToResults({ allowEmptyQuery, template, templateFields }))
router.get(path, renderResults(client, { pageSize, maxPagesToShow, resultsFormatter, template, templateFields }))

return router
}

export function post({ allowEmptyQuery, template, templateFields }: PostOptions) {
export function redirectToResults({ allowEmptyQuery, template, templateFields }: PostOptions) {
return (req: Request, res: Response) => {
const query = req.body['probation-search-input']
if (!allowEmptyQuery && (query == null || query.length === 0)) {
if (!allowEmptyQuery && (query == null || query === '')) {
const probationSearchResults: ResultTemplateParams = {
errorMessage: { text: 'Please enter a search term' },
...securityParams(res),
}
res.render(template, { probationSearchResults, ...templateFields(req, res) })
} else {
res.redirect(addParameters(req.url, { q: query, page: '1' }))
res.redirect(addParameters(req, { q: query, page: '1' }))
}
}
}

export function get(
export function renderResults(
client: ProbationSearchClient,
{ resultsFormatter, template, templateFields, pageSize, maxPagesToShow }: GetOptions,
) {
return wrapAsync(async (req: Request, res: Response) => {
const query = req.query.q as string
if (query == null || query === '') {
// No query, render empty search screen
res.render(template, { probationSearchResults: securityParams(res), ...templateFields(req, res) })
if (SearchParameters.inSession(req)) {
// No query in the url, but we have one stored in the session, redirect using session values
res.redirect(SearchParameters.loadFromSession(req))
} else {
// No query in the url or session, render empty search screen
res.render(template, { probationSearchResults: securityParams(res), ...templateFields(req, res) })
}
} else {
// Render search results
const pageNumber = req.query.page ? Number.parseInt(req.query.page as string, 10) : 1
const matchAllTerms = (req.query.matchAllTerms ?? 'true') === 'true'
const providersFilter = (req.query.providers as string[]) ?? []
const asUsername = res.locals.user.username
const request = { query, matchAllTerms, providersFilter, asUsername, pageNumber, pageSize }
// Load search results
const request = {
query,
matchAllTerms: (req.query.matchAllTerms ?? 'true') === 'true',
providersFilter: (req.query.providers as string[]) ?? [],
asUsername: res.locals.user.username,
pageNumber: req.query.page ? Number.parseInt(req.query.page as string, 10) : 1,
pageSize,
}

const response = await client.search(request)

Expand All @@ -77,16 +86,17 @@ export function get(
results: await resultsFormatter(response, request),
suggestions: getSuggestionLinks(response, parseurl(req)),
pagination: getPaginationLinks(
pageNumber,
request.pageNumber,
response.totalPages,
response.totalElements,
page => addParameters(req.url, { page: page.toString() }),
page => addParameters(req, { page: page.toString() }),
pageSize,
maxPagesToShow,
),
...securityParams(res),
}
res.render(template, { probationSearchResults, ...templateFields(req, res) })
SearchParameters.saveToSession(req)
}
})
}
Expand Down Expand Up @@ -119,7 +129,6 @@ function securityParams(res: Response): { csrfToken: string; cspNonce: string; u
}

interface GetOptions {
path?: string
pageSize?: number
maxPagesToShow?: number
template?: string
Expand All @@ -134,7 +143,6 @@ interface GetOptions {
}

interface PostOptions {
path?: string
template?: string
templateFields?: (req: Request, res: Response) => object
allowEmptyQuery?: boolean
Expand All @@ -144,6 +152,7 @@ export type ProbationSearchRouteOptions = {
environment: 'local' | 'dev' | 'preprod' | 'prod'
oauthClient: OAuthClient
router: Router
path?: string
localData?: ProbationSearchResult[]
} & GetOptions &
PostOptions
Expand Down
34 changes: 32 additions & 2 deletions packages/probation-search-frontend/utils/url.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,43 @@
import addParameters from './url'
import { Request } from 'express'
import { addParameters, getAbsoluteUrl, removeParameters } from './url'

describe('getAbsoluteUrl', () => {
test.each([
['http', 'example.com', '/path', 'http://example.com/path'],
['https', 'example.com', '/path', 'https://example.com/path'],
['http', 'localhost:3000', '/path?param=value', 'http://localhost:3000/path?param=value'],
])('returns the correct absolute URL for %s://%s%s', (protocol, host, originalUrl, expected) => {
const req = { protocol, originalUrl, get: () => host } as unknown as Request
expect(getAbsoluteUrl(req)).toBe(expected)
})
})

describe('addParameters', () => {
it.each([
['https://example.com', { p1: 'value1' }, 'https://example.com/?p1=value1'],
['https://example.com/path', { p1: 'value1' }, 'https://example.com/path?p1=value1'],
['https://example.com?p1=value1', { p1: 'newValue', p2: 'value2' }, 'https://example.com/?p1=newValue&p2=value2'],
['/path?p1=value1', { p1: 'newValue', p2: 'value2' }, '/path?p1=newValue&p2=value2'],
['https://example.com?p1=value1', { p1: ['1', '2', '3'] }, 'https://example.com/?p1=1&p1=2&p1=3'],
['https://example.com/path?p1=value1', undefined, 'https://example.com/path?p1=value1'],
])('should add parameters to the URL', (url, params, expected) => {
const result = addParameters(url, params)
expect(result).toBe(expected)
})
})

describe('removeParameters', () => {
it.each([
[
'https://example.com/?param1=value1&param2=value2&param3=value3',
['param1', 'param3'],
'https://example.com/?param2=value2',
],
['https://example.com/?param1=value1', [], 'https://example.com/?param1=value1'],
['https://example.com/?param1=value1', ['param2'], 'https://example.com/?param1=value1'],
['https://example.com/path', ['param1'], 'https://example.com/path'],
['https://example.com/path?param1=value', [undefined], 'https://example.com/path?param1=value'],
])('should remove specified parameters from the URL (%s)', (url, paramsToRemove: string[], expected) => {
const result = removeParameters(url, ...paramsToRemove)
expect(result).toBe(expected)
})
})
30 changes: 23 additions & 7 deletions packages/probation-search-frontend/utils/url.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,25 @@
import { parse, format } from 'url'
import { Request } from 'express'

export default function addParameters(url: string, params: { [key: string]: string }): string {
const parsedUrl = parse(url)
const urlSearchParams = new URLSearchParams(parsedUrl.search)
Object.entries(params).forEach(([key, value]) => urlSearchParams.set(key, value))
parsedUrl.search = urlSearchParams.toString()
return format(parsedUrl)
export function getAbsoluteUrl(req: Request): string {
return `${req.protocol}://${req.get('host')}${req.originalUrl}`
}

export function addParameters(url: string | Request, params?: Record<string, string | string[]>): string {
const newUrl = new URL(typeof url === 'string' ? url : getAbsoluteUrl(url))
if (params)
Object.entries(params).forEach(([key, value]) => {
newUrl.searchParams.delete(key)
if (Array.isArray(value)) {
value.forEach(v => newUrl.searchParams.append(key, v.toString()))
} else {
newUrl.searchParams.set(key, value.toString())
}
})
return newUrl.toString()
}

export function removeParameters(url: string | Request, ...params: string[]): string {
const newUrl = new URL(typeof url === 'string' ? url : getAbsoluteUrl(url))
params.forEach(key => newUrl.searchParams.delete(key))
return newUrl.toString()
}
Loading

0 comments on commit 3aec169

Please sign in to comment.