@ideal-postcodes/postcode-lookup
Version:
UK Postcode Lookup plugin from Ideal Postcodes
742 lines (741 loc) • 24.4 kB
JavaScript
/**
* @module Controller
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.Controller = exports.defaults = exports.selectEvent = exports.click = exports.keypress = void 0;
/* eslint-disable no-invalid-this */
const core_axios_1 = require("@ideal-postcodes/core-axios");
const core_interface_1 = require("@ideal-postcodes/core-interface");
const jsutil_1 = require("@ideal-postcodes/jsutil");
const util_1 = require("./util");
/**
* @hidden
*/
const NOOP = () => { };
/**
* @hidden
*/
const returnFalse = () => false;
/**
* Keypress listener on input field
*
* @hidden
*/
const keypress = function (event) {
if ((0, jsutil_1.toKey)(event) === "Enter") {
event.preventDefault();
this.handleClick();
return false;
}
return;
};
exports.keypress = keypress;
/**
* Button Click handler
*
* @hidden
*/
const click = function (event) {
if (event.cancelable)
event.preventDefault();
this.options.onButtonTrigger.call(this);
this.handleClick();
return false;
};
exports.click = click;
/**
* Select change handler
*
* @hidden
*/
const selectEvent = function () {
const nextPage = this.select.value.split("-");
if (nextPage[0] === "next") {
const next = parseInt(nextPage[1], 10);
if (isNaN(next))
return;
this.executeSearch(this.lastLookup, next);
return;
}
const value = parseInt(this.select.value, 10);
if (isNaN(value))
return;
this.selectAddress(value);
};
exports.selectEvent = selectEvent;
/**
* Default Controller configuration
*/
exports.defaults = {
// Client
apiKey: "",
checkKey: true,
context: "",
// DOM
outputScope: null,
// Callbacks
onButtonTrigger: NOOP,
onSearchCompleted: NOOP,
onAddressesRetrieved: NOOP,
onAddressSelected: NOOP,
onSelectCreated: NOOP,
onSelectRemoved: NOOP,
onLookupTriggered: NOOP,
shouldLookupTrigger: () => true,
onSearchError: NOOP,
onLoaded: NOOP,
onFailedCheck: NOOP,
onRemove: NOOP,
onAddressPopulated: NOOP,
onUnhide: NOOP,
// Input
input: null,
inputId: null,
inputClass: "idpc-input",
inputAriaLabel: "Search a postcode to retrieve your address",
placeholder: "Search your postcode",
// Button
button: null,
buttonId: null,
buttonLabel: "Find my Address",
buttonClass: "idpc-button",
// Select
selectContainer: null,
selectId: null,
selectClass: "idpc-select",
selectContainerId: null,
selectContainerClass: "idpc-select-container",
selectAriaLabel: "Select your address",
// Hide / unhide
unhide: null,
unhideClass: "idpc-unhide",
// Message
message: null,
messageId: null,
messageClass: "idpc-error",
msgSelect: "Please select your address",
msgDisabled: "Finding addresses...",
msgNotFound: "Your postcode could not be found. Please type in your address",
msgAddressNotFound: "We could not find a match for your address. Please type in your address",
msgError: "Sorry, we weren't able to get the address you were looking for. Please type in your address",
msgUnhide: "Enter address manually",
// Plugin behaviour
cooloff: 500,
removeOrganisation: false,
selectSinglePremise: false,
titleizePostTown: true,
postcodeSearchFormatter: util_1.postcodeSearchFormatter,
addressSearchFormatter: util_1.addressSearchFormatter,
outputFields: {},
strictlyPostcodes: true,
limit: 10,
inputStyle: {},
buttonStyle: {},
messageStyle: {},
selectStyle: {},
contextStyle: {},
hide: [],
autocomplete: "none",
populateCounty: true,
};
/**
* A Postcode Lookup Controller instances manages the state of a postcode or address search widget and updates the DOM accordingly
*
* To detach from the DOM call use the `#removeAll()` method
*/
class Controller {
constructor(options) {
this.prevContext = null;
// Merge user config with any defaults
this.options = {
...{ scope: window.document, document: window.document },
...exports.defaults,
...options,
};
this.client = new core_axios_1.Client({ ...this.options, api_key: this.options.apiKey });
// Scope the operations of this controller to a document or DOM subtree
this.scope = (0, jsutil_1.getScope)(this.options.scope);
// Assign a parent Document for elem creation
this.document = (0, jsutil_1.getDocument)(this.scope);
// Assign a document or DOM subtree to scope outputs. Defaults to controller scope
this.outputScope = this.findOrCreate(this.options.outputScope, () => this.scope);
this.data = [];
this.lastLookup = "";
// Cache container element for Postcode Lookup controller instance
this.context = this.findOrCreate(this.options.context);
// Set context styles if configured
this.prevContext = (0, jsutil_1.setStyle)(this.context, this.options.contextStyle);
this.keypress = exports.keypress.bind(this);
this.click = exports.click.bind(this);
this.selectEvent = exports.selectEvent.bind(this);
this.unhideEvent = this.unhideFields.bind(this);
// Create DOM elements
this.input = this.createInput();
this.button = this.createButton();
this.message = this.createMessage();
this.select = this.createSelect();
this.selectContainer = this.createContainer();
this.unhide = this.createUnhide();
this.init();
}
/**
* Retrieve Element
* - If string, assumes is valid and returns first match within scope
* - If null, invokes the create method to return a default
* - If HTMLElement returns instance
*
* @hidden
*/
findOrCreate(q, create) {
if ((0, jsutil_1.isString)(q))
return this.scope.querySelector(q);
if (create && q === null)
return create();
return q;
}
/**
* Creates a clickable element that can trigger unhiding of fields
*/
createUnhide() {
const e = this.findOrCreate(this.options.unhide, () => {
const e = this.document.createElement("p");
e.innerText = this.options.msgUnhide;
e.setAttribute("role", "button");
e.setAttribute("tabindex", "0");
if (this.options.unhideClass)
e.className = this.options.unhideClass;
return e;
});
e.addEventListener("click", this.unhideEvent);
e.addEventListener("keyup", (e) => e.key === "Enter" && this.unhideEvent(e));
return e;
}
/**
* Removes unhide elem from DOM
*/
unmountUnhide() {
this.unhide.removeEventListener("click", this.unhideEvent);
if (!this.options.unhide && this.options.hide.length)
(0, jsutil_1.remove)(this.unhide);
}
/**
* Creates select container instance
*
* @hidden
*/
createContainer() {
return this.findOrCreate(this.options.selectContainer, () => {
const c = this.options;
const div = this.document.createElement("div");
if (c.selectContainerId)
div.id = c.selectContainerId;
if (c.selectContainerClass)
div.className = c.selectContainerClass;
div.setAttribute("aria-live", "polite");
(0, jsutil_1.hide)(div);
return div;
});
}
/**
* Removes select container from DOM
*/
unmountContainer() {
(0, jsutil_1.remove)(this.selectContainer);
}
/**
* Create input field and binds event listeners
*
* - If a selector (this.input) is specified, that input is used
* - If no selector specified, a new input field is generated and added to context
*
* @hidden
*/
createInput() {
const input = this.findOrCreate(this.options.input, () => {
const i = this.document.createElement("input");
const c = this.options;
i.type = "text";
if (c.inputId)
i.id = c.inputId;
if (c.inputClass)
i.className = c.inputClass;
if (c.placeholder)
i.placeholder = c.placeholder;
if (c.inputAriaLabel)
i.setAttribute("aria-label", c.inputAriaLabel);
if (c.autocomplete)
i.setAttribute("autocomplete", c.autocomplete);
(0, jsutil_1.setStyle)(i, this.options.inputStyle);
return i;
});
input.addEventListener("keypress", this.keypress);
input.addEventListener("submit", returnFalse);
return input;
}
/**
* Removes address input artefacts from DOM
* - Removes event listeners from input field
* - Removes input field, unless input field is provided by the user
*/
unmountInput() {
this.input.removeEventListener("keypress", this.keypress);
this.input.removeEventListener("submit", returnFalse);
if (this.options.input === null)
(0, jsutil_1.remove)(this.input);
}
/**
* Creates button and binds event listeners
*
* @hidden
*/
createButton() {
const button = this.findOrCreate(this.options.button, () => {
const b = this.document.createElement("button");
const c = this.options;
b.type = "button";
if (c.buttonLabel)
b.innerText = c.buttonLabel;
if (c.buttonId)
b.id = c.buttonId;
if (c.buttonClass)
b.className = c.buttonClass;
(0, jsutil_1.setStyle)(b, this.options.buttonStyle);
b.onclick = util_1.preventDefault;
return b;
});
button.addEventListener("submit", returnFalse);
button.addEventListener("click", this.click);
return button;
}
/**
* unmountButton
* - Remove listener events
* - Remove button from DOM if generated by this controller
*/
unmountButton() {
this.button.removeEventListener("submit", returnFalse);
this.button.removeEventListener("click", this.click);
if (this.options.button === null)
(0, jsutil_1.remove)(this.button);
}
/**
* Mounts message container
*
* @hidden
*/
createMessage() {
return this.findOrCreate(this.options.message, () => {
const p = this.document.createElement("p");
const c = this.options;
if (c.messageClass)
p.className = c.messageClass;
if (c.messageId)
p.id = c.messageId;
p.setAttribute("role", "alert");
(0, jsutil_1.setStyle)(p, this.options.messageStyle);
(0, jsutil_1.hide)(p);
return p;
});
}
/**
* Removes message container from DOM
*/
unmountMessage() {
if (this.options.message === null)
(0, jsutil_1.remove)(this.message);
}
/**
* Creates Select HTML Element
*/
createSelect() {
const select = this.document.createElement("select");
const c = this.options;
if (c.selectId)
select.id = c.selectId;
if (c.selectClass)
select.className = c.selectClass;
(0, jsutil_1.setStyle)(select, this.options.selectStyle);
if (c.selectAriaLabel)
select.setAttribute("aria-label", c.selectAriaLabel);
select.addEventListener("change", this.selectEvent);
return select;
}
/**
* Mounts dropdown menu to DOM and attach event listeners
*
* Removes dropdown from DOM if data is undefined
*/
mountSelect(data, nextPage) {
if (data)
this.data = data;
(0, util_1.removeOptions)(this.select);
// Add initial select message
this.select.appendChild(this.createOption("ideal", this.options.msgSelect));
// Add address options
for (let i = 0; i < this.data.length; i += 1) {
this.select.appendChild(this.createOption(i.toString(), this.formatAddress(this.data[i])));
}
if (nextPage) {
this.select.appendChild(this.createOption("blank", ""));
this.select.appendChild(this.createOption("next-" + String(nextPage), "-- Show more addresses at this postcode (page " +
(nextPage + 1) +
") --"));
}
this.selectContainer.appendChild(this.select);
(0, jsutil_1.show)(this.selectContainer);
this.options.onSelectCreated.call(this, this.select);
}
/**
* Remove dropdown from DOM
*/
unmountSelect() {
(0, jsutil_1.remove)(this.select);
(0, jsutil_1.hide)(this.selectContainer);
this.options.onSelectRemoved.call(this);
}
/**
* Selects an address by its offset `i` in the list of address results
*/
selectAddress(i) {
const address = this.data[i];
if (!address)
return;
this.populateAddress(address);
this.options.onAddressSelected.call(this, address);
}
/**
* Callback for address search click event
*
* @hidden
*/
handleClick() {
if (!this.options.shouldLookupTrigger.call(this))
return false;
this.options.onLookupTriggered.call(this);
const term = this.input.value;
if (this.lastLookup === term)
return false;
this.lastLookup = term;
this.reset();
this.disableButton();
this.executeSearch(term);
return false;
}
/**
* Prevents lookup button from being triggered
*/
disableButton(message) {
// Cancel if custom button
if (this.options.button)
return;
this.button.setAttribute("disabled", "true");
this.button.innerText = message || this.options.msgDisabled;
}
/**
* Enables lookup button to trigger searches
*/
enableButton() {
// Cancel if custom button
if (this.options.button)
return;
this.button.removeAttribute("disabled");
this.button.innerText = this.options.buttonLabel;
}
/**
* Allows lookup button to be triggered and applies a cooloff timer if configured
*/
enableLookup() {
if (this.options.button)
return;
const { cooloff } = this.options;
if (cooloff === 0)
return this.enableButton();
setTimeout(() => this.enableButton(), cooloff);
}
/**
* Resets address search fields
* - Removes any existing address selection dropdown
* - Removes any visiable messages
*/
reset() {
this.unmountSelect();
this.hideMessage();
}
/**
* Removes all elements from DOM including dropdown, input, button and any error message
* - Remove all event listeners
* - Remove non-custom elements DOM
*/
removeAll() {
this.unmountInput();
this.unmountButton();
this.unmountContainer();
this.unmountMessage();
this.unmountUnhide();
(0, jsutil_1.restoreStyle)(this.context, this.prevContext);
this.options.onRemove.call(this);
}
/**
* Returns not found message
*
* @hidden
*/
notFoundMessage() {
return this.options.strictlyPostcodes
? this.options.msgNotFound
: this.options.msgAddressNotFound;
}
/**
* Triggers a search based on term and mounts addresses to DOM in the address
* dropdown
*
* Validate search term and then trigger postcode lookup
* - On successful search, display results in a dropdown menu
* - On successful search but no addresses, show error message
* - On failed search, show error message
*/
executeSearch(term, page) {
this.enableLookup();
const query = this.options.strictlyPostcodes
? this.searchPostcode(term, page)
: this.searchAddress(term, page);
return (query
// Check if postcode not found with suggestions
.catch((error) => {
if (error instanceof core_axios_1.errors.IdpcPostcodeNotFoundError === false)
throw error;
const suggestions = error.response.body.suggestions || [];
// Present suggestions to user
if (suggestions.length > 1) {
this.suggestionsMessage(suggestions);
return null;
}
// Input and trigger search
if (suggestions.length === 1) {
this.input.value = suggestions[0];
this.executeSearch(suggestions[0]);
return null;
}
return "not_found";
})
.then((response) => {
if (response === null)
return;
if (response === "not_found") {
this.options.onSearchCompleted.call(this, null, []);
return this.setMessage(this.notFoundMessage());
}
const { addresses, total, page, limit } = response;
this.options.onSearchCompleted.call(this, null, addresses);
if (addresses.length === 0) {
return this.setMessage(this.notFoundMessage());
}
else {
this.setMessage();
}
// Cache last search term
this.lastLookup = term;
this.data = addresses;
// Invoke successful address search callback
this.options.onAddressesRetrieved.call(this, addresses);
if (this.options.selectSinglePremise && addresses.length === 1)
return this.selectAddress(0);
let nextPage;
// Check if there are outstanding pages
if (total > (page + 1) * limit)
nextPage = page + 1;
this.mountSelect(addresses, nextPage);
})
.catch((error) => {
this.setMessage(this.options.msgError);
this.options.onSearchCompleted.call(this, null, []);
this.options.onSearchError.call(this, error);
}));
}
suggestionsMessage(suggestions) {
const span = this.document.createElement("span");
span.innerHTML = `We couldn't find <b>${this.input.value}</b>. Did you mean `;
suggestions.forEach((suggestion, i) => {
const a = this.document.createElement("a");
if (i === 0) {
a.innerText = `${suggestion}`;
}
else if (i === suggestions.length - 1) {
a.innerText = ` or ${suggestion}`;
}
else {
a.innerText = `, ${suggestion}`;
}
a.style.cursor = "pointer";
a.addEventListener("click", (e) => {
e.preventDefault();
this.input.value = suggestion;
this.executeSearch(suggestion);
this.hideMessage();
});
span.appendChild(a);
});
(0, jsutil_1.show)(this.message);
this.message.innerHTML = "";
this.message.appendChild(span);
}
/**
* Invoke postcode lookup
*
* @hidden
*/
searchPostcode(postcode, page = 0) {
const options = (0, core_interface_1.toAddressIdQuery)({ client: this.client });
if (page > 0)
options.query.page = String(page);
return core_axios_1.postcodes
.retrieve(this.client, postcode, options)
.then((response) => {
return {
addresses: response.body.result,
//@ts-ignore
page: response.body.page,
//@ts-ignore
total: response.body.total,
//@ts-ignore
limit: response.body.limit,
};
});
}
/**
* Invoke an address search
*
* @hidden
*/
searchAddress(query, _) {
const options = (0, core_interface_1.toAddressLookupQuery)({
client: this.client,
query,
limit: this.options.limit,
});
return core_axios_1.addresses.list(this.client, options).then((response) => {
return {
addresses: response.body.result.hits,
page: response.body.result.page,
total: response.body.result.hits.length,
limit: response.body.result.limit,
};
});
}
/**
* Formats address according to whether text or postcode search is active
*
* @hidden
*/
formatAddress(address) {
const formatter = this.options.strictlyPostcodes
? this.options.postcodeSearchFormatter
: this.options.addressSearchFormatter;
return formatter(address);
}
createOption(value, text) {
const option = this.document.createElement("option");
option.text = text;
option.value = value;
return option;
}
/**
* Sets the error message
*
* Removes error message from DOM if undefined
*/
setMessage(message) {
if (!this.message)
return;
if (message === undefined)
return this.hideMessage();
(0, jsutil_1.show)(this.message);
this.message.innerText = message;
}
/**
* Hides any messages
*/
hideMessage() {
if (!this.message)
return;
this.message.innerText = "";
(0, jsutil_1.hide)(this.message);
}
/**
* Call to initially render the DOM elements
*
* This will perform an optional keyCheck if required
*/
init() {
const initPlugin = () => {
this.render();
this.hideFields();
this.options.onLoaded.call(this);
};
if (!this.options.checkKey)
return initPlugin();
(0, core_axios_1.checkKeyUsability)({ client: this.client })
.then(({ available }) => {
if (!available)
return Promise.reject("Key not available");
return initPlugin();
})
.catch((error) => {
if (this.options.onFailedCheck)
this.options.onFailedCheck(error);
});
}
/**
* Writes a selected to the input fields specified in the controller config
*/
populateAddress(address) {
this.unhideFields();
const outputFields = this.options.outputFields;
const config = { ...this.options, scope: this.outputScope };
(0, jsutil_1.populateAddress)({ outputFields, address, config });
this.options.onAddressPopulated.call(this, address);
}
hiddenFields() {
return this.options.hide
.map((e) => {
if ((0, jsutil_1.isString)(e))
return (0, jsutil_1.toHtmlElem)(this.scope, e);
return e;
})
.filter((e) => e !== null);
}
/**
* Hides fields marked for hiding
*/
hideFields() {
this.hiddenFields().forEach(jsutil_1.hide);
}
/**
* Unhides fields marked for hiding and triggers callback
*/
unhideFields() {
this.hiddenFields().forEach(jsutil_1.show);
this.options.onUnhide.call(this);
}
/**
* Empties context and appends postcode lookup input, button, message field
* and select container
*
* Does not render element if a custom element has been provided
*/
render() {
this.context.innerHTML = "";
if (!this.options.input)
this.context.appendChild(this.input);
if (!this.options.button)
this.context.appendChild(this.button);
if (!this.options.selectContainer)
this.context.appendChild(this.selectContainer);
if (!this.options.message)
this.context.appendChild(this.message);
if (!this.options.unhide && this.options.hide.length)
this.context.appendChild(this.unhide);
}
}
exports.Controller = Controller;
;