Skip to content

Commit

Permalink
PI-1449 Enable filtering of results by provider / probation area
Browse files Browse the repository at this point in the history
Also adds support for "match all terms"
  • Loading branch information
marcus-bcl committed Sep 19, 2023
1 parent 466482a commit 61e53fa
Show file tree
Hide file tree
Showing 7 changed files with 162 additions and 95 deletions.
59 changes: 10 additions & 49 deletions assets/scss/new-tech.scss
Original file line number Diff line number Diff line change
Expand Up @@ -42,61 +42,22 @@ $govuk-page-width: $moj-page-width;
}

.app-national-search-filter {
background-color: govuk-colour("mid-grey");
margin-bottom: 10px;

button {
z-index: 100;
position: relative;
border: none;
display: block;
width: 100%;
text-align: left;
cursor: pointer;
padding: govuk-spacing(2);
background: govuk-colour("mid-grey") url(../images/accordion-arrow.png) no-repeat;

&.open {
background-position: right -50px;
}
background-color: govuk-colour("light-grey");

&.closed {
background-position: right -5px;
}
legend {
font-weight: $govuk-font-weight-bold;
margin-bottom: 0;
padding: govuk-spacing(2);
}

.filter-container {
border: $govuk-border-colour 5px solid;
border-top: 0;
fieldset > div {
background-color: govuk-colour("white");
max-height: 20vh;

position: relative;
border: 5px solid govuk-colour("light-grey");
border-top: 0;
max-height: 30vh;
overflow-y: auto;
overflow-x: hidden;

&.closed {
display: none;
}

label {
//@include govuk-font($size: 16);
display: block;
padding: govuk-spacing(2) govuk-spacing(4);
cursor: pointer;
border-bottom: 1px $govuk-border-colour solid;

&:hover {
background-color: govuk-colour("light-grey");
}
}

.filter-option {
float: left;
width: 20px;
height: 20px;
margin-left: -10px;
}
padding: govuk-spacing(2) 0 govuk-spacing(2) govuk-spacing(4);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,20 +56,16 @@
</div>
</form>

{% if params.results.response.totalElements > 0 %}
<div id="{{ params.id }}-results">
<div id="{{ params.id }}-results">
{% if params.results.results is string %}
{{ params.results.results | safe }}
{% elif params.results.response.totalElements > 0 %}
<p>Showing {{ params.results.page.from }} to {{ params.results.page.to }} of {{ params.results.page.totalResults }} results.</p>

{% if params.results.results is string %}
{{ params.results.results | safe }}
{% else %}
{{ govukTable({ firstCellIsHeader: true, head: params.results.results.head, rows: params.results.results.rows }) }}
{% endif %}

{% if params.results.page.items | length > 1 %}
{{ govukPagination({ previous: { href: params.results.page.prev }, next: { href: params.results.page.next }, items: params.results.page.items }) }}
{% endif %}
</div>
{% elif params.results.query != null %}
<div id="{{ params.id }}-results"><p>There are no results for your search. Try refining your query above.</p></div>
{% endif %}
{{ govukTable({ firstCellIsHeader: true, head: params.results.results.head, rows: params.results.results.rows }) }}
{% elif params.results.query != null %}
<p>There are no results for your search. Try refining your query above.</p>
{% endif %}
{% if params.results.page.items | length > 1 %}
{{ govukPagination({ previous: { href: params.results.page.prev }, next: { href: params.results.page.next }, items: params.results.page.items }) }}
{% endif %}
</div>
62 changes: 49 additions & 13 deletions packages/probation-search-frontend/data/probationSearchClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,19 @@ import OAuthClient from './oauthClient'
import config, { Environment } from '../config'

export default class ProbationSearchClient {
constructor(private oauthClient: OAuthClient, private dataSource: Environment | ProbationSearchResult[]) {}
constructor(
private oauthClient: OAuthClient,
private dataSource: Environment | ProbationSearchResult[],
) {}

async search(query: string, asUsername: string = null, page = 1, size = 10): Promise<ProbationSearchResponse> {
async search({
query,
matchAllTerms = true,
providersFilter = [],
asUsername,
page = 1,
size = 10,
}: ProbationSearchRequest): Promise<ProbationSearchResponse> {
if (this.dataSource instanceof Array) {
return Promise.resolve(this.localSearch(this.dataSource, page, size))
}
Expand All @@ -18,22 +28,57 @@ export default class ProbationSearchClient {
.retry(2)
.send({
phrase: query,
matchAllTerms: true,
probationAreasFilter: providersFilter,
matchAllTerms,
})
return response.body
}

private localSearch(data: ProbationSearchResult[], page: number, size: number): ProbationSearchResponse {
const content = data.slice((page - 1) * size, page * size)
const probationAreaAggregations = Array.from(
data
.map(r => r.offenderManagers?.filter(manager => manager.active).shift().probationArea)
.reduce((map, obj) => {
map.set(obj.code, { ...obj, count: map.has(obj.code) ? map.get(obj.code).count + 1 : 1 })
return map
}, new Map())
.values(),
)
return {
content,
probationAreaAggregations,
size: content.length,
totalElements: this.dataSource.length,
totalPages: Math.ceil(this.dataSource.length / size),
}
}
}

export interface ProbationSearchRequest {
query: string
matchAllTerms: boolean
providersFilter: string[]
asUsername: string
page: number
size: number
}

export interface ProbationSearchResponse {
content: ProbationSearchResult[]
suggestions?: {
suggest?: { [key: string]: Suggestion[] }
}
probationAreaAggregations: {
code: string
description: string
count: number
}[]
size: number
totalElements: number
totalPages: number
}

export interface ProbationSearchResult {
otherIds: {
crn: string
Expand All @@ -51,6 +96,7 @@ export interface ProbationSearchResult {
offenderManagers?: {
active: boolean
probationArea: {
code: string
description: string
}
staff: {
Expand All @@ -61,16 +107,6 @@ export interface ProbationSearchResult {
accessDenied?: boolean
}

export interface ProbationSearchResponse {
content: ProbationSearchResult[]
suggestions?: {
suggest?: { [key: string]: Suggestion[] }
}
size: number
totalElements: number
totalPages: number
}

export interface Suggestion {
text: string
options: {
Expand Down
24 changes: 19 additions & 5 deletions packages/probation-search-frontend/routes/search.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { format, parseISO } from 'date-fns'
import { NextFunction, Request, RequestHandler, Response, Router } from 'express'
import ProbationSearchClient, { ProbationSearchResponse, ProbationSearchResult } from '../data/probationSearchClient'
import ProbationSearchClient, {
ProbationSearchRequest,
ProbationSearchResponse,
ProbationSearchResult,
} from '../data/probationSearchClient'
import OAuthClient from '../data/oauthClient'

export interface ProbationSearchRouteOptions {
Expand All @@ -12,7 +16,7 @@ export interface ProbationSearchRouteOptions {
template?: string
nameFormatter?: (result: ProbationSearchResult) => string
dateFormatter?: (date: Date) => string
responseFormatter?: (result: ProbationSearchResponse) => string | Table
resultsFormatter?: (apiResponse: ProbationSearchResponse, apiRequest: ProbationSearchRequest) => string | Table
localData?: ProbationSearchResult[]
allowEmptyQuery?: boolean
pageSize?: number
Expand All @@ -33,7 +37,7 @@ export default function probationSearchRoutes({
template = 'pages/search',
nameFormatter = (result: ProbationSearchResult) => `${result.firstName} ${result.surname}`,
dateFormatter = (date: Date) => format(date, 'dd/MM/yyyy'),
responseFormatter = (response: ProbationSearchResponse) => {
resultsFormatter = (response: ProbationSearchResponse) => {
return {
head: [{ text: 'Name' }, { text: 'CRN' }, { text: 'Date of Birth' }],
rows: response.content?.map(result => [
Expand Down Expand Up @@ -87,12 +91,22 @@ export default function probationSearchRoutes({
path,
wrapAsync(async (req, res) => {
const query = req.query.q as string
const providers = (req.query.providers as string[]) ?? []
const matchAllTerms = (req.query.matchAllTerms ?? 'true') === 'true'
if (query == null || query === '') {
res.render(template, { probationSearchResults: defaultResult(res) })
} else {
const currentPage = req.query.page ? Number.parseInt(req.query.page as string, 10) : 1
const response = await client.search(query, res.locals.user.username, currentPage, pageSize)
const results = responseFormatter(response)
const request = {
query,
matchAllTerms,
providersFilter: providers,
asUsername: res.locals.user.username,
page: currentPage,
size: pageSize,
}
const response = await client.search(request)
const results = resultsFormatter(response, request)
res.render(template, {
probationSearchResults: {
query,
Expand Down
35 changes: 24 additions & 11 deletions server/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ import { type RequestHandler, Router } from 'express'

import probationSearchRoutes from '@ministryofjustice/probation-search-frontend/routes/search'
import nunjucks from 'nunjucks'
import { ProbationSearchResponse } from '@ministryofjustice/probation-search-frontend/data/probationSearchClient'
import {
ProbationSearchResponse,
ProbationSearchRequest,
} from '@ministryofjustice/probation-search-frontend/data/probationSearchClient'
import asyncMiddleware from '../middleware/asyncMiddleware'
import config from '../config'
import type { Services } from '../services'
Expand Down Expand Up @@ -31,7 +34,7 @@ export default function routes(service: Services): Router {
router,
path: '/newTech',
template: 'pages/newTech/index',
responseFormatter: response => nunjucks.render('pages/newTech/results.njk', { results: mapResults(response) }),
resultsFormatter: (res, req) => nunjucks.render('pages/newTech/results.njk', mapResponse(res, req)),
allowEmptyQuery: true,
environment: config.environment,
oauthClient: service.hmppsAuthClient,
Expand All @@ -42,13 +45,23 @@ export default function routes(service: Services): Router {
return router
}

function mapResults(response: ProbationSearchResponse) {
return response.content.map(result => {
const activeManager = result.offenderManagers?.filter(manager => manager.active).shift()
return {
...result,
provider: activeManager.probationArea.description,
officer: `${activeManager.staff.surname}, ${activeManager.staff.forenames}`,
}
})
function mapResponse(response: ProbationSearchResponse, request: ProbationSearchRequest) {
return {
results: response.content.map(result => {
const activeManager = result.offenderManagers?.filter(manager => manager.active).shift()
return {
...result,
provider: activeManager.probationArea.description,
officer: `${activeManager.staff.surname}, ${activeManager.staff.forenames}`,
}
}),
providers: response.probationAreaAggregations
.map(p => ({
value: p.code,
text: `${p.description} (${p.count})`,
checked: request.providersFilter.includes(p.code),
}))
.sort((a, b) => a.text?.localeCompare(b.text)),
matchAllTerms: request.matchAllTerms,
}
}
20 changes: 20 additions & 0 deletions server/views/pages/newTech/index.njk
Original file line number Diff line number Diff line change
Expand Up @@ -60,4 +60,24 @@
<script src="/assets/govuk/all.js"></script>
<script src="/assets/govukFrontendInit.js"></script>
<script src="/assets/moj/all.js"></script>
<script nonce="{{ cspNonce }}">
// This code will be replaced as part of adding live result / ajax functionality. It's just here to enable testing of the filters.
document.getElementsByName('match-all-terms').forEach(function(el) {
el.addEventListener('input', function() {
const url = new URL(window.location.href)
url.searchParams.set('matchAllTerms', this.value)
window.location.href = url.toString()
})
})
document.getElementsByName('providers-filter').forEach(function(el) {
el.addEventListener('input', function() {
const url = new URL(window.location.href)
url.searchParams.delete('providers[]')
document.querySelectorAll('input[name="providers-filter"]:checked').forEach(function(el) {
url.searchParams.append('providers[]', el.value)
})
window.location.href = url.toString()
})
})
</script>
{% endblock %}
29 changes: 28 additions & 1 deletion server/views/pages/newTech/results.njk
Original file line number Diff line number Diff line change
@@ -1,8 +1,32 @@
{% from "govuk/components/radios/macro.njk" import govukRadios %}
{% from "govuk/components/checkboxes/macro.njk" import govukCheckboxes %}
{% from "govuk/components/details/macro.njk" import govukDetails %}

<div class="govuk-grid-row">
<div class="govuk-grid-column-one-third">
<pre>// TODO Filters</pre>
<div class="app-national-search-filter">
{{ govukRadios({
name: "match-all-terms",
classes: "govuk-radios--inline govuk-radios--small",
fieldset: { legend: { text: "Match all terms?" } },
items: [
{ value: "true", text: "Yes", checked: matchAllTerms },
{ value: "false", text: "No", checked: not matchAllTerms }
]
}) }}
</div>

<div class="app-national-search-filter">
{{ govukCheckboxes({
name: "providers-filter",
classes: "govuk-checkboxes--small",
fieldset: { legend: { text: "Providers" } },
items: providers
}) }}
</div>
</div>
<div class="govuk-grid-column-two-thirds">
{% if results | length > 0 %}
<ul class="govuk-list">
{% for result in results %}
<li>
Expand Down Expand Up @@ -111,5 +135,8 @@
</li>
{% endfor %}
</ul>
{% else %}
<p>There are no results for your search. Try refining your query above.</p>
{% endif %}
</div>
</div>

0 comments on commit 61e53fa

Please sign in to comment.