1class PredictiveSearch{constructor(config={}){this.config=config||{};this.debug=Boolean(this.config.debug||typeof uolDebug!=="undefined"&&uolDebug);this.debugStyle=this.config.debugStyle||"color: #5274d1ff; font-weight: bold";this.debounceMs=this.config.debounceMs??100;this.minChars=this.config.minChars??3;this.debounceTimer=null;this.inputSelector=this.config.inputSelector||".uol-form__input--predictive"}initAutocomplete(){const input=document.querySelector(this.inputSelector);if(!input){if(this.debug)console.log("%cAutocomplete selector not found:",this.debugStyle);return}else{if(this.debug)console.log("%cAutocomplete initialised.",this.debugStyle)}let debounceTimer;input.addEventListener("input",event=>{if(this.debug)console.log("%cAutocomplete listener added:",this.debugStyle);const query=event.target.value.trim();clearTimeout(debounceTimer);debounceTimer=setTimeout(()=>{if(query.length>=this.minChars){this.getAutocompleteSuggestions(query);if(this.debug)console.log("%cAutocomplete triggered:",this.debugStyle,query)}else{this.clearPredictiveSuggestions()}},100)})}getAdditionalParams(){return{}}getAutocompleteSuggestions(query){const input=document.querySelector(this.inputSelector);if(!input)return;const baseUrl=this.config.endpoint||"/search-ajax";const queryParams=new URLSearchParams({predictive:1,partial_query:query});const extraParams=this.getAdditionalParams();Object.keys(extraParams).forEach(key=>{queryParams.append(key,extraParams[key])});const endpoint=`${baseUrl}?${queryParams.toString()}`;fetch(endpoint).then(res=>res.json()).then(data=>{if(this.debug)console.log("%cFetched suggestion data:",this.debugStyle,data);if(!Array.isArray(data)||data.length===0){return}this.clearPredictiveSuggestions();const input=document.querySelector('input[type="predictive"], input.uol-form__input--predictive, input#query');if(input){input.setAttribute("aria-expanded","true")}const suggestionList=document.createElement("ul");suggestionList.id="predictive-search-list";suggestionList.className="uol-predictive-search__list";suggestionList.setAttribute("role","listbox");suggestionList.setAttribute("aria-label","Predictive search results");data.forEach(suggestion=>{const li=document.createElement("li");li.className="uol-predictive-search__item";li.setAttribute("role","presentation");const link=document.createElement("a");link.className="uol-predictive-search__link";link.textContent=suggestion;link.dataset.value=suggestion;link.setAttribute("role","option");link.href="#";link.addEventListener("click",e=>this.handleSuggestionClick(e,suggestion,input,suggestionList));li.appendChild(link);suggestionList.appendChild(li)});input.parentNode.appendChild(suggestionList)}).catch(err=>console.error("Error fetching suggestions:",err))}handleSuggestionClick(e,suggestion,input){e.preventDefault();input.value=suggestion;this.clearPredictiveSuggestions();if(input.setAttribute)input.setAttribute("aria-expanded","false");this.onSuggestionSelected(suggestion)}onSuggestionSelected(suggestion){}clearPredictiveSuggestions(){try{const oldList=document.querySelector("#predictive-search-list");if(oldList)oldList.remove();const input=document.querySelector('input[type="predictive"], input.uol-form__input--predictive, input#query');if(input&&typeof input.setAttribute==="function"){input.setAttribute("aria-expanded","false")}}catch(err){if(this.debug)console.warn("clearPredictiveSuggestions error:",err)}}}class UoLFunnelbackSearch extends PredictiveSearch{config;handleLinks=true;funnelback="";fbSearchEl;clearFilterPanelEl;formData={};useTwigTemplates=true;debug=false;debugStyle="color: #b552d1; font-weight: bold";constructor(config){super(config||{});if(!config){throw new Error("Config object is required.")}if(typeof uolDebug!=="undefined"&&uolDebug){this.debug=true}this.config=config;this.parent=this;if(this.config.htmlUpdateTargets.form&&!this.config.htmlUpdateTargets.wrapper){this.config.htmlUpdateTargets.wrapper=this.config.htmlUpdateTargets.form}this.initAutocomplete();this.initSearchForm()}onSuggestionSelected(suggestion){}initSearchForm(){this.fbSearchEl=document.getElementById("funnelbackSiteSearch")||document.querySelector(this.config.htmlUpdateTargets.wrapper);if(!this.fbSearchEl){console.error("No fbSearchEl element found.");return}this.initialiseFormData();if(this.fbSearchEl.id==="funnelbackSiteSearch"){this.useTwigTemplates=false;this.doFbSearch()}else{if(!this.config.endpoint){throw new Error("Endpoint is not defined. Please set this in the config object.")}this.handleLinks=false;
1if(document.querySelector(this.config.htmlUpdateTargets.wrapper)){this.addFormListeners()}if(this.fbSearchEl.classList.contains("uol-search--has-filters")){this.addFilterListeners()}}}initialiseFormData(){if(this.debug)console.log("%cinitForm(): formData",this.debugStyle,this.formData);let urlParams=new URLSearchParams(window.location.search);this.formData.query=urlParams.get("q")||urlParams.get("query")||this.fbSearchEl.getAttribute("data-query");if(this.fbSearchEl){this.formData.start_rank=this.fbSearchEl.getAttribute("data-start_rank")||0;this.formData.sort=this.fbSearchEl.getAttribute("data-sort");this.formData.pagination=this.fbSearchEl.getAttribute("data-pagination");this.formData.profile="_default";this.formData.sort_by=null;this.formData.form=this.fbSearchEl.getAttribute("data-start_rank")||0;this.formData.term=urlParams.get("term")||null;this.clearFilterPanelEl=document.getElementById("clear_filter_panel")}}updateFormData(form=null){if(null!==form){let data=new FormData(form);this.formData.query=data.get("query");this.formData.term=data.get("course-academic-year");this.formData.instance=data.get("instance");this.formData.profile=data.get("profile");this.formData.form=data.get("form");this.formData.facetScope=data.get("facetScope")}if(null!==document.getElementById("sort-select")){this.formData.sort_by=document.getElementById("sort-select").value}if(this.debug)console.log("%cupdateFormData(): formData",this.debugStyle,this.formData)}addFormListeners(){var formEl=document.querySelector(this.config.htmlUpdateTargets.formContainer);if(formEl){const{listeners,searchTriggers}=this.config;if(listeners){Object.entries(listeners).forEach(([selector,eventType])=>{const elements=document.querySelectorAll(selector);if(elements.length>0){elements.forEach(element=>{element.addEventListener(eventType,e=>{e.preventDefault();this.updateFormData(this.fbSearchEl);if(searchTriggers&&searchTriggers[selector]){if(this.debug)console.log("%cTriggering method:",this.debugStyle,searchTriggers[selector]);this[searchTriggers[selector]]()}});if(this.debug)console.log(`%cListener added to ${selector}`,this.debugStyle,element)})}})}this.addTypeaheadListeners(formEl);let radios=formEl.querySelectorAll('input[type="radio"]');radios.forEach(radio=>{radio.addEventListener("change",e=>{if(this.debug)console.log("%cfl:radio changed",this.debugStyle,e.target.value);const url=e.target.value.startsWith("?")?e.target.value:null;if(url==null){this.formData.term=e.target.value}else{this.formData.term=null}this.doFbSearch(e,url)});if(this.debug)console.log("%cListener added to radio",this.debugStyle,radio)});window.addEventListener("popstate",e=>{if(e.state!==null){location.reload()}})}if(this.clearFilterPanelEl){this.clearFilterPanelEl.addEventListener("click",e=>{if(this.debug)console.log("%cfl:clear_filter_panel",this.debugStyle,e.target.getAttribute("href"));this.doFbSearch(e,e.target.getAttribute("href"))})}}addFilterListeners(){try{var searchFiltersEl=document.getElementById("search-filters");if(searchFiltersEl){let showSearchResultsEl=document.getElementById("show_search_results");if(showSearchResultsEl){showSearchResultsEl.addEventListener("click",e=>{e.preventDefault()});if(this.debug)console.log("%cListener added to showSearchResultsEl",this.debugStyle,showSearchResultsEl)}let checkboxes=searchFiltersEl.querySelectorAll("input[type=checkbox]");checkboxes.forEach(checkbox=>{checkbox.addEventListener("change",e=>{if(this.debug)console.log("%cfl:checkbox",this.debugStyle);this.doFbSearch(e,e.target.value)});if(this.debug)console.log("%cListener added to checkbox",this.debugStyle,checkbox)});let radios=searchFiltersEl.querySelectorAll("input[type=radio]");radios.forEach(radio=>{radio.addEventListener("change",e=>{if(this.debug)console.log("%cfl:radio",this.debugStyle,e.target.value);this.doFbSearch(e,e.target.value)});if(this.debug)console.log("%cListener added to radio",this.debugStyle,radio)});this.addTypeaheadListeners(searchFiltersEl);let chips=document.querySelectorAll(".uol-chips__item");chips.forEach(chip=>{chip.addEventListener("click",e=>{if(this.debug)console.log("%cfl:chip clicked",this.debugStyle,e.target);const target=e.target.closest(".uol-chips__button");let url=null;if(target.tagName.toLowerCase()==="a"){if(this.debug)console.log("%cAnchor chip clicked",this.debugStyle,chip);url=target.getAttribute("href")}else if(target.tagName.toLowerCase()==="button"){if(this.debug)console.log("%cButton chip clicked",this.debugStyle,chip);const label=target.getAttribute("data-label");if(label){const allSelects=document.querySelectorAll("select");for(let select of allSelects){const match=Array.from(select.options).find(opt=>opt.text.trim()===label.trim());if(match){url=match.value;break}}}}if(url){this.doFbSearch(e,url)}else if(this.debug){console.warn("%cNo URL found for chip click",this.debugStyle,e.target)}});if(this.debug)console.log("%cListener added to chip",this.debugStyle,chip)})}else{if(this.debug)console.warn("No search-filters element found.")}}catch(e){console.error("Filter listener error: "+e.message)}}addTypeaheadListeners(parentEl){try{let typeaheadLists=parentEl.querySelectorAll(".uol-typeahead__list");if(typeaheadLists.length>
10){typeaheadLists.forEach(typeaheadList=>{typeaheadList.addEventListener("click",e=>{let dataOptionValue=e.target.getAttribute("data-option-value");if(this.debug)console.log("%cfl: "+dataOptionValue,this.debugStyle);this.doFbSearch(e,dataOptionValue)});if(this.debug)console.log("%cMouse click listener added to typeaheadList",this.debugStyle,typeaheadList)})}else{let selectLists=parentEl.querySelectorAll(".uol-form__input--select");if(selectLists.length>0){selectLists.forEach(selectList=>{selectList.addEventListener("change",e=>{let dataOptionValue=e.currentTarget.value;if(this.debug)console.log("%cfl: "+dataOptionValue,this.debugStyle);this.doFbSearch(e,dataOptionValue)});if(this.debug)console.log("%cchange listener added to selectList",this.debugStyle,selectList)})}}typeaheadLists=parentEl.querySelectorAll(".uol-typeahead");if(typeaheadLists.length>0){typeaheadLists.forEach(typeaheadList=>{if(typeaheadList.tagName==="INPUT"){typeaheadList.addEventListener("keydown",e=>{if(e.key==="Enter"){let activedescendant=e.target.getAttribute("aria-activedescendant");if(this.debug)console.log("%cfl:activedescendant = "+activedescendant,this.debugStyle);if(activedescendant){let dataOptionValue=document.getElementById(activedescendant).getAttribute("data-option-value");this.doFbSearch(e,dataOptionValue)}}});if(this.debug)console.log("%cKeyboard listener added to typeaheadList",this.debugStyle,typeaheadList)}})}}catch(e){console.error("Typeahead listener error: "+e.message)}}addListenersToElements(elements,event,handler){elements.forEach(element=>{element.addEventListener(event,handler);if(this.debug)console.log(`%cListener added to element`,this.debugStyle,element)})}writeResponse(responseText=null){if(null!=responseText){if(this.useTwigTemplates){let json=JSON.parse(responseText);this.updateForm(json);this.updateResultsContainer(json);this.updateResultsCount(json);this.updatePagination(json);this.updateFiltersPanel(json);this.updateChipsContainer(json)}else{this.fbSearchEl.innerHTML=responseText}}else{console.error("responseText was not received from the xmlRequest.")}}updateForm(json){if(this.config.htmlUpdateTargets.formContainer&&typeof json.form==="string"){const formContainer=document.querySelector(this.config.htmlUpdateTargets.formContainer);if(formContainer){formContainer.outerHTML=json.form;if(typeof searchAjaxLoad!=="undefined"){searchAjaxLoad.onload=true}this.addFormListeners();if(this.debug)console.log("%cForm updated:",this.debugStyle,formContainer)}else{console.warn(`%cForm container not found for selector: ${this.config.htmlUpdateTargets.formContainer}`,this.debugStyle)}}else{if(this.debug){console.log("%cIf you want to update the form these conditions need to be met:",this.debugStyle,{formContainer:this.config.htmlUpdateTargets.formContainer,jsonFormValid:typeof json.form==="string"})}}}updateResultsContainer(json){if(this.config.htmlUpdateTargets.resultsContainer){let resultsContainer=this.fbSearchEl.querySelector(this.config.htmlUpdateTargets.resultsContainer);if(resultsContainer){let replaceResultsEl=null;if(this.config.htmlUpdateTargets.replaceResultsSelector){replaceResultsEl=resultsContainer.querySelector(this.config.htmlUpdateTargets.replaceResultsSelector);if(this.debug)console.log("%cr:replaceResultsEl",this.debugStyle,replaceResultsEl)}if(replaceResultsEl){replaceResultsEl.outerHTML=json.resultsItems;if(this.debug)console.log("%cr: Replaced element using replaceResultsSelector",this.debugStyle,replaceResultsEl)}else{let resultsItemsEl=this.fbSearchEl.querySelector(this.config.htmlUpdateTargets.resultsItems);if(resultsItemsEl){resultsItemsEl.outerHTML=json.resultsItems;if(this.debug)console.log("%cr:Updated results by replacing resultsItemsEl",this.debugStyle,resultsItemsEl)}else{let newResultsItemsEl=document.createElement("div");newResultsItemsEl.innerHTML=json.resultsItems;resultsContainer.appendChild(newResultsItemsEl.firstElementChild);if(this.debug)console.log("%cr:No results element found, so created a new one and appended it to resultsContainer.",this.debugStyle,resultsItemsEl)}}}else{console.error("resultsContainer element not found.")}}else{console.warn("resultsContainer configuration missing.")}}updateResultsCount(json){if(this.config.htmlUpdateTargets.resultsCount){let resultsCountEl=this.fbSearchEl.querySelector(this.config.htmlUpdateTargets.resultsCount);if(resultsCountEl){resultsCountEl.outerHTML=json.resultsCount||"";if(this.debug)console.log("%cr:resultsCountEl updated or cleared",this.debugStyle,resultsCountEl)}else{if(this.debug)console.warn("resultsCount element not found in DOM.")}}else{if(this.debug)console.warn("resultsCount configuration missing.")}}updatePagination(json){if(this.config.htmlUpdateTargets.pagination){let paginationEl=this.fbSearchEl.querySelector(this.config.htmlUpdateTargets.pagination);if(this.debug)console.log("%cr:paginationEl",this.debugStyle,paginationEl);if(json.pagination){if(paginationEl){if(this.debug)console.log("%cr:updated existing pagination block.",this.debugStyle);paginationEl.outerHTML=json.pagination}else{if(this.debug)console.log("%cr:No pagination element found, created a new one.",this.debugStyle);paginationEl=document.createElement("nav");this.fbSearchEl.querySelector(this.config.htmlUpdateTargets.resultsContainer).appendChild(paginationEl);paginationEl.outerHTML=json.pagination}}else if(paginationEl){if(this.debug)console.log("%cr:Does not require pagination, removed.",this.debugStyle);paginationEl.remove()}}else{if(this.debug)console.warn("pagination configuration missing.")}}updateFiltersPanel(json){if(this.config.htmlUpdateTargets.filterPanel){let searchFiltersEl=document.querySelector(this.config.htmlUpdateTargets.filterPanel);if(searchFiltersEl){let filterGroups=searchFiltersEl.querySelectorAll(this.config.htmlUpdateTargets.filterGroups);filterGroups.forEach(filterGroup=>{filterGroup.remove();if(this.debug)console.log("%cr: Removed filters: "+this.config.htmlUpdateTargets.filterGroups,this.debugStyle)});if(json.filters){let filterPanelTitleEl=document.querySelector(this.config.htmlUpdateTargets.filterPanelTitle);let filterPanelFooterEl=searchFiltersEl.querySelector(this.config.htmlUpdateTargets.filterPanelFooter);filterPanelTitleEl.style.display=filterPanelFooterEl.style.display="";
1json.filters.reverse().forEach(filter=>{let inputContainerEl=document.createElement("div");filterPanelTitleEl.after(inputContainerEl);inputContainerEl.outerHTML=filter});if(json.filterButtons&&json.filterButtons.links){this.updateClearFilterButton(json.filterButtons.links);this.updateShowSearchResultsButton(json.filterButtons.links,json.totalResults)}}}else{if(this.debug)console.warn("filterPanel element not found in DOM.")}}else{if(this.debug)console.warn("filterPanel configuration missing.")}}updateClearFilterButton(links){if(this.clearFilterPanelEl){links.forEach(link=>{if(link.id==="clear_filter_panel"){this.clearFilterPanelEl.href=link.url;if(this.debug)console.log("%cUpdated clear filters button link.",this.debugStyle)}})}else{if(this.debug)console.warn("Clear all filters button not found.")}}updateShowSearchResultsButton(links,totalResults){const showSearchResultsBtn=document.querySelector(this.config.htmlUpdateTargets.showSearchResultsButton);if(showSearchResultsBtn){links.forEach(link=>{if(link.id===showSearchResultsBtn.getAttribute("id")){showSearchResultsBtn.textContent="Show "+totalResults+" search results";if(this.debug)console.log("%cUpdated show search results button.",this.debugStyle)}})}else{console.warn("Show search results button not found in DOM.")}}updateChipsContainer(json){if(this.config.htmlUpdateTargets.chipsContainer){let chipsContainerEl=this.fbSearchEl.querySelector(this.config.htmlUpdateTargets.chipsContainer);if(chipsContainerEl){let chipsEl=this.fbSearchEl.querySelector(this.config.htmlUpdateTargets.chips);if(json.chips){if(chipsEl){chipsEl.outerHTML=json.chips;if(this.debug)console.log("%cr: updated chips(s).",this.debugStyle)}else{chipsEl=document.createElement("ul");chipsContainerEl.appendChild(chipsEl);chipsEl.outerHTML=json.chips;if(this.debug)console.log("%cr: created and added new chip(s).",this.debugStyle)}}else if(chipsEl){chipsContainerEl.removeChild(chipsEl);if(this.debug)console.log("%cr: cleared chips.",this.debugStyle)}}else{console.warn("chipsContainer element not found in DOM.")}}else{console.warn("chipsContainer configuration missing.")}}xmlRequest(payload=null){let btn=document.getElementById(this.config.htmlUpdateTargets.formButton);try{if(null!=payload){payload.config=this.config;if(this.debug)console.log("%cxmlRequest(): payload",this.debugStyle,payload);let xhr=new XMLHttpRequest;xhr.open("POST",this.config.endpoint,true);xhr.setRequestHeader("Content-Type","application/json");xhr.send(JSON.stringify(payload));xhr.onload=()=>{if(xhr.responseText){if(this.debug)console.log("%cxmlRequest(): response received, html updated",this.debugStyle,JSON.parse(xhr.responseText));this.writeResponse(xhr.responseText);if(this.handleLinks){this.convertLinks()}
1if(this.config.searchAjaxListener!==false){searchAjaxLoad.onload=true}this.updateCustomOnLoad(xhr.responseText)}else{console.error("xmlRequest(): Empty responseText received from the server.")}};xhr.onloadend=()=>{if(btn)btn.disabled=false;if(this.config.searchAjaxListener!==false){searchAjaxLoad.onload=false}if(this.useTwigTemplates){if(JSON.parse(xhr.responseText).filters){this.addFilterListeners()}this.updateBrowserHistory(JSON.parse(xhr.responseText))}}}else{console.error("No payload was provided to xmlRequest.");if(btn)btn.disabled=false}}catch(e){console.error("xmlRequest error: "+e.message);if(btn)btn.disabled=false}}doFbSearch(e=null,url=null){if(this.debug)console.log("%chandleSearchOrFilter()",this.debugStyle,url);if(e)e.preventDefault();this.clearPredictiveSuggestions();let btn=document.getElementById(this.config.htmlUpdateTargets.formButton);if(btn){btn.disabled=true}const payload={query:this.formData.query};if(url){this.formData.toggleURL=url;payload.url=url}if(this.useTwigTemplates){payload.target="main"}if(this.formData.sort_by){payload.sort=this.formData.sort_by}this.addCustomPayload(payload);this.xmlRequest(payload)}addCustomPayload(payload){}updateCustomOnLoad(responseText){}convertLinks(){if(this.debug)console.group("%cconvertLinks()",this.debugStyle);try{var links=document.getElementById("search-results").getElementsByTagName("a")}catch(e){if(this.debug)console.error("No element with the id of search-results was found: "+e.message)}if(null!=links){for(var i=0;i<links.length;i++){if(this.debug)console.log("%clink "+i,this.debugStyle,links[i].getAttribute("href"));if(links[i].classList.contains("clickTrackingUrl")){this.handleContextmenuClickTrackingUrl(links[i])}else if(links[i].classList.contains("curated-link")){this.handleContextmenuCuratedLink(links[i])}else{if(this.debug)console.log("%cUnhandled context",this.debugStyle)}}}if(this.debug)console.groupEnd()}handleContextmenuCuratedLink(link){var url=link.getAttribute("href")}updateBrowserHistory(json){const url=new URL(window.location.href.split("?")[0]);const searchParams=url.searchParams;const hasNoResultsMessage=Boolean(String(json?.noResultsMessage??"").trim());const query=hasNoResultsMessage?null:this.formData.query;if(this.formData.filter){searchParams.set("complete_url",this.formData.filter)}else if(query){searchParams.set("query",query)}this.addCustomHistoryParams(searchParams);url.search=searchParams.toString();let updatedUrl=url.toString();if(this.formData.filter){window.history.pushState({complete_url:decodeURIComponent(updatedUrl)},"",decodeURIComponent(updatedUrl));this.formData.filter=null}else{window.history.pushState({url:updatedUrl,query:query},"",updatedUrl)}this.updateCustomHistoryState();if(this.debug)console.log("%cupdateBrowserHistory: state",this.debugStyle,history.state)}addCustomHistoryParams(searchParams){}updateCustomHistoryState(){}}const searchAjaxLoad={Onload:false,onloadListener:function(v){},set onload(v){this.Onload=v;this.onloadListener(v)},get onload(){return this.Onload},registerListener:function(listener){this.onloadListener=listener}};
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.