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:
Braden MacDonald
2024-04-10 21:31:06 -07:00
committed by GitHub
parent aaf4989610
commit fc3e38f63b
24 changed files with 1129 additions and 109 deletions

134
package-lock.json generated
View File

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

View File

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

View File

@@ -283,7 +283,7 @@ const ContentTagsDropDownSelector = ({
<Icon
src={isOpen(tagData.value) ? ArrowDropUp : ArrowDropDown}
onClick={() => clickAndEnterHandler(tagData.value)}
tabIndex="-1"
tabIndex={-1}
/>
</div>
)}

View File

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

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

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

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

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

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

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

View 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 ? <>,&nbsp;<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;

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

View File

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

View 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.
}
}

View File

@@ -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', () => {

View File

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

View File

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

View 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
]);
});
});
});
});

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

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

View File

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

View File

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

View File

@@ -180,7 +180,7 @@ const ManageOrgsModal = ({
key={org}
iconAfter={Close}
onIconAfterClick={() => setSelectedOrgs(selectedOrgs.filter((o) => o !== org))}
disabled={allOrgs}
disabled={!!allOrgs}
>
{org}
</Chip>