diff --git a/package-lock.json b/package-lock.json index 595b4a9cfe..ded7637afb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 442a2347a1..fc1b9e35c4 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/content-tags-drawer/ContentTagsDropDownSelector.jsx b/src/content-tags-drawer/ContentTagsDropDownSelector.jsx index 39620bfc2d..4960e30912 100644 --- a/src/content-tags-drawer/ContentTagsDropDownSelector.jsx +++ b/src/content-tags-drawer/ContentTagsDropDownSelector.jsx @@ -283,7 +283,7 @@ const ContentTagsDropDownSelector = ({ clickAndEnterHandler(tagData.value)} - tabIndex="-1" + tabIndex={-1} /> )} diff --git a/src/header/Header.jsx b/src/header/Header.jsx index 470f088057..7cc1adcb08 100644 --- a/src/header/Header.jsx +++ b/src/header/Header.jsx @@ -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`, diff --git a/src/index.scss b/src/index.scss old mode 100755 new mode 100644 index 4565fba554..27e23358ca --- a/src/index.scss +++ b/src/index.scss @@ -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"; diff --git a/src/search-modal/BlockTypeLabel.jsx b/src/search-modal/BlockTypeLabel.jsx new file mode 100644 index 0000000000..8a6048df47 --- /dev/null +++ b/src/search-modal/BlockTypeLabel.jsx @@ -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 ; + } + // 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 {type.replace(/[_-]/g, ' ')}; +}; + +export default BlockTypeLabel; diff --git a/src/search-modal/ClearFiltersButton.jsx b/src/search-modal/ClearFiltersButton.jsx new file mode 100644 index 0000000000..2b33c981d2 --- /dev/null +++ b/src/search-modal/ClearFiltersButton.jsx @@ -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>} + */ +const ClearFiltersButton = () => { + const { refine, canRefine } = useClearRefinements(); + if (canRefine) { + return ( + + ); + } + return null; +}; + +export default ClearFiltersButton; diff --git a/src/search-modal/EmptyStates.jsx b/src/search-modal/EmptyStates.jsx new file mode 100644 index 0000000000..a90a294df2 --- /dev/null +++ b/src/search-modal/EmptyStates.jsx @@ -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

Enter a keyword or select a filter to begin searching.

; + } + if (nbHits === 0) { + // Note this isn't localized because it's going to be replaced in a fast-follow PR. + return

No results found. Change your search and try again.

; + } + + return children; +}; + +export default EmptyStates; diff --git a/src/search-modal/FilterByBlockType.jsx b/src/search-modal/FilterByBlockType.jsx new file mode 100644 index 0000000000..8c7f590259 --- /dev/null +++ b/src/search-modal/FilterByBlockType.jsx @@ -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>} + */ +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 ( + ({ label: }))} + label={} + > + + item.value)} + > + + { + items.map((item) => ( + + {' '} + {item.count} + + )) + } + { + // Show a message if there are no options at all to avoid the impression that the dropdown isn't working + items.length === 0 ? ( + + ) : null + } + + + + { + canToggleShowMore && !isShowingMore + ? + : null + } + + ); +}; + +export default FilterByBlockType; diff --git a/src/search-modal/FilterByTags.jsx b/src/search-modal/FilterByTags.jsx new file mode 100644 index 0000000000..68bd92ee70 --- /dev/null +++ b/src/search-modal/FilterByTags.jsx @@ -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 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) => ( + + + {item.label}{' '} + {item.count} + + {item.data && } + + )) + } + + ); +}; + +/** @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 ( + } + > + + + + { + // Show a message if there are no options at all to avoid the impression that the dropdown isn't working + items.length === 0 ? ( + + ) : null + } + + + { + canToggleShowMore && !isShowingMore + ? + : null + } + + ); +}; + +export default FilterByTags; diff --git a/src/search-modal/SearchEndpointLoader.jsx b/src/search-modal/SearchEndpointLoader.jsx new file mode 100644 index 0000000000..664a3b5e03 --- /dev/null +++ b/src/search-modal/SearchEndpointLoader.jsx @@ -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 ; + } + return ( + <> + {title} + + {/* @ts-ignore */} + {isLoading ? : {error?.message ?? String(error)}} + + + ); +}; + +export default SearchEndpointLoader; diff --git a/src/search-modal/SearchFilterWidget.jsx b/src/search-modal/SearchFilterWidget.jsx new file mode 100644 index 0000000000..27430ca060 --- /dev/null +++ b/src/search-modal/SearchFilterWidget.jsx @@ -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 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 ( + <> +
+ +
+ +
+ {props.children} +
+
+ + ); +}; + +export default SearchFilterWidget; diff --git a/src/search-modal/SearchKeywordsField.jsx b/src/search-modal/SearchKeywordsField.jsx new file mode 100644 index 0000000000..15614d8c8b --- /dev/null +++ b/src/search-modal/SearchKeywordsField.jsx @@ -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} + */ +const SearchKeywordsField = (props) => { + const intl = useIntl(); + const { query, refine } = useSearchBox(props); + + return ( + refine('')} + value={query} + className={props.className} + placeholder={intl.formatMessage(messages.inputPlaceholder)} + /> + ); +}; + +export default SearchKeywordsField; diff --git a/src/search-modal/SearchModal.jsx b/src/search-modal/SearchModal.jsx index 1b1bf6e1e5..93fce12720 100644 --- a/src/search-modal/SearchModal.jsx +++ b/src/search-modal/SearchModal.jsx @@ -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 = ; - } else if (isLoading) { - body = ; - } else { - // @ts-ignore - body = {error?.message ?? String(error)}; - } + const title = intl.formatMessage(messages.title); return ( { 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" > - {title} - {body} + ); }; diff --git a/src/search-modal/SearchModal.scss b/src/search-modal/SearchModal.scss new file mode 100644 index 0000000000..c67c6ba2a9 --- /dev/null +++ b/src/search-modal/SearchModal.scss @@ -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 / component. + } +} diff --git a/src/search-modal/SearchModal.test.jsx b/src/search-modal/SearchModal.test.jsx index baf366b854..5c1b5a6881 100644 --- a/src/search-modal/SearchModal.test.jsx +++ b/src/search-modal/SearchModal.test.jsx @@ -58,8 +58,8 @@ describe('', () => { index: 'test-index', apiKey: 'test-api-key', }); - const { findByTestId } = render(); - expect(await findByTestId('search-ui')).toBeInTheDocument(); + const { findByText } = render(); + expect(await findByText('Enter a keyword or select a filter to begin searching.')).toBeInTheDocument(); }); it('should render the spinner while the config is loading', () => { diff --git a/src/search-modal/SearchResult.jsx b/src/search-modal/SearchResult.jsx index 8f3369c702..cb28172eac 100644 --- a/src/search-modal/SearchResult.jsx +++ b/src/search-modal/SearchResult.jsx @@ -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 }) => ( - <> -
- +
+
+ {' '} + ()
-

-
- { /* @ts-ignore Wrong type definition upstream */ } +
- { /* @ts-ignore Wrong type definition upstream */ }
-
+
{hit.breadcrumbs.map((bc, i) => ( // eslint-disable-next-line react/no-array-index-key - {bc.display_name} {i !== hit.breadcrumbs.length - 1 ? '>' : ''} + {bc.display_name} {i !== hit.breadcrumbs.length - 1 ? '/' : ''} ))}
- +
); export default SearchResult; diff --git a/src/search-modal/SearchUI.jsx b/src/search-modal/SearchUI.jsx index 8e2f46ecbe..8becfbe64d 100644 --- a/src/search-modal/SearchUI.jsx +++ b/src/search-modal/SearchUI.jsx @@ -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 ( -
- - - - Refine by component type: - - Refine by tag: - - - -
+ + {/* Add in a filter for the current course, if relevant */} + + {/* We need to override z-index here or the appears behind the + * But it can't be more then 9 because the close button has z-index 10. */} + + +
+ + + + + + + + + +
+
+ + + +
+
+
+ + + {/* If there are no results (yet), EmptyStates displays a friendly messages. Otherwise we see the results. */} + + + + + ); }; diff --git a/src/search-modal/SearchUI.test.jsx b/src/search-modal/SearchUI.test.jsx new file mode 100644 index 0000000000..a790d57e5c --- /dev/null +++ b/src/search-modal/SearchUI.test.jsx @@ -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 +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 }) => ( + + + + {children} + + + +); + +describe('', () => { + 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(); + // 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(); + // 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(); + // 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(); + 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 + ]); + }); + }); + }); +}); diff --git a/src/search-modal/Stats.jsx b/src/search-modal/Stats.jsx new file mode 100644 index 0000000000..fabfe76a4f --- /dev/null +++ b/src/search-modal/Stats.jsx @@ -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>} + */ +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 ( + + ); +}; + +export default Stats; diff --git a/src/search-modal/__mocks__/search-result.json b/src/search-modal/__mocks__/search-result.json new file mode 100644 index 0000000000..ad57f3ca78 --- /dev/null +++ b/src/search-modal/__mocks__/search-result.json @@ -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": {} + } + ] +} diff --git a/src/search-modal/data/apiHooks.js b/src/search-modal/data/apiHooks.js index 4169cb3af3..36e5c2e12f 100644 --- a/src/search-modal/data/apiHooks.js +++ b/src/search-modal/data/apiHooks.js @@ -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, }) ); diff --git a/src/search-modal/messages.js b/src/search-modal/messages.js index b0dc782732..4f85c472e7 100644 --- a/src/search-modal/messages.js +++ b/src/search-modal/messages.js @@ -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 {(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; diff --git a/src/taxonomy/manage-orgs/ManageOrgsModal.jsx b/src/taxonomy/manage-orgs/ManageOrgsModal.jsx index 6c2ace49e2..1d8db279e5 100644 --- a/src/taxonomy/manage-orgs/ManageOrgsModal.jsx +++ b/src/taxonomy/manage-orgs/ManageOrgsModal.jsx @@ -180,7 +180,7 @@ const ManageOrgsModal = ({ key={org} iconAfter={Close} onIconAfterClick={() => setSelectedOrgs(selectedOrgs.filter((o) => o !== org))} - disabled={allOrgs} + disabled={!!allOrgs} > {org}