feat: Content Search Modal: Filters [FC-0040] (#918)
Implementation of openedx/modular-learning#201 Implements a modal for searching course content with filters for searching in current or all courses, filtering by content type, content tags and text.
This commit is contained in:
134
package-lock.json
generated
134
package-lock.json
generated
@@ -26,7 +26,7 @@
|
||||
"@fortawesome/free-regular-svg-icons": "5.15.4",
|
||||
"@fortawesome/free-solid-svg-icons": "5.15.4",
|
||||
"@fortawesome/react-fontawesome": "0.2.0",
|
||||
"@meilisearch/instant-meilisearch": "^0.16.0",
|
||||
"@meilisearch/instant-meilisearch": "^0.17.0",
|
||||
"@openedx-plugins/course-app-calculator": "file:plugins/course-apps/calculator",
|
||||
"@openedx-plugins/course-app-edxnotes": "file:plugins/course-apps/edxnotes",
|
||||
"@openedx-plugins/course-app-learning_assistant": "file:plugins/course-apps/learning_assistant",
|
||||
@@ -83,6 +83,7 @@
|
||||
"axios": "^0.28.0",
|
||||
"axios-mock-adapter": "1.22.0",
|
||||
"eslint-import-resolver-webpack": "^0.13.8",
|
||||
"fetch-mock-jest": "^1.5.1",
|
||||
"glob": "7.2.3",
|
||||
"husky": "7.0.4",
|
||||
"jest-canvas-mock": "^2.5.2",
|
||||
@@ -4449,11 +4450,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@meilisearch/instant-meilisearch": {
|
||||
"version": "0.16.0",
|
||||
"resolved": "https://registry.npmjs.org/@meilisearch/instant-meilisearch/-/instant-meilisearch-0.16.0.tgz",
|
||||
"integrity": "sha512-JdqG/Wq+8cbzwxz4DKLuUuTetpiAcpaGQaOvi2wJzzXyYfyoiGh3/F12XMY8CA/pfDRLZtrZpDYvYLcsH+QUqw==",
|
||||
"version": "0.17.0",
|
||||
"resolved": "https://registry.npmjs.org/@meilisearch/instant-meilisearch/-/instant-meilisearch-0.17.0.tgz",
|
||||
"integrity": "sha512-6SDDivDWsmYjX33m2fAUCcBvatjutBbvqV8Eg+CEllz0l6piAiDK/WlukVpYrSmhYN2YGQsJSm62WbMGciPhUg==",
|
||||
"dependencies": {
|
||||
"meilisearch": "^0.37.0"
|
||||
"meilisearch": "^0.38.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@newrelic/publish-sourcemap": {
|
||||
@@ -10923,6 +10924,95 @@
|
||||
"bser": "2.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/fetch-mock": {
|
||||
"version": "9.11.0",
|
||||
"resolved": "https://registry.npmjs.org/fetch-mock/-/fetch-mock-9.11.0.tgz",
|
||||
"integrity": "sha512-PG1XUv+x7iag5p/iNHD4/jdpxL9FtVSqRMUQhPab4hVDt80T1MH5ehzVrL2IdXO9Q2iBggArFvPqjUbHFuI58Q==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.0.0",
|
||||
"@babel/runtime": "^7.0.0",
|
||||
"core-js": "^3.0.0",
|
||||
"debug": "^4.1.1",
|
||||
"glob-to-regexp": "^0.4.0",
|
||||
"is-subset": "^0.1.1",
|
||||
"lodash.isequal": "^4.5.0",
|
||||
"path-to-regexp": "^2.2.1",
|
||||
"querystring": "^0.2.0",
|
||||
"whatwg-url": "^6.5.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "charity",
|
||||
"url": "https://www.justgiving.com/refugee-support-europe"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"node-fetch": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"node-fetch": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fetch-mock-jest": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/fetch-mock-jest/-/fetch-mock-jest-1.5.1.tgz",
|
||||
"integrity": "sha512-+utwzP8C+Pax1GSka3nFXILWMY3Er2L+s090FOgqVNrNCPp0fDqgXnAHAJf12PLHi0z4PhcTaZNTz8e7K3fjqQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"fetch-mock": "^9.11.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "charity",
|
||||
"url": "https://www.justgiving.com/refugee-support-europe"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"node-fetch": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"node-fetch": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fetch-mock/node_modules/path-to-regexp": {
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-2.4.0.tgz",
|
||||
"integrity": "sha512-G6zHoVqC6GGTQkZwF4lkuEyMbVOjoBKAEybQUypI1WTkqinCOrq2x6U2+phkJ1XsEMTy4LjtwPI7HW+NVrRR2w==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/fetch-mock/node_modules/tr46": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz",
|
||||
"integrity": "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fetch-mock/node_modules/webidl-conversions": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz",
|
||||
"integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/fetch-mock/node_modules/whatwg-url": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-6.5.0.tgz",
|
||||
"integrity": "sha512-rhRZRqx/TLJQWUpQ6bmrt2UV4f0HCQ463yQuONJqC6fO2VoEb1pTYddbe59SkYq87aoM5A3bdhMZiUiVws+fzQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"lodash.sortby": "^4.7.0",
|
||||
"tr46": "^1.0.1",
|
||||
"webidl-conversions": "^4.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/figures": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz",
|
||||
@@ -13166,6 +13256,12 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/is-subset": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-subset/-/is-subset-0.1.1.tgz",
|
||||
"integrity": "sha512-6Ybun0IkarhmEqxXCNw/C0bna6Zb/TkfUX9UbwJtK6ObwAVCxmAP308WWTHviM/zAqXk05cdhYsUsZeGQh99iw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/is-symbol": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz",
|
||||
@@ -15107,6 +15203,12 @@
|
||||
"resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz",
|
||||
"integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g=="
|
||||
},
|
||||
"node_modules/lodash.isequal": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
|
||||
"integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/lodash.isplainobject": {
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
|
||||
@@ -15127,6 +15229,12 @@
|
||||
"resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz",
|
||||
"integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw=="
|
||||
},
|
||||
"node_modules/lodash.sortby": {
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz",
|
||||
"integrity": "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/lodash.throttle": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz",
|
||||
@@ -15325,9 +15433,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/meilisearch": {
|
||||
"version": "0.37.0",
|
||||
"resolved": "https://registry.npmjs.org/meilisearch/-/meilisearch-0.37.0.tgz",
|
||||
"integrity": "sha512-LdbK6JmRghCawrmWKJSEQF0OiE82md+YqJGE/U2JcCD8ROwlhTx0KM6NX4rQt0u0VpV0QZVG9umYiu3CSSIJAQ==",
|
||||
"version": "0.38.0",
|
||||
"resolved": "https://registry.npmjs.org/meilisearch/-/meilisearch-0.38.0.tgz",
|
||||
"integrity": "sha512-bHaq8nYxSKw9/Qslq1Zes5g9tHgFkxy/I9o8942wv2PqlNOT0CzptIkh/x98N52GikoSZOXSQkgt6oMjtf5uZw==",
|
||||
"dependencies": {
|
||||
"cross-fetch": "^3.1.6"
|
||||
}
|
||||
@@ -17785,6 +17893,16 @@
|
||||
"node": ">=0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/querystring": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.1.tgz",
|
||||
"integrity": "sha512-wkvS7mL/JMugcup3/rMitHmd9ecIGd2lhFhK9N3UUQ450h66d1r3Y9nvXzQAW1Lq+wyx61k/1pfKS5KuKiyEbg==",
|
||||
"deprecated": "The querystring API is considered Legacy. new code should use the URLSearchParams API instead.",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=0.4.x"
|
||||
}
|
||||
},
|
||||
"node_modules/querystringify": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
"@fortawesome/free-regular-svg-icons": "5.15.4",
|
||||
"@fortawesome/free-solid-svg-icons": "5.15.4",
|
||||
"@fortawesome/react-fontawesome": "0.2.0",
|
||||
"@meilisearch/instant-meilisearch": "^0.16.0",
|
||||
"@meilisearch/instant-meilisearch": "^0.17.0",
|
||||
"@openedx-plugins/course-app-calculator": "file:plugins/course-apps/calculator",
|
||||
"@openedx-plugins/course-app-edxnotes": "file:plugins/course-apps/edxnotes",
|
||||
"@openedx-plugins/course-app-learning_assistant": "file:plugins/course-apps/learning_assistant",
|
||||
@@ -110,6 +110,7 @@
|
||||
"axios": "^0.28.0",
|
||||
"axios-mock-adapter": "1.22.0",
|
||||
"eslint-import-resolver-webpack": "^0.13.8",
|
||||
"fetch-mock-jest": "^1.5.1",
|
||||
"glob": "7.2.3",
|
||||
"husky": "7.0.4",
|
||||
"jest-canvas-mock": "^2.5.2",
|
||||
|
||||
@@ -283,7 +283,7 @@ const ContentTagsDropDownSelector = ({
|
||||
<Icon
|
||||
src={isOpen(tagData.value) ? ArrowDropUp : ArrowDropDown}
|
||||
onClick={() => clickAndEnterHandler(tagData.value)}
|
||||
tabIndex="-1"
|
||||
tabIndex={-1}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -22,7 +22,7 @@ const Header = ({
|
||||
const [isShowSearchModalOpen, openSearchModal, closeSearchModal] = useToggle(false);
|
||||
|
||||
const studioBaseUrl = getConfig().STUDIO_BASE_URL;
|
||||
const meiliSearchEnabled = getConfig().MEILISEARCH_ENABLED || null;
|
||||
const meiliSearchEnabled = [true, 'true'].includes(getConfig().MEILISEARCH_ENABLED);
|
||||
const mainMenuDropdowns = [
|
||||
{
|
||||
id: `${intl.formatMessage(messages['header.links.content'])}-dropdown-menu`,
|
||||
|
||||
1
src/index.scss
Executable file → Normal file
1
src/index.scss
Executable file → Normal file
@@ -25,4 +25,5 @@
|
||||
@import "course-checklist/CourseChecklist";
|
||||
@import "content-tags-drawer/ContentTagsDropDownSelector";
|
||||
@import "content-tags-drawer/ContentTagsCollapsible";
|
||||
@import "search-modal/SearchModal";
|
||||
@import "certificates/scss/Certificates";
|
||||
|
||||
25
src/search-modal/BlockTypeLabel.jsx
Normal file
25
src/search-modal/BlockTypeLabel.jsx
Normal file
@@ -0,0 +1,25 @@
|
||||
/* eslint-disable react/prop-types */
|
||||
// @ts-check
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import messages from './messages';
|
||||
|
||||
/**
|
||||
* Displays a friendly, localized text name for the given XBlock/component type
|
||||
* e.g. `vertical` becomes `"Unit"`
|
||||
* @type {React.FC<{type: string}>}
|
||||
*/
|
||||
const BlockTypeLabel = ({ type }) => {
|
||||
// TODO: Load the localized list of Component names from Studio REST API?
|
||||
const msg = messages[`blockType.${type}`];
|
||||
|
||||
if (msg) {
|
||||
return <FormattedMessage {...msg} />;
|
||||
}
|
||||
// Replace underscores and hypens with spaces, then let the browser capitalize this
|
||||
// in a locale-aware way to get a reasonable display value.
|
||||
// e.g. 'drag-and-drop-v2' -> "Drag And Drop V2"
|
||||
return <span style={{ textTransform: 'capitalize' }}>{type.replace(/[_-]/g, ' ')}</span>;
|
||||
};
|
||||
|
||||
export default BlockTypeLabel;
|
||||
25
src/search-modal/ClearFiltersButton.jsx
Normal file
25
src/search-modal/ClearFiltersButton.jsx
Normal file
@@ -0,0 +1,25 @@
|
||||
/* 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';
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
return (
|
||||
<Button variant="link" size="sm" onClick={refine}>
|
||||
<FormattedMessage {...messages.clearFilters} />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export default ClearFiltersButton;
|
||||
30
src/search-modal/EmptyStates.jsx
Normal file
30
src/search-modal/EmptyStates.jsx
Normal file
@@ -0,0 +1,30 @@
|
||||
/* eslint-disable react/prop-types */
|
||||
// @ts-check
|
||||
import React from 'react';
|
||||
import { useStats, useClearRefinements } from 'react-instantsearch';
|
||||
|
||||
/**
|
||||
* If the user hasn't put any keywords/filters yet, display an "empty state".
|
||||
* Likewise, if the results are empty (0 results), display a special message.
|
||||
* Otherwise, display the results, which are assumed to be the children prop.
|
||||
* @type {React.FC<{children: React.ReactElement}>}
|
||||
*/
|
||||
const EmptyStates = ({ children }) => {
|
||||
const { nbHits, query } = useStats();
|
||||
const { canRefine: hasFiltersApplied } = useClearRefinements();
|
||||
const hasQuery = !!query;
|
||||
|
||||
if (!hasQuery && !hasFiltersApplied) {
|
||||
// We haven't started the search yet. Display the "start your search" empty state
|
||||
// Note this isn't localized because it's going to be replaced in a fast-follow PR.
|
||||
return <p className="text-muted text-center mt-6">Enter a keyword or select a filter to begin searching.</p>;
|
||||
}
|
||||
if (nbHits === 0) {
|
||||
// Note this isn't localized because it's going to be replaced in a fast-follow PR.
|
||||
return <p className="text-muted text-center mt-6">No results found. Change your search and try again.</p>;
|
||||
}
|
||||
|
||||
return children;
|
||||
};
|
||||
|
||||
export default EmptyStates;
|
||||
90
src/search-modal/FilterByBlockType.jsx
Normal file
90
src/search-modal/FilterByBlockType.jsx
Normal file
@@ -0,0 +1,90 @@
|
||||
/* eslint-disable react/prop-types */
|
||||
// @ts-check
|
||||
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';
|
||||
|
||||
/**
|
||||
* A button with a dropdown that allows filtering the current search by component type (XBlock type)
|
||||
* e.g. Limit results to "Text" (html) and "Problem" (problem) components.
|
||||
* The button displays the first type selected, and a count of how many other types are selected, if more than one.
|
||||
* @type {React.FC<Record<never, never>>}
|
||||
*/
|
||||
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);
|
||||
|
||||
const handleCheckboxChange = React.useCallback((e) => {
|
||||
refine(e.target.value);
|
||||
}, [refine]);
|
||||
|
||||
return (
|
||||
<SearchFilterWidget
|
||||
appliedFilters={appliedItems.map(item => ({ label: <BlockTypeLabel type={String(item.value)} /> }))}
|
||||
label={<FormattedMessage {...messages.blockTypeFilter} />}
|
||||
>
|
||||
<Form.Group>
|
||||
<Form.CheckboxSet
|
||||
name="block-type-filter"
|
||||
defaultValue={appliedItems.map(item => item.value)}
|
||||
>
|
||||
<Menu style={{ boxShadow: 'none' }}>
|
||||
{
|
||||
items.map((item) => (
|
||||
<MenuItem
|
||||
key={item.value}
|
||||
as={Form.Checkbox}
|
||||
value={item.value}
|
||||
checked={item.isRefined}
|
||||
onChange={handleCheckboxChange}
|
||||
>
|
||||
<BlockTypeLabel type={item.value} />{' '}
|
||||
<Badge variant="light" pill>{item.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 ? (
|
||||
<MenuItem disabled><FormattedMessage {...messages['blockTypeFilter.empty']} /></MenuItem>
|
||||
) : null
|
||||
}
|
||||
</Menu>
|
||||
</Form.CheckboxSet>
|
||||
</Form.Group>
|
||||
{
|
||||
canToggleShowMore && !isShowingMore
|
||||
? <Button onClick={toggleShowMore}><FormattedMessage {...messages.showMore} /></Button>
|
||||
: null
|
||||
}
|
||||
</SearchFilterWidget>
|
||||
);
|
||||
};
|
||||
|
||||
export default FilterByBlockType;
|
||||
119
src/search-modal/FilterByTags.jsx
Normal file
119
src/search-modal/FilterByTags.jsx
Normal file
@@ -0,0 +1,119 @@
|
||||
/* eslint-disable react/prop-types */
|
||||
// @ts-check
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Button,
|
||||
Badge,
|
||||
Form,
|
||||
Menu,
|
||||
MenuItem,
|
||||
} from '@openedx/paragon';
|
||||
import { useHierarchicalMenu } from 'react-instantsearch';
|
||||
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 */
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* @type {React.FC<{
|
||||
* items: HierarchicalMenuItem[],
|
||||
* refine: (value: string) => void,
|
||||
* depth?: number,
|
||||
* }>}
|
||||
*/
|
||||
const FilterOptions = ({ items, refine, depth = 0 }) => {
|
||||
const handleCheckboxChange = React.useCallback((e) => {
|
||||
refine(e.target.value);
|
||||
}, [refine]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{
|
||||
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>
|
||||
))
|
||||
}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
/** @type {React.FC} */
|
||||
const FilterByTags = () => {
|
||||
const {
|
||||
items,
|
||||
refine,
|
||||
canToggleShowMore,
|
||||
isShowingMore,
|
||||
toggleShowMore,
|
||||
} = useHierarchicalMenu({
|
||||
attributes: [
|
||||
'tags.taxonomy',
|
||||
'tags.level0',
|
||||
'tags.level1',
|
||||
'tags.level2',
|
||||
'tags.level3',
|
||||
],
|
||||
});
|
||||
|
||||
// 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]);
|
||||
|
||||
return (
|
||||
<SearchFilterWidget
|
||||
appliedFilters={appliedItems}
|
||||
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
|
||||
}
|
||||
</Menu>
|
||||
</Form.Group>
|
||||
{
|
||||
canToggleShowMore && !isShowingMore
|
||||
? <Button onClick={toggleShowMore}><FormattedMessage {...messages.showMore} /></Button>
|
||||
: null
|
||||
}
|
||||
</SearchFilterWidget>
|
||||
);
|
||||
};
|
||||
|
||||
export default FilterByTags;
|
||||
41
src/search-modal/SearchEndpointLoader.jsx
Normal file
41
src/search-modal/SearchEndpointLoader.jsx
Normal file
@@ -0,0 +1,41 @@
|
||||
/* 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}>} */
|
||||
const SearchEndpointLoader = ({ courseId }) => {
|
||||
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} />;
|
||||
}
|
||||
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;
|
||||
60
src/search-modal/SearchFilterWidget.jsx
Normal file
60
src/search-modal/SearchFilterWidget.jsx
Normal file
@@ -0,0 +1,60 @@
|
||||
/* eslint-disable react/prop-types */
|
||||
// @ts-check
|
||||
import React from 'react';
|
||||
import { ArrowDropDown } from '@openedx/paragon/icons';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
ModalPopup,
|
||||
useToggle,
|
||||
} from '@openedx/paragon';
|
||||
|
||||
/**
|
||||
* A button that represents a filter on the search.
|
||||
* If the filter is active, the button displays the currently applied values.
|
||||
* So when no filter is active it may look like:
|
||||
* [ Type ▼ ]
|
||||
* Or when a filter is active and limited to two values, it may look like:
|
||||
* [ Type: HTML, +1 ▼ ]
|
||||
*
|
||||
* When clicked, the button will display a dropdown menu containing this
|
||||
* element's `children`. So use this to wrap a <RefinementList> etc.
|
||||
*
|
||||
* @type {React.FC<{appliedFilters: {label: React.ReactNode}[], label: React.ReactNode, children: React.ReactNode}>}
|
||||
*/
|
||||
const SearchFilterWidget = ({ appliedFilters, ...props }) => {
|
||||
const [isOpen, open, close] = useToggle(false);
|
||||
const [target, setTarget] = React.useState(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="d-flex mr-3">
|
||||
<Button
|
||||
ref={setTarget}
|
||||
variant={appliedFilters.length ? 'light' : 'outline-primary'}
|
||||
size="sm"
|
||||
onClick={open}
|
||||
iconAfter={ArrowDropDown}
|
||||
>
|
||||
{props.label}
|
||||
{appliedFilters.length >= 1 ? <>: {appliedFilters[0].label}</> : null}
|
||||
{appliedFilters.length > 1 ? <>, <Badge variant="secondary">+{appliedFilters.length - 1}</Badge></> : null}
|
||||
</Button>
|
||||
</div>
|
||||
<ModalPopup
|
||||
positionRef={target}
|
||||
isOpen={isOpen}
|
||||
onClose={close}
|
||||
>
|
||||
<div
|
||||
className="bg-white rounded shadow"
|
||||
style={{ textAlign: 'start' }}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
</ModalPopup>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchFilterWidget;
|
||||
29
src/search-modal/SearchKeywordsField.jsx
Normal file
29
src/search-modal/SearchKeywordsField.jsx
Normal file
@@ -0,0 +1,29 @@
|
||||
/* 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';
|
||||
|
||||
/**
|
||||
* 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}>}
|
||||
*/
|
||||
const SearchKeywordsField = (props) => {
|
||||
const intl = useIntl();
|
||||
const { query, refine } = useSearchBox(props);
|
||||
|
||||
return (
|
||||
<SearchField
|
||||
onSubmit={refine}
|
||||
onChange={refine}
|
||||
onClear={() => refine('')}
|
||||
value={query}
|
||||
className={props.className}
|
||||
placeholder={intl.formatMessage(messages.inputPlaceholder)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchKeywordsField;
|
||||
@@ -1,46 +1,16 @@
|
||||
/* 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 { ModalDialog } from '@openedx/paragon';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { LoadingSpinner } from '../generic/Loading';
|
||||
import SearchUI from './SearchUI';
|
||||
import { useContentSearch } from './data/apiHooks';
|
||||
import SearchEndpointLoader from './SearchEndpointLoader';
|
||||
import messages from './messages';
|
||||
|
||||
// Using TypeScript here is blocked until we have frontend-build 14:
|
||||
// interface Props {
|
||||
// courseId: string;
|
||||
// isOpen: boolean;
|
||||
// onClose: () => void;
|
||||
// }
|
||||
|
||||
/** @type {React.FC<{courseId: string, isOpen: boolean, onClose: () => void}>} */
|
||||
const SearchModal = ({ courseId, ...props }) => {
|
||||
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['courseSearch.title']);
|
||||
let body;
|
||||
if (searchEndpointData) {
|
||||
body = <SearchUI {...searchEndpointData} />;
|
||||
} else if (isLoading) {
|
||||
body = <LoadingSpinner />;
|
||||
} else {
|
||||
// @ts-ignore
|
||||
body = <ErrorAlert isError>{error?.message ?? String(error)}</ErrorAlert>;
|
||||
}
|
||||
const title = intl.formatMessage(messages.title);
|
||||
|
||||
return (
|
||||
<ModalDialog
|
||||
@@ -49,10 +19,12 @@ const SearchModal = ({ courseId, ...props }) => {
|
||||
isOpen={props.isOpen}
|
||||
onClose={props.onClose}
|
||||
hasCloseButton
|
||||
// We need isOverflowVisible={false} - see the .scss file in this folder
|
||||
isOverflowVisible={false}
|
||||
isFullscreenOnMobile
|
||||
className="courseware-search-modal"
|
||||
>
|
||||
<ModalDialog.Header><ModalDialog.Title>{title}</ModalDialog.Title></ModalDialog.Header>
|
||||
<ModalDialog.Body>{body}</ModalDialog.Body>
|
||||
<SearchEndpointLoader courseId={courseId} />
|
||||
</ModalDialog>
|
||||
);
|
||||
};
|
||||
|
||||
71
src/search-modal/SearchModal.scss
Normal file
71
src/search-modal/SearchModal.scss
Normal file
@@ -0,0 +1,71 @@
|
||||
// Simulate Tailwind-style arbitrary value classNames. We have to hard code this one though.
|
||||
.h-\[calc\(100vh-200px\)\] {
|
||||
height: calc(100vh - 200px);
|
||||
}
|
||||
|
||||
// Helper to set a minimum width for the This Course / All Courses toggle
|
||||
.pgn__menu-select.with-min-toggle-width {
|
||||
& > button {
|
||||
min-width: 155px;
|
||||
}
|
||||
}
|
||||
|
||||
.courseware-search-modal {
|
||||
// Fix so the 'This course' / 'All courses' dropdown is not cut off on the right hand side,
|
||||
// But still preserve correct scrolling behavior for the results list (vertical)
|
||||
// (If we set 'isOverflowVisible: true', the scrolling of the results list is messed up)
|
||||
overflow: visible;
|
||||
|
||||
.pgn__modal-header .pgn__menu-select {
|
||||
// The "All courses" / "This course" toggle button
|
||||
& > button {
|
||||
min-width: 155px; // Set a minumum width so it doesn't change size when you change the selection
|
||||
// The current Open edX theme makes the search field square but the button round and it looks bad. We need this
|
||||
// hacky override until the theme is fixed to be more consistent.
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 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-item {
|
||||
// Fix a bug in Paragon menu dropdowns: the checkbox currently shrinks if the text is too long.
|
||||
// https://github.com/openedx/paragon/pull/3019
|
||||
// This can be removed once we upgrade Paragon - https://github.com/openedx/frontend-app-course-authoring/pull/933
|
||||
input[type="checkbox"] {
|
||||
flex-grow: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
// Fix a bug in Paragon menu dropdowns: very long text is not truncated with an ellipsis
|
||||
// https://github.com/openedx/paragon/pull/3019
|
||||
// This can be removed once we upgrade Paragon - https://github.com/openedx/frontend-app-course-authoring/pull/933
|
||||
> div {
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.ais-InfiniteHits-loadPrevious,
|
||||
.ais-InfiniteHits-loadMore--disabled {
|
||||
display: none; // temporary; remove this once we implement our own <Hits>/<InfiniteHits> component.
|
||||
}
|
||||
}
|
||||
@@ -58,8 +58,8 @@ describe('<SearchModal />', () => {
|
||||
index: 'test-index',
|
||||
apiKey: 'test-api-key',
|
||||
});
|
||||
const { findByTestId } = render(<RootWrapper />);
|
||||
expect(await findByTestId('search-ui')).toBeInTheDocument();
|
||||
const { findByText } = render(<RootWrapper />);
|
||||
expect(await findByText('Enter a keyword or select a filter to begin searching.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render the spinner while the config is loading', () => {
|
||||
|
||||
@@ -2,42 +2,36 @@
|
||||
// @ts-check
|
||||
import React from 'react';
|
||||
import { Highlight } from 'react-instantsearch';
|
||||
import BlockTypeLabel from './BlockTypeLabel';
|
||||
|
||||
/* This component will be replaced by a new search UI component that will be developed in the future.
|
||||
* See:
|
||||
* - https://github.com/openedx/modular-learning/issues/200
|
||||
* - https://github.com/openedx/modular-learning/issues/201
|
||||
*/
|
||||
/* istanbul ignore next */
|
||||
/** @type {React.FC<{hit: import('instantsearch.js').Hit<{
|
||||
/**
|
||||
* A single search result (row), usually represents an XBlock/Component
|
||||
* @type {React.FC<{hit: import('instantsearch.js').Hit<{
|
||||
* id: string,
|
||||
* display_name: string,
|
||||
* block_type: string,
|
||||
* content: {
|
||||
* html_content: string,
|
||||
* capa_content: string
|
||||
* },
|
||||
* 'content.html_content'?: string,
|
||||
* 'content.capa_content'?: string,
|
||||
* breadcrumbs: {display_name: string}[]}>,
|
||||
* }>} */
|
||||
* }>}
|
||||
*/
|
||||
const SearchResult = ({ hit }) => (
|
||||
<>
|
||||
<div className="hit-name">
|
||||
<strong><Highlight attribute="display_name" hit={hit} /></strong>
|
||||
<div className="my-2 pb-2 border-bottom">
|
||||
<div className="hit-name small">
|
||||
<strong><Highlight attribute="display_name" hit={hit} /></strong>{' '}
|
||||
(<BlockTypeLabel type={hit.block_type} />)
|
||||
</div>
|
||||
<p className="hit-block_type"><em><Highlight attribute="block_type" hit={hit} /></em></p>
|
||||
<div className="hit-description">
|
||||
{ /* @ts-ignore Wrong type definition upstream */ }
|
||||
<div className="hit-description x-small text-truncate">
|
||||
<Highlight attribute="content.html_content" hit={hit} />
|
||||
{ /* @ts-ignore Wrong type definition upstream */ }
|
||||
<Highlight attribute="content.capa_content" hit={hit} />
|
||||
</div>
|
||||
<div style={{ fontSize: '8px' }}>
|
||||
<div className="text-muted x-small">
|
||||
{hit.breadcrumbs.map((bc, i) => (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<span key={i}>{bc.display_name} {i !== hit.breadcrumbs.length - 1 ? '>' : ''} </span>
|
||||
<span key={i}>{bc.display_name} {i !== hit.breadcrumbs.length - 1 ? '/' : ''} </span>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default SearchResult;
|
||||
|
||||
@@ -2,51 +2,85 @@
|
||||
// @ts-check
|
||||
import React from 'react';
|
||||
import {
|
||||
HierarchicalMenu,
|
||||
InfiniteHits,
|
||||
InstantSearch,
|
||||
RefinementList,
|
||||
SearchBox,
|
||||
Stats,
|
||||
} from 'react-instantsearch';
|
||||
MenuItem,
|
||||
ModalDialog,
|
||||
SelectMenu,
|
||||
} from '@openedx/paragon';
|
||||
import { Check } from '@openedx/paragon/icons';
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import { Configure, InfiniteHits, InstantSearch } from 'react-instantsearch';
|
||||
import { instantMeiliSearch } from '@meilisearch/instant-meilisearch';
|
||||
import 'instantsearch.css/themes/algolia-min.css';
|
||||
|
||||
import ClearFiltersButton from './ClearFiltersButton';
|
||||
import EmptyStates from './EmptyStates';
|
||||
import SearchResult from './SearchResult';
|
||||
import SearchKeywordsField from './SearchKeywordsField';
|
||||
import FilterByBlockType from './FilterByBlockType';
|
||||
import FilterByTags from './FilterByTags';
|
||||
import Stats from './Stats';
|
||||
import messages from './messages';
|
||||
|
||||
/* This component will be replaced by a new search UI component that will be developed in the future.
|
||||
* See:
|
||||
* - https://github.com/openedx/modular-learning/issues/200
|
||||
* - https://github.com/openedx/modular-learning/issues/201
|
||||
*/
|
||||
/* istanbul ignore next */
|
||||
/** @type {React.FC<{url: string, apiKey: string, indexName: string}>} */
|
||||
/** @type {React.FC<{courseId: string, url: string, apiKey: string, indexName: string}>} */
|
||||
const SearchUI = (props) => {
|
||||
const { searchClient } = React.useMemo(
|
||||
() => instantMeiliSearch(props.url, props.apiKey, { primaryKey: 'id' }),
|
||||
[props.url, props.apiKey],
|
||||
);
|
||||
|
||||
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;
|
||||
|
||||
return (
|
||||
<div data-testid="search-ui" className="ais-InstantSearch">
|
||||
<InstantSearch indexName={props.indexName} searchClient={searchClient}>
|
||||
<Stats />
|
||||
<SearchBox />
|
||||
<strong>Refine by component type:</strong>
|
||||
<RefinementList attribute="block_type" />
|
||||
<strong>Refine by tag:</strong>
|
||||
<HierarchicalMenu
|
||||
attributes={[
|
||||
'tags.taxonomy',
|
||||
'tags.level0',
|
||||
'tags.level1',
|
||||
'tags.level2',
|
||||
'tags.level3',
|
||||
]}
|
||||
/>
|
||||
<InfiniteHits hitComponent={SearchResult} />
|
||||
</InstantSearch>
|
||||
</div>
|
||||
<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 }}
|
||||
>
|
||||
{/* Add in a filter for the current course, if relevant */}
|
||||
<Configure filters={searchThisCourse ? `context_key = "${props.courseId}"` : undefined} />
|
||||
{/* 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">
|
||||
<ModalDialog.Title><FormattedMessage {...messages.title} /></ModalDialog.Title>
|
||||
<div className="d-flex mt-3">
|
||||
<SearchKeywordsField className="flex-grow-1 mr-1" />
|
||||
<SelectMenu variant="primary">
|
||||
<MenuItem
|
||||
onClick={switchToThisCourse}
|
||||
defaultSelected={searchThisCourse}
|
||||
iconAfter={searchThisCourse ? Check : undefined}
|
||||
disabled={!props.courseId}
|
||||
>
|
||||
<FormattedMessage {...messages.searchThisCourse} />
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={switchToAllCourses}
|
||||
defaultSelected={!searchThisCourse}
|
||||
iconAfter={searchThisCourse ? undefined : Check}
|
||||
>
|
||||
<FormattedMessage {...messages.searchAllCourses} />
|
||||
</MenuItem>
|
||||
</SelectMenu>
|
||||
</div>
|
||||
<div className="d-flex mt-3 align-items-center">
|
||||
<FilterByBlockType />
|
||||
<FilterByTags />
|
||||
<ClearFiltersButton />
|
||||
<div className="flex-grow-1" />
|
||||
<div className="text-muted x-small align-middle"><Stats /></div>
|
||||
</div>
|
||||
</ModalDialog.Header>
|
||||
<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={SearchResult} />
|
||||
</EmptyStates>
|
||||
</ModalDialog.Body>
|
||||
</InstantSearch>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
196
src/search-modal/SearchUI.test.jsx
Normal file
196
src/search-modal/SearchUI.test.jsx
Normal file
@@ -0,0 +1,196 @@
|
||||
/* eslint-disable react/prop-types */
|
||||
// @ts-check
|
||||
import React from 'react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
waitFor,
|
||||
getByLabelText as getByLabelTextIn,
|
||||
} from '@testing-library/react';
|
||||
import fetchMock from 'fetch-mock-jest';
|
||||
|
||||
// @ts-ignore
|
||||
import mockResult from './__mocks__/search-result.json';
|
||||
import SearchUI from './SearchUI';
|
||||
|
||||
// mockResult contains only a single result - this one:
|
||||
const mockResultDisplayName = 'Test HTML Block';
|
||||
|
||||
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';
|
||||
|
||||
/** @type {React.FC<{children:React.ReactNode}>} */
|
||||
const Wrap = ({ children }) => (
|
||||
<AppProvider>
|
||||
<IntlProvider locale="en" messages={{}}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
</IntlProvider>
|
||||
</AppProvider>
|
||||
);
|
||||
|
||||
describe('<SearchUI />', () => {
|
||||
beforeEach(async () => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
fetchMock.post(searchEndpoint, (_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.
|
||||
mockResult.results[0].query = query;
|
||||
// And create the required '_formatted' field; not sure why it's there - seems very redundant. But it's required.
|
||||
// eslint-disable-next-line no-underscore-dangle, no-param-reassign
|
||||
mockResult.results[0]?.hits.forEach((hit) => { hit._formatted = { ...hit }; });
|
||||
return mockResult;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
fetchMock.mockReset();
|
||||
});
|
||||
|
||||
it('should render an empty state', async () => {
|
||||
const { getByText } = render(<Wrap><SearchUI {...defaults} /></Wrap>);
|
||||
// Before the results have even loaded, we see this message:
|
||||
expect(getByText('Enter a keyword or select a filter to begin searching.')).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'); });
|
||||
// And that message is still displayed even after the initial results/filters have loaded:
|
||||
expect(getByText('Enter a keyword or select a filter to begin searching.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('defaults to searching "All Courses" if used outside of any particular course', async () => {
|
||||
const { getByText, queryByText, getByRole } = render(<Wrap><SearchUI {...defaults} courseId="" /></Wrap>);
|
||||
// We default to searching all courses:
|
||||
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'); });
|
||||
// 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'); });
|
||||
// Now we should see the results:
|
||||
expect(queryByText('Enter a keyword')).toBeNull();
|
||||
// The result:
|
||||
expect(getByText('1 result found')).toBeInTheDocument();
|
||||
expect(getByText(mockResultDisplayName)).toBeInTheDocument();
|
||||
// Breadcrumbs showing where the result came from:
|
||||
expect(getByText('The Little Unit That Could')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('defaults to searching "This Course" if used in a course', async () => {
|
||||
const { getByText, queryByText, getByRole } = render(<Wrap><SearchUI {...defaults} /></Wrap>);
|
||||
// We default to searching all courses:
|
||||
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'); });
|
||||
// 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'); });
|
||||
// And make sure the request was limited to this course:
|
||||
expect(fetchMock).toHaveLastFetched((_url, req) => {
|
||||
const requestData = JSON.parse(req.body?.toString() ?? '');
|
||||
const requestedFilter = requestData?.queries[0].filter;
|
||||
return requestedFilter?.[0] === 'context_key = "course-v1:org+test+123"';
|
||||
});
|
||||
// Now we should see the results:
|
||||
expect(queryByText('Enter a keyword')).toBeNull();
|
||||
// The result:
|
||||
expect(getByText('1 result found')).toBeInTheDocument();
|
||||
expect(getByText(mockResultDisplayName)).toBeInTheDocument();
|
||||
// Breadcrumbs showing where the result came from:
|
||||
expect(getByText('The Little Unit That Could')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('filters', () => {
|
||||
/** @type {import('@testing-library/react').RenderResult} */
|
||||
let rendered;
|
||||
beforeEach(async () => {
|
||||
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'); });
|
||||
// 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'); });
|
||||
// And make sure the request was limited to this course:
|
||||
expect(fetchMock).toHaveLastFetched((_url, req) => {
|
||||
const requestData = JSON.parse(req.body?.toString() ?? '');
|
||||
const requestedFilter = requestData?.queries[0].filter;
|
||||
return (requestedFilter?.length === 1); // the filter is: 'context_key = "course-v1:org+test+123"'
|
||||
});
|
||||
// Now we should see the results:
|
||||
expect(getByText('1 result found')).toBeInTheDocument();
|
||||
expect(getByText(mockResultDisplayName)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('can filter results by component/XBlock type', async () => {
|
||||
const { getByRole } = rendered;
|
||||
// Now open the filters menu:
|
||||
fireEvent.click(getByRole('button', { name: 'Type' }), {});
|
||||
// The dropdown menu has role="group"
|
||||
await waitFor(() => { expect(getByRole('group')).toBeInTheDocument(); });
|
||||
const popupMenu = getByRole('group');
|
||||
const problemFilterCheckbox = getByLabelTextIn(popupMenu, /Problem/i);
|
||||
fireEvent.click(problemFilterCheckbox, {});
|
||||
// Now wait for the filter to be applied and the new results to be fetched.
|
||||
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(4, 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) => {
|
||||
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"',
|
||||
['"block_type"="problem"'], // <-- the newly added filter, sent with the request
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('can filter results by tag', async () => {
|
||||
const { getByRole, getByLabelText } = 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 competentciesCheckbox = getByLabelText(/ESDC Skills and Competencies/i);
|
||||
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'); });
|
||||
// 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) => {
|
||||
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.taxonomy"="ESDC Skills and Competencies"'], // <-- the newly added filter, sent with the request
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
27
src/search-modal/Stats.jsx
Normal file
27
src/search-modal/Stats.jsx
Normal file
@@ -0,0 +1,27 @@
|
||||
/* 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';
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
if (!hasQuery && !hasFiltersApplied) {
|
||||
// We haven't started the search yet.
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<FormattedMessage {...messages.numResults} values={{ numResults: nbHits }} />
|
||||
);
|
||||
};
|
||||
|
||||
export default Stats;
|
||||
79
src/search-modal/__mocks__/search-result.json
Normal file
79
src/search-modal/__mocks__/search-result.json
Normal file
@@ -0,0 +1,79 @@
|
||||
{
|
||||
"comment": "This is a mock of the response from Meilisearch, based on an actual search in Studio.",
|
||||
"results": [
|
||||
{
|
||||
"indexUid": "studio",
|
||||
"hits": [
|
||||
{
|
||||
"type": "course_block",
|
||||
"display_name": "Test HTML Block",
|
||||
"block_id": "test_html",
|
||||
"content": {
|
||||
"html_content": "This is the content of the test HTML block. You can do a keyword search and it will find matches within this text."
|
||||
},
|
||||
"id": "block-v1edxTestCourse24typehtmlblocktest_html-e47ff4c0",
|
||||
"usage_key": "block-v1:edx+TestCourse+24+type@html+block@test_html",
|
||||
"block_type": "html",
|
||||
"context_key": "course-v1:edx+TestCourse+24",
|
||||
"org": "edx",
|
||||
"breadcrumbs": [
|
||||
{ "display_name": "TheCourse" },
|
||||
{ "display_name": "Section 2" },
|
||||
{ "display_name": "Subsection 3" },
|
||||
{ "display_name": "The Little Unit That Could" }
|
||||
],
|
||||
"tags": {
|
||||
"taxonomy": [
|
||||
"ESDC Skills and Competencies",
|
||||
"FlatTaxonomy",
|
||||
"TwoLevelTaxonomy"
|
||||
],
|
||||
"level0": [
|
||||
"ESDC Skills and Competencies > Personal Attributes",
|
||||
"ESDC Skills and Competencies > Work Context",
|
||||
"FlatTaxonomy > flat taxonomy tag 589",
|
||||
"TwoLevelTaxonomy > two level tag 1"
|
||||
],
|
||||
"level1": [
|
||||
"ESDC Skills and Competencies > Personal Attributes > Self-Improvement",
|
||||
"ESDC Skills and Competencies > Work Context > Physical Work Environment",
|
||||
"TwoLevelTaxonomy > two level tag 1 > two level tag 1.1"
|
||||
],
|
||||
"level2": [
|
||||
"ESDC Skills and Competencies > Personal Attributes > Self-Improvement > Adjustment",
|
||||
"ESDC Skills and Competencies > Personal Attributes > Self-Improvement > Learning Orientation",
|
||||
"ESDC Skills and Competencies > Work Context > Physical Work Environment > Environmental Conditions"
|
||||
],
|
||||
"level3": [
|
||||
"ESDC Skills and Competencies > Personal Attributes > Self-Improvement > Adjustment > Adaptability",
|
||||
"ESDC Skills and Competencies > Personal Attributes > Self-Improvement > Learning Orientation > Active Learning",
|
||||
"ESDC Skills and Competencies > Work Context > Physical Work Environment > Environmental Conditions > Biological Agents"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"query": "learn",
|
||||
"processingTimeMs": 1,
|
||||
"limit": 2,
|
||||
"offset": 0,
|
||||
"estimatedTotalHits": 1,
|
||||
"facetDistribution": {
|
||||
"block_type": {
|
||||
"html": 1,
|
||||
"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": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -14,7 +14,10 @@ export const useContentSearch = () => (
|
||||
useQuery({
|
||||
queryKey: ['content_search'],
|
||||
queryFn: getContentSearchConfig,
|
||||
staleTime: 60 * 60, // If cache is up to one hour old, no need to re-fetch
|
||||
cacheTime: 60 * 60_000, // Even if we're not actively using the search modal, keep it in memory up to an hour
|
||||
staleTime: 60 * 60_000, // If cache is up to one hour old, no need to re-fetch
|
||||
refetchInterval: 60 * 60_000,
|
||||
refetchOnWindowFocus: false, // This doesn't need to be refreshed when the user switches back to this tab.
|
||||
refetchOnMount: false,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -2,14 +2,119 @@
|
||||
import { defineMessages as _defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
// frontend-platform currently doesn't provide types... do it ourselves.
|
||||
const defineMessages = /** @type {<T>(x: T) => x} */(_defineMessages);
|
||||
const defineMessages = /** @type {import('react-intl').defineMessages} */(_defineMessages);
|
||||
|
||||
const messages = defineMessages({
|
||||
'courseSearch.title': {
|
||||
id: 'courseSearch.title',
|
||||
blockTypeFilter: {
|
||||
id: 'course-authoring.course-search.blockTypeFilter',
|
||||
defaultMessage: 'Type',
|
||||
description: 'Label for the filter that allows limiting results to a specific component type',
|
||||
},
|
||||
'blockTypeFilter.empty': {
|
||||
id: 'course-authoring.course-search.blockTypeFilter.empty',
|
||||
defaultMessage: 'No matching components',
|
||||
description: 'Label shown when there are no options available to filter by component type',
|
||||
},
|
||||
blockTagsFilter: {
|
||||
id: 'course-authoring.course-search.blockTagsFilter',
|
||||
defaultMessage: 'Tags',
|
||||
description: 'Label for the filter that allows finding components with specific tags',
|
||||
},
|
||||
'blockTagsFilter.empty': {
|
||||
id: 'course-authoring.course-search.blockTagsFilter.empty',
|
||||
defaultMessage: 'No tags in current results',
|
||||
description: 'Label shown when there are no options available to filter by tags',
|
||||
},
|
||||
'blockType.annotatable': {
|
||||
id: 'course-authoring.course-search.blockType.annotatable',
|
||||
defaultMessage: 'Annotation',
|
||||
description: 'Name of the "Annotation" component type in Studio',
|
||||
},
|
||||
'blockType.chapter': {
|
||||
id: 'course-authoring.course-search.blockType.chapter',
|
||||
defaultMessage: 'Section',
|
||||
description: 'Name of the "Section" course outline level in Studio',
|
||||
},
|
||||
'blockType.discussion': {
|
||||
id: 'course-authoring.course-search.blockType.discussion',
|
||||
defaultMessage: 'Discussion',
|
||||
description: 'Name of the "Discussion" component type in Studio',
|
||||
},
|
||||
'blockType.drag-and-drop-v2': {
|
||||
id: 'course-authoring.course-search.blockType.drag-and-drop-v2',
|
||||
defaultMessage: 'Drag and Drop',
|
||||
description: 'Name of the "Drag and Drop" component type in Studio',
|
||||
},
|
||||
'blockType.html': {
|
||||
id: 'course-authoring.course-search.blockType.html',
|
||||
defaultMessage: 'Text',
|
||||
description: 'Name of the "Text" component type in Studio',
|
||||
},
|
||||
'blockType.library_content': {
|
||||
id: 'course-authoring.course-search.blockType.library_content',
|
||||
defaultMessage: 'Library Content',
|
||||
description: 'Name of the "Library Content" component type in Studio',
|
||||
},
|
||||
'blockType.openassessment': {
|
||||
id: 'course-authoring.course-search.blockType.openassessment',
|
||||
defaultMessage: 'Open Response Assessment',
|
||||
description: 'Name of the "Open Response Assessment" component type in Studio',
|
||||
},
|
||||
'blockType.problem': {
|
||||
id: 'course-authoring.course-search.blockType.problem',
|
||||
defaultMessage: 'Problem',
|
||||
description: 'Name of the "Problem" component type in Studio',
|
||||
},
|
||||
'blockType.sequential': {
|
||||
id: 'course-authoring.course-search.blockType.sequential',
|
||||
defaultMessage: 'Subsection',
|
||||
description: 'Name of the "Subsection" course outline level in Studio',
|
||||
},
|
||||
'blockType.vertical': {
|
||||
id: 'course-authoring.course-search.blockType.vertical',
|
||||
defaultMessage: 'Unit',
|
||||
description: 'Name of the "Unit" course outline level in Studio',
|
||||
},
|
||||
'blockType.video': {
|
||||
id: 'course-authoring.course-search.blockType.video',
|
||||
defaultMessage: 'Video',
|
||||
description: 'Name of the "Video" component type in Studio',
|
||||
},
|
||||
clearFilters: {
|
||||
id: 'course-authoring.course-search.clearFilters',
|
||||
defaultMessage: 'Clear Filters',
|
||||
description: 'Label for the button that removes all applied search filters',
|
||||
},
|
||||
numResults: {
|
||||
id: 'course-authoring.course-search.num-results',
|
||||
defaultMessage: '{numResults, plural, one {# result} other {# results}} found',
|
||||
description: 'This count displays how many matching results were found from the user\'s search',
|
||||
},
|
||||
searchAllCourses: {
|
||||
id: 'course-authoring.course-search.searchAllCourses',
|
||||
defaultMessage: 'All courses',
|
||||
description: 'Option to get search results from all courses.',
|
||||
},
|
||||
searchThisCourse: {
|
||||
id: 'course-authoring.course-search.searchThisCourse',
|
||||
defaultMessage: 'This course',
|
||||
description: 'Option to limit search results to the current course only.',
|
||||
},
|
||||
title: {
|
||||
id: 'course-authoring.course-search.title',
|
||||
defaultMessage: 'Search',
|
||||
description: 'Title for the course search dialog',
|
||||
},
|
||||
inputPlaceholder: {
|
||||
id: 'course-authoring.course-search.inputPlaceholder',
|
||||
defaultMessage: 'Search',
|
||||
description: 'Placeholder text shown in the keyword input field when the user has not yet entered a keyword',
|
||||
},
|
||||
showMore: {
|
||||
id: 'course-authoring.course-search.showMore',
|
||||
defaultMessage: 'Show more',
|
||||
description: 'Show more tags / filter options',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -180,7 +180,7 @@ const ManageOrgsModal = ({
|
||||
key={org}
|
||||
iconAfter={Close}
|
||||
onIconAfterClick={() => setSelectedOrgs(selectedOrgs.filter((o) => o !== org))}
|
||||
disabled={allOrgs}
|
||||
disabled={!!allOrgs}
|
||||
>
|
||||
{org}
|
||||
</Chip>
|
||||
|
||||
Reference in New Issue
Block a user