Skip to content

Commit f6d694b

Browse files
authored
Hdpi 1779 address capture (#615)
1 parent 0f81d8f commit f6d694b

File tree

33 files changed

+3017
-263
lines changed

33 files changed

+3017
-263
lines changed

JOURNEY_ENGINE_README.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,3 +152,27 @@ The engine uses a smart template resolution system:
152152
4. Falls back to step ID as template path
153153

154154
Place templates in `views/journey-slug/` or use the defaults in `views/_defaults/`.
155+
156+
## Address Lookup (OS Places)
157+
158+
The engine includes a composite `address` field type that adds UK postcode lookup via the OS Places API and a manual entry form.
159+
160+
Example usage in a step:
161+
162+
```
163+
fields: {
164+
homeAddress: {
165+
type: 'address',
166+
label: { text: 'Address lookup' },
167+
validate: { required: true }
168+
},
169+
continueButton: { type: 'button', attributes: { type: 'submit' } }
170+
}
171+
```
172+
173+
Config required:
174+
175+
- `osPostcodeLookup.url` (e.g. `https://api.os.uk/search/places/v1`)
176+
- `secrets.pcs.pcs-os-client-lookup-key` – OS Places API key
177+
178+
The lookup is performed client-side against `/api/postcode-lookup` and returns a list of addresses to populate the form.

config/default.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,8 @@
2424
"key": "s2s:service-token"
2525
},
2626
"ccd": {
27-
"url": "http://ccd-data-store-api-aat.service.core-compute-aat.internal",
28-
"caseTypeId": "PCS"
27+
"url": "https://ccd-data-store-api-pcs-api-pr-316.preview.platform.hmcts.net",
28+
"caseTypeId": "PCS-316"
2929
},
3030
"osPostcodeLookup": {
3131
"url": "https://api.os.uk/search/places/v1"

src/main/assets/js/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import '../scss/main.scss';
22
import { initAll } from 'govuk-frontend';
33

4+
import { initPostcodeLookup } from './postcode-lookup';
45
import { initPostcodeSelection } from './postcode-select';
56

67
initAll();
78
initPostcodeSelection();
9+
initPostcodeLookup();
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
/**
2+
* Lightweight postcode lookup UI behaviour.
3+
* Expects the following DOM elements (ids are fixed for now):
4+
* - #lookupPostcode: input for entering postcode
5+
* - #findAddressBtn: button (type=button) to trigger lookup
6+
* - #selectedAddress: select populated with results
7+
* - #addressLine1, #addressLine2, #town, #county, #postcode: inputs to populate
8+
*/
9+
let postcodeLookupDelegatedBound = false;
10+
11+
export function initPostcodeLookup(): void {
12+
const containers = Array.from(document.querySelectorAll<HTMLElement>('[data-address-component]'));
13+
14+
// Helper utilities that work per-container
15+
const getParts = (container: HTMLElement) => {
16+
const prefix = container.dataset.namePrefix || 'address';
17+
const byId = (id: string) => container.querySelector<HTMLElement>(`#${prefix}-${id}`);
18+
return {
19+
prefix,
20+
byId,
21+
postcodeInput: byId('lookupPostcode') as HTMLInputElement | null,
22+
findBtn: byId('findAddressBtn') as HTMLButtonElement | null,
23+
select: byId('selectedAddress') as HTMLSelectElement | null,
24+
selectContainer: byId('addressSelectContainer') as HTMLDivElement | null,
25+
addressLine1: byId('addressLine1') as HTMLInputElement | null,
26+
addressLine2: byId('addressLine2') as HTMLInputElement | null,
27+
town: byId('town') as HTMLInputElement | null,
28+
county: byId('county') as HTMLInputElement | null,
29+
postcodeOut: byId('postcode') as HTMLInputElement | null,
30+
details: container.querySelector('.govuk-details, details'),
31+
};
32+
};
33+
34+
const clearOptions = (select: HTMLSelectElement) => {
35+
while (select.options.length) {
36+
select.remove(0);
37+
}
38+
};
39+
40+
const populateOptions = (
41+
select: HTMLSelectElement,
42+
selectContainer: HTMLDivElement | null,
43+
addresses: Record<string, string>[]
44+
) => {
45+
clearOptions(select);
46+
const defaultOpt = document.createElement('option');
47+
defaultOpt.value = '';
48+
defaultOpt.textContent = `${addresses.length} address${addresses.length === 1 ? '' : 'es'} found`;
49+
select.appendChild(defaultOpt);
50+
51+
for (let i = 0; i < addresses.length; i++) {
52+
const addr = addresses[i];
53+
const opt = document.createElement('option');
54+
opt.value = String(i);
55+
opt.textContent = addr.fullAddress || '';
56+
opt.dataset.line1 = addr.addressLine1 || '';
57+
opt.dataset.line2 = addr.addressLine2 || '';
58+
opt.dataset.town = addr.town || '';
59+
opt.dataset.county = addr.county || '';
60+
opt.dataset.postcode = addr.postcode || '';
61+
select.appendChild(opt);
62+
}
63+
if (selectContainer) {
64+
selectContainer.hidden = false;
65+
}
66+
select.hidden = false;
67+
select.focus();
68+
};
69+
70+
const populateAddressFields = (container: HTMLElement, selected: HTMLOptionElement) => {
71+
const { addressLine1, addressLine2, town, county, postcodeOut, details } = getParts(container);
72+
73+
if (details && !(details as HTMLDetailsElement).open) {
74+
(details as HTMLDetailsElement).open = true;
75+
}
76+
77+
const fieldMappings = [
78+
{ field: addressLine1, value: selected.dataset.line1 },
79+
{ field: addressLine2, value: selected.dataset.line2 },
80+
{ field: town, value: selected.dataset.town },
81+
{ field: county, value: selected.dataset.county },
82+
{ field: postcodeOut, value: selected.dataset.postcode },
83+
];
84+
85+
fieldMappings.forEach(({ field, value }) => {
86+
if (field) {
87+
field.value = value || '';
88+
}
89+
});
90+
91+
addressLine1?.focus();
92+
};
93+
94+
const handleSelectionChange = (container: HTMLElement, select: HTMLSelectElement) => {
95+
const selected = select.options[select.selectedIndex];
96+
if (!selected?.value) {
97+
return;
98+
}
99+
populateAddressFields(container, selected);
100+
};
101+
102+
const performPostcodeLookup = async (
103+
postcode: string,
104+
select: HTMLSelectElement,
105+
selectContainer: HTMLDivElement | null,
106+
button: HTMLButtonElement
107+
) => {
108+
button.disabled = true;
109+
try {
110+
const resp = await fetch(`/api/postcode-lookup?postcode=${encodeURIComponent(postcode)}`, {
111+
headers: { Accept: 'application/json' },
112+
credentials: 'same-origin',
113+
});
114+
if (!resp.ok) {
115+
throw new Error('Lookup failed');
116+
}
117+
const json = (await resp.json()) as { addresses?: Record<string, string>[] };
118+
const addresses = json.addresses || [];
119+
populateOptions(select, selectContainer, addresses);
120+
} catch {
121+
clearOptions(select);
122+
const opt = document.createElement('option');
123+
opt.value = '';
124+
opt.textContent = 'No addresses found';
125+
select.appendChild(opt);
126+
if (selectContainer) {
127+
selectContainer.hidden = false;
128+
}
129+
select.hidden = false;
130+
} finally {
131+
button.disabled = false;
132+
}
133+
};
134+
135+
// If more than one component, use event delegation to avoid multiple handlers
136+
if (containers.length > 1) {
137+
if (postcodeLookupDelegatedBound) {
138+
return;
139+
}
140+
postcodeLookupDelegatedBound = true;
141+
142+
document.addEventListener('click', async evt => {
143+
const target = evt.target as Element | null;
144+
if (!target) {
145+
return;
146+
}
147+
const btn = target.closest('button[id$="-findAddressBtn"]') as HTMLButtonElement;
148+
if (!btn) {
149+
return;
150+
}
151+
const container = btn.closest('[data-address-component]') as HTMLElement;
152+
if (!container) {
153+
return;
154+
}
155+
const { postcodeInput, select, selectContainer } = getParts(container);
156+
if (!postcodeInput || !select) {
157+
return;
158+
}
159+
160+
const value = postcodeInput.value?.trim();
161+
if (!value) {
162+
return;
163+
}
164+
await performPostcodeLookup(value, select, selectContainer, btn);
165+
});
166+
167+
document.addEventListener('change', evt => {
168+
const target = evt.target as Element | null;
169+
if (!target) {
170+
return;
171+
}
172+
const select = target.closest('select[id$="-selectedAddress"]') as HTMLSelectElement;
173+
if (!select) {
174+
return;
175+
}
176+
const container = select.closest('[data-address-component]') as HTMLElement;
177+
if (!container) {
178+
return;
179+
}
180+
handleSelectionChange(container, select);
181+
});
182+
return;
183+
}
184+
185+
// Fallback: bind directly when 0 or 1 components present
186+
if (!containers.length) {
187+
return;
188+
}
189+
190+
containers.forEach(container => {
191+
const { postcodeInput, findBtn, select, selectContainer } = getParts(container);
192+
if (!postcodeInput || !findBtn || !select) {
193+
return;
194+
}
195+
196+
// Selection behaviour for single component
197+
select.addEventListener('change', () => handleSelectionChange(container, select));
198+
199+
findBtn.addEventListener('click', async () => {
200+
const value = postcodeInput.value?.trim();
201+
if (!value) {
202+
return;
203+
}
204+
await performPostcodeLookup(value, select, selectContainer, findBtn);
205+
});
206+
});
207+
}

src/main/assets/js/postcode-select.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,19 @@ export function initPostcodeSelection(): void {
22
const addressSelect = document.getElementById('selectedAddress') as HTMLSelectElement | null;
33

44
// ✅ Step 1: Focus the dropdown if we just came from a postcode lookup
5-
const url = new URL(window.location.href);
6-
const isLookup = url.searchParams.get('lookup') === '1';
5+
// Be robust to environments that cannot safely stub window.location
6+
let href = '';
7+
// Prefer explicit test hook when present
8+
if ((window as { __testHref?: string }).__testHref) {
9+
href = (window as { __testHref?: string }).__testHref as string;
10+
} else {
11+
try {
12+
href = (window as { location?: { href?: string } })?.location?.href || '';
13+
} catch {
14+
href = '';
15+
}
16+
}
17+
const isLookup = /(^|[?&])lookup=1(&|$)/.test(href);
718

819
if (isLookup && addressSelect) {
920
addressSelect.focus();

src/main/assets/locales/cy/eligibility.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,5 +126,12 @@
126126
"reason": "cyYou need to be 18 or over to make a possession claim.",
127127
"changeAnswer": "cyIf you think this is incorrect, you can go back and change your answer.",
128128
"exit": "cyExit application"
129+
},
130+
"errors": {
131+
"address": {
132+
"addressLine1": "cyEnter Address line 1",
133+
"town": "cyEnter Town or city",
134+
"postcode": "cyEnter a valid Postcode"
135+
}
129136
}
130137
}

src/main/assets/locales/en/eligibility.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@
2222
"page3": {
2323
"title": "What are your grounds for possession?",
2424
"description": "Select all grounds that apply to your case",
25-
"groundsLabel": "Select all that apply",
2625
"options": {
2726
"rentArrears8": {
2827
"text": "Rent arrears (ground 8)",
@@ -126,5 +125,12 @@
126125
"reason": "You need to be 18 or over to make a possession claim.",
127126
"changeAnswer": "If you think this is incorrect, you can go back and change your answer.",
128127
"exit": "Exit application"
128+
},
129+
"errors": {
130+
"address": {
131+
"addressLine1": "Enter Address line 1",
132+
"town": "Enter Town or city",
133+
"postcode": "Enter a valid Postcode"
134+
}
129135
}
130136
}

src/main/assets/scss/main.scss

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,38 @@
77
align-items: center;
88
justify-content: space-between;
99
}
10+
11+
.claim-journey-secondary-heading {
12+
color: $govuk-secondary-text-colour;
13+
font-weight: 300;
14+
}
15+
16+
.correspondence-address-form {
17+
.pcs-address-component {
18+
.govuk-details__text {
19+
border-left: none;
20+
padding-left: 0;
21+
padding-bottom: 0;
22+
position: relative;
23+
24+
&::before {
25+
content: '';
26+
display: block;
27+
position: absolute;
28+
top: -5px;
29+
left: -40px;
30+
width: 5px;
31+
height: 15px;
32+
background-color: govuk-colour('white');
33+
}
34+
}
35+
36+
.govuk-details {
37+
margin-bottom: 5px;
38+
}
39+
}
40+
41+
#correspondenceAddress-addressSelectContainer {
42+
margin-bottom: 30px;
43+
}
44+
}

src/main/interfaces/osPostcodeLookup.interface.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ export interface OSResponse {
2828
POST_TOWN: string;
2929
LOCAL_CUSTODIAN_CODE_DESCRIPTION?: string;
3030
POSTCODE: string;
31-
COUNTRY_CODE_DESCRIPTION?: string;
31+
COUNTRY_CODE?: string;
3232
};
3333
}[];
3434
}

src/main/journeys/eligibility/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { JourneyDraft } from '../../modules/journey/engine/schema';
22

33
import confirmation from './steps/confirmation/step';
4+
import correspondenceAddress from './steps/correspondenceAddress/step';
45
import ineligible from './steps/ineligible/step';
56
import page2 from './steps/page2/step';
67
import page3 from './steps/page3/step';
@@ -22,6 +23,7 @@ const journey: JourneyDraft = {
2223
page3,
2324
page4,
2425
page5,
26+
correspondenceAddress,
2527
summary,
2628
confirmation,
2729
},

0 commit comments

Comments
 (0)