UNPKG

@ideal-postcodes/postcode-lookup

Version:
742 lines (741 loc) 24.4 kB
"use strict"; /** * @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;