From 7d9b0ac76b7eaae83567ceae036fd4ceb1f29c38 Mon Sep 17 00:00:00 2001 From: Precious Onyenaucheya <86783201+precious-onyenaucheya-ons@users.noreply.github.com> Date: Thu, 7 Nov 2024 15:31:53 +0000 Subject: [PATCH] Update Fuse.js configuration to adjust search threshold (#3288) * increase search threshold * update threshold * add option to extend search * update logic * update autosggest * update validation * Update src/components/autosuggest/example-autosuggest-country.njk Co-authored-by: rmccar <42928680+rmccar@users.noreply.github.com> * Update src/components/autosuggest/autosuggest.ui.js Co-authored-by: rmccar <42928680+rmccar@users.noreply.github.com> * rename to resultsThreshold * update * update * update test * update fuse config * update param type in md file * update from tenary to if else statement block * update fuse version * fix issue with search * fix failing tests * remove log --------- Co-authored-by: rmccar <42928680+rmccar@users.noreply.github.com> --- package.json | 2 +- src/components/autosuggest/_macro-options.md | 41 ++++++++-------- src/components/autosuggest/_macro.njk | 1 + src/components/autosuggest/_macro.spec.js | 8 +++- src/components/autosuggest/autosuggest.ui.js | 48 +++++++++++++++---- .../example-autosuggest-country.njk | 3 +- src/components/autosuggest/fuse-config.js | 9 +++- src/js/analytics.js | 2 +- yarn.lock | 39 +++------------ 9 files changed, 85 insertions(+), 68 deletions(-) diff --git a/package.json b/package.json index 3555c80139..1d843290b9 100644 --- a/package.json +++ b/package.json @@ -84,7 +84,7 @@ "express": "^4.17.1", "front-matter": "^4.0.2", "fs-extra": "^11.1.1", - "fuse.js": "^3.6.1", + "fuse.js": "^7.0.0", "glob": "^10.2.3", "gulp": "^4.0.2", "gulp-babel": "^8.0.0", diff --git a/src/components/autosuggest/_macro-options.md b/src/components/autosuggest/_macro-options.md index e169e62837..90f8b166ab 100644 --- a/src/components/autosuggest/_macro-options.md +++ b/src/components/autosuggest/_macro-options.md @@ -1,20 +1,21 @@ -| Name | Type | Required | Description | -| ------------------- | ------------------------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| autosuggestData | string | false | URL of the JSON file with the autosuggest data that needs to be searched. Required if not using the address index api | -| allowMultiple | boolean | false | Allows the component to accept multiple selections | -| instructions | string | true | Instructions on how to use the autosuggest that will be read out by screen readers | -| ariaYouHaveSelected | string | true | Aria message to tell the user that they have selected an answer | -| ariaMinChars | string | true | Aria message to tell the user how many characters they need to enter before autosuggest will start | -| minChars | integer | false | Minimum number of characters to run a query. Default is 3 | -| ariaOneResult | string | true | Aria message to tell the user there is only one suggestion left | -| ariaNResults | string | true | Aria message to tell the user how many suggestions are left | -| ariaLimitedResults | string | true | Aria message to tell the user if the results have been limited and what they are limited to | -| moreResults | string | true | Aria message to tell the user to continue to type to refine suggestions | -| noResults | string | true | message to tell the user there are no results | -| tooManyResults | string | false | message to tell the user there are too many results to display and the user should refine the search. This is only required when using the address index api | -| typeMore | string | true | message to encourage the user to enter more characters to get suggestions | -| resultsTitle | string | true | Title of results to be displayed on screen at the top of the results | -| resultsTitleId | string | true | ID for the results title. The ID is used in the results `aria-labelledby` to provide context for the results | -| input | `Input` [_(ref)_](/components/input) | true | Configuration object for the input | -| language | string | false | The ISO 639-1 Code will override the default language in page. Please note that only 'en', 'cy' and 'ni' is currently supported | -| id | string | false | The `id` of the input | +| Name | Type | Required | Description | +| ------------------- | ------------------------------------ | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| autosuggestData | string | false | URL of the JSON file with the autosuggest data that needs to be searched. Required if not using the address index api | +| allowMultiple | boolean | false | Allows the component to accept multiple selections | +| instructions | string | true | Instructions on how to use the autosuggest that will be read out by screen readers | +| ariaYouHaveSelected | string | true | Aria message to tell the user that they have selected an answer | +| ariaMinChars | string | true | Aria message to tell the user how many characters they need to enter before autosuggest will start | +| minChars | integer | false | Minimum number of characters to run a query. Default is 3 | +| ariaOneResult | string | true | Aria message to tell the user there is only one suggestion left | +| ariaNResults | string | true | Aria message to tell the user how many suggestions are left | +| ariaLimitedResults | string | true | Aria message to tell the user if the results have been limited and what they are limited to | +| moreResults | string | true | Aria message to tell the user to continue to type to refine suggestions | +| noResults | string | true | message to tell the user there are no results | +| tooManyResults | string | false | message to tell the user there are too many results to display and the user should refine the search. This is only required when using the address index api | +| typeMore | string | true | message to encourage the user to enter more characters to get suggestions | +| resultsTitle | string | true | Title of results to be displayed on screen at the top of the results | +| resultsTitleId | string | true | ID for the results title. The ID is used in the results `aria-labelledby` to provide context for the results | +| input | `Input` [_(ref)_](/components/input) | true | Configuration object for the input | +| language | string | false | The ISO 639-1 Code will override the default language in page. Please note that only 'en', 'cy' and 'ni' is currently supported | +| resultsThreshold | float | false | Option to adjust the search threshold and fuzziness. Accepts a range from 0 to 1, where 0 provides the closest match and 1 allows for more distant matches. Defaults to 0.2. | +| id | string | false | The `id` of the input | diff --git a/src/components/autosuggest/_macro.njk b/src/components/autosuggest/_macro.njk index b8c2f4c52f..a5d18997e6 100644 --- a/src/components/autosuggest/_macro.njk +++ b/src/components/autosuggest/_macro.njk @@ -15,6 +15,7 @@ data-results-title="{{ params.resultsTitle }}" data-no-results="{{ params.noResults }}" data-type-more="{{ params.typeMore }}" + {% if params.resultsThreshold %}data-result-threshold="{{ params.resultsThreshold }}"{% endif %} {% if params.apiDomain %}data-api-domain="{{ params.apiDomain }}"{% endif %} {% if params.apiDomainBearerToken %}data-authorization-token="{{ params.apiDomainBearerToken }}"{% endif %} {% if params.apiManualQueryParams == true %}data-query-params=""{% endif %} diff --git a/src/components/autosuggest/_macro.spec.js b/src/components/autosuggest/_macro.spec.js index efac0908e7..ee9c897fdf 100644 --- a/src/components/autosuggest/_macro.spec.js +++ b/src/components/autosuggest/_macro.spec.js @@ -32,6 +32,11 @@ const EXAMPLE_AUTOSUGGEST = { typeMore: 'Continue entering to get suggestions', }; +const EXAMPLE_AUTOSUGGEST_WITH_RESULTS_THRESHOLD = { + ...EXAMPLE_AUTOSUGGEST, + resultsThreshold: 0.5, +}; + describe('macro: autosuggest', () => { it('passes jest-axe checks', async () => { const $ = cheerio.load(renderComponent('autosuggest', EXAMPLE_AUTOSUGGEST)); @@ -47,7 +52,7 @@ describe('macro: autosuggest', () => { }); it('has the provided data attributes', () => { - const $ = cheerio.load(renderComponent('autosuggest', EXAMPLE_AUTOSUGGEST)); + const $ = cheerio.load(renderComponent('autosuggest', EXAMPLE_AUTOSUGGEST_WITH_RESULTS_THRESHOLD)); const $element = $('.ons-autosuggest'); expect($element.attr('data-allow-multiple')).toBeUndefined(); @@ -63,6 +68,7 @@ describe('macro: autosuggest', () => { expect($element.attr('data-no-results')).toBe('No suggestions found. You can enter your own answer'); expect($element.attr('data-results-title')).toBe('Suggestions'); expect($element.attr('data-type-more')).toBe('Continue entering to get suggestions'); + expect($element.attr('data-result-threshold')).toBe('0.5'); }); it('has the `data-allow-multiple` attribute when `allowMultiple` is `true`', () => { diff --git a/src/components/autosuggest/autosuggest.ui.js b/src/components/autosuggest/autosuggest.ui.js index 1a19883df5..74cce5e8a0 100644 --- a/src/components/autosuggest/autosuggest.ui.js +++ b/src/components/autosuggest/autosuggest.ui.js @@ -37,6 +37,7 @@ export default class AutosuggestUI { errorAPI, errorAPILinkText, typeMore, + customResultsThreshold, }) { // DOM Elements this.context = context; @@ -65,6 +66,7 @@ export default class AutosuggestUI { this.errorAPI = errorAPI || context.getAttribute('data-error-api'); this.errorAPILinkText = errorAPILinkText || context.getAttribute('data-error-api-link-text'); this.typeMore = typeMore || context.getAttribute('data-type-more'); + this.customResultsThreshold = customResultsThreshold || context.getAttribute('data-result-threshold'); this.language = context.getAttribute('data-lang'); this.allowMultiple = context.getAttribute('data-allow-multiple') || false; this.listboxId = this.listbox.getAttribute('id'); @@ -293,9 +295,30 @@ export default class AutosuggestUI { async fetchSuggestions(sanitisedQuery, data) { this.abortFetch(); - const results = await runFuse(sanitisedQuery, data, this.lang, this.resultLimit); + + const threshold = + this.customResultsThreshold != null && this.customResultsThreshold >= 0 && this.customResultsThreshold <= 1 + ? this.customResultsThreshold + : 0.2; + + let distance; + if (threshold >= 0.6) { + distance = 500; + } else if (threshold >= 0.4) { + distance = 300; + } else { + distance = 100; + } + + const results = await runFuse(sanitisedQuery, data, this.lang, threshold, distance); + results.forEach((result) => { - result.sanitisedText = sanitiseAutosuggestText(result[this.lang], this.sanitisedQueryReplaceChars); + const resultItem = result.item ?? result; + + result.sanitisedText = sanitiseAutosuggestText( + resultItem[this.lang] ?? resultItem['formattedAddress'], + this.sanitisedQueryReplaceChars, + ); }); return { status: this.responseStatus, @@ -345,16 +368,18 @@ export default class AutosuggestUI { this.listbox.innerHTML = ''; if (this.results) { this.resultOptions = this.results.map((result, index) => { - let innerHTML = this.emboldenMatch(result[this.lang], this.query); + const resultItem = result.item ?? result; + + let innerHTML = this.emboldenMatch(resultItem[this.lang] ?? resultItem['formattedAddress'], this.query); const listElement = document.createElement('li'); listElement.className = classAutosuggestOption; listElement.setAttribute('id', `${this.listboxId}__option--${index}`); listElement.setAttribute('role', 'option'); - if (result.category) { + if (resultItem.category) { innerHTML = innerHTML + - `${result.category}`; + `${resultItem.category}`; } listElement.innerHTML = innerHTML; listElement.addEventListener('click', () => { @@ -485,16 +510,19 @@ export default class AutosuggestUI { if (this.results.length) { this.settingResult = true; const result = this.results[index || this.highlightedResultIndex || 0]; + const resultItem = result.item ?? result; + const resultValue = resultItem[this.lang] ?? resultItem['formattedAddress']; + this.resultSelected = true; if (this.allowMultiple === 'true') { - let value = this.storeExistingSelections(result[this.lang]); + let value = this.storeExistingSelections(resultValue); result.displayText = value; - } else if (result.url) { - result.displayText = result[this.lang]; - window.location = result.url; + } else if (resultItem.url) { + result.displayText = resultValue; + window.location = resultItem.url; } else { - result.displayText = result[this.lang]; + result.displayText = resultValue; } this.onSelect(result).then(() => (this.settingResult = false)); diff --git a/src/components/autosuggest/example-autosuggest-country.njk b/src/components/autosuggest/example-autosuggest-country.njk index ddf071ddb0..3358178ad1 100644 --- a/src/components/autosuggest/example-autosuggest-country.njk +++ b/src/components/autosuggest/example-autosuggest-country.njk @@ -24,7 +24,8 @@ "resultsTitleId": "country-of-birth-suggestions", "autosuggestData": "/examples/data/country-of-birth.json", "noResults": "No suggestions found. You can enter your own answer", - "typeMore": "Continue entering to get suggestions" + "typeMore": "Continue entering to get suggestions", + "resultsThreshold": 0.2 }) }} diff --git a/src/components/autosuggest/fuse-config.js b/src/components/autosuggest/fuse-config.js index 2b7724a47e..70dade58b6 100644 --- a/src/components/autosuggest/fuse-config.js +++ b/src/components/autosuggest/fuse-config.js @@ -1,14 +1,19 @@ import Fuse from 'fuse.js'; -export default function runFuse(query, data, searchFields) { +export default function runFuse(query, data, searchFields, threshold, distance) { const options = { shouldSort: true, - threshold: 0.2, + threshold: threshold, + distance: distance, keys: [ { name: searchFields, weight: 0.9, }, + { + name: 'formattedAddress', + weight: 0.9, + }, { name: 'tags', weight: 0.1, diff --git a/src/js/analytics.js b/src/js/analytics.js index 1e6ac1cd86..3bc1d3dad7 100644 --- a/src/js/analytics.js +++ b/src/js/analytics.js @@ -44,7 +44,7 @@ export default function initAnalytics() { document.body.addEventListener('click', ({ target }) => { if (target.getAttribute('data-ga') === 'click') { return trackElement(target, 'click'); - } else if (target.parentElement.getAttribute('data-ga') === 'click') { + } else if (target.parentElement?.getAttribute('data-ga') === 'click') { return trackElement(target.parentElement, 'click'); } }); diff --git a/yarn.lock b/yarn.lock index 208423613e..56930019ef 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5655,10 +5655,10 @@ functions-have-names@^1.2.3: resolved "https://registry.yarnpkg.com/functions-have-names/-/functions-have-names-1.2.3.tgz#0404fe4ee2ba2f607f0e0ec3c80bae994133b834" integrity sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ== -fuse.js@^3.6.1: - version "3.6.1" - resolved "https://registry.yarnpkg.com/fuse.js/-/fuse.js-3.6.1.tgz#7de85fdd6e1b3377c23ce010892656385fd9b10c" - integrity sha512-hT9yh/tiinkmirKrlv4KWOjztdoZo1mx9Qh4KvWqC7isoXwdUY3PNWUxceF4/qO9R6riA2C29jdTOeQOIROjgw== +fuse.js@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/fuse.js/-/fuse.js-7.0.0.tgz#6573c9fcd4c8268e403b4fc7d7131ffcf99a9eb2" + integrity sha512-14F4hBIxqKvD4Zz/XjDc3y94mNZN6pRv3U13Udo0lNLCWRBUsrMv2xwcF/y/Z5sV6+FQW+/ow68cHpm4sunt8Q== gensync@^1.0.0-beta.2: version "1.0.0-beta.2" @@ -11308,7 +11308,7 @@ string-length@^4.0.1: char-regex "^1.0.2" strip-ansi "^6.0.0" -"string-width-cjs@npm:string-width@^4.2.0": +"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -11326,15 +11326,6 @@ string-width@^1.0.1, string-width@^1.0.2: is-fullwidth-code-point "^1.0.0" strip-ansi "^3.0.0" -string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - string-width@^5.0.1, string-width@^5.1.2: version "5.1.2" resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794" @@ -11403,7 +11394,7 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -11424,13 +11415,6 @@ strip-ansi@^4.0.0: dependencies: ansi-regex "^3.0.0" -strip-ansi@^6.0.0, strip-ansi@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - strip-ansi@^7.0.0, strip-ansi@^7.0.1, strip-ansi@^7.1.0: version "7.1.0" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" @@ -12811,7 +12795,7 @@ which@^2.0.1: dependencies: isexe "^2.0.0" -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -12828,15 +12812,6 @@ wrap-ansi@^2.0.0: string-width "^1.0.1" strip-ansi "^3.0.1" -wrap-ansi@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"