diff --git a/package-lock.json b/package-lock.json index e46b24c7a3..062af01e6e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -46,16 +46,15 @@ "email-validator": "2.0.4", "file-saver": "^2.0.5", "formik": "2.2.6", - "instantsearch.css": "^8.1.0", "jszip": "^3.10.1", "lodash": "4.17.21", + "meilisearch": "^0.38.0", "moment": "2.29.4", "prop-types": "15.7.2", "react": "17.0.2", "react-datepicker": "^4.13.0", "react-dom": "17.0.2", "react-helmet": "^6.1.0", - "react-instantsearch": "^7.7.1", "react-redux": "7.2.9", "react-responsive": "9.0.2", "react-router": "6.16.0", @@ -110,159 +109,6 @@ "integrity": "sha512-rE0Pygv0sEZ4vBWHlAgJLGDU7Pm8xoO6p3wsEceb7GYAjScrOHpEo8KK/eVkAcnSM+slAEtXjA2JpdjLp4fJQQ==", "dev": true }, - "node_modules/@algolia/cache-browser-local-storage": { - "version": "4.23.2", - "resolved": "https://registry.npmjs.org/@algolia/cache-browser-local-storage/-/cache-browser-local-storage-4.23.2.tgz", - "integrity": "sha512-PvRQdCmtiU22dw9ZcTJkrVKgNBVAxKgD0/cfiqyxhA5+PHzA2WDt6jOmZ9QASkeM2BpyzClJb/Wr1yt2/t78Kw==", - "peer": true, - "dependencies": { - "@algolia/cache-common": "4.23.2" - } - }, - "node_modules/@algolia/cache-common": { - "version": "4.23.2", - "resolved": "https://registry.npmjs.org/@algolia/cache-common/-/cache-common-4.23.2.tgz", - "integrity": "sha512-OUK/6mqr6CQWxzl/QY0/mwhlGvS6fMtvEPyn/7AHUx96NjqDA4X4+Ju7aXFQKh+m3jW9VPB0B9xvEQgyAnRPNw==", - "peer": true - }, - "node_modules/@algolia/cache-in-memory": { - "version": "4.23.2", - "resolved": "https://registry.npmjs.org/@algolia/cache-in-memory/-/cache-in-memory-4.23.2.tgz", - "integrity": "sha512-rfbi/SnhEa3MmlqQvgYz/9NNJ156NkU6xFxjbxBtLWnHbpj+qnlMoKd+amoiacHRITpajg6zYbLM9dnaD3Bczw==", - "peer": true, - "dependencies": { - "@algolia/cache-common": "4.23.2" - } - }, - "node_modules/@algolia/client-account": { - "version": "4.23.2", - "resolved": "https://registry.npmjs.org/@algolia/client-account/-/client-account-4.23.2.tgz", - "integrity": "sha512-VbrOCLIN/5I7iIdskSoSw3uOUPF516k4SjDD4Qz3BFwa3of7D9A0lzBMAvQEJJEPHWdVraBJlGgdJq/ttmquJQ==", - "peer": true, - "dependencies": { - "@algolia/client-common": "4.23.2", - "@algolia/client-search": "4.23.2", - "@algolia/transporter": "4.23.2" - } - }, - "node_modules/@algolia/client-analytics": { - "version": "4.23.2", - "resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-4.23.2.tgz", - "integrity": "sha512-lLj7irsAztGhMoEx/SwKd1cwLY6Daf1Q5f2AOsZacpppSvuFvuBrmkzT7pap1OD/OePjLKxicJS8wNA0+zKtuw==", - "peer": true, - "dependencies": { - "@algolia/client-common": "4.23.2", - "@algolia/client-search": "4.23.2", - "@algolia/requester-common": "4.23.2", - "@algolia/transporter": "4.23.2" - } - }, - "node_modules/@algolia/client-common": { - "version": "4.23.2", - "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-4.23.2.tgz", - "integrity": "sha512-Q2K1FRJBern8kIfZ0EqPvUr3V29ICxCm/q42zInV+VJRjldAD9oTsMGwqUQ26GFMdFYmqkEfCbY4VGAiQhh22g==", - "peer": true, - "dependencies": { - "@algolia/requester-common": "4.23.2", - "@algolia/transporter": "4.23.2" - } - }, - "node_modules/@algolia/client-personalization": { - "version": "4.23.2", - "resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-4.23.2.tgz", - "integrity": "sha512-vwPsgnCGhUcHhhQG5IM27z8q7dWrN9itjdvgA6uKf2e9r7vB+WXt4OocK0CeoYQt3OGEAExryzsB8DWqdMK5wg==", - "peer": true, - "dependencies": { - "@algolia/client-common": "4.23.2", - "@algolia/requester-common": "4.23.2", - "@algolia/transporter": "4.23.2" - } - }, - "node_modules/@algolia/client-search": { - "version": "4.23.2", - "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-4.23.2.tgz", - "integrity": "sha512-CxSB29OVGSE7l/iyoHvamMonzq7Ev8lnk/OkzleODZ1iBcCs3JC/XgTIKzN/4RSTrJ9QybsnlrN/bYCGufo7qw==", - "peer": true, - "dependencies": { - "@algolia/client-common": "4.23.2", - "@algolia/requester-common": "4.23.2", - "@algolia/transporter": "4.23.2" - } - }, - "node_modules/@algolia/events": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@algolia/events/-/events-4.0.1.tgz", - "integrity": "sha512-FQzvOCgoFXAbf5Y6mYozw2aj5KCJoA3m4heImceldzPSMbdyS4atVjJzXKMsfX3wnZTFYwkkt8/z8UesLHlSBQ==" - }, - "node_modules/@algolia/logger-common": { - "version": "4.23.2", - "resolved": "https://registry.npmjs.org/@algolia/logger-common/-/logger-common-4.23.2.tgz", - "integrity": "sha512-jGM49Q7626cXZ7qRAWXn0jDlzvoA1FvN4rKTi1g0hxKsTTSReyYk0i1ADWjChDPl3Q+nSDhJuosM2bBUAay7xw==", - "peer": true - }, - "node_modules/@algolia/logger-console": { - "version": "4.23.2", - "resolved": "https://registry.npmjs.org/@algolia/logger-console/-/logger-console-4.23.2.tgz", - "integrity": "sha512-oo+lnxxEmlhTBTFZ3fGz1O8PJ+G+8FiAoMY2Qo3Q4w23xocQev6KqDTA1JQAGPDxAewNA2VBwWOsVXeXFjrI/Q==", - "peer": true, - "dependencies": { - "@algolia/logger-common": "4.23.2" - } - }, - "node_modules/@algolia/recommend": { - "version": "4.23.2", - "resolved": "https://registry.npmjs.org/@algolia/recommend/-/recommend-4.23.2.tgz", - "integrity": "sha512-Q75CjnzRCDzgIlgWfPnkLtrfF4t82JCirhalXkSSwe/c1GH5pWh4xUyDOR3KTMo+YxxX3zTlrL/FjHmUJEWEcg==", - "peer": true, - "dependencies": { - "@algolia/cache-browser-local-storage": "4.23.2", - "@algolia/cache-common": "4.23.2", - "@algolia/cache-in-memory": "4.23.2", - "@algolia/client-common": "4.23.2", - "@algolia/client-search": "4.23.2", - "@algolia/logger-common": "4.23.2", - "@algolia/logger-console": "4.23.2", - "@algolia/requester-browser-xhr": "4.23.2", - "@algolia/requester-common": "4.23.2", - "@algolia/requester-node-http": "4.23.2", - "@algolia/transporter": "4.23.2" - } - }, - "node_modules/@algolia/requester-browser-xhr": { - "version": "4.23.2", - "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-4.23.2.tgz", - "integrity": "sha512-TO9wLlp8+rvW9LnIfyHsu8mNAMYrqNdQ0oLF6eTWFxXfxG3k8F/Bh7nFYGk2rFAYty4Fw4XUtrv/YjeNDtM5og==", - "peer": true, - "dependencies": { - "@algolia/requester-common": "4.23.2" - } - }, - "node_modules/@algolia/requester-common": { - "version": "4.23.2", - "resolved": "https://registry.npmjs.org/@algolia/requester-common/-/requester-common-4.23.2.tgz", - "integrity": "sha512-3EfpBS0Hri0lGDB5H/BocLt7Vkop0bTTLVUBB844HH6tVycwShmsV6bDR7yXbQvFP1uNpgePRD3cdBCjeHmk6Q==", - "peer": true - }, - "node_modules/@algolia/requester-node-http": { - "version": "4.23.2", - "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-4.23.2.tgz", - "integrity": "sha512-SVzgkZM/malo+2SB0NWDXpnT7nO5IZwuDTaaH6SjLeOHcya1o56LSWXk+3F3rNLz2GVH+I/rpYKiqmHhSOjerw==", - "peer": true, - "dependencies": { - "@algolia/requester-common": "4.23.2" - } - }, - "node_modules/@algolia/transporter": { - "version": "4.23.2", - "resolved": "https://registry.npmjs.org/@algolia/transporter/-/transporter-4.23.2.tgz", - "integrity": "sha512-GY3aGKBy+8AK4vZh8sfkatDciDVKad5rTY2S10Aefyjh7e7UGBP4zigf42qVXwU8VOPwi7l/L7OACGMOFcjB0Q==", - "peer": true, - "dependencies": { - "@algolia/cache-common": "4.23.2", - "@algolia/logger-common": "4.23.2", - "@algolia/requester-common": "4.23.2" - } - }, "node_modules/@ampproject/remapping": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", @@ -5694,11 +5540,6 @@ "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.3.3.tgz", "integrity": "sha512-LKVP3cgXBT9RYj+t+9FDKwS5tdI+rPBXaNSkma7hvqy35lc7mAokC2zsqWJH0LaqIt3B962nuYI77hsJoT1gow==" }, - "node_modules/@types/dom-speech-recognition": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/@types/dom-speech-recognition/-/dom-speech-recognition-0.0.1.tgz", - "integrity": "sha512-udCxb8DvjcDKfk1WTBzDsxFbLgYxmQGKrE/ricoMqHRNjSlSUCcamVTA5lIQqzY10mY5qCY0QDwBfFEwhfoDPw==" - }, "node_modules/@types/eslint": { "version": "8.56.7", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.7.tgz", @@ -5753,11 +5594,6 @@ "@types/node": "*" } }, - "node_modules/@types/google.maps": { - "version": "3.55.6", - "resolved": "https://registry.npmjs.org/@types/google.maps/-/google.maps-3.55.6.tgz", - "integrity": "sha512-RDtveRsejIi7KRnahz+PE1+Uo+6axr98Susjn/7DxNPPej/T0sMMJfnwm3NcQgvVDWvixWCMOn2Sfukq5UVF2g==" - }, "node_modules/@types/graceful-fs": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", @@ -5766,11 +5602,6 @@ "@types/node": "*" } }, - "node_modules/@types/hogan.js": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@types/hogan.js/-/hogan.js-3.0.5.tgz", - "integrity": "sha512-/uRaY3HGPWyLqOyhgvW9Aa43BNnLZrNeQxl2p8wqId4UHMfPKolSB+U7BlZyO1ng7MkLnyEAItsBzCG0SDhqrA==" - }, "node_modules/@types/hoist-non-react-statics": { "version": "3.3.5", "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.5.tgz", @@ -6547,11 +6378,6 @@ "integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==", "deprecated": "Use your platform's native atob() and btoa() methods instead" }, - "node_modules/abbrev": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", - "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==" - }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -6720,40 +6546,6 @@ "ajv": "^6.9.1" } }, - "node_modules/algoliasearch": { - "version": "4.23.2", - "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-4.23.2.tgz", - "integrity": "sha512-8aCl055IsokLuPU8BzLjwzXjb7ty9TPcUFFOk0pYOwsE5DMVhE3kwCMFtsCFKcnoPZK7oObm+H5mbnSO/9ioxQ==", - "peer": true, - "dependencies": { - "@algolia/cache-browser-local-storage": "4.23.2", - "@algolia/cache-common": "4.23.2", - "@algolia/cache-in-memory": "4.23.2", - "@algolia/client-account": "4.23.2", - "@algolia/client-analytics": "4.23.2", - "@algolia/client-common": "4.23.2", - "@algolia/client-personalization": "4.23.2", - "@algolia/client-search": "4.23.2", - "@algolia/logger-common": "4.23.2", - "@algolia/logger-console": "4.23.2", - "@algolia/recommend": "4.23.2", - "@algolia/requester-browser-xhr": "4.23.2", - "@algolia/requester-common": "4.23.2", - "@algolia/requester-node-http": "4.23.2", - "@algolia/transporter": "4.23.2" - } - }, - "node_modules/algoliasearch-helper": { - "version": "3.17.0", - "resolved": "https://registry.npmjs.org/algoliasearch-helper/-/algoliasearch-helper-3.17.0.tgz", - "integrity": "sha512-R5422OiQjvjlK3VdpNQ/Qk7KsTIGeM5ACm8civGifOVWdRRV/3SgXuKmeNxe94Dz6fwj/IgpVmXbHutU4mHubg==", - "dependencies": { - "@algolia/events": "^4.0.1" - }, - "peerDependencies": { - "algoliasearch": ">= 3.1 < 6" - } - }, "node_modules/ansi-escapes": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", @@ -12001,18 +11793,6 @@ "value-equal": "^1.0.1" } }, - "node_modules/hogan.js": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/hogan.js/-/hogan.js-3.0.2.tgz", - "integrity": "sha512-RqGs4wavGYJWE07t35JQccByczmNUXQT0E12ZYV1VKYu5UiAU9lsos/yBAcf840+zrUQQxgVduCR5/B8nNtibg==", - "dependencies": { - "mkdirp": "0.3.0", - "nopt": "1.0.10" - }, - "bin": { - "hulk": "bin/hulk" - } - }, "node_modules/hoist-non-react-statics": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", @@ -12037,11 +11817,6 @@ "wbuf": "^1.1.0" } }, - "node_modules/htm": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/htm/-/htm-3.1.1.tgz", - "integrity": "sha512-983Vyg8NwUE7JkZ6NmOqpCZ+sh1bKv2iYTlUkzlWmA5JD2acKoxd4KVxbMmxX/85mtfdnDmTFoNKcg5DGAvxNQ==" - }, "node_modules/html-encoding-sniffer": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-2.0.1.tgz", @@ -12661,52 +12436,6 @@ "node": ">=12.0.0" } }, - "node_modules/instantsearch-ui-components": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/instantsearch-ui-components/-/instantsearch-ui-components-0.4.0.tgz", - "integrity": "sha512-Isa9Ankm89e9PUXsUto6TxYzcQpXKlWZMsKLXc//dO4i9q5JS8s0Es+c+U65jRLK2j1DiVlNx/Z6HshRIZwA8w==", - "dependencies": { - "@babel/runtime": "^7.1.2" - } - }, - "node_modules/instantsearch.css": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/instantsearch.css/-/instantsearch.css-8.1.0.tgz", - "integrity": "sha512-rPhcAZ02bLwUn3iOXbldZW/yl+17guWoH3qWYZ8nQEwNBx5+wZ6Bv8mFqqK448+R2aU4nbFKIhmoTIPXI5Zobg==" - }, - "node_modules/instantsearch.js": { - "version": "4.66.1", - "resolved": "https://registry.npmjs.org/instantsearch.js/-/instantsearch.js-4.66.1.tgz", - "integrity": "sha512-RXFLrDSVHTBXeaGrS9Gqb6Vo1a6U0iCoDzNsJDn2kzIGjzP/SaFVLMdFW5ewAgCn9EUPmP2yImQv7mqgzmxe/g==", - "dependencies": { - "@algolia/events": "^4.0.1", - "@types/dom-speech-recognition": "^0.0.1", - "@types/google.maps": "^3.45.3", - "@types/hogan.js": "^3.0.0", - "@types/qs": "^6.5.3", - "algoliasearch-helper": "3.17.0", - "hogan.js": "^3.0.2", - "htm": "^3.0.0", - "instantsearch-ui-components": "0.4.0", - "preact": "^10.10.0", - "qs": "^6.5.1 < 6.10", - "search-insights": "^2.13.0" - }, - "peerDependencies": { - "algoliasearch": ">= 3.1 < 6" - } - }, - "node_modules/instantsearch.js/node_modules/qs": { - "version": "6.9.7", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.9.7.tgz", - "integrity": "sha512-IhMFgUmuNpyRfxA90umL7ByLlgRXu6tIfKPpF5TmcfRLlLCckfP/g3IQmju6jjpu+Hh8rA+2p6A27ZSPOOHdKw==", - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/internal-slot": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.7.tgz", @@ -15734,15 +15463,6 @@ "node": ">=0.10.0" } }, - "node_modules/mkdirp": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.3.0.tgz", - "integrity": "sha512-OHsdUcVAQ6pOtg5JYWpCBo9W/GySVuwvP9hueRMW7UqshC0tbfzLv8wjySTPm3tfUZ/21CE9E1pJagOA91Pxew==", - "deprecated": "Legacy versions of mkdirp are no longer supported. Please update to mkdirp 1.x. (Note that the API surface has changed to use Promises in 1.x.)", - "engines": { - "node": "*" - } - }, "node_modules/mkdirp-classic": { "version": "0.5.3", "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", @@ -16070,20 +15790,6 @@ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz", "integrity": "sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==" }, - "node_modules/nopt": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-1.0.10.tgz", - "integrity": "sha512-NWmpvLSqUrgrAC9HCuxEvb+PSloHpqVu+FqcO4eeF2h5qYRhA7ev6KvelyQAKtegUbC6RypJnlEOhd8vloNKYg==", - "dependencies": { - "abbrev": "1" - }, - "bin": { - "nopt": "bin/nopt.js" - }, - "engines": { - "node": "*" - } - }, "node_modules/normalize-package-data": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", @@ -17577,15 +17283,6 @@ "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==" }, - "node_modules/preact": { - "version": "10.20.1", - "resolved": "https://registry.npmjs.org/preact/-/preact-10.20.1.tgz", - "integrity": "sha512-JIFjgFg9B2qnOoGiYMVBtrcFxHqn+dNXbq76bVmcaHYJFYR4lW67AOcXgAYQQTDYXDOg/kTZrKPNCdRgJ2UJmw==", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/preact" - } - }, "node_modules/prebuild-install": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.2.tgz", @@ -18309,37 +18006,6 @@ "react-is": "^16.13.1" } }, - "node_modules/react-instantsearch": { - "version": "7.7.1", - "resolved": "https://registry.npmjs.org/react-instantsearch/-/react-instantsearch-7.7.1.tgz", - "integrity": "sha512-o6nLY4IZWql6m0LYFSKpPKlAZ8zV3fwnwgswGs1okdw2skb3TXB535/mQCQZF39YjrUqBc3thl/YMnEDnKtVaQ==", - "dependencies": { - "@babel/runtime": "^7.1.2", - "instantsearch-ui-components": "0.4.0", - "instantsearch.js": "4.66.1", - "react-instantsearch-core": "7.7.1" - }, - "peerDependencies": { - "algoliasearch": ">= 3.1 < 5", - "react": ">= 16.8.0 < 19", - "react-dom": ">= 16.8.0 < 19" - } - }, - "node_modules/react-instantsearch-core": { - "version": "7.7.1", - "resolved": "https://registry.npmjs.org/react-instantsearch-core/-/react-instantsearch-core-7.7.1.tgz", - "integrity": "sha512-OTvf/QtJT5zd+EQW+osjPPFNr7Vo9FAzy/zUxeeP+87IS6tiUpQQEDhgFFYBbvU5+97pYl9YmvGQARakNDHJOw==", - "dependencies": { - "@babel/runtime": "^7.1.2", - "algoliasearch-helper": "3.17.0", - "instantsearch.js": "4.66.1", - "use-sync-external-store": "^1.0.0" - }, - "peerDependencies": { - "algoliasearch": ">= 3.1 < 5", - "react": ">= 16.8.0 < 19" - } - }, "node_modules/react-intl": { "version": "6.6.4", "resolved": "https://registry.npmjs.org/react-intl/-/react-intl-6.6.4.tgz", @@ -19728,11 +19394,6 @@ "url": "https://opencollective.com/webpack" } }, - "node_modules/search-insights": { - "version": "2.13.0", - "resolved": "https://registry.npmjs.org/search-insights/-/search-insights-2.13.0.tgz", - "integrity": "sha512-Orrsjf9trHHxFRuo9/rzm0KIWmgzE8RMlZMzuhZOJ01Rnz3D0YBAe+V6473t6/H6c7irs6Lt48brULAiRWb3Vw==" - }, "node_modules/select-hose": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz", diff --git a/package.json b/package.json index 32987c4d0a..75fa7904e0 100644 --- a/package.json +++ b/package.json @@ -73,16 +73,15 @@ "email-validator": "2.0.4", "file-saver": "^2.0.5", "formik": "2.2.6", - "instantsearch.css": "^8.1.0", "jszip": "^3.10.1", "lodash": "4.17.21", + "meilisearch": "^0.38.0", "moment": "2.29.4", "prop-types": "15.7.2", "react": "17.0.2", "react-datepicker": "^4.13.0", "react-dom": "17.0.2", "react-helmet": "^6.1.0", - "react-instantsearch": "^7.7.1", "react-redux": "7.2.9", "react-responsive": "9.0.2", "react-router": "6.16.0", diff --git a/src/search-modal/ClearFiltersButton.jsx b/src/search-modal/ClearFiltersButton.jsx index 2b33c981d2..40e88d4e17 100644 --- a/src/search-modal/ClearFiltersButton.jsx +++ b/src/search-modal/ClearFiltersButton.jsx @@ -1,20 +1,20 @@ /* eslint-disable react/prop-types */ // @ts-check import React from 'react'; -import { useClearRefinements } from 'react-instantsearch'; import { FormattedMessage } from '@edx/frontend-platform/i18n'; import { Button } from '@openedx/paragon'; import messages from './messages'; +import { useSearchContext } from './manager/SearchManager'; /** * A button that appears when at least one filter is active, and will clear the filters when clicked. * @type {React.FC>} */ const ClearFiltersButton = () => { - const { refine, canRefine } = useClearRefinements(); - if (canRefine) { + const { canClearFilters, clearFilters } = useSearchContext(); + if (canClearFilters) { return ( - ); diff --git a/src/search-modal/EmptyStates.jsx b/src/search-modal/EmptyStates.jsx index f878f433b2..fa5c77b227 100644 --- a/src/search-modal/EmptyStates.jsx +++ b/src/search-modal/EmptyStates.jsx @@ -2,9 +2,9 @@ // @ts-check import React from 'react'; import { FormattedMessage } from '@edx/frontend-platform/i18n'; -import { Stack } from '@openedx/paragon'; -import { useStats, useClearRefinements } from 'react-instantsearch'; +import { Alert, Stack } from '@openedx/paragon'; +import { useSearchContext } from './manager/SearchManager'; import EmptySearchImage from './images/empty-search.svg'; import NoResultImage from './images/no-results.svg'; import messages from './messages'; @@ -24,10 +24,21 @@ const InfoMessage = ({ title, subtitle, image }) => ( * @type {React.FC<{children: React.ReactElement}>} */ const EmptyStates = ({ children }) => { - const { nbHits, query } = useStats(); - const { canRefine: hasFiltersApplied } = useClearRefinements(); - const hasQuery = !!query; + const { + canClearFilters: hasFiltersApplied, + totalHits, + searchKeywords, + hasError, + } = useSearchContext(); + const hasQuery = !!searchKeywords; + if (hasError) { + return ( + + + + ); + } if (!hasQuery && !hasFiltersApplied) { // We haven't started the search yet. Display the "start your search" empty state return ( @@ -38,7 +49,7 @@ const EmptyStates = ({ children }) => { /> ); } - if (nbHits === 0) { + if (totalHits === 0) { return ( { const { - items, - refine, - canToggleShowMore, - isShowingMore, - toggleShowMore, - } = useRefinementList({ attribute: 'block_type', sortBy: ['count:desc', 'name'] }); - - // Get the list of applied 'items' (selected block types to filter) in the original order that the user clicked them. - // The first choice will be shown on the button, and we don't want it to change as the user selects more options. - // (But for the dropdown menu, we always want them sorted by 'count:desc' and 'name'; not in order of selection.) - const refinementsData = useCurrentRefinements({ includedAttributes: ['block_type'] }); - const appliedItems = refinementsData.items[0]?.refinements ?? []; - // If we didn't need to preserve the order the user clicked on, the above two lines could be simplified to: - // const appliedItems = items.filter(item => item.isRefined); + blockTypes, + blockTypesFilter, + setBlockTypesFilter, + } = useSearchContext(); + // TODO: sort blockTypes first by count, then by name const handleCheckboxChange = React.useCallback((e) => { - refine(e.target.value); - }, [refine]); + setBlockTypesFilter(currentFilters => { + if (currentFilters.includes(e.target.value)) { + return currentFilters.filter(x => x !== e.target.value); + } + return [...currentFilters, e.target.value]; + }); + }, [setBlockTypesFilter]); return ( ({ label: }))} + appliedFilters={blockTypesFilter.map(blockType => ({ label: }))} label={} > item.value)} + defaultValue={blockTypesFilter} > { - items.map((item) => ( + Object.entries(blockTypes).map(([blockType, count]) => ( - {' '} - {item.count} + {' '} + {count} )) } { // Show a message if there are no options at all to avoid the impression that the dropdown isn't working - items.length === 0 ? ( + blockTypes.length === 0 ? ( ) : null } - { - canToggleShowMore && !isShowingMore - ? - : null - } ); }; diff --git a/src/search-modal/FilterByTags.jsx b/src/search-modal/FilterByTags.jsx index 68bd92ee70..d36c3e8cfc 100644 --- a/src/search-modal/FilterByTags.jsx +++ b/src/search-modal/FilterByTags.jsx @@ -1,117 +1,212 @@ /* eslint-disable react/prop-types */ // @ts-check import React from 'react'; -import { FormattedMessage } from '@edx/frontend-platform/i18n'; +import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; import { - Button, Badge, Form, + Icon, + IconButton, Menu, MenuItem, + SearchField, } from '@openedx/paragon'; -import { useHierarchicalMenu } from 'react-instantsearch'; +import { ArrowDropDown, ArrowDropUp, Warning } from '@openedx/paragon/icons'; import SearchFilterWidget from './SearchFilterWidget'; import messages from './messages'; +import { useSearchContext } from './manager/SearchManager'; +import { useTagFilterOptions } from './data/apiHooks'; +import { LoadingSpinner } from '../generic/Loading'; +import { TAG_SEP } from './data/api'; -// eslint-disable-next-line max-len -/** @typedef {import('instantsearch.js/es/connectors/hierarchical-menu/connectHierarchicalMenu').HierarchicalMenuItem} HierarchicalMenuItem */ +/** + * A menu item with a checkbox and an optional ▼ button (to show/hide children) + * @type {React.FC<{ + * label: string; + * tagPath: string; + * isChecked: boolean; + * onClickCheckbox: () => void; + * tagCount: number; + * hasChildren?: boolean; + * isExpanded?: boolean; + * onToggleChildren?: (tagPath: string) => void; + * }>} + */ +const TagMenuItem = ({ + label, + tagPath, + tagCount, + isChecked, + onClickCheckbox, + hasChildren, + isExpanded, + onToggleChildren, +}) => { + const intl = useIntl(); + const randomNumber = React.useMemo(() => Math.floor(Math.random() * 1000), []); + const checkboxId = tagPath.replace(/[\W]/g, '_') + randomNumber; + + return ( +
+ + + { + hasChildren + ? ( + onToggleChildren?.(tagPath)} + variant="primary" + size="sm" + /> + ) : null + } +
+ ); +}; /** - * A button with a dropdown menu to allow filtering the search using tags. - * This version is based on Instantsearch's component, so it only allows selecting one tag at a - * time. We will replace it with a custom version that allows multi-select. + * A list of menu items with all of the options for tags at one level of the hierarchy. * @type {React.FC<{ - * items: HierarchicalMenuItem[], - * refine: (value: string) => void, - * depth?: number, + * tagSearchKeywords: string; + * parentTagPath?: string; + * toggleTagChildren?: (tagPath: string) => void; + * expandedTags: string[], * }>} */ -const FilterOptions = ({ items, refine, depth = 0 }) => { - const handleCheckboxChange = React.useCallback((e) => { - refine(e.target.value); - }, [refine]); +const TagOptions = ({ + parentTagPath = '', + tagSearchKeywords, + expandedTags, + toggleTagChildren, +}) => { + const searchContext = useSearchContext(); + const { data, isLoading, isError } = useTagFilterOptions({ + ...searchContext, + parentTagPath, + tagSearchKeywords, + }); + + if (isError) { + return ; + } + if (isLoading || data.tags === undefined) { + return ; + } + + // Show a message if there are no options at all to avoid the impression that the dropdown isn't working + if (data.tags.length === 0 && !parentTagPath) { + return ; + } return ( - <> +
+ { + data.tags.map(({ tagName, tagPath, ...t }) => { + const isExpanded = expandedTags.includes(tagPath); + return ( + + { + searchContext.setTagsFilter((tf) => ( + tf.includes(tagPath) ? tf.filter(tp => tp !== tagPath) : [...tf, tagPath] + )); + }} + hasChildren={t.hasChildren} + isExpanded={isExpanded} + onToggleChildren={toggleTagChildren} + /> + {isExpanded ? ( +
+ +
+ ) : null} +
+ ); + }) + } { - items.map((item) => ( - - - {item.label}{' '} - {item.count} + // Sometimes, due to limitations of how the search index/API works, we aren't able to retrieve all the options: + data.mayBeMissingResults + ? ( + + - {item.data && } - - )) + ) : null } - +
); }; /** @type {React.FC} */ const FilterByTags = () => { - const { - items, - refine, - canToggleShowMore, - isShowingMore, - toggleShowMore, - } = useHierarchicalMenu({ - attributes: [ - 'tags.taxonomy', - 'tags.level0', - 'tags.level1', - 'tags.level2', - 'tags.level3', - ], - }); + const intl = useIntl(); + const { tagsFilter } = useSearchContext(); + const [tagSearchKeywords, setTagSearchKeywords] = React.useState(''); - // Recurse over the 'items' tree and find all the selected leaf tags - (with no children that are checked/"refined") - const appliedItems = React.useMemo(() => { - /** @type {{label: string}[]} */ - const result = []; - /** @type {(itemSet: HierarchicalMenuItem[]) => void} */ - const findSelectedLeaves = (itemSet) => { - itemSet.forEach(item => { - if (item.isRefined && item.data?.find(child => child.isRefined) === undefined) { - result.push({ label: item.label }); - } - if (item.data) { - findSelectedLeaves(item.data); - } - }); - }; - findSelectedLeaves(items); - return result; - }, [items]); + // e.g. {"Location", "Location > North America"} if those two paths of the tag tree are expanded + const [expandedTags, setExpandedTags] = React.useState(/** @type {string[]} */([])); + const toggleTagChildren = React.useCallback(tagWithLineage => { + setExpandedTags(currentList => { + if (currentList.includes(tagWithLineage)) { + return currentList.filter(x => x !== tagWithLineage); + } + return [...currentList, tagWithLineage]; + }); + }, [setExpandedTags]); return ( ({ label: tf.split(TAG_SEP).pop() }))} label={} > - - - - { - // Show a message if there are no options at all to avoid the impression that the dropdown isn't working - items.length === 0 ? ( - - ) : null - } + + setTagSearchKeywords('')} + value={tagSearchKeywords} + screenReaderText={{ + label: intl.formatMessage(messages.searchTagsByKeywordPlaceholder), + submitButton: intl.formatMessage(messages.submitSearchTagsByKeyword), + }} + placeholder={intl.formatMessage(messages.searchTagsByKeywordPlaceholder)} + className="mx-3 mb-1" + /> + + - { - canToggleShowMore && !isShowingMore - ? - : null - } ); }; diff --git a/src/search-modal/Highlight.jsx b/src/search-modal/Highlight.jsx new file mode 100644 index 0000000000..46ddef9f8f --- /dev/null +++ b/src/search-modal/Highlight.jsx @@ -0,0 +1,28 @@ +/* eslint-disable react/no-array-index-key */ +/* eslint-disable react/prop-types */ +// @ts-check +import React from 'react'; + +import { highlightPostTag, highlightPreTag } from './data/api'; + +/** + * Render some text that contains matching words which should be highlighted + * @type {React.FC<{text: string}>} + */ +const Highlight = ({ text }) => { + const parts = text.split(highlightPreTag); + return ( + + {parts.map((part, idx) => { + if (idx === 0) { return {part}; } + const endIdx = part.indexOf(highlightPostTag); + if (endIdx === -1) { return {part}; } + const highLightPart = part.substring(0, endIdx); + const otherPart = part.substring(endIdx + highlightPostTag.length); + return {highLightPart}{otherPart}; + })} + + ); +}; + +export default Highlight; diff --git a/src/search-modal/SearchEndpointLoader.jsx b/src/search-modal/SearchEndpointLoader.jsx deleted file mode 100644 index 10c5eadcda..0000000000 --- a/src/search-modal/SearchEndpointLoader.jsx +++ /dev/null @@ -1,41 +0,0 @@ -/* eslint-disable react/prop-types */ -// @ts-check -import React from 'react'; -import { ModalDialog } from '@openedx/paragon'; -import { ErrorAlert } from '@edx/frontend-lib-content-components'; -import { useIntl } from '@edx/frontend-platform/i18n'; - -import { LoadingSpinner } from '../generic/Loading'; -import { useContentSearch } from './data/apiHooks'; -import SearchUI from './SearchUI'; -import messages from './messages'; - -/** @type {React.FC<{courseId: string, closeSearch?: () => void}>} */ -const SearchEndpointLoader = ({ courseId, closeSearch }) => { - const intl = useIntl(); - - // Load the Meilisearch connection details from the LMS: the URL to use, the index name, and an API key specific - // to us (to the current user) that allows us to search all content we have permission to view. - const { - data: searchEndpointData, - isLoading, - error, - } = useContentSearch(); - - const title = intl.formatMessage(messages.title); - - if (searchEndpointData) { - return ; - } - return ( - <> - {title} - - {/* @ts-ignore */} - {isLoading ? : {error?.message ?? String(error)}} - - - ); -}; - -export default SearchEndpointLoader; diff --git a/src/search-modal/SearchKeywordsField.jsx b/src/search-modal/SearchKeywordsField.jsx index 15614d8c8b..809dd7b430 100644 --- a/src/search-modal/SearchKeywordsField.jsx +++ b/src/search-modal/SearchKeywordsField.jsx @@ -1,25 +1,25 @@ /* eslint-disable react/prop-types */ // @ts-check import React from 'react'; -import { useSearchBox } from 'react-instantsearch'; import { useIntl } from '@edx/frontend-platform/i18n'; import { SearchField } from '@openedx/paragon'; import messages from './messages'; +import { useSearchContext } from './manager/SearchManager'; /** * The "main" input field where users type in search keywords. The search happens as they type (no need to press enter). - * @type {React.FC} + * @type {React.FC<{className?: string}>} */ const SearchKeywordsField = (props) => { const intl = useIntl(); - const { query, refine } = useSearchBox(props); + const { searchKeywords, setSearchKeywords } = useSearchContext(); return ( refine('')} - value={query} + onSubmit={setSearchKeywords} + onChange={setSearchKeywords} + onClear={() => setSearchKeywords('')} + value={searchKeywords} className={props.className} placeholder={intl.formatMessage(messages.inputPlaceholder)} /> diff --git a/src/search-modal/SearchModal.jsx b/src/search-modal/SearchModal.jsx index d7cd9ea91b..22171e87cd 100644 --- a/src/search-modal/SearchModal.jsx +++ b/src/search-modal/SearchModal.jsx @@ -4,8 +4,8 @@ import React from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; import { ModalDialog } from '@openedx/paragon'; -import SearchEndpointLoader from './SearchEndpointLoader'; import messages from './messages'; +import SearchUI from './SearchUI'; /** @type {React.FC<{courseId: string, isOpen: boolean, onClose: () => void}>} */ const SearchModal = ({ courseId, ...props }) => { @@ -24,7 +24,7 @@ const SearchModal = ({ courseId, ...props }) => { isFullscreenOnMobile className="courseware-search-modal" > - + ); }; diff --git a/src/search-modal/SearchModal.scss b/src/search-modal/SearchModal.scss index 50b1906208..228df4407c 100644 --- a/src/search-modal/SearchModal.scss +++ b/src/search-modal/SearchModal.scss @@ -16,6 +16,14 @@ // (If we set 'isOverflowVisible: true', the scrolling of the results list is messed up) overflow: visible; + // Highlight matching terms using bold, not yellow highlighting + mark { + font-weight: bold; + background-color: transparent; + padding: 0; + display: inline; + } + .pgn__modal-header .pgn__menu-select { // The "All courses" / "This course" toggle button & > button { @@ -27,24 +35,10 @@ } // Options for the "filter by tag" menu - .pgn__menu { - $indent-initial: 1.3rem; - $indent-each: 1.6rem; - - .tag-option-1 { - padding-left: $indent-initial + (1 * $indent-each); - } - - .tag-option-2 { - padding-left: $indent-initial + (2 * $indent-each); - } - - .tag-option-3 { - padding-left: $indent-initial + (3 * $indent-each); - } - - .tag-option-4 { - padding-left: $indent-initial + (4 * $indent-each); + .pgn__menu.tags-refinement-menu { + .pgn__menu-item { + // Make the "filter by tag" menu much wider than normal, because we need the space to display the tags hierarchy + width: 100%; } } diff --git a/src/search-modal/SearchModal.test.jsx b/src/search-modal/SearchModal.test.jsx index 6c77043958..a14bb0fc2d 100644 --- a/src/search-modal/SearchModal.test.jsx +++ b/src/search-modal/SearchModal.test.jsx @@ -67,17 +67,9 @@ describe('', () => { expect(await findByText('Start searching to find content')).toBeInTheDocument(); }); - it('should render the spinner while the config is loading', () => { - axiosMock.onGet(getContentSearchConfigUrl()).replyOnce(200, new Promise(() => {})); // never resolves - const { getByRole } = render(); - - const spinner = getByRole('status'); - expect(spinner.textContent).toEqual('Loading...'); - }); - it('should render the error message if the api call throws', async () => { axiosMock.onGet(getContentSearchConfigUrl()).networkError(); const { findByText } = render(); - expect(await findByText('Network Error')).toBeInTheDocument(); + expect(await findByText('An error occurred. Unable to load search results.')).toBeInTheDocument(); }); }); diff --git a/src/search-modal/SearchResult.jsx b/src/search-modal/SearchResult.jsx index 62abf56840..c15371d81b 100644 --- a/src/search-modal/SearchResult.jsx +++ b/src/search-modal/SearchResult.jsx @@ -1,6 +1,6 @@ /* eslint-disable react/prop-types */ // @ts-check -import React, { useCallback, useMemo } from 'react'; +import React from 'react'; import { getConfig, getPath } from '@edx/frontend-platform'; import { useIntl } from '@edx/frontend-platform/i18n'; import { @@ -12,78 +12,42 @@ import { Article, Folder, OpenInNew, - Question, - TextFields, - Videocam, } from '@openedx/paragon/icons'; -import { - Highlight, - Snippet, -} from 'react-instantsearch'; import { useSelector } from 'react-redux'; import { useNavigate } from 'react-router-dom'; +import { COMPONENT_TYPE_ICON_MAP, TYPE_ICONS_MAP } from '../course-unit/constants'; import { getStudioHomeData } from '../studio-home/data/selectors'; +import { useSearchContext } from './manager/SearchManager'; +import Highlight from './Highlight'; import messages from './messages'; -/** - * @typedef {import('instantsearch.js').Hit<{ - * id: string, - * usage_key: string, - * context_key: string, - * display_name: string, - * block_type: string, - * 'content.html_content'?: string, - * 'content.capa_content'?: string, - * breadcrumbs: {display_name: string}[] - * breadcrumbsNames: string[], - * }>} CustomHit - */ - -/** - * Custom Highlight component that uses the tag for highlighting - * @type {React.FC<{ - * attribute: keyof CustomHit | string[], - * hit: CustomHit, - * separator?: string, - * }>} - */ -const CustomHighlight = ({ attribute, hit, separator }) => ( - -); - -const ItemIcon = { - vertical: Folder, +const STRUCTURAL_TYPE_ICONS = { + vertical: TYPE_ICONS_MAP.vertical, sequential: Folder, chapter: Folder, - problem: Question, - video: Videocam, - html: TextFields, }; +/** @param {string} blockType */ +function getItemIcon(blockType) { + return STRUCTURAL_TYPE_ICONS[blockType] ?? COMPONENT_TYPE_ICON_MAP[blockType] ?? Article; +} + /** * A single search result (row), usually represents an XBlock/Component - * @type {React.FC<{ hit: CustomHit, closeSearch?: () => void}>} + * @type {React.FC<{hit: import('./data/api').ContentHit}>} */ -const SearchResult = ({ hit, closeSearch }) => { +const SearchResult = ({ hit }) => { const intl = useIntl(); const navigate = useNavigate(); + const { closeSearchModal } = useSearchContext(); const { libraryAuthoringMfeUrl, redirectToLibraryAuthoringMfe } = useSelector(getStudioHomeData); /** * Returns the URL for the context of the hit - * @param {CustomHit} hit - * @param {boolean?} newWindow - * @param {string} libraryAuthoringMfeUrl - * @returns {string?} */ - const getContextUrl = useCallback((newWindow) => { - const { context_key: contextKey, usage_key: usageKey } = hit; + const getContextUrl = React.useCallback((newWindow = false) => { + const { contextKey, usageKey } = hit; if (contextKey.startsWith('course-v1:')) { const courseSufix = `course/${contextKey}?show=${encodeURIComponent(usageKey)}`; if (newWindow) { @@ -101,30 +65,31 @@ const SearchResult = ({ hit, closeSearch }) => { return undefined; }, [libraryAuthoringMfeUrl, redirectToLibraryAuthoringMfe]); - const redirectUrl = useMemo(() => getContextUrl(), [libraryAuthoringMfeUrl, redirectToLibraryAuthoringMfe]); - const newWindowUrl = useMemo(() => getContextUrl(true), [libraryAuthoringMfeUrl, redirectToLibraryAuthoringMfe]); + const redirectUrl = React.useMemo(() => getContextUrl(), [libraryAuthoringMfeUrl, redirectToLibraryAuthoringMfe]); + const newWindowUrl = React.useMemo( + () => getContextUrl(true), + [libraryAuthoringMfeUrl, redirectToLibraryAuthoringMfe], + ); /** - * Opens the context of the hit in a new window - * @param {React.MouseEvent} e - * @returns {void} - * */ + * Opens the context of the hit in a new window + * @param {React.MouseEvent} e + * @returns {void} + */ const openContextInNewWindow = (e) => { e.stopPropagation(); - /* istanbul ignore next */ if (!newWindowUrl) { return; } - window.open(newWindowUrl, '_blank'); }; /** - * Navigates to the context of the hit - * @param {(React.MouseEvent | React.KeyboardEvent)} e - * @returns {void} - * */ + * Navigates to the context of the hit + * @param {(React.MouseEvent | React.KeyboardEvent)} e + * @returns {void} + */ const navigateToContext = (e) => { e.stopPropagation(); @@ -146,7 +111,7 @@ const SearchResult = ({ hit, closeSearch }) => { } navigate(redirectUrl); - closeSearch?.(); + closeSearchModal(); }; return ( @@ -159,17 +124,17 @@ const SearchResult = ({ hit, closeSearch }) => { tabIndex={redirectUrl ? 0 : undefined} role="button" > - +
- +
- - + +
- + {hit.breadcrumbs.map(bc => bc.displayName).join(' / ')}
>} + */ +const SearchResults = () => { + const intl = useIntl(); + const { + hits, + hasNextPage, + isFetchingNextPage, + fetchNextPage, + } = useSearchContext(); + + const labels = { + default: intl.formatMessage(messages.showMoreResults), + pending: intl.formatMessage(messages.loadingMoreResults), + }; + + return ( + <> + {hits.map((hit) => )} + {hasNextPage + ? ( + + ) : null} + + ); +}; + +export default SearchResults; diff --git a/src/search-modal/SearchUI.jsx b/src/search-modal/SearchUI.jsx index 1fc44643e6..be85e97632 100644 --- a/src/search-modal/SearchUI.jsx +++ b/src/search-modal/SearchUI.jsx @@ -1,58 +1,37 @@ /* eslint-disable react/prop-types */ // @ts-check -import React, { useCallback } from 'react'; +import React from 'react'; import { MenuItem, ModalDialog, SelectMenu, } from '@openedx/paragon'; import { Check } from '@openedx/paragon/icons'; -import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; -import { Configure, InfiniteHits, InstantSearch } from 'react-instantsearch'; -import { instantMeiliSearch } from '@meilisearch/instant-meilisearch'; +import { FormattedMessage } from '@edx/frontend-platform/i18n'; import ClearFiltersButton from './ClearFiltersButton'; import EmptyStates from './EmptyStates'; -import SearchResult from './SearchResult'; +import SearchResults from './SearchResults'; import SearchKeywordsField from './SearchKeywordsField'; import FilterByBlockType from './FilterByBlockType'; import FilterByTags from './FilterByTags'; import Stats from './Stats'; +import { SearchContextProvider } from './manager/SearchManager'; import messages from './messages'; -/** @type {React.FC<{courseId: string, url: string, apiKey: string, indexName: string, closeSearch?: () => void}>} */ +/** @type {React.FC<{courseId: string, closeSearchModal?: () => void}>} */ const SearchUI = (props) => { - const { searchClient } = React.useMemo( - () => instantMeiliSearch(props.url, props.apiKey, { primaryKey: 'id' }), - [props.url, props.apiKey], - ); - - const intl = useIntl(); - const hasCourseId = Boolean(props.courseId); const [_searchThisCourseEnabled, setSearchThisCourse] = React.useState(hasCourseId); const switchToThisCourse = React.useCallback(() => setSearchThisCourse(true), []); const switchToAllCourses = React.useCallback(() => setSearchThisCourse(false), []); const searchThisCourse = hasCourseId && _searchThisCourseEnabled; - const HitComponent = useCallback( - ({ hit }) => , - [], - ); - return ( - - {/* Add in a filter for the current course, if relevant */} - - {/* We need to override z-index here or the appears behind the * But it can't be more then 9 because the close button has z-index 10. */} @@ -88,38 +67,10 @@ const SearchUI = (props) => { {/* If there are no results (yet), EmptyStates displays a friendly messages. Otherwise we see the results. */} - items.map((item) => ({ - ...item, - breadcrumbsNames: searchThisCourse - ? item.breadcrumbs.slice(1).map((bc) => bc.display_name) - : item.breadcrumbs.map((bc) => bc.display_name), - _highlightResult: { - // eslint-disable-next-line no-underscore-dangle - ...item._highlightResult, - breadcrumbsNames: searchThisCourse - // @ts-ignore - // eslint-disable-next-line no-underscore-dangle - ? item._highlightResult?.breadcrumbs.slice(1).map((bc) => bc.display_name) - // @ts-ignore - // eslint-disable-next-line no-underscore-dangle - : item._highlightResult?.breadcrumbs.map((bc) => bc.display_name), - }, - }))} - /> + - + ); }; diff --git a/src/search-modal/SearchUI.test.jsx b/src/search-modal/SearchUI.test.jsx index 3e217c4a7d..752bd7c584 100644 --- a/src/search-modal/SearchUI.test.jsx +++ b/src/search-modal/SearchUI.test.jsx @@ -3,8 +3,10 @@ import React from 'react'; import { IntlProvider } from '@edx/frontend-platform/i18n'; import { initializeMockApp } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { AppProvider } from '@edx/frontend-platform/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import MockAdapter from 'axios-mock-adapter'; import { fireEvent, render, @@ -19,7 +21,16 @@ import initializeStore from '../store'; import mockResult from './__mocks__/search-result.json'; // @ts-ignore import mockEmptyResult from './__mocks__/empty-search-result.json'; +// @ts-ignore +import mockTagsFacetResult from './__mocks__/facet-search.json'; +// @ts-ignore +import mockTagsFacetResultLevel0 from './__mocks__/facet-search-level0.json'; +// @ts-ignore +import mockTagsFacetResultLevel1 from './__mocks__/facet-search-level1.json'; +// @ts-ignore +import mockTagsKeywordSearchResult from './__mocks__/tags-keyword-search.json'; import SearchUI from './SearchUI'; +import { getContentSearchConfigUrl } from './data/api'; // mockResult contains only a single result - this one: const mockResultDisplayName = 'Test HTML Block'; @@ -29,12 +40,11 @@ const queryClient = new QueryClient(); // Default props for const defaults = { - url: 'http://mock.meilisearch.local/', - apiKey: 'test-key', - indexName: 'studio', courseId: 'course-v1:org+test+123', }; const searchEndpoint = 'http://mock.meilisearch.local/multi-search'; +const facetSearchEndpoint = 'http://mock.meilisearch.local/indexes/studio/facet-search'; +const tagsKeywordSearchEndpoint = 'http://mock.meilisearch.local/indexes/studio/search'; const mockNavigate = jest.fn(); @@ -53,15 +63,15 @@ const Wrap = ({ children }) => ( ); +let axiosMock; const returnEmptyResult = (_url, req) => { const requestData = JSON.parse(req.body?.toString() ?? ''); const query = requestData?.queries[0]?.q ?? ''; // We have to replace the query (search keywords) in the mock results with the actual query, - // because otherwise Instantsearch will update the UI and change the query, - // leading to unexpected results in the test cases. + // because otherwise we may have an inconsistent state that causes more queries and unexpected results. mockEmptyResult.results[0].query = query; - // And create the required '_formatted' field; not sure why it's there - seems very redundant. But it's required. + // And fake the required '_formatted' fields; it contains the highlighting ... around matched words // eslint-disable-next-line no-underscore-dangle, no-param-reassign mockEmptyResult.results[0]?.hits.forEach((hit) => { hit._formatted = { ...hit }; }); return mockEmptyResult; @@ -78,6 +88,14 @@ describe('', () => { }, }); store = initializeStore(); + // The API method to get the Meilisearch connection details uses Axios: + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + axiosMock.onGet(getContentSearchConfigUrl()).reply(200, { + url: 'http://mock.meilisearch.local', + index_name: 'studio', + api_key: 'test-key', + }); + // The Meilisearch client-side API uses fetch, not Axios. fetchMock.post(searchEndpoint, (_url, req) => { const requestData = JSON.parse(req.body?.toString() ?? ''); const query = requestData?.queries[0]?.q ?? ''; @@ -85,11 +103,12 @@ describe('', () => { // because otherwise Instantsearch will update the UI and change the query, // leading to unexpected results in the test cases. mockResult.results[0].query = query; - // And create the required '_formatted' field; not sure why it's there - seems very redundant. But it's required. + // And fake the required '_formatted' fields; it contains the highlighting ... around matched words // eslint-disable-next-line no-underscore-dangle, no-param-reassign mockResult.results[0]?.hits.forEach((hit) => { hit._formatted = { ...hit }; }); return mockResult; }); + fetchMock.post(tagsKeywordSearchEndpoint, mockTagsKeywordSearchResult); }); afterEach(async () => { @@ -100,8 +119,8 @@ describe('', () => { const { getByText } = render(); // Before the results have even loaded, we see this message: expect(getByText('Start searching to find content')).toBeInTheDocument(); - // When this UI loads, Instantsearch makes two queries. I think one to load the facets and one "blank" search. - await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(2, searchEndpoint, 'post'); }); + // When this UI loads, we do a "placeholder" search to load the filter options + await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); }); // And that message is still displayed even after the initial results/filters have loaded: expect(getByText('Start searching to find content')).toBeInTheDocument(); }); @@ -112,14 +131,14 @@ describe('', () => { // Return an empty result set: // Before the results have even loaded, we see this message: expect(getByText('Start searching to find content')).toBeInTheDocument(); - // When this UI loads, Instantsearch makes two queries. I think one to load the facets and one "blank" search. - await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(2, searchEndpoint, 'post'); }); + // When this UI loads, the UI makes a search, to get the available "block type" facet values. + await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); }); // And that message is still displayed even after the initial results/filters have loaded: expect(getByText('Start searching to find content')).toBeInTheDocument(); // Enter a keyword - search for 'noresults': fireEvent.change(getByRole('searchbox'), { target: { value: 'noresults' } }); // Wait for the new search request to load all the results: - await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(3, searchEndpoint, 'post'); }); + await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(2, searchEndpoint, 'post'); }); expect(getByText('We didn\'t find anything matching your search')).toBeInTheDocument(); }); @@ -129,18 +148,18 @@ describe('', () => { expect(getByText('All courses')).toBeInTheDocument(); expect(queryByText('This course')).toBeNull(); // Wait for the initial search request that loads all the filter options: - await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(2, searchEndpoint, 'post'); }); + await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); }); // Enter a keyword - search for 'giraffe': fireEvent.change(getByRole('searchbox'), { target: { value: 'giraffe' } }); // Wait for the new search request to load all the results: - await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(3, searchEndpoint, 'post'); }); + await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(2, searchEndpoint, 'post'); }); // Now we should see the results: expect(queryByText('Enter a keyword')).toBeNull(); // The result: expect(getByText('2 results found')).toBeInTheDocument(); expect(getByText(mockResultDisplayName)).toBeInTheDocument(); // Breadcrumbs showing where the result came from: - expect(getByText('The Little Unit That Could')).toBeInTheDocument(); + expect(getByText('TheCourse / Section 2 / Subsection 3 / The Little Unit That Could')).toBeInTheDocument(); const resultItem = getByRole('button', { name: /The Little Unit That Could/ }); @@ -165,11 +184,11 @@ describe('', () => { expect(getByText('This course')).toBeInTheDocument(); expect(queryByText('All courses')).toBeNull(); // Wait for the initial search request that loads all the filter options: - await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(2, searchEndpoint, 'post'); }); + await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); }); // Enter a keyword - search for 'giraffe': fireEvent.change(getByRole('searchbox'), { target: { value: 'giraffe' } }); // Wait for the new search request to load all the results: - await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(3, searchEndpoint, 'post'); }); + await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(2, searchEndpoint, 'post'); }); // And make sure the request was limited to this course: expect(fetchMock).toHaveLastFetched((_url, req) => { const requestData = JSON.parse(req.body?.toString() ?? ''); @@ -182,21 +201,31 @@ describe('', () => { expect(getByText('2 results found')).toBeInTheDocument(); expect(getByText(mockResultDisplayName)).toBeInTheDocument(); // Breadcrumbs showing where the result came from: - expect(getByText('The Little Unit That Could')).toBeInTheDocument(); + expect(getByText('TheCourse / Section 2 / Subsection 3 / The Little Unit That Could')).toBeInTheDocument(); }); describe('filters', () => { /** @type {import('@testing-library/react').RenderResult} */ let rendered; beforeEach(async () => { + fetchMock.post(facetSearchEndpoint, (_path, req) => { + const requestData = JSON.parse(req.body?.toString() ?? ''); + switch (requestData.facetName) { + case 'tags.taxonomy': return mockTagsFacetResult; + case 'tags.level0': return mockTagsFacetResultLevel0; + case 'tags.level1': return mockTagsFacetResultLevel1; + default: throw new Error(`Facet ${requestData.facetName} not mocked for testing`); + } + }); + rendered = render(); const { getByRole, getByText } = rendered; // Wait for the initial search request that loads all the filter options: - await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(2, searchEndpoint, 'post'); }); + await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); }); // Enter a keyword - search for 'giraffe': fireEvent.change(getByRole('searchbox'), { target: { value: 'giraffe' } }); // Wait for the new search request to load all the results and the filter options, based on the search so far: - await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(3, searchEndpoint, 'post'); }); + await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(2, searchEndpoint, 'post'); }); // And make sure the request was limited to this course: expect(fetchMock).toHaveLastFetched((_url, req) => { const requestData = JSON.parse(req.body?.toString() ?? ''); @@ -217,8 +246,9 @@ describe('', () => { const popupMenu = getByRole('group'); const problemFilterCheckbox = getByLabelTextIn(popupMenu, /Problem/i); fireEvent.click(problemFilterCheckbox, {}); + await waitFor(() => { expect(rendered.getByText('Type: Problem')).toBeInTheDocument(); }); // Now wait for the filter to be applied and the new results to be fetched. - await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(4, searchEndpoint, 'post'); }); + await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(3, searchEndpoint, 'post'); }); // Because we're mocking the results, there's no actual changes to the mock results, // but we can verify that the filter was sent in the request expect(fetchMock).toHaveLastFetched((_url, req) => { @@ -226,7 +256,7 @@ describe('', () => { const requestedFilter = requestData?.queries[0].filter; return JSON.stringify(requestedFilter) === JSON.stringify([ 'context_key = "course-v1:org+test+123"', - ['"block_type"="problem"'], // <-- the newly added filter, sent with the request + ['block_type = problem'], // <-- the newly added filter, sent with the request ]); }); }); @@ -236,20 +266,72 @@ describe('', () => { // Now open the filters menu: fireEvent.click(getByRole('button', { name: 'Tags' }), {}); // The dropdown menu in this case doesn't have a role; let's just assume it's displayed. - const competentciesCheckbox = getByLabelText(/ESDC Skills and Competencies/i); + const checkboxLabel = /^ESDC Skills and Competencies/i; + await waitFor(() => { expect(getByLabelText(checkboxLabel)).toBeInTheDocument(); }); + // In addition to the checkbox, there is another button to show the child tags: + expect(getByLabelText(/Expand to show child tags of "ESDC Skills and Competencies"/i)).toBeInTheDocument(); + const competentciesCheckbox = getByLabelText(checkboxLabel); fireEvent.click(competentciesCheckbox, {}); // Now wait for the filter to be applied and the new results to be fetched. - await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(4, searchEndpoint, 'post'); }); + await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(3, searchEndpoint, 'post'); }); // Because we're mocking the results, there's no actual changes to the mock results, // but we can verify that the filter was sent in the request - expect(fetchMock).toHaveLastFetched((_url, req) => { + expect(fetchMock).toBeDone((_url, req) => { const requestData = JSON.parse(req.body?.toString() ?? ''); - const requestedFilter = requestData?.queries[0].filter; + const requestedFilter = requestData?.queries?.[0]?.filter; return JSON.stringify(requestedFilter) === JSON.stringify([ 'context_key = "course-v1:org+test+123"', - ['"tags.taxonomy"="ESDC Skills and Competencies"'], // <-- the newly added filter, sent with the request + 'tags.taxonomy = "ESDC Skills and Competencies"', // <-- the newly added filter, sent with the request ]); }); }); + + it('can filter results by a child tag', async () => { + const { getByRole, getByLabelText, queryByLabelText } = rendered; + // Now open the filters menu: + fireEvent.click(getByRole('button', { name: 'Tags' }), {}); + // The dropdown menu in this case doesn't have a role; let's just assume it's displayed. + const expandButtonLabel = /Expand to show child tags of "ESDC Skills and Competencies"/i; + await waitFor(() => { expect(getByLabelText(expandButtonLabel)).toBeInTheDocument(); }); + + // First, the child tag is not shown: + const childTagLabel = /^Abilities/i; + expect(queryByLabelText(childTagLabel)).toBeNull(); + // Click on the button to show children + const expandButton = getByLabelText(expandButtonLabel); + fireEvent.click(expandButton, {}); + // Now the child tag is visible: + await waitFor(() => { expect(queryByLabelText(childTagLabel)).toBeInTheDocument(); }); + // Click on it: + const abilitiesTagFilterCheckbox = getByLabelText(childTagLabel); + fireEvent.click(abilitiesTagFilterCheckbox); + // Now wait for the filter to be applied and the new results to be fetched. + await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(3, searchEndpoint, 'post'); }); + // Because we're mocking the results, there's no actual changes to the mock results, + // but we can verify that the filter was sent in the request + expect(fetchMock).toBeDone((_url, req) => { + const requestData = JSON.parse(req.body?.toString() ?? ''); + const requestedFilter = requestData?.queries?.[0]?.filter; + return JSON.stringify(requestedFilter) === JSON.stringify([ + 'context_key = "course-v1:org+test+123"', + 'tags.level0 = "ESDC Skills and Competencies > Abilities"', + ]); + }); + }); + + it('can do a keyword search of the tag options', async () => { + const { getByRole, getByLabelText, queryByLabelText } = rendered; + // Now open the filters menu: + fireEvent.click(getByRole('button', { name: 'Tags' }), {}); + // The dropdown menu in this case doesn't have a role; let's just assume it's displayed. + const expandButtonLabel = /Expand to show child tags of "ESDC Skills and Competencies"/i; + await waitFor(() => { expect(getByLabelText(expandButtonLabel)).toBeInTheDocument(); }); + + const input = getByLabelText('Search tags'); + fireEvent.change(input, { target: { value: 'Lightcast' } }); + + await waitFor(() => { expect(queryByLabelText(/^ESDC Skills and Competencies/i)).toBeNull(); }); + expect(queryByLabelText(/^Lightcast/i)).toBeInTheDocument(); + }); }); }); diff --git a/src/search-modal/Stats.jsx b/src/search-modal/Stats.jsx index fabfe76a4f..63bcd8f4f5 100644 --- a/src/search-modal/Stats.jsx +++ b/src/search-modal/Stats.jsx @@ -1,26 +1,24 @@ /* eslint-disable react/prop-types */ // @ts-check import React from 'react'; -import { useStats, useClearRefinements } from 'react-instantsearch'; import { FormattedMessage } from '@edx/frontend-platform/i18n'; import messages from './messages'; +import { useSearchContext } from './manager/SearchManager'; /** * Simple component that displays the # of matching results * @type {React.FC>} */ -const Stats = (props) => { - const { nbHits, query } = useStats(props); - const { canRefine: hasFiltersApplied } = useClearRefinements(); - const hasQuery = !!query; +const Stats = () => { + const { totalHits, searchKeywords, canClearFilters } = useSearchContext(); - if (!hasQuery && !hasFiltersApplied) { + if (!searchKeywords && !canClearFilters) { // We haven't started the search yet. return null; } return ( - + ); }; diff --git a/src/search-modal/__mocks__/empty-search-result.json b/src/search-modal/__mocks__/empty-search-result.json index 70d3e8f2fa..a0ba5d6db9 100644 --- a/src/search-modal/__mocks__/empty-search-result.json +++ b/src/search-modal/__mocks__/empty-search-result.json @@ -2,16 +2,24 @@ "comment": "This is a mock of the empty response from Meilisearch, based on an actual search in Studio.", "results": [ { - "indexUid": "tutor_studio_content", + "indexUid": "studio", "hits": [], "query": "noresult", "processingTimeMs": 0, - "limit": 21, + "limit": 20, + "offset": 0, + "estimatedTotalHits": 0 + }, + { + "indexUid": "studio", + "hits": [], + "query": "noresult", + "processingTimeMs": 0, + "limit": 0, "offset": 0, "estimatedTotalHits": 0, "facetDistribution": { - "block_type": {}, - "tags.taxonomy": {} + "block_type": {} }, "facetStats": {} } diff --git a/src/search-modal/__mocks__/facet-search-level0.json b/src/search-modal/__mocks__/facet-search-level0.json new file mode 100644 index 0000000000..b4201c6d88 --- /dev/null +++ b/src/search-modal/__mocks__/facet-search-level0.json @@ -0,0 +1,13 @@ +{ + "facetHits": [ + { "value": "ESDC Skills and Competencies > Abilities", "count": 5 }, + { "value": "ESDC Skills and Competencies > Interests", "count": 1 }, + { "value": "ESDC Skills and Competencies > Knowledge", "count": 7 }, + { "value": "ESDC Skills and Competencies > Personal Attributes", "count": 3 }, + { "value": "ESDC Skills and Competencies > Skills", "count": 8 }, + { "value": "ESDC Skills and Competencies > Work Activities", "count": 5 }, + { "value": "ESDC Skills and Competencies > Work Context", "count": 10 } + ], + "facetQuery": "", + "processingTimeMs": 0 +} diff --git a/src/search-modal/__mocks__/facet-search-level1.json b/src/search-modal/__mocks__/facet-search-level1.json new file mode 100644 index 0000000000..f6a68e3336 --- /dev/null +++ b/src/search-modal/__mocks__/facet-search-level1.json @@ -0,0 +1,8 @@ +{ + "facetHits": [ + { "value": "ESDC Skills and Competencies > Abilities > Cognitive Abilities", "count": 3 }, + { "value": "ESDC Skills and Competencies > Abilities > Physical Abilities", "count": 2 } + ], + "facetQuery": "", + "processingTimeMs": 0 +} diff --git a/src/search-modal/__mocks__/facet-search.json b/src/search-modal/__mocks__/facet-search.json new file mode 100644 index 0000000000..71db55107e --- /dev/null +++ b/src/search-modal/__mocks__/facet-search.json @@ -0,0 +1,12 @@ +{ + "facetHits": [ + { "value": "ESDC Skills and Competencies", "count": 7 }, + { "value": "FlatTaxonomy", "count": 7 }, + { "value": "HierarchicalTaxonomy", "count": 6 }, + { "value": "Lightcast Open Skills Taxonomy", "count": 6 }, + { "value": "MultiOrgTaxonomy", "count": 7 }, + { "value": "TwoLevelTaxonomy", "count": 7 } + ], + "facetQuery": "", + "processingTimeMs": 0 +} diff --git a/src/search-modal/__mocks__/search-result.json b/src/search-modal/__mocks__/search-result.json index 234bb10c4e..ff4397a406 100644 --- a/src/search-modal/__mocks__/search-result.json +++ b/src/search-modal/__mocks__/search-result.json @@ -74,21 +74,22 @@ "processingTimeMs": 1, "limit": 2, "offset": 0, - "estimatedTotalHits": 2, + "estimatedTotalHits": 2 + }, + { + "indexUid": "studio", + "hits": [], + "query": "learn", + "processingTimeMs": 1, + "limit": 0, + "offset": 0, + "estimatedTotalHits": 0, "facetDistribution": { "block_type": { "html": 2, "problem": 16, "vertical": 2, "video": 1 - }, - "tags.taxonomy": { - "ESDC Skills and Competencies": 1, - "FlatTaxonomy": 2, - "HierarchicalTaxonomy": 1, - "Lightcast Open Skills Taxonomy": 1, - "MultiOrgTaxonomy": 1, - "TwoLevelTaxonomy": 2 } }, "facetStats": {} diff --git a/src/search-modal/__mocks__/tags-keyword-search.json b/src/search-modal/__mocks__/tags-keyword-search.json new file mode 100644 index 0000000000..51be970963 --- /dev/null +++ b/src/search-modal/__mocks__/tags-keyword-search.json @@ -0,0 +1,48 @@ +{ + "comment": "Because this document has at least one tag that matches the search 'lightcast', all of its tags get returned.", + "hits": [ + { + "tags": { + "taxonomy": [ + "ESDC Skills and Competencies", + "FlatTaxonomy", + "HierarchicalTaxonomy", + "Lightcast Open Skills Taxonomy" + ], + "level0": [ + "ESDC Skills and Competencies > Interests", + "FlatTaxonomy > flat taxonomy tag 1420", + "FlatTaxonomy > flat taxonomy tag 1683", + "FlatTaxonomy > flat taxonomy tag 2633", + "HierarchicalTaxonomy > hierarchical taxonomy tag 1", + "HierarchicalTaxonomy > hierarchical taxonomy tag 2", + "HierarchicalTaxonomy > hierarchical taxonomy tag 4", + "Lightcast Open Skills Taxonomy > Information Technology Category" + ], + "level1": [ + "ESDC Skills and Competencies > Interests > Holland Codes", + "HierarchicalTaxonomy > hierarchical taxonomy tag 1 > hierarchical taxonomy tag 1.3", + "HierarchicalTaxonomy > hierarchical taxonomy tag 2 > hierarchical taxonomy tag 2.16", + "HierarchicalTaxonomy > hierarchical taxonomy tag 4 > hierarchical taxonomy tag 4.8", + "Lightcast Open Skills Taxonomy > Information Technology Category > Web Content" + ], + "level2": [ + "ESDC Skills and Competencies > Interests > Holland Codes > Interests - Holland Codes", + "HierarchicalTaxonomy > hierarchical taxonomy tag 1 > hierarchical taxonomy tag 1.3 > hierarchical taxonomy tag 1.3.7", + "HierarchicalTaxonomy > hierarchical taxonomy tag 2 > hierarchical taxonomy tag 2.16 > hierarchical taxonomy tag 2.16.31", + "HierarchicalTaxonomy > hierarchical taxonomy tag 4 > hierarchical taxonomy tag 4.8 > hierarchical taxonomy tag 4.8.25", + "Lightcast Open Skills Taxonomy > Information Technology Category > Web Content > Web Resource" + ], + "level3": [ + "ESDC Skills and Competencies > Interests > Holland Codes > Interests - Holland Codes > Artistic", + "ESDC Skills and Competencies > Interests > Holland Codes > Interests - Holland Codes > Investigative" + ] + } + } + ], + "query": "lightcast", + "processingTimeMs": 3, + "limit": 1000, + "offset": 0, + "estimatedTotalHits": 23 +} diff --git a/src/search-modal/data/api.js b/src/search-modal/data/api.js index ce306ee49e..126df5248f 100644 --- a/src/search-modal/data/api.js +++ b/src/search-modal/data/api.js @@ -1,5 +1,5 @@ // @ts-check -import { getConfig } from '@edx/frontend-platform'; +import { camelCaseObject, getConfig } from '@edx/frontend-platform'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; export const getContentSearchConfigUrl = () => new URL( @@ -7,6 +7,12 @@ export const getContentSearchConfigUrl = () => new URL( getConfig().STUDIO_BASE_URL, ).href; +/** The separator used for hierarchical tags in the search index, e.g. tags.level1 = "Subject > Math > Calculus" */ +export const TAG_SEP = ' > '; + +export const highlightPreTag = '__meili-highlight__'; // Indicate the start of a highlighted (matching) term +export const highlightPostTag = '__/meili-highlight__'; // Indicate the end of a highlighted (matching) term + /** * Get the content search configuration from the CMS. * @@ -21,3 +27,361 @@ export const getContentSearchConfig = async () => { apiKey: response.data.api_key, }; }; + +/** + * Detailed "content" of an XBlock/component, from the block's index_dictionary function. Contents depends on the type. + * @typedef {{htmlContent?: string, capaContent?: string, [k: string]: any}} ContentDetails + */ + +/** + * Meilisearch filters can be expressed as strings or arrays. + * This helper method converts from any supported input format to an array, for consistency. + * @param {import('meilisearch').Filter} [filter] A filter expression, e.g. 'foo = bar' or [['a = b', 'a = c'], 'd = e'] + * @returns {(string | string[])[]} + */ +function forceArray(filter) { + if (typeof filter === 'string') { + return [filter]; + } + if (filter === undefined) { + return []; + } + return filter; +} + +/** + * Given tag paths like ["Difficulty > Hard", "Subject > Math"], convert them to an array of Meilisearch + * filter conditions. The tag filters are all AND conditions (not OR). + * @param {string[]} [tagsFilter] e.g. ["Difficulty > Hard", "Subject > Math"] + * @returns {string[]} + */ +function formatTagsFilter(tagsFilter) { + /** @type {string[]} */ + const filters = []; + + tagsFilter?.forEach((tagPath) => { + const parts = tagPath.split(TAG_SEP); + if (parts.length === 1) { + filters.push(`tags.taxonomy = "${tagPath}"`); + } else { + filters.push(`tags.level${parts.length - 2} = "${tagPath}"`); + } + }); + + return filters; +} + +/** + * Information about a single XBlock returned in the search results + * Defined in edx-platform/openedx/core/djangoapps/content/search/documents.py + * @typedef {Object} ContentHit + * @property {string} id + * @property {string} usageKey + * @property {"course_block"|"library_block"} type + * @property {string} blockId + * @property {string} displayName + * @property {string} blockType The block_type part of the usage key. What type of XBlock this is. + * @property {string} contextKey The course or library ID + * @property {string} org + * @property {{displayName: string}[]} breadcrumbs First one is the name of the course/library itself. + * After that is the name of any parent Section/Subsection/Unit/etc. + * @property {Record<'taxonomy'|'level0'|'level1'|'level2'|'level3', string[]>} tags + * @property {ContentDetails} [content] + * @property {{displayName: string, content: ContentDetails}} formatted Same fields with ... highlights + */ + +/** + * Convert search hits to camelCase + * @param {Record} hit A search result directly from Meilisearch + * @returns {ContentHit} + */ +function formatSearchHit(hit) { + const { _formatted, ...newHit } = hit; + newHit.formatted = { + displayName: _formatted.display_name, + content: _formatted.content ?? {}, + }; + return camelCaseObject(newHit); +} + +/** + * @param {{ + * client: import('meilisearch').MeiliSearch, + * indexName: string, + * searchKeywords: string, + * blockTypesFilter?: string[], + * tagsFilter?: string[], + * extraFilter?: import('meilisearch').Filter, + * offset?: number, + * }} context + * @returns {Promise<{ + * hits: ContentHit[], + * nextOffset: number|undefined, + * totalHits: number, + * blockTypes: Record, + * }>} + */ +export async function fetchSearchResults({ + client, + indexName, + searchKeywords, + blockTypesFilter, + /** The full path of tags that each result MUST have, e.g. ["Difficulty > Hard", "Subject > Math"] */ + tagsFilter, + extraFilter, + /** How many results to skip, e.g. if limit=20 then passing offset=20 gets the second page. */ + offset = 0, +}) { + /** @type {import('meilisearch').MultiSearchQuery[]} */ + const queries = []; + + // Convert 'extraFilter' into an array + const extraFilterFormatted = forceArray(extraFilter); + + const blockTypesFilterFormatted = blockTypesFilter?.length ? [blockTypesFilter.map(bt => `block_type = ${bt}`)] : []; + + const tagsFilterFormatted = formatTagsFilter(tagsFilter); + + const limit = 20; // How many results to retrieve per page. + + // First query is always to get the hits, with all the filters applied. + queries.push({ + indexUid: indexName, + q: searchKeywords, + filter: [ + // top-level entries in the array are AND conditions and must all match + // Inner arrays are OR conditions, where only one needs to match. + ...extraFilterFormatted, + ...blockTypesFilterFormatted, + ...tagsFilterFormatted, + ], + attributesToHighlight: ['display_name', 'content'], + highlightPreTag, + highlightPostTag, + attributesToCrop: ['content'], + cropLength: 20, + offset, + limit, + }); + + // The second query is to get the possible values for the "block types" filter + queries.push({ + indexUid: indexName, + q: searchKeywords, + facets: ['block_type'], + filter: [ + ...extraFilterFormatted, + // We exclude the block type filter here so we get all the other available options for it. + ...tagsFilterFormatted, + ], + limit: 0, // We don't need any "hits" for this - just the facetDistribution + }); + + const { results } = await client.multiSearch(({ queries })); + return { + hits: results[0].hits.map(formatSearchHit), + totalHits: results[0].totalHits ?? results[0].estimatedTotalHits ?? results[0].hits.length, + blockTypes: results[1].facetDistribution?.block_type ?? {}, + nextOffset: results[0].hits.length === limit ? offset + limit : undefined, + }; +} + +/** + * In the context of a particular search (which may already be filtered to a specific course, specific block types, + * and/or have a keyword search applied), get the tree of tags that can be used to further filter/refine the search. + * + * @param {object} context + * @param {import('meilisearch').MeiliSearch} context.client The Meilisearch client instance + * @param {string} context.indexName Which index to search + * @param {string} context.searchKeywords Overall query string for the search; may be empty + * @param {string[]} [context.blockTypesFilter] Filter to only include these block types e.g. ["problem", "html"] + * @param {import('meilisearch').Filter} [context.extraFilter] Any other filters to apply, e.g. course ID. + * @param {string} [context.parentTagPath] Only fetch tags below this parent tag/taxonomy e.g. "Places > North America" + * @returns {Promise<{ + * tags: {tagName: string, tagPath: string, tagCount: number, hasChildren: boolean}[]; + * mayBeMissingResults: boolean; + * }>} + */ +export async function fetchAvailableTagOptions({ + client, + indexName, + searchKeywords, + blockTypesFilter, + extraFilter, + parentTagPath, + // Ideally this would include 'tagSearchKeywords' to filter the tag tree by keyword search but that's not possible yet +}) { + const meilisearchFacetLimit = 100; // The 'maxValuesPerFacet' on the index. For Open edX we leave the default, 100. + + // Convert 'extraFilter' into an array + const extraFilterFormatted = forceArray(extraFilter); + + const blockTypesFilterFormatted = blockTypesFilter?.length ? [blockTypesFilter.map(bt => `block_type = ${bt}`)] : []; + + // Figure out which "facet" (attribute of the documents in the search index) holds the tags at the level we want. + // e.g. "tags.taxonomy" is the facet/attribute that holds the root tags, and "tags.level0" has its child tags. + let facetName; + let depth; + /** @type {string[]} */ + let parentFilter = []; + if (!parentTagPath) { + facetName = 'tags.taxonomy'; + depth = 0; + } else { + const parentParts = parentTagPath.split(TAG_SEP); + depth = parentParts.length; + facetName = `tags.level${depth - 1}`; + const parentFacetName = parentParts.length === 1 ? 'tags.taxonomy' : `tags.level${parentParts.length - 2}`; + parentFilter = [`${parentFacetName} = "${parentTagPath}"`]; + } + + // As an optimization, start pre-loading the data about "has child tags", if we will need it later. + // Notice we don't 'await' the result of this request, so it can happen in parallel with the main request that follows + const maybeHasChildren = depth > 0 && depth < 4; // If depth=0, it definitely has children; we don't support depth > 4 + const nextLevelFacet = `tags.level${depth}`; // This will give the children of the current tags. + const preloadChildTagsData = maybeHasChildren ? client.index(indexName).searchForFacetValues({ + facetName: nextLevelFacet, + facetQuery: parentTagPath, + q: searchKeywords, + filter: [...extraFilterFormatted, ...blockTypesFilterFormatted, ...parentFilter], + }) : undefined; + + // Now load the facet values. Doing it with this API gives us much more flexibility in loading than if we just + // requested the facets by passing { facets: ["tags"] } into the main search request; that works fine for loading the + // root tags but can't load specific child tags like we can using this approach. + /** @type {{tagName: string, tagPath: string, tagCount: number, hasChildren: boolean}[]} */ + const tags = []; + const { facetHits } = await client.index(indexName).searchForFacetValues({ + facetName, + // It's not super clear in the documentation, but facetQuery is basically a "startsWith" query, which is what we + // need here to return just the tags below the selected parent tag. However, it's a fuzzy query that may match + // more tags than we want it to, so we have to explicitly post-process and reduce the set of results using an + // exact match. + facetQuery: parentTagPath, + q: searchKeywords, + filter: [...extraFilterFormatted, ...blockTypesFilterFormatted, ...parentFilter], + }); + facetHits.forEach(({ value: tagPath, count: tagCount }) => { + if (!parentTagPath) { + tags.push({ + tagName: tagPath, + tagPath, + tagCount, + hasChildren: true, // You can't tag something with just a taxonomy, so this definitely has child tags. + }); + } else { + const parts = tagPath.split(TAG_SEP); + const tagName = parts[parts.length - 1]; + if (tagPath === `${parentTagPath}${TAG_SEP}${tagName}`) { + tags.push({ + tagName, + tagPath, + tagCount, + hasChildren: false, // We'll set this later + }); + } // Else this is a tag from another taxonomy/parent that was included because this search is "fuzzy". Ignore it. + } + }); + + // Figure out if [some of] the tags at this level have children: + if (maybeHasChildren) { + if (preloadChildTagsData === undefined) { throw new Error('Child tags data unexpectedly not pre-loaded'); } + // Retrieve the children of the current tags: + const { facetHits: childFacetHits } = await preloadChildTagsData; + if (childFacetHits.length >= meilisearchFacetLimit) { + // Assume they all have child tags; we can't retrieve more than 100 facet values (per Meilisearch docs) so + // we can't say for sure on a tag-by-tag basis, but we know that at least some of them have children, so + // it's a safe bet that most/all of them have children. And it's not a huge problem if we say they have children + // but they don't. + // eslint-disable-next-line no-param-reassign + tags.forEach((t) => { t.hasChildren = true; }); + } else if (childFacetHits.length > 0) { + // Some (or maybe all) of these tags have child tags. Let's figure out which ones exactly. + /** @type {Set} */ + const tagsWithChildren = new Set(); + childFacetHits.forEach(({ value }) => { + // Trim the child tag off: 'Places > North America > New York' becomes 'Places > North America' + const tagPath = value.split(TAG_SEP).slice(0, -1).join(TAG_SEP); + tagsWithChildren.add(tagPath); + }); + // eslint-disable-next-line no-param-reassign + tags.forEach((t) => { t.hasChildren = tagsWithChildren.has(t.tagPath); }); + } + } + + // If we hit the limit of facetHits, there are probably even more tags, but there is no API to retrieve + // them (no pagination etc.), so just tell the user that not all tags could be displayed. This should be pretty rare. + return { tags, mayBeMissingResults: facetHits.length >= meilisearchFacetLimit }; +} + +/** + * Best-effort search for *all* tags among the search results (with filters applied) that contain the given keyword. + * + * Unfortunately there is no good Meilisearch API for this, so we just have to do the best we can. If more than 1,000 + * objects are tagged with matching tags, this will be an incomplete result. For example, if 1,000 XBlocks/components + * are tagged with "Tag Alpha 1" and 10 XBlocks are tagged with "Tag Alpha 2", a search for "Alpha" may only return + * ["Tag Alpha 1"] instead of the correct result ["Tag Alpha 1", "Tag Alpha 2"] because we are limited to 1,000 matches, + * which may all have the same tags. + * + * @param {object} context + * @param {import('meilisearch').MeiliSearch} context.client The Meilisearch client instance + * @param {string} context.indexName Which index to search + * @param {string[]} [context.blockTypesFilter] Filter to only include these block types e.g. ["problem", "html"] + * @param {import('meilisearch').Filter} [context.extraFilter] Any other filters to apply to the overall search. + * @param {string} [context.tagSearchKeywords] Only show taxonomies/tags that match these keywords + * @returns {Promise<{ mayBeMissingResults: boolean; matches: {tagPath: string}[] }>} + */ +export async function fetchTagsThatMatchKeyword({ + client, + indexName, + blockTypesFilter, + extraFilter, + tagSearchKeywords, +}) { + if (!tagSearchKeywords || tagSearchKeywords.trim() === '') { + // This data isn't needed if there is no tag keyword search. Don't bother making a search query. + return { matches: [], mayBeMissingResults: false }; + } + // Convert 'extraFilter' into an array + const extraFilterFormatted = forceArray(extraFilter); + + const blockTypesFilterFormatted = blockTypesFilter?.length ? [blockTypesFilter.map(bt => `block_type = ${bt}`)] : []; + + const limit = 1000; // This is the most results we can retrieve in a single query. + + // We search for any matches of the keyword in the "tags" field, respecting the current filters like block type filter + // or current course filter. (Unfortunately we cannot also include the overall `searchKeywords` so this will match + // against more content than it should.) + const { hits } = await client.index(indexName).search(tagSearchKeywords, { + filter: [...extraFilterFormatted, ...blockTypesFilterFormatted], + attributesToSearchOn: ['tags.taxonomy', 'tags.level0', 'tags.level1', 'tags.level2', 'tags.level3'], + attributesToRetrieve: ['tags'], + limit, + // We'd like to use 'showMatchesPosition: true' to know exaclty which tags match, but it doesn't provide the + // detail we need; it's impossible to tell which tag at a given level matched based on the returned _matchesPosition + // data - https://github.com/orgs/meilisearch/discussions/550 + }); + + const tagSearchKeywordsLower = tagSearchKeywords.toLocaleLowerCase(); + + /** @type {Set} */ + const matches = new Set(); + + // We have data like this: + // hits: [ + // { + // tags: { taxonomy: "Competency", "level0": "Competency > Abilities", "level1": "Competency > Abilities > ..." }, + // }, ... + // ] + hits.forEach((hit) => { + Object.values(hit.tags).forEach((tagPathList) => { + tagPathList.forEach((tagPath) => { + if (tagPath.toLocaleLowerCase().includes(tagSearchKeywordsLower)) { + matches.add(tagPath); + } + }); + }); + }); + + return { matches: Array.from(matches).map((tagPath) => ({ tagPath })), mayBeMissingResults: hits.length === limit }; +} diff --git a/src/search-modal/data/apiHooks.js b/src/search-modal/data/apiHooks.js index 36e5c2e12f..02488635da 100644 --- a/src/search-modal/data/apiHooks.js +++ b/src/search-modal/data/apiHooks.js @@ -1,16 +1,21 @@ // @ts-check +import React from 'react'; +import { useInfiniteQuery, useQuery } from '@tanstack/react-query'; -import { useQuery } from '@tanstack/react-query'; - -import { getContentSearchConfig } from './api'; +import { + TAG_SEP, + fetchAvailableTagOptions, + fetchSearchResults, + fetchTagsThatMatchKeyword, + getContentSearchConfig, +} from './api'; /** - * Load the Meilisearch connection details from the CMS: the URL to use, the index name, and an API key specific - * to the current user that allows it to search all content he have permission to view. - * - */ -/* eslint-disable import/prefer-default-export */ -export const useContentSearch = () => ( + * Load the Meilisearch connection details from the CMS: the URL to use, the index name, and an API key specific + * to the current user that allows it to search all content he have permission to view. + * + */ +export const useContentSearchConnection = () => ( useQuery({ queryKey: ['content_search'], queryFn: getContentSearchConfig, @@ -21,3 +26,166 @@ export const useContentSearch = () => ( refetchOnMount: false, }) ); + +/** + * Get the results of a search + * @param {object} context + * @param {import('meilisearch').MeiliSearch} [context.client] The Meilisearch API client + * @param {string} [context.indexName] Which search index contains the content data + * @param {import('meilisearch').Filter} [context.extraFilter] Other filters to apply to the search, e.g. course ID + * @param {string} context.searchKeywords The keywords that the user is searching for, if any + * @param {string[]} context.blockTypesFilter Only search for these block types (e.g. ["html", "problem"]) + * @param {string[]} context.tagsFilter Required tags (all must match), e.g. ["Difficulty > Hard", "Subject > Math"] + */ +export const useContentSearchResults = ({ + client, + indexName, + extraFilter, + searchKeywords, + blockTypesFilter, + tagsFilter, +}) => { + const query = useInfiniteQuery({ + enabled: client !== undefined && indexName !== undefined, + queryKey: [ + 'content_search', + 'results', + client?.config.apiKey, + client?.config.host, + indexName, + extraFilter, + searchKeywords, + blockTypesFilter, + tagsFilter, + ], + queryFn: ({ pageParam = 0 }) => { + if (client === undefined || indexName === undefined) { + throw new Error('Required data unexpectedly undefined. Check "enable" condition of useQuery.'); + } + return fetchSearchResults({ + client, + extraFilter, + indexName, + searchKeywords, + blockTypesFilter, + tagsFilter, + // For infinite pagination of results, we can retrieve additional pages if requested. + // Note that if there are 20 results per page, the "second page" has offset=20, not 2. + offset: pageParam, + }); + }, + getNextPageParam: (lastPage) => lastPage.nextOffset, + // Avoid flickering results when user is typing... keep old results until new is available. + keepPreviousData: true, + refetchOnWindowFocus: false, // This doesn't need to be refreshed when the user switches back to this tab. + }); + + const pages = query.data?.pages; + const hits = React.useMemo( + () => pages?.reduce((allHits, page) => [...allHits, ...page.hits], []) ?? [], + [pages], + ); + + return { + hits, + // The distribution of block type filter options + blockTypes: pages?.[0]?.blockTypes ?? {}, + status: query.status, + isFetching: query.isFetching, + isError: query.isError, + isFetchingNextPage: query.isFetchingNextPage, + // Call this to load more pages. We include some "safety" features recommended by the docs: this should never be + // called while already fetching a page, and parameters (like 'event') should not be passed into fetchNextPage(). + // See https://tanstack.com/query/v4/docs/framework/react/guides/infinite-queries + fetchNextPage: () => { if (!query.isFetching && !query.isFetchingNextPage) { query.fetchNextPage(); } }, + hasNextPage: query.hasNextPage, + // The last page has the most accurate count of total hits + totalHits: pages?.[pages.length - 1]?.totalHits ?? 0, + }; +}; + +/** + * Get the available tags that can be used to refine a search, based on the search filters applied so far. + * Also the user can use a keyword search to find specific tags. + * @param {object} args + * @param {import('meilisearch').MeiliSearch} [args.client] The Meilisearch client instance + * @param {string} [args.indexName] Which index to search + * @param {string} args.searchKeywords Overall query string for the search; may be empty + * @param {string[]} [args.blockTypesFilter] Filter to only include these block types e.g. ["problem", "html"] + * @param {import('meilisearch').Filter} [args.extraFilter] Any other filters to apply to the overall search. + * @param {string} [args.tagSearchKeywords] Only show taxonomies/tags that match these keywords + * @param {string} [args.parentTagPath] Only fetch tags below this parent tag/taxonomy e.g. "Places > North America" + */ +export const useTagFilterOptions = (args) => { + const mainQuery = useQuery({ + enabled: args.client !== undefined && args.indexName !== undefined, + queryKey: [ + 'content_search', + 'tag_filter_options', + args.client?.config.apiKey, + args.client?.config.host, + args.indexName, + args.extraFilter, + args.searchKeywords, + args.blockTypesFilter, + args.parentTagPath, + ], + queryFn: () => { + const { client, indexName } = args; + if (client === undefined || indexName === undefined) { + throw new Error('Required data unexpectedly undefined. Check "enable" condition of useQuery.'); + } + return fetchAvailableTagOptions({ ...args, client, indexName }); + }, + // Avoid flickering results when user is typing... keep old results until new is available. + keepPreviousData: true, + refetchOnWindowFocus: false, // This doesn't need to be refreshed when the user switches back to this tab. + }); + + const tagKeywordSearchData = useQuery({ + enabled: args.client !== undefined && args.indexName !== undefined, + queryKey: [ + 'content_search', + 'tags_keyword_search_data', + args.client?.config.apiKey, + args.client?.config.host, + args.indexName, + args.extraFilter, + args.blockTypesFilter, + args.tagSearchKeywords, + ], + queryFn: () => { + const { client, indexName } = args; + if (client === undefined || indexName === undefined) { + throw new Error('Required data unexpectedly undefined. Check "enable" condition of useQuery.'); + } + return fetchTagsThatMatchKeyword({ ...args, client, indexName }); + }, + // Avoid flickering results when user is typing... keep old results until new is available. + keepPreviousData: true, + refetchOnWindowFocus: false, // This doesn't need to be refreshed when the user switches back to this tab. + }); + + const data = React.useMemo(() => { + if (!args.tagSearchKeywords || !tagKeywordSearchData.data) { + // If there's no keyword search being used to filter the list of available tags, just use the results of the + // main query. + return { tags: mainQuery.data?.tags, mayBeMissingResults: mainQuery.data?.mayBeMissingResults ?? false }; + } + if (mainQuery.data === undefined) { + return { tags: undefined, mayBeMissingResults: false }; + } + // Combine these two queries to filter the list of tags based on the keyword search. + const tags = mainQuery.data.tags.filter( + ({ tagPath }) => tagKeywordSearchData.data.matches.some( + (matchingTag) => matchingTag.tagPath === tagPath || matchingTag.tagPath.startsWith(tagPath + TAG_SEP), + ), + ); + return { + tags, + mayBeMissingResults: mainQuery.data.mayBeMissingResults || tagKeywordSearchData.data.mayBeMissingResults, + }; + }, [mainQuery.data, tagKeywordSearchData.data]); + + return { ...mainQuery, data }; +}; diff --git a/src/search-modal/manager/SearchManager.js b/src/search-modal/manager/SearchManager.js new file mode 100644 index 0000000000..bbed2e08e5 --- /dev/null +++ b/src/search-modal/manager/SearchManager.js @@ -0,0 +1,104 @@ +/* eslint-disable react/prop-types */ +// @ts-check +/** + * This is a search manager that provides search functionality similar to the + * Instantsearch library. We use it because Instantsearch doesn't support + * multiple selections of hierarchical tags. + * https://github.com/algolia/instantsearch/issues/1658 + */ +import React from 'react'; +import { MeiliSearch } from 'meilisearch'; + +import { useContentSearchConnection, useContentSearchResults } from '../data/apiHooks'; + +/** + * @type {React.Context>, + * blockTypesFilter: string[], + * setBlockTypesFilter: React.Dispatch>, + * tagsFilter: string[], + * setTagsFilter: React.Dispatch>, + * blockTypes: Record, + * extraFilter?: import('meilisearch').Filter, + * canClearFilters: boolean, + * clearFilters: () => void, + * hits: import('../data/api').ContentHit[], + * totalHits: number, + * isFetching: boolean, + * hasNextPage: boolean | undefined, + * isFetchingNextPage: boolean, + * fetchNextPage: () => void, + * closeSearchModal: () => void, + * hasError: boolean, + * }>} + */ +const SearchContext = /** @type {any} */(React.createContext(undefined)); + +/** + * @type {React.FC<{ +* extraFilter?: import('meilisearch').Filter, +* children: React.ReactNode, +* closeSearchModal?: () => void, +* }>} +*/ +export const SearchContextProvider = ({ extraFilter, children, closeSearchModal }) => { + const [searchKeywords, setSearchKeywords] = React.useState(''); + const [blockTypesFilter, setBlockTypesFilter] = React.useState(/** type {string[]} */([])); + const [tagsFilter, setTagsFilter] = React.useState(/** type {string[]} */([])); + + const canClearFilters = blockTypesFilter.length > 0 || tagsFilter.length > 0; + const clearFilters = React.useCallback(() => { + setBlockTypesFilter([]); + setTagsFilter([]); + }, []); + + // Initialize a connection to Meilisearch: + const { data: connectionDetails, isError: hasConnectionError } = useContentSearchConnection(); + const indexName = connectionDetails?.indexName; + const client = React.useMemo(() => { + if (connectionDetails?.apiKey === undefined || connectionDetails?.url === undefined) { + return undefined; + } + return new MeiliSearch({ host: connectionDetails.url, apiKey: connectionDetails.apiKey }); + }, [connectionDetails?.apiKey, connectionDetails?.url]); + + // Run the search + const result = useContentSearchResults({ + client, + indexName, + extraFilter, + searchKeywords, + blockTypesFilter, + tagsFilter, + }); + + return React.createElement(SearchContext.Provider, { + value: { + client, + indexName, + searchKeywords, + setSearchKeywords, + blockTypesFilter, + setBlockTypesFilter, + tagsFilter, + setTagsFilter, + extraFilter, + canClearFilters, + clearFilters, + closeSearchModal: closeSearchModal ?? (() => {}), + hasError: hasConnectionError || result.isError, + ...result, + }, + }, children); +}; + +export const useSearchContext = () => { + const ctx = React.useContext(SearchContext); + if (ctx === undefined) { + throw new Error('Cannot use search components outside of '); + } + return ctx; +}; diff --git a/src/search-modal/messages.js b/src/search-modal/messages.js index ad429af29d..d72a5b6b78 100644 --- a/src/search-modal/messages.js +++ b/src/search-modal/messages.js @@ -25,6 +25,16 @@ const messages = defineMessages({ defaultMessage: 'No tags in current results', description: 'Label shown when there are no options available to filter by tags', }, + 'blockTagsFilter.error': { + id: 'course-authoring.course-search.blockTagsFilter.error', + defaultMessage: 'Error loading tags', + description: 'Label shown when the tags could not be loaded', + }, + 'blockTagsFilter.incomplete': { + id: 'course-authoring.course-search.blockTagsFilter.incomplete', + defaultMessage: 'Sorry, not all tags could be loaded', + description: 'Label shown when the system is not able to display all of the available tag options.', + }, 'blockType.annotatable': { id: 'course-authoring.course-search.blockType.annotatable', defaultMessage: 'Annotation', @@ -80,6 +90,16 @@ const messages = defineMessages({ defaultMessage: 'Video', description: 'Name of the "Video" component type in Studio', }, + childTagsExpand: { + id: 'course-authoring.course-search.child-tags-expand', + defaultMessage: 'Expand to show child tags of "{tagName}"', + description: 'This text describes the ▼ expand toggle button to non-visual users.', + }, + childTagsCollapse: { + id: 'course-authoring.course-search.child-tags-collapse', + defaultMessage: 'Collapse to hide child tags of "{tagName}"', + description: 'This text describes the ▲ collapse toggle button to non-visual users.', + }, clearFilters: { id: 'course-authoring.course-search.clearFilters', defaultMessage: 'Clear Filters', @@ -110,11 +130,31 @@ const messages = defineMessages({ defaultMessage: 'Search', description: 'Placeholder text shown in the keyword input field when the user has not yet entered a keyword', }, + searchTagsByKeywordPlaceholder: { + id: 'course-authoring.course-search.searchTagsByKeywordPlaceholder', + defaultMessage: 'Search tags', + description: 'Placeholder text shown in the input field that allows searching through the available tags', + }, + submitSearchTagsByKeyword: { + id: 'course-authoring.course-search.submitSearchTagsByKeyword', + defaultMessage: 'Submit tag keyword search', + description: 'Text shown to screen reader users for the search button on the tags keyword search', + }, showMore: { id: 'course-authoring.course-search.showMore', defaultMessage: 'Show more', description: 'Show more tags / filter options', }, + showMoreResults: { + id: 'course-authoring.course-search.showMoreResults', + defaultMessage: 'Show more results', + description: 'Show more results - a button to add to the list of results by loading more from the server', + }, + loadingMoreResults: { + id: 'course-authoring.course-search.loadingMoreResults', + defaultMessage: 'Loading more results', + description: 'Loading more results - the button displays this message while more results are loading', + }, emptySearchTitle: { id: 'course-authoring.course-search.emptySearchTitle', defaultMessage: 'Start searching to find content', @@ -135,16 +175,16 @@ const messages = defineMessages({ defaultMessage: 'Please try a different search term or filter', description: 'Subtitle shown when the search returned no results', }, - showMoreResults: { - id: 'course-authoring.course-search.showMoreResults', - defaultMessage: 'Show more results', - description: 'Show more results button label', - }, openInNewWindow: { id: 'course-authoring.course-search.openInNewWindow', defaultMessage: 'Open in new window', description: 'Alt text for the button that opens the search result in a new window', }, + searchError: { + id: 'course-authoring.course-search.searchError', + defaultMessage: 'An error occurred. Unable to load search results.', + description: 'Error message shown when search is not working.', + }, }); export default messages;