diff --git a/app/frontend/controllers/accessible_autocomplete_controller.js b/app/frontend/controllers/accessible_autocomplete_controller.js
index d3c900485..935594529 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 { 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 () {
+ 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[1]}[${matches[2]}_raw]`;
+
accessibleAutocomplete.enhanceSelectElement({
- defaultValue: '',
- selectElement: this.element
- })
+ defaultValue: "",
+ 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..91966be9a
--- /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