diff --git a/app/frontend/controllers/accessible_autocomplete_controller.js b/app/frontend/controllers/accessible_autocomplete_controller.js index d3c900485..0b17e8aa8 100644 --- a/app/frontend/controllers/accessible_autocomplete_controller.js +++ b/app/frontend/controllers/accessible_autocomplete_controller.js @@ -1,12 +1,36 @@ import { Controller } from '@hotwired/stimulus' import accessibleAutocomplete from 'accessible-autocomplete' import 'accessible-autocomplete/dist/accessible-autocomplete.min.css' +import { enhanceOption, suggestion, sort } from '../modules/search' export default class extends Controller { connect () { + const selectEl = this.element + const selectOptions = Array.from(selectEl.options) + const options = selectOptions.map((o) => enhanceOption(o)) + + const matches = /^(\w+)\[(\w+)\]$/.exec(selectEl.name) + const rawFieldName = matches ? `${matches[1]}[${matches[2]}_raw]` : '' + accessibleAutocomplete.enhanceSelectElement({ defaultValue: '', - selectElement: this.element + selectElement: selectEl, + minLength: 2, + source: (query, populateResults) => { + if (/\S/.test(query)) { + populateResults(sort(query, options)) + } + }, + autoselect: true, + templates: { suggestion: (value) => suggestion(value, options) }, + name: rawFieldName, + onConfirm: (val) => { + const selectedOption = [].filter.call( + selectOptions, + (option) => (option.textContent || option.innerText) === val + )[0] + if (selectedOption) selectedOption.selected = true + } }) } } diff --git a/app/frontend/modules/search.js b/app/frontend/modules/search.js new file mode 100644 index 000000000..d9d233ab1 --- /dev/null +++ b/app/frontend/modules/search.js @@ -0,0 +1,106 @@ +const addWeightWithBoost = (option, query) => { + option.weight = calculateWeight(option.clean, query) * option.clean.boost + + return option +} + +const clean = (text) => + text + .trim() + .replace(/['’]/g, '') + .replace(/[.,"/#!$%^&*;:{}=\-_`~()]/g, ' ') + .toLowerCase() + +const cleanseOption = (option) => { + option.clean = { + name: clean(option.name), + nameWithoutStopWords: removeStopWords(option.name), + boost: option.boost || 1 + } + + return option +} + +const hasWeight = (option) => option.weight > 0 + +const byWeightThenAlphabetically = (a, b) => { + if (a.weight > b.weight) return -1 + if (a.weight < b.weight) return 1 + if (a.name < b.name) return -1 + if (a.name > b.name) return 1 + + return 0 +} + +const optionName = (option) => option.name +const exactMatch = (word, query) => word === query + +const startsWithRegExp = (query) => new RegExp('\\b' + query, 'i') +const startsWith = (word, query) => word.search(startsWithRegExp(query)) === 0 + +const wordsStartsWithQuery = (word, regExps) => + regExps.every((regExp) => word.search(regExp) >= 0) + +const calculateWeight = ({ name, nameWithoutStopWords }, query) => { + const queryWithoutStopWords = removeStopWords(query) + + if (exactMatch(name, query)) return 100 + if (exactMatch(nameWithoutStopWords, queryWithoutStopWords)) return 95 + + if (startsWith(name, query)) return 60 + if (startsWith(nameWithoutStopWords, queryWithoutStopWords)) return 55 + const startsWithRegExps = queryWithoutStopWords + .split(/\s+/) + .map(startsWithRegExp) + + if (wordsStartsWithQuery(nameWithoutStopWords, startsWithRegExps)) return 25 + + return 0 +} + +const stopWords = ['the', 'of', 'in', 'and', 'at', '&'] + +const removeStopWords = (text) => { + const isAllStopWords = text + .trim() + .split(' ') + .every((word) => stopWords.includes(word)) + + if (isAllStopWords) { + return text + } + + const regex = new RegExp( + stopWords.map((word) => `(\\s+)?${word}(\\s+)?`).join('|'), + 'gi' + ) + return text.replace(regex, ' ').trim() +} + +export const sort = (query, options) => { + const cleanQuery = clean(query) + + return options + .map(cleanseOption) + .map((option) => addWeightWithBoost(option, cleanQuery)) + .filter(hasWeight) + .sort(byWeightThenAlphabetically) + .map(optionName) +} + +export const suggestion = (value, options) => { + const option = options.find((o) => o.name === value) + if (option) { + const html = `${value}` + return html + } else { + return 'No results found' + } +} + +export const enhanceOption = (option) => { + return { + name: option.label, + boost: parseFloat(option.getAttribute('data-boost')) || 1 + } +} diff --git a/spec/features/form/accessible_autocomplete_spec.rb b/spec/features/form/accessible_autocomplete_spec.rb index e196954da..a8537199b 100644 --- a/spec/features/form/accessible_autocomplete_spec.rb +++ b/spec/features/form/accessible_autocomplete_spec.rb @@ -21,23 +21,41 @@ RSpec.describe "Accessible Automcomplete" do sign_in user end - it "allows type ahead filtering", js: true do - visit("/logs/#{case_log.id}/accessible-select") - find("#case-log-prevloc-field").click.native.send_keys("T", "h", "a", "n", :down, :enter) - expect(find("#case-log-prevloc-field").value).to eq("Thanet") - end + context "when using accessible autocomplete" do + before do + visit("/logs/#{case_log.id}/accessible-select") + end - it "maintains enhancement state across back navigation", js: true do - visit("/logs/#{case_log.id}/accessible-select") - find("#case-log-prevloc-field").click.native.send_keys("T", "h", "a", "n", :down, :enter) - click_button("Save and continue") - click_link(text: "Back") - expect(page).to have_selector("input", class: "autocomplete__input", count: 1) - end + it "allows type ahead filtering", js: true do + find("#case-log-prevloc-field").click.native.send_keys("T", "h", "a", "n", :down, :enter) + expect(find("#case-log-prevloc-field").value).to eq("Thanet") + end - it "has a disabled null option" do - visit("/logs/#{case_log.id}/accessible-select") - expect(page).to have_select("case-log-prevloc-field", disabled_options: ["Select an option"]) + it "ignores punctuation", js: true do + find("#case-log-prevloc-field").click.native.send_keys("T", "h", "a", "'", "n", :down, :enter) + expect(find("#case-log-prevloc-field").value).to eq("Thanet") + end + + it "ignores stop words", js: true do + find("#case-log-prevloc-field").click.native.send_keys("t", "h", "e", " ", "W", "e", "s", "t", "m", :down, :enter) + expect(find("#case-log-prevloc-field").value).to eq("Westminster") + end + + it "does not perform an exact match", js: true do + find("#case-log-prevloc-field").click.native.send_keys("o", "n", "l", "y", " ", "t", "o", "w", "n", :down, :enter) + expect(find("#case-log-prevloc-field").value).to eq("The one and only york town") + end + + it "maintains enhancement state across back navigation", js: true do + find("#case-log-prevloc-field").click.native.send_keys("T", "h", "a", "n", :down, :enter) + click_button("Save and continue") + click_link(text: "Back") + expect(page).to have_selector("input", class: "autocomplete__input", count: 1) + end + + it "has a disabled null option" do + expect(page).to have_select("case-log-prevloc-field", disabled_options: ["Select an option"]) + end end it "has the correct option selected if one has been saved" do diff --git a/spec/fixtures/forms/2021_2022.json b/spec/fixtures/forms/2021_2022.json index f8afa669e..9a24381e9 100644 --- a/spec/fixtures/forms/2021_2022.json +++ b/spec/fixtures/forms/2021_2022.json @@ -326,7 +326,7 @@ "E07000178": "Oxford", "E07000114": "Thanet", "E09000033": "Westminster", - "E06000014": "York" + "E06000014": "The one and only york town" } } }, diff --git a/spec/models/form/question_spec.rb b/spec/models/form/question_spec.rb index 78909766b..31021fe90 100644 --- a/spec/models/form/question_spec.rb +++ b/spec/models/form/question_spec.rb @@ -158,7 +158,7 @@ RSpec.describe Form::Question, type: :model do end it "can map label from value" do - expect(question.label_from_value("E06000014")).to eq("York") + expect(question.label_from_value("E09000033")).to eq("Westminster") end context "when the saved answer is not in the value map" do