feat: Allow filtering by multiple tags [FC-0040] (#945)

As of #918 , the content search only allows filtering the results by one tag at a time, which is a limitation of Instantsearch.

So with this change, usage of Instantsearch + instant-meilisearch has been replaced with direct usage of Meilisearch. Not only does this simplify the code and make our MFE bundle size smaller, but it allows us much more control over how the tags filtering works, so that we can implement searching by multiple tags.

Trying to modify Instantsearch to do that was too difficult, given the complexity of its codebase.

Related ticket: openedx/modular-learning#201
This commit is contained in:
Braden MacDonald
2024-04-23 20:45:17 -07:00
committed by GitHub
parent 34104495c5
commit c32462e21e
27 changed files with 1274 additions and 736 deletions

341
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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<Record<never, never>>}
*/
const ClearFiltersButton = () => {
const { refine, canRefine } = useClearRefinements();
if (canRefine) {
const { canClearFilters, clearFilters } = useSearchContext();
if (canClearFilters) {
return (
<Button variant="link" size="sm" onClick={refine}>
<Button variant="link" size="sm" onClick={clearFilters}>
<FormattedMessage {...messages.clearFilters} />
</Button>
);

View File

@@ -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 (
<Alert variant="danger">
<FormattedMessage {...messages.searchError} />
</Alert>
);
}
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 (
<InfoMessage
title={messages.noResultsTitle}

View File

@@ -3,19 +3,15 @@
import React from 'react';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import {
Button,
Badge,
Form,
Menu,
MenuItem,
} from '@openedx/paragon';
import {
useCurrentRefinements,
useRefinementList,
} from 'react-instantsearch';
import SearchFilterWidget from './SearchFilterWidget';
import messages from './messages';
import BlockTypeLabel from './BlockTypeLabel';
import { useSearchContext } from './manager/SearchManager';
/**
* A button with a dropdown that allows filtering the current search by component type (XBlock type)
@@ -25,64 +21,55 @@ import BlockTypeLabel from './BlockTypeLabel';
*/
const FilterByBlockType = () => {
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 (
<SearchFilterWidget
appliedFilters={appliedItems.map(item => ({ label: <BlockTypeLabel type={String(item.value)} /> }))}
appliedFilters={blockTypesFilter.map(blockType => ({ label: <BlockTypeLabel type={blockType} /> }))}
label={<FormattedMessage {...messages.blockTypeFilter} />}
>
<Form.Group>
<Form.CheckboxSet
name="block-type-filter"
defaultValue={appliedItems.map(item => item.value)}
defaultValue={blockTypesFilter}
>
<Menu style={{ boxShadow: 'none' }}>
{
items.map((item) => (
Object.entries(blockTypes).map(([blockType, count]) => (
<MenuItem
key={item.value}
key={blockType}
as={Form.Checkbox}
value={item.value}
checked={item.isRefined}
value={blockType}
checked={blockTypesFilter.includes(blockType)}
onChange={handleCheckboxChange}
>
<BlockTypeLabel type={item.value} />{' '}
<Badge variant="light" pill>{item.count}</Badge>
<BlockTypeLabel type={blockType} />{' '}
<Badge variant="light" pill>{count}</Badge>
</MenuItem>
))
}
{
// 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 ? (
<MenuItem disabled><FormattedMessage {...messages['blockTypeFilter.empty']} /></MenuItem>
) : null
}
</Menu>
</Form.CheckboxSet>
</Form.Group>
{
canToggleShowMore && !isShowingMore
? <Button onClick={toggleShowMore}><FormattedMessage {...messages.showMore} /></Button>
: null
}
</SearchFilterWidget>
);
};

View File

@@ -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';
// eslint-disable-next-line max-len
/** @typedef {import('instantsearch.js/es/connectors/hierarchical-menu/connectHierarchicalMenu').HierarchicalMenuItem} HierarchicalMenuItem */
import { useSearchContext } from './manager/SearchManager';
import { useTagFilterOptions } from './data/apiHooks';
import { LoadingSpinner } from '../generic/Loading';
import { TAG_SEP } from './data/api';
/**
* A button with a dropdown menu to allow filtering the search using tags.
* This version is based on Instantsearch's <HierarchichalMenu/> component, so it only allows selecting one tag at a
* time. We will replace it with a custom version that allows multi-select.
* A menu item with a checkbox and an optional ▼ button (to show/hide children)
* @type {React.FC<{
* items: HierarchicalMenuItem[],
* refine: (value: string) => void,
* depth?: number,
* label: string;
* tagPath: string;
* isChecked: boolean;
* onClickCheckbox: () => void;
* tagCount: number;
* hasChildren?: boolean;
* isExpanded?: boolean;
* onToggleChildren?: (tagPath: string) => void;
* }>}
*/
const FilterOptions = ({ items, refine, depth = 0 }) => {
const handleCheckboxChange = React.useCallback((e) => {
refine(e.target.value);
}, [refine]);
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 (
<>
<div className="pgn__menu-item pgn__form-checkbox tag-toggle-item" role="group">
<input
type="checkbox"
id={checkboxId}
checked={isChecked}
onChange={onClickCheckbox}
className="pgn__form-checkbox-input flex-shrink-0"
/>
<label htmlFor={checkboxId} className="flex-shrink-1">
{label}{' '}
<Badge variant="light" pill>{tagCount}</Badge>
</label>
{
items.map((item) => (
<React.Fragment key={item.value}>
<MenuItem
as={Form.Checkbox}
value={item.value}
checked={item.isRefined}
onChange={handleCheckboxChange}
className={`tag-option-${depth}`}
>
{item.label}{' '}
<Badge variant="light" pill>{item.count}</Badge>
</MenuItem>
{item.data && <FilterOptions items={item.data} refine={refine} depth={depth + 1} />}
</React.Fragment>
))
hasChildren
? (
<IconButton
src={isExpanded ? ArrowDropUp : ArrowDropDown}
iconAs={Icon}
alt={
intl.formatMessage(
isExpanded ? messages.childTagsCollapse : messages.childTagsExpand,
{ tagName: label },
)
}
onClick={() => onToggleChildren?.(tagPath)}
variant="primary"
size="sm"
/>
) : null
}
</>
</div>
);
};
/**
* A list of menu items with all of the options for tags at one level of the hierarchy.
* @type {React.FC<{
* tagSearchKeywords: string;
* parentTagPath?: string;
* toggleTagChildren?: (tagPath: string) => void;
* expandedTags: string[],
* }>}
*/
const TagOptions = ({
parentTagPath = '',
tagSearchKeywords,
expandedTags,
toggleTagChildren,
}) => {
const searchContext = useSearchContext();
const { data, isLoading, isError } = useTagFilterOptions({
...searchContext,
parentTagPath,
tagSearchKeywords,
});
if (isError) {
return <MenuItem disabled><FormattedMessage {...messages['blockTagsFilter.error']} /></MenuItem>;
}
if (isLoading || data.tags === undefined) {
return <LoadingSpinner />;
}
// 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 <MenuItem disabled><FormattedMessage {...messages['blockTagsFilter.empty']} /></MenuItem>;
}
return (
<div role="group">
{
data.tags.map(({ tagName, tagPath, ...t }) => {
const isExpanded = expandedTags.includes(tagPath);
return (
<React.Fragment key={tagName}>
<TagMenuItem
key={tagName}
label={tagName}
tagCount={t.tagCount}
tagPath={tagPath}
isChecked={searchContext.tagsFilter.includes(tagPath)}
onClickCheckbox={() => {
searchContext.setTagsFilter((tf) => (
tf.includes(tagPath) ? tf.filter(tp => tp !== tagPath) : [...tf, tagPath]
));
}}
hasChildren={t.hasChildren}
isExpanded={isExpanded}
onToggleChildren={toggleTagChildren}
/>
{isExpanded ? (
<div className="ml-4">
<TagOptions
parentTagPath={tagPath}
expandedTags={expandedTags}
tagSearchKeywords={tagSearchKeywords}
toggleTagChildren={toggleTagChildren}
/>
</div>
) : null}
</React.Fragment>
);
})
}
{
// Sometimes, due to limitations of how the search index/API works, we aren't able to retrieve all the options:
data.mayBeMissingResults
? (
<MenuItem iconBefore={Warning} disabled>
<FormattedMessage {...messages['blockTagsFilter.incomplete']} />
</MenuItem>
) : null
}
</div>
);
};
/** @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 (
<SearchFilterWidget
appliedFilters={appliedItems}
appliedFilters={tagsFilter.map(tf => ({ label: tf.split(TAG_SEP).pop() }))}
label={<FormattedMessage {...messages.blockTagsFilter} />}
>
<Form.Group>
<Menu style={{ boxShadow: 'none' }}>
<FilterOptions items={items} refine={refine} />
{
// Show a message if there are no options at all to avoid the impression that the dropdown isn't working
items.length === 0 ? (
<MenuItem disabled><FormattedMessage {...messages['blockTagsFilter.empty']} /></MenuItem>
) : null
}
<Form.Group className="pt-3">
<SearchField
onSubmit={setTagSearchKeywords}
onChange={setTagSearchKeywords}
onClear={() => setTagSearchKeywords('')}
value={tagSearchKeywords}
screenReaderText={{
label: intl.formatMessage(messages.searchTagsByKeywordPlaceholder),
submitButton: intl.formatMessage(messages.submitSearchTagsByKeyword),
}}
placeholder={intl.formatMessage(messages.searchTagsByKeywordPlaceholder)}
className="mx-3 mb-1"
/>
<Menu className="tags-refinement-menu" style={{ boxShadow: 'none' }}>
<TagOptions
tagSearchKeywords={tagSearchKeywords}
toggleTagChildren={toggleTagChildren}
expandedTags={expandedTags}
/>
</Menu>
</Form.Group>
{
canToggleShowMore && !isShowingMore
? <Button onClick={toggleShowMore}><FormattedMessage {...messages.showMore} /></Button>
: null
}
</SearchFilterWidget>
);
};

View File

@@ -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 (
<span>
{parts.map((part, idx) => {
if (idx === 0) { return <React.Fragment key={idx}>{part}</React.Fragment>; }
const endIdx = part.indexOf(highlightPostTag);
if (endIdx === -1) { return <React.Fragment key={idx}>{part}</React.Fragment>; }
const highLightPart = part.substring(0, endIdx);
const otherPart = part.substring(endIdx + highlightPostTag.length);
return <React.Fragment key={idx}><mark>{highLightPart}</mark>{otherPart}</React.Fragment>;
})}
</span>
);
};
export default Highlight;

View File

@@ -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 <SearchUI {...searchEndpointData} courseId={courseId} closeSearch={closeSearch} />;
}
return (
<>
<ModalDialog.Header><ModalDialog.Title>{title}</ModalDialog.Title></ModalDialog.Header>
<ModalDialog.Body className="h-[calc(100vh-200px)]">
{/* @ts-ignore */}
{isLoading ? <LoadingSpinner /> : <ErrorAlert isError>{error?.message ?? String(error)}</ErrorAlert>}
</ModalDialog.Body>
</>
);
};
export default SearchEndpointLoader;

View File

@@ -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<import('react-instantsearch').UseSearchBoxProps & {className?: string}>}
* @type {React.FC<{className?: string}>}
*/
const SearchKeywordsField = (props) => {
const intl = useIntl();
const { query, refine } = useSearchBox(props);
const { searchKeywords, setSearchKeywords } = useSearchContext();
return (
<SearchField
onSubmit={refine}
onChange={refine}
onClear={() => refine('')}
value={query}
onSubmit={setSearchKeywords}
onChange={setSearchKeywords}
onClear={() => setSearchKeywords('')}
value={searchKeywords}
className={props.className}
placeholder={intl.formatMessage(messages.inputPlaceholder)}
/>

View File

@@ -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"
>
<SearchEndpointLoader courseId={courseId} closeSearch={props.onClose} />
<SearchUI courseId={courseId} closeSearchModal={props.onClose} />
</ModalDialog>
);
};

View File

@@ -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%;
}
}

View File

@@ -67,17 +67,9 @@ describe('<SearchModal />', () => {
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(<RootWrapper />);
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(<RootWrapper />);
expect(await findByText('Network Error')).toBeInTheDocument();
expect(await findByText('An error occurred. Unable to load search results.')).toBeInTheDocument();
});
});

View File

@@ -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 <b> tag for highlighting
* @type {React.FC<{
* attribute: keyof CustomHit | string[],
* hit: CustomHit,
* separator?: string,
* }>}
*/
const CustomHighlight = ({ attribute, hit, separator }) => (
<Highlight
attribute={attribute}
hit={hit}
separator={separator}
highlightedTagName="b"
/>
);
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"
>
<Icon className="text-muted" src={ItemIcon[hit.block_type] || Article} />
<Icon className="text-muted" src={getItemIcon(hit.blockType)} />
<Stack>
<div className="hit-name small">
<CustomHighlight attribute="display_name" hit={hit} />
<Highlight text={hit.formatted.displayName} />
</div>
<div className="hit-description x-small text-truncate">
<Snippet attribute="content.html_content" hit={hit} highlightedTagName="b" />
<Snippet attribute="content.capa_content" hit={hit} highlightedTagName="b" />
<Highlight text={hit.formatted.content?.htmlContent ?? ''} />
<Highlight text={hit.formatted.content?.capaContent ?? ''} />
</div>
<div className="text-muted x-small">
<CustomHighlight attribute="breadcrumbsNames" separator=" / " hit={hit} />
{hit.breadcrumbs.map(bc => bc.displayName).join(' / ')}
</div>
</Stack>
<IconButton

View File

@@ -0,0 +1,50 @@
/* eslint-disable react/prop-types */
// @ts-check
import React from 'react';
import { StatefulButton } from '@openedx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useSearchContext } from './manager/SearchManager';
import SearchResult from './SearchResult';
import messages from './messages';
/**
* All of the single results ("hits"), based on the user's search.
*
* Uses "infinite pagination" to load more pages as needed (if users click the
* "Show more results" button).
*
* @type {React.FC<Record<never, never>>}
*/
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) => <SearchResult key={hit.id} hit={hit} />)}
{hasNextPage
? (
<StatefulButton
className="mt-2"
variant="primary"
state={isFetchingNextPage ? 'pending' : 'default'}
labels={labels}
onClick={fetchNextPage}
/>
) : null}
</>
);
};
export default SearchResults;

View File

@@ -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 }) => <SearchResult hit={hit} closeSearch={props.closeSearch} />,
[],
);
return (
<InstantSearch
indexName={props.indexName}
searchClient={searchClient}
// We enable this option as recommended by the documentation, for forwards compatibility with the next version:
future={{ preserveSharedStateOnUnmount: true }}
<SearchContextProvider
extraFilter={searchThisCourse ? `context_key = "${props.courseId}"` : undefined}
closeSearchModal={props.closeSearchModal}
>
{/* Add in a filter for the current course, if relevant */}
<Configure
filters={searchThisCourse ? `context_key = "${props.courseId}"` : undefined}
attributesToSnippet={['content.html_content:20', 'content.capa_content:20']}
/>
{/* We need to override z-index here or the <Dropdown.Menu> appears behind the <ModalDialog.Body>
* But it can't be more then 9 because the close button has z-index 10. */}
<ModalDialog.Header style={{ zIndex: 9 }} className="border-bottom">
@@ -88,38 +67,10 @@ const SearchUI = (props) => {
<ModalDialog.Body className="h-[calc(100vh-200px)]">
{/* If there are no results (yet), EmptyStates displays a friendly messages. Otherwise we see the results. */}
<EmptyStates>
<InfiniteHits
hitComponent={HitComponent}
classNames={{
list: 'list-unstyled',
loadMore: 'btn btn-primary',
disabledLoadMore: 'disabled',
}}
translations={{
showMoreButtonText: intl.formatMessage(messages.showMoreResults),
}}
showPrevious={false}
transformItems={(/** @type {import("./SearchResult").CustomHit[]} */ items) => 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),
},
}))}
/>
<SearchResults />
</EmptyStates>
</ModalDialog.Body>
</InstantSearch>
</SearchContextProvider>
);
};

View File

@@ -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 <SearchUI />
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 }) => (
</IntlProvider>
</AppProvider>
);
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 <mark>...</mark> 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('<SearchUI />', () => {
},
});
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('<SearchUI />', () => {
// 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 <mark>...</mark> 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('<SearchUI />', () => {
const { getByText } = render(<Wrap><SearchUI {...defaults} /></Wrap>);
// 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('<SearchUI />', () => {
// 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('<SearchUI />', () => {
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('<SearchUI />', () => {
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('<SearchUI />', () => {
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(<Wrap><SearchUI {...defaults} /></Wrap>);
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('<SearchUI />', () => {
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('<SearchUI />', () => {
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('<SearchUI />', () => {
// 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();
});
});
});

View File

@@ -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<Record<never, never>>}
*/
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 (
<FormattedMessage {...messages.numResults} values={{ numResults: nbHits }} />
<FormattedMessage {...messages.numResults} values={{ numResults: totalHits }} />
);
};

View File

@@ -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": {}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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": {}

View File

@@ -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
}

View File

@@ -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 <mark>...</mark> highlights
*/
/**
* Convert search hits to camelCase
* @param {Record<string, any>} 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<string, number>,
* }>}
*/
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<string>} */
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<string>} */
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 };
}

View File

@@ -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 };
};

View File

@@ -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<undefined|{
* client?: MeiliSearch,
* indexName?: string,
* searchKeywords: string,
* setSearchKeywords: React.Dispatch<React.SetStateAction<string>>,
* blockTypesFilter: string[],
* setBlockTypesFilter: React.Dispatch<React.SetStateAction<string[]>>,
* tagsFilter: string[],
* setTagsFilter: React.Dispatch<React.SetStateAction<string[]>>,
* blockTypes: Record<string, number>,
* 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 <SearchContextProvider>');
}
return ctx;
};

View File

@@ -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;