feat: Collections page (in libraries) (#1281)
This commit is contained in:
@@ -8,6 +8,7 @@ jest.mock('react-router', () => ({
|
||||
blockId: 'company-id1',
|
||||
blockType: 'html',
|
||||
}),
|
||||
useLocation: () => {},
|
||||
}));
|
||||
|
||||
const props = { learningContextId: 'cOuRsEId' };
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useLocation, useParams } from 'react-router-dom';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
import EditorPage from './EditorPage';
|
||||
@@ -8,7 +8,7 @@ interface Props {
|
||||
/** Course ID or Library ID */
|
||||
learningContextId: string;
|
||||
/** Event handler sometimes called when user cancels out of the editor page */
|
||||
onClose?: () => void;
|
||||
onClose?: (prevPath?: string) => void;
|
||||
/**
|
||||
* Event handler called after when user saves their changes using an editor
|
||||
* and sometimes called when user cancels the editor, instead of onClose.
|
||||
@@ -17,7 +17,7 @@ interface Props {
|
||||
* TODO: clean this up so there are separate onCancel and onSave callbacks,
|
||||
* and they are used consistently instead of this mess.
|
||||
*/
|
||||
returnFunction?: () => (newData: Record<string, any> | undefined) => void;
|
||||
returnFunction?: (prevPath?: string) => (newData: Record<string, any> | undefined) => void;
|
||||
}
|
||||
|
||||
const EditorContainer: React.FC<Props> = ({
|
||||
@@ -26,6 +26,8 @@ const EditorContainer: React.FC<Props> = ({
|
||||
returnFunction,
|
||||
}) => {
|
||||
const { blockType, blockId } = useParams();
|
||||
const location = useLocation();
|
||||
|
||||
if (blockType === undefined || blockId === undefined) {
|
||||
// istanbul ignore next - This shouldn't be possible; it's just here to satisfy the type checker.
|
||||
return <div>Error: missing URL parameters</div>;
|
||||
@@ -38,8 +40,8 @@ const EditorContainer: React.FC<Props> = ({
|
||||
blockId={blockId}
|
||||
studioEndpointUrl={getConfig().STUDIO_BASE_URL}
|
||||
lmsEndpointUrl={getConfig().LMS_BASE_URL}
|
||||
onClose={onClose}
|
||||
returnFunction={returnFunction}
|
||||
onClose={onClose ? () => onClose(location.state?.from) : null}
|
||||
returnFunction={returnFunction ? () => returnFunction(location.state?.from) : null}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,6 +4,12 @@
|
||||
.pgn__icon {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
&:hover, &:active, &:focus {
|
||||
background-color: darken(#005C9E, 15%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.component-style-html {
|
||||
@@ -12,6 +18,12 @@
|
||||
.pgn__icon {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
&:hover, &:active, &:focus {
|
||||
background-color: darken(#9747FF, 15%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.component-style-collection {
|
||||
@@ -20,6 +32,12 @@
|
||||
.pgn__icon {
|
||||
color: black;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
&:hover, &:active, &:focus {
|
||||
background-color: darken(#FFCD29, 15%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.component-style-video {
|
||||
@@ -28,6 +46,12 @@
|
||||
.pgn__icon {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
&:hover, &:active, &:focus {
|
||||
background-color: darken(#358F0A, 15%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.component-style-vertical {
|
||||
@@ -36,6 +60,12 @@
|
||||
.pgn__icon {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
&:hover, &:active, &:focus {
|
||||
background-color: darken(#0B8E77, 15%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.component-style-other {
|
||||
@@ -44,4 +74,10 @@
|
||||
.pgn__icon {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
&:hover, &:active, &:focus {
|
||||
background-color: darken(#646464, 15%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,54 +1,46 @@
|
||||
import React, { useContext, useCallback } from 'react';
|
||||
import { useParams } from 'react-router';
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import type { MessageDescriptor } from 'react-intl';
|
||||
import {
|
||||
Button, Stack,
|
||||
} from '@openedx/paragon';
|
||||
import { Add } from '@openedx/paragon/icons';
|
||||
import { ClearFiltersButton } from '../search-manager';
|
||||
import messages from './messages';
|
||||
import { LibraryContext } from './common/context';
|
||||
import { useContentLibrary } from './data/apiHooks';
|
||||
|
||||
type NoSearchResultsProps = {
|
||||
searchType?: 'collection' | 'component',
|
||||
};
|
||||
|
||||
export const NoComponents = ({ searchType = 'component' }: NoSearchResultsProps) => {
|
||||
const { openAddContentSidebar, openCreateCollectionModal } = useContext(LibraryContext);
|
||||
export const NoComponents = ({
|
||||
infoText = messages.noComponents,
|
||||
addBtnText = messages.addComponent,
|
||||
handleBtnClick,
|
||||
}: {
|
||||
infoText?: MessageDescriptor;
|
||||
addBtnText?: MessageDescriptor;
|
||||
handleBtnClick: () => void;
|
||||
}) => {
|
||||
const { libraryId } = useParams();
|
||||
const { data: libraryData } = useContentLibrary(libraryId);
|
||||
const canEditLibrary = libraryData?.canEditLibrary ?? false;
|
||||
|
||||
const handleOnClickButton = useCallback(() => {
|
||||
if (searchType === 'collection') {
|
||||
openCreateCollectionModal();
|
||||
} else {
|
||||
openAddContentSidebar();
|
||||
}
|
||||
}, [searchType]);
|
||||
|
||||
return (
|
||||
<Stack direction="horizontal" gap={3} className="mt-6 justify-content-center">
|
||||
{searchType === 'collection'
|
||||
? <FormattedMessage {...messages.noCollections} />
|
||||
: <FormattedMessage {...messages.noComponents} />}
|
||||
<FormattedMessage {...infoText} />
|
||||
{canEditLibrary && (
|
||||
<Button iconBefore={Add} onClick={handleOnClickButton}>
|
||||
{searchType === 'collection'
|
||||
? <FormattedMessage {...messages.addCollection} />
|
||||
: <FormattedMessage {...messages.addComponent} />}
|
||||
<Button iconBefore={Add} onClick={handleBtnClick}>
|
||||
<FormattedMessage {...addBtnText} />
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export const NoSearchResults = ({ searchType = 'component' }: NoSearchResultsProps) => (
|
||||
export const NoSearchResults = ({
|
||||
infoText = messages.noSearchResults,
|
||||
}: {
|
||||
infoText?: MessageDescriptor;
|
||||
}) => (
|
||||
<Stack direction="horizontal" gap={3} className="my-6 justify-content-center">
|
||||
{searchType === 'collection'
|
||||
? <FormattedMessage {...messages.noSearchResultsCollections} />
|
||||
: <FormattedMessage {...messages.noSearchResults} />}
|
||||
<FormattedMessage {...infoText} />
|
||||
<ClearFiltersButton variant="primary" size="md" />
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -20,3 +20,8 @@
|
||||
height: 100vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
// Reduce breadcrumb bottom margin
|
||||
ol.list-inline {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
SearchSortWidget,
|
||||
} from '../search-manager';
|
||||
import LibraryComponents from './components/LibraryComponents';
|
||||
import LibraryCollections from './LibraryCollections';
|
||||
import LibraryCollections from './collections/LibraryCollections';
|
||||
import LibraryHome from './LibraryHome';
|
||||
import { useContentLibrary } from './data/apiHooks';
|
||||
import { LibrarySidebar } from './library-sidebar';
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import React from 'react';
|
||||
import React, { useContext } from 'react';
|
||||
import { Stack } from '@openedx/paragon';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { useSearchContext } from '../search-manager';
|
||||
import { NoComponents, NoSearchResults } from './EmptyStates';
|
||||
import LibraryCollections from './LibraryCollections';
|
||||
import LibraryCollections from './collections/LibraryCollections';
|
||||
import { LibraryComponents } from './components';
|
||||
import LibrarySection from './components/LibrarySection';
|
||||
import LibraryRecentlyModified from './LibraryRecentlyModified';
|
||||
import messages from './messages';
|
||||
import { LibraryContext } from './common/context';
|
||||
|
||||
type LibraryHomeProps = {
|
||||
libraryId: string,
|
||||
@@ -23,10 +24,11 @@ const LibraryHome = ({ libraryId, tabList, handleTabChange } : LibraryHomeProps)
|
||||
totalCollectionHits: collectionCount,
|
||||
isFiltered,
|
||||
} = useSearchContext();
|
||||
const { openAddContentSidebar } = useContext(LibraryContext);
|
||||
|
||||
const renderEmptyState = () => {
|
||||
if (componentCount === 0 && collectionCount === 0) {
|
||||
return isFiltered ? <NoSearchResults /> : <NoComponents />;
|
||||
return isFiltered ? <NoSearchResults /> : <NoComponents handleBtnClick={openAddContentSidebar} />;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ import LibraryAuthoringPage from './LibraryAuthoringPage';
|
||||
import { LibraryProvider } from './common/context';
|
||||
import { CreateCollectionModal } from './create-collection';
|
||||
import { invalidateComponentData } from './data/apiHooks';
|
||||
import LibraryCollectionPage from './collections/LibraryCollectionPage';
|
||||
|
||||
const LibraryLayout = () => {
|
||||
const { libraryId } = useParams();
|
||||
@@ -24,14 +25,20 @@ const LibraryLayout = () => {
|
||||
}
|
||||
|
||||
const navigate = useNavigate();
|
||||
const goBack = React.useCallback(() => {
|
||||
// Go back to the library
|
||||
navigate(`/library/${libraryId}`);
|
||||
const goBack = React.useCallback((prevPath?: string) => {
|
||||
if (prevPath) {
|
||||
// Redirects back to the previous route like collection page or library page
|
||||
navigate(prevPath);
|
||||
} else {
|
||||
// Go back to the library
|
||||
navigate(`/library/${libraryId}`);
|
||||
}
|
||||
}, []);
|
||||
const returnFunction = React.useCallback(() => {
|
||||
|
||||
const returnFunction = React.useCallback((prevPath?: string) => {
|
||||
// When changes are cancelled, either onClose (goBack) or this returnFunction will be called.
|
||||
// When changes are saved, this returnFunction is called.
|
||||
goBack();
|
||||
goBack(prevPath);
|
||||
return (args) => {
|
||||
if (args === undefined) {
|
||||
return; // Do nothing - the user cancelled the changes
|
||||
@@ -58,6 +65,10 @@ const LibraryLayout = () => {
|
||||
</PageWrap>
|
||||
)}
|
||||
/>
|
||||
<Route
|
||||
path="collection/:collectionId"
|
||||
element={<LibraryCollectionPage />}
|
||||
/>
|
||||
<Route
|
||||
path="*"
|
||||
element={<LibraryAuthoringPage />}
|
||||
|
||||
218
src/library-authoring/__mocks__/collection-search.json
Normal file
218
src/library-authoring/__mocks__/collection-search.json
Normal file
@@ -0,0 +1,218 @@
|
||||
{
|
||||
"comment": "This mock is captured from a real search result and roughly edited to match the mocks in src/library-authoring/data/api.mocks.ts",
|
||||
"note": "The _formatted fields have been removed from this result and should be re-added programatically when mocking.",
|
||||
"results": [
|
||||
{
|
||||
"indexUid": "studio_content",
|
||||
"hits": [
|
||||
{
|
||||
"id": "lbaximtesthtml571fe018-f3ce-45c9-8f53-5dafcb422fdd-273ebd90",
|
||||
"display_name": "Introduction to Testing",
|
||||
"block_id": "571fe018-f3ce-45c9-8f53-5dafcb422fdd",
|
||||
"content": {
|
||||
"html_content": "This is a text component which uses HTML."
|
||||
},
|
||||
"tags": {},
|
||||
"type": "library_block",
|
||||
"breadcrumbs": [
|
||||
{
|
||||
"display_name": "Test Library"
|
||||
}
|
||||
],
|
||||
"created": 1721857069.042984,
|
||||
"modified": 1725878053.420395,
|
||||
"last_published": 1725035862.450613,
|
||||
"usage_key": "lb:Axim:TEST:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd",
|
||||
"block_type": "html",
|
||||
"context_key": "lib:Axim:TEST",
|
||||
"org": "Axim",
|
||||
"access_id": 15,
|
||||
"collections": {
|
||||
"display_name": [
|
||||
"My first collection"
|
||||
],
|
||||
"key": [
|
||||
"my-first-collection"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "lbaximtesthtml73a22298-bcd9-4f4c-ae34-0bc2b0612480-46b4a7f2",
|
||||
"display_name": "Second Text Component",
|
||||
"block_id": "73a22298-bcd9-4f4c-ae34-0bc2b0612480",
|
||||
"content": {
|
||||
"html_content": "Preview of the second text component here"
|
||||
},
|
||||
"type": "library_block",
|
||||
"breadcrumbs": [
|
||||
{
|
||||
"display_name": "Test Library"
|
||||
}
|
||||
],
|
||||
"created": 1724879593.066427,
|
||||
"modified": 1725034981.663482,
|
||||
"last_published": 1725035862.450613,
|
||||
"usage_key": "lb:Axim:TEST:html:73a22298-bcd9-4f4c-ae34-0bc2b0612480",
|
||||
"block_type": "html",
|
||||
"context_key": "lib:Axim:TEST",
|
||||
"org": "Axim",
|
||||
"access_id": 15,
|
||||
"collections": {
|
||||
"display_name": [
|
||||
"My first collection"
|
||||
],
|
||||
"key": [
|
||||
"my-first-collection"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "lbaximtesthtmlbe5b5db9-26ba-4fac-86af-654538c70b5e-73dbaa95",
|
||||
"display_name": "Third Text component",
|
||||
"block_id": "be5b5db9-26ba-4fac-86af-654538c70b5e",
|
||||
"content": {
|
||||
"html_content": "This is a text component that I've edited within the library. "
|
||||
},
|
||||
"tags": {},
|
||||
"type": "library_block",
|
||||
"breadcrumbs": [
|
||||
{
|
||||
"display_name": "Test Library"
|
||||
}
|
||||
],
|
||||
"created": 1721857034.455737,
|
||||
"modified": 1722551300.377488,
|
||||
"last_published": 1724879092.002222,
|
||||
"usage_key": "lb:Axim:TEST:html:be5b5db9-26ba-4fac-86af-654538c70b5e",
|
||||
"block_type": "html",
|
||||
"context_key": "lib:Axim:TEST",
|
||||
"org": "Axim",
|
||||
"access_id": 15,
|
||||
"collections": {
|
||||
"display_name": [
|
||||
"My first collection",
|
||||
"My second collection"
|
||||
],
|
||||
"key": [
|
||||
"my-first-collection",
|
||||
"my-second-collection"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "lbaximtesthtmle59e8c73-4056-4894-bca4-062781fb3f68-46a404b2",
|
||||
"display_name": "Text 4",
|
||||
"block_id": "e59e8c73-4056-4894-bca4-062781fb3f68",
|
||||
"content": {
|
||||
"html_content": ""
|
||||
},
|
||||
"tags": {},
|
||||
"type": "library_block",
|
||||
"breadcrumbs": [
|
||||
{
|
||||
"display_name": "Test Library"
|
||||
}
|
||||
],
|
||||
"created": 1720774228.49832,
|
||||
"modified": 1720774228.49832,
|
||||
"last_published": 1724879092.002222,
|
||||
"usage_key": "lb:Axim:TEST:html:e59e8c73-4056-4894-bca4-062781fb3f68",
|
||||
"block_type": "html",
|
||||
"context_key": "lib:Axim:TEST",
|
||||
"org": "Axim",
|
||||
"access_id": 15,
|
||||
"collections": {
|
||||
"display_name": [
|
||||
"My first collection"
|
||||
],
|
||||
"key": [
|
||||
"my-first-collection"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "lbaximtestproblemf16116c9-516e-4bb9-b99e-103599f62417-f2798115",
|
||||
"display_name": "Blank Problem",
|
||||
"block_id": "f16116c9-516e-4bb9-b99e-103599f62417",
|
||||
"content": {
|
||||
"problem_types": [],
|
||||
"capa_content": " "
|
||||
},
|
||||
"type": "library_block",
|
||||
"breadcrumbs": [
|
||||
{
|
||||
"display_name": "Test Library"
|
||||
}
|
||||
],
|
||||
"created": 1724725821.973896,
|
||||
"modified": 1724725821.973896,
|
||||
"last_published": 1724879092.002222,
|
||||
"usage_key": "lb:Axim:TEST:problem:f16116c9-516e-4bb9-b99e-103599f62417",
|
||||
"block_type": "problem",
|
||||
"context_key": "lib:Axim:TEST",
|
||||
"org": "Axim",
|
||||
"access_id": 15,
|
||||
"collections": {
|
||||
"display_name": [
|
||||
"My first collection"
|
||||
],
|
||||
"key": [
|
||||
"my-first-collection"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"query": "",
|
||||
"processingTimeMs": 1,
|
||||
"limit": 20,
|
||||
"offset": 0,
|
||||
"estimatedTotalHits": 5
|
||||
},
|
||||
{
|
||||
"indexUid": "studio_content",
|
||||
"hits": [],
|
||||
"query": "",
|
||||
"processingTimeMs": 0,
|
||||
"limit": 0,
|
||||
"offset": 0,
|
||||
"estimatedTotalHits": 5,
|
||||
"facetDistribution": {
|
||||
"block_type": {
|
||||
"html": 4,
|
||||
"problem": 1
|
||||
},
|
||||
"content.problem_types": {}
|
||||
},
|
||||
"facetStats": {}
|
||||
},
|
||||
{
|
||||
"indexUid": "studio_content",
|
||||
"hits": [
|
||||
{
|
||||
"display_name": "My first collection",
|
||||
"block_id": "my-first-collection",
|
||||
"description": "A collection for testing",
|
||||
"id": 1,
|
||||
"type": "collection",
|
||||
"breadcrumbs": [
|
||||
{
|
||||
"display_name": "CS problems 2"
|
||||
}
|
||||
],
|
||||
"created": 1726740779.564664,
|
||||
"modified": 1726740811.684142,
|
||||
"usage_key": "lib-collection:OpenedX:CSPROB2:collection-from-meilisearch",
|
||||
"context_key": "lib:OpenedX:CSPROB2",
|
||||
"org": "OpenedX",
|
||||
"access_id": 16,
|
||||
"num_children": 5
|
||||
}
|
||||
],
|
||||
"query": "",
|
||||
"processingTimeMs": 0,
|
||||
"limit": 1,
|
||||
"offset": 0,
|
||||
"estimatedTotalHits": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -16,12 +16,12 @@ import {
|
||||
ContentPaste,
|
||||
} from '@openedx/paragon/icons';
|
||||
import { v4 as uuid4 } from 'uuid';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||
|
||||
import { ToastContext } from '../../generic/toast-context';
|
||||
import { useCopyToClipboard } from '../../generic/clipboard';
|
||||
import { getCanEdit } from '../../course-unit/data/selectors';
|
||||
import { useCreateLibraryBlock, useLibraryPasteClipboard } from '../data/apiHooks';
|
||||
import { useCreateLibraryBlock, useLibraryPasteClipboard, useUpdateCollectionComponents } from '../data/apiHooks';
|
||||
import { getEditUrl } from '../components/utils';
|
||||
|
||||
import messages from './messages';
|
||||
@@ -62,8 +62,11 @@ const AddContentButton = ({ contentType, onCreateContent } : AddContentButtonPro
|
||||
const AddContentContainer = () => {
|
||||
const intl = useIntl();
|
||||
const navigate = useNavigate();
|
||||
const { libraryId } = useParams();
|
||||
const location = useLocation();
|
||||
const currentPath = location.pathname;
|
||||
const { libraryId, collectionId } = useParams();
|
||||
const createBlockMutation = useCreateLibraryBlock();
|
||||
const updateComponentsMutation = useUpdateCollectionComponents(libraryId, collectionId);
|
||||
const pasteClipboardMutation = useLibraryPasteClipboard();
|
||||
const { showToast } = useContext(ToastContext);
|
||||
const canEdit = useSelector(getCanEdit);
|
||||
@@ -149,8 +152,13 @@ const AddContentContainer = () => {
|
||||
definitionId: `${uuid4()}`,
|
||||
}).then((data) => {
|
||||
const editUrl = getEditUrl(data.id);
|
||||
updateComponentsMutation.mutateAsync([data.id]).catch(() => {
|
||||
showToast(intl.formatMessage(messages.errorAssociateComponentMessage));
|
||||
});
|
||||
if (editUrl) {
|
||||
navigate(editUrl);
|
||||
// Pass currentPath in state so that we can come back to
|
||||
// current page on save or cancel
|
||||
navigate(editUrl, { state: { from: currentPath } });
|
||||
} else {
|
||||
// We can't start editing this right away so just show a toast message:
|
||||
showToast(intl.formatMessage(messages.successCreateMessage));
|
||||
@@ -168,7 +176,7 @@ const AddContentContainer = () => {
|
||||
|
||||
return (
|
||||
<Stack direction="vertical">
|
||||
<AddContentButton contentType={collectionButtonData} onCreateContent={onCreateContent} />
|
||||
{!collectionId && <AddContentButton contentType={collectionButtonData} onCreateContent={onCreateContent} />}
|
||||
<hr className="w-100 bg-gray-500" />
|
||||
{contentTypes.map((contentType) => (
|
||||
<AddContentButton
|
||||
|
||||
@@ -51,6 +51,11 @@ const messages = defineMessages({
|
||||
defaultMessage: 'There was an error creating the content.',
|
||||
description: 'Message when creation of content in library is on error',
|
||||
},
|
||||
errorAssociateComponentMessage: {
|
||||
id: 'course-authoring.library-authoring.associate-collection-content.error.text',
|
||||
defaultMessage: 'There was an error linking the content to this collection.',
|
||||
description: 'Message when linking of content to a collection in library fails',
|
||||
},
|
||||
addContentTitle: {
|
||||
id: 'course-authoring.library-authoring.sidebar.title.add-content',
|
||||
defaultMessage: 'Add Content',
|
||||
|
||||
28
src/library-authoring/collections/CollectionInfo.tsx
Normal file
28
src/library-authoring/collections/CollectionInfo.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Tab,
|
||||
Tabs,
|
||||
} from '@openedx/paragon';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
const CollectionInfo = () => {
|
||||
const intl = useIntl();
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
variant="tabs"
|
||||
className="my-3 d-flex justify-content-around"
|
||||
defaultActiveKey="manage"
|
||||
>
|
||||
<Tab eventKey="manage" title={intl.formatMessage(messages.manageTabTitle)}>
|
||||
Manage tab placeholder
|
||||
</Tab>
|
||||
<Tab eventKey="details" title={intl.formatMessage(messages.detailsTabTitle)}>
|
||||
Details tab placeholder
|
||||
</Tab>
|
||||
</Tabs>
|
||||
);
|
||||
};
|
||||
|
||||
export default CollectionInfo;
|
||||
13
src/library-authoring/collections/CollectionInfoHeader.tsx
Normal file
13
src/library-authoring/collections/CollectionInfoHeader.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { type CollectionHit } from '../../search-manager/data/api';
|
||||
|
||||
interface CollectionInfoHeaderProps {
|
||||
collection?: CollectionHit;
|
||||
}
|
||||
|
||||
const CollectionInfoHeader = ({ collection } : CollectionInfoHeaderProps) => (
|
||||
<div className="d-flex flex-wrap">
|
||||
{collection?.displayName}
|
||||
</div>
|
||||
);
|
||||
|
||||
export default CollectionInfoHeader;
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useContext } from 'react';
|
||||
import { Stack } from '@openedx/paragon';
|
||||
import { NoComponents, NoSearchResults } from '../EmptyStates';
|
||||
import { useSearchContext } from '../../search-manager';
|
||||
import { LibraryComponents } from '../components';
|
||||
import messages from './messages';
|
||||
import { LibraryContext } from '../common/context';
|
||||
|
||||
const LibraryCollectionComponents = ({ libraryId }: { libraryId: string }) => {
|
||||
const { totalHits: componentCount, isFiltered } = useSearchContext();
|
||||
const { openAddContentSidebar } = useContext(LibraryContext);
|
||||
|
||||
if (componentCount === 0) {
|
||||
return isFiltered
|
||||
? <NoSearchResults infoText={messages.noSearchResultsInCollection} />
|
||||
: (
|
||||
<NoComponents
|
||||
infoText={messages.noComponentsInCollection}
|
||||
addBtnText={messages.addComponentsInCollection}
|
||||
handleBtnClick={openAddContentSidebar}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack direction="vertical" gap={3}>
|
||||
<h3 className="text-gray">Content ({componentCount})</h3>
|
||||
<LibraryComponents libraryId={libraryId} variant="full" />
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default LibraryCollectionComponents;
|
||||
325
src/library-authoring/collections/LibraryCollectionPage.test.tsx
Normal file
325
src/library-authoring/collections/LibraryCollectionPage.test.tsx
Normal file
@@ -0,0 +1,325 @@
|
||||
import fetchMock from 'fetch-mock-jest';
|
||||
import { cloneDeep } from 'lodash';
|
||||
import {
|
||||
fireEvent,
|
||||
initializeMocks,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from '../../testUtils';
|
||||
import mockResult from '../__mocks__/collection-search.json';
|
||||
import {
|
||||
mockContentLibrary, mockLibraryBlockTypes, mockXBlockFields,
|
||||
} from '../data/api.mocks';
|
||||
import { mockContentSearchConfig } from '../../search-manager/data/api.mock';
|
||||
import { mockBroadcastChannel } from '../../generic/data/api.mock';
|
||||
import { LibraryLayout } from '..';
|
||||
|
||||
mockContentSearchConfig.applyMock();
|
||||
mockContentLibrary.applyMock();
|
||||
mockLibraryBlockTypes.applyMock();
|
||||
mockXBlockFields.applyMock();
|
||||
mockBroadcastChannel();
|
||||
|
||||
const searchEndpoint = 'http://mock.meilisearch.local/multi-search';
|
||||
const path = '/library/:libraryId/*';
|
||||
const libraryTitle = mockContentLibrary.libraryData.title;
|
||||
const mockCollection = {
|
||||
collectionId: mockResult.results[2].hits[0].block_id,
|
||||
collectionNeverLoads: 'collection-always-loading',
|
||||
collectionEmpty: 'collection-no-data',
|
||||
collectionNoComponents: 'collection-no-components',
|
||||
title: mockResult.results[2].hits[0].display_name,
|
||||
};
|
||||
|
||||
describe('<LibraryCollectionPage />', () => {
|
||||
beforeEach(() => {
|
||||
initializeMocks();
|
||||
|
||||
// The Meilisearch client-side API uses fetch, not Axios.
|
||||
fetchMock.post(searchEndpoint, (_url, req) => {
|
||||
const requestData = JSON.parse(req.body?.toString() ?? '');
|
||||
const query = requestData?.queries[0]?.q ?? '';
|
||||
const mockResultCopy = cloneDeep(mockResult);
|
||||
// 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.
|
||||
mockResultCopy.results[0].query = query;
|
||||
mockResultCopy.results[2].query = query;
|
||||
// And fake the required '_formatted' fields; it contains the highlighting <mark>...</mark> around matched words
|
||||
// eslint-disable-next-line no-underscore-dangle, no-param-reassign
|
||||
mockResultCopy.results[0]?.hits.forEach((hit) => { hit._formatted = { ...hit }; });
|
||||
const collectionQueryId = requestData?.queries[2]?.filter[2]?.split('block_id = "')[1].split('"')[0];
|
||||
switch (collectionQueryId) {
|
||||
case mockCollection.collectionNeverLoads:
|
||||
return new Promise<any>(() => {});
|
||||
case mockCollection.collectionEmpty:
|
||||
mockResultCopy.results[2].hits = [];
|
||||
mockResultCopy.results[2].estimatedTotalHits = 0;
|
||||
break;
|
||||
case mockCollection.collectionNoComponents:
|
||||
mockResultCopy.results[0].hits = [];
|
||||
mockResultCopy.results[0].estimatedTotalHits = 0;
|
||||
mockResultCopy.results[1].facetDistribution.block_type = {};
|
||||
mockResultCopy.results[2].hits[0].num_children = 0;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return mockResultCopy;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
fetchMock.mockReset();
|
||||
});
|
||||
|
||||
const renderLibraryCollectionPage = async (collectionId?: string, libraryId?: string) => {
|
||||
const libId = libraryId || mockContentLibrary.libraryId;
|
||||
const colId = collectionId || mockCollection.collectionId;
|
||||
render(<LibraryLayout />, {
|
||||
path,
|
||||
routerProps: {
|
||||
initialEntries: [`/library/${libId}/collection/${colId}`],
|
||||
},
|
||||
});
|
||||
|
||||
if (colId !== mockCollection.collectionNeverLoads) {
|
||||
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); });
|
||||
}
|
||||
};
|
||||
|
||||
it('shows the spinner before the query is complete', async () => {
|
||||
// This mock will never return data about the collection (it loads forever):
|
||||
await renderLibraryCollectionPage(mockCollection.collectionNeverLoads);
|
||||
const spinner = screen.getByRole('status');
|
||||
expect(spinner.textContent).toEqual('Loading...');
|
||||
});
|
||||
|
||||
it('shows an error component if no collection returned', async () => {
|
||||
// This mock will simulate incorrect collection id
|
||||
await renderLibraryCollectionPage(mockCollection.collectionEmpty);
|
||||
screen.debug();
|
||||
expect(await screen.findByTestId('notFoundAlert')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows collection data', async () => {
|
||||
await renderLibraryCollectionPage();
|
||||
expect(await screen.findByText('All Collections')).toBeInTheDocument();
|
||||
expect((await screen.findAllByText(libraryTitle))[0]).toBeInTheDocument();
|
||||
expect((await screen.findAllByText(mockCollection.title))[0]).toBeInTheDocument();
|
||||
|
||||
expect(screen.queryByText('This collection is currently empty.')).not.toBeInTheDocument();
|
||||
|
||||
// "Recently Modified" sort shown
|
||||
expect(screen.getAllByText('Recently Modified').length).toEqual(1);
|
||||
expect((await screen.findAllByText('Introduction to Testing'))[0]).toBeInTheDocument();
|
||||
// Content header with count
|
||||
expect(await screen.findByText('Content (5)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a collection without associated components', async () => {
|
||||
await renderLibraryCollectionPage(mockCollection.collectionNoComponents);
|
||||
|
||||
expect(await screen.findByText('All Collections')).toBeInTheDocument();
|
||||
expect((await screen.findAllByText(libraryTitle))[0]).toBeInTheDocument();
|
||||
expect((await screen.findAllByText(mockCollection.title))[0]).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText('This collection is currently empty.')).toBeInTheDocument();
|
||||
|
||||
const addComponentButton = screen.getAllByRole('button', { name: /new/i })[1];
|
||||
fireEvent.click(addComponentButton);
|
||||
expect(screen.getByText(/add content/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the new content button', async () => {
|
||||
await renderLibraryCollectionPage();
|
||||
|
||||
expect(await screen.findByText('All Collections')).toBeInTheDocument();
|
||||
expect(await screen.findByText('Content (5)')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /new/i })).toBeInTheDocument();
|
||||
expect(screen.queryByText('Read Only')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows an empty read-only library collection, without a new button', async () => {
|
||||
// Use a library mock that is read-only:
|
||||
const libraryId = mockContentLibrary.libraryIdReadOnly;
|
||||
// Update search mock so it returns no results:
|
||||
await renderLibraryCollectionPage(mockCollection.collectionNoComponents, libraryId);
|
||||
|
||||
expect(await screen.findByText('All Collections')).toBeInTheDocument();
|
||||
expect(screen.getByText('This collection is currently empty.')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /new/i })).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Read Only')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('show a collection without search results', async () => {
|
||||
// Update search mock so it returns no results:
|
||||
await renderLibraryCollectionPage(mockCollection.collectionNoComponents);
|
||||
|
||||
expect(await screen.findByText('All Collections')).toBeInTheDocument();
|
||||
expect((await screen.findAllByText(libraryTitle))[0]).toBeInTheDocument();
|
||||
expect((await screen.findAllByText(mockCollection.title))[0]).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByRole('searchbox'), { target: { value: 'noresults' } });
|
||||
|
||||
// Ensure the search endpoint is called again, only once more since the recently modified call
|
||||
// should not be impacted by the search
|
||||
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(2, searchEndpoint, 'post'); });
|
||||
|
||||
expect(screen.queryByText('No matching components found in this collections.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should open and close new content sidebar', async () => {
|
||||
await renderLibraryCollectionPage();
|
||||
|
||||
expect(await screen.findByText('All Collections')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/add content/i)).not.toBeInTheDocument();
|
||||
|
||||
const newButton = screen.getByRole('button', { name: /new/i });
|
||||
fireEvent.click(newButton);
|
||||
|
||||
expect(screen.getByText(/add content/i)).toBeInTheDocument();
|
||||
|
||||
const closeButton = screen.getByRole('button', { name: /close/i });
|
||||
fireEvent.click(closeButton);
|
||||
|
||||
expect(screen.queryByText(/add content/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should open collection Info by default', async () => {
|
||||
await renderLibraryCollectionPage();
|
||||
|
||||
expect(await screen.findByText('All Collections')).toBeInTheDocument();
|
||||
expect((await screen.findAllByText(libraryTitle))[0]).toBeInTheDocument();
|
||||
expect((await screen.findAllByText(mockCollection.title))[0]).toBeInTheDocument();
|
||||
expect((await screen.findAllByText(mockCollection.title))[1]).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText('Manage')).toBeInTheDocument();
|
||||
expect(screen.getByText('Details')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should close and open Collection Info', async () => {
|
||||
await renderLibraryCollectionPage();
|
||||
|
||||
expect(await screen.findByText('All Collections')).toBeInTheDocument();
|
||||
expect((await screen.findAllByText(libraryTitle))[0]).toBeInTheDocument();
|
||||
expect((await screen.findAllByText(mockCollection.title))[0]).toBeInTheDocument();
|
||||
expect((await screen.findAllByText(mockCollection.title))[1]).toBeInTheDocument();
|
||||
|
||||
// Open by default; close the library info sidebar
|
||||
const closeButton = screen.getByRole('button', { name: /close/i });
|
||||
fireEvent.click(closeButton);
|
||||
expect(screen.queryByText('Draft')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('(Never Published)')).not.toBeInTheDocument();
|
||||
|
||||
// Open library info sidebar with 'Library info' button
|
||||
const collectionInfoBtn = screen.getByRole('button', { name: /collection info/i });
|
||||
fireEvent.click(collectionInfoBtn);
|
||||
expect(screen.getByText('Manage')).toBeInTheDocument();
|
||||
expect(screen.getByText('Details')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sorts collection components', async () => {
|
||||
await renderLibraryCollectionPage();
|
||||
|
||||
expect(await screen.findByTitle('Sort search results')).toBeInTheDocument();
|
||||
|
||||
const testSortOption = (async (optionText, sortBy, isDefault) => {
|
||||
// Open the drop-down menu
|
||||
fireEvent.click(screen.getByTitle('Sort search results'));
|
||||
|
||||
// Click the option with the given text
|
||||
// Since the sort drop-down also shows the selected sort
|
||||
// option in its toggle button, we need to make sure we're
|
||||
// clicking on the last one found.
|
||||
const options = screen.getAllByText(optionText);
|
||||
expect(options.length).toBeGreaterThan(0);
|
||||
fireEvent.click(options[options.length - 1]);
|
||||
|
||||
// Did the search happen with the expected sort option?
|
||||
const bodyText = sortBy ? `"sort":["${sortBy}"]` : '"sort":[]';
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenLastCalledWith(searchEndpoint, {
|
||||
body: expect.stringContaining(bodyText),
|
||||
method: 'POST',
|
||||
headers: expect.anything(),
|
||||
});
|
||||
});
|
||||
|
||||
// Is the sort option stored in the query string?
|
||||
// Note: we can't easily check this at the moment with <MemoryRouter>
|
||||
// const searchText = isDefault ? '' : `?sort=${encodeURIComponent(sortBy)}`;
|
||||
// expect(window.location.href).toEqual(searchText);
|
||||
|
||||
// Is the selected sort option shown in the toggle button (if not default)
|
||||
// as well as in the drop-down menu?
|
||||
expect(screen.getAllByText(optionText).length).toEqual(isDefault ? 1 : 2);
|
||||
});
|
||||
|
||||
await testSortOption('Title, A-Z', 'display_name:asc', false);
|
||||
await testSortOption('Title, Z-A', 'display_name:desc', false);
|
||||
await testSortOption('Newest', 'created:desc', false);
|
||||
await testSortOption('Oldest', 'created:asc', false);
|
||||
|
||||
// Sorting by Recently Published also excludes unpublished components
|
||||
await testSortOption('Recently Published', 'last_published:desc', false);
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenLastCalledWith(searchEndpoint, {
|
||||
body: expect.stringContaining('last_published IS NOT NULL'),
|
||||
method: 'POST',
|
||||
headers: expect.anything(),
|
||||
});
|
||||
});
|
||||
|
||||
// Re-selecting the previous sort option resets sort to default "Recently Modified"
|
||||
await testSortOption('Recently Published', 'modified:desc', true);
|
||||
expect(screen.getAllByText('Recently Modified').length).toEqual(2);
|
||||
|
||||
// Enter a keyword into the search box
|
||||
const searchBox = await screen.findByRole('searchbox');
|
||||
fireEvent.change(searchBox, { target: { value: 'words to find' } });
|
||||
|
||||
// Default sort option changes to "Most Relevant"
|
||||
expect(screen.getAllByText('Most Relevant').length).toEqual(2);
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenLastCalledWith(searchEndpoint, {
|
||||
body: expect.stringContaining('"sort":[]'),
|
||||
method: 'POST',
|
||||
headers: expect.anything(),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should open and close the component sidebar', async () => {
|
||||
const mockResult0 = mockResult.results[0].hits[0];
|
||||
const displayName = 'Introduction to Testing';
|
||||
expect(mockResult0.display_name).toStrictEqual(displayName);
|
||||
await renderLibraryCollectionPage();
|
||||
|
||||
// Click on the first component. It should appear twice, in both "Recently Modified" and "Components"
|
||||
fireEvent.click((await screen.findAllByText(displayName))[0]);
|
||||
|
||||
const sidebar = screen.getByTestId('library-sidebar');
|
||||
|
||||
const { getByRole, getByText } = within(sidebar);
|
||||
|
||||
await waitFor(() => expect(getByText(displayName)).toBeInTheDocument());
|
||||
|
||||
const closeButton = getByRole('button', { name: /close/i });
|
||||
fireEvent.click(closeButton);
|
||||
|
||||
await waitFor(() => expect(screen.queryByTestId('library-sidebar')).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('has an empty type filter when there are no results', async () => {
|
||||
await renderLibraryCollectionPage(mockCollection.collectionNoComponents);
|
||||
|
||||
const filterButton = screen.getByRole('button', { name: /type/i });
|
||||
fireEvent.click(filterButton);
|
||||
|
||||
expect(screen.getByText(/no matching components/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
208
src/library-authoring/collections/LibraryCollectionPage.tsx
Normal file
208
src/library-authoring/collections/LibraryCollectionPage.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
import { useContext, useEffect } from 'react';
|
||||
import { StudioFooter } from '@edx/frontend-component-footer';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Breadcrumb,
|
||||
Container,
|
||||
Icon,
|
||||
IconButton,
|
||||
Stack,
|
||||
} from '@openedx/paragon';
|
||||
import { Add, InfoOutline } from '@openedx/paragon/icons';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
|
||||
import { SearchParams } from 'meilisearch';
|
||||
import Loading from '../../generic/Loading';
|
||||
import SubHeader from '../../generic/sub-header/SubHeader';
|
||||
import Header from '../../header';
|
||||
import NotFoundAlert from '../../generic/NotFoundAlert';
|
||||
import {
|
||||
ClearFiltersButton,
|
||||
FilterByBlockType,
|
||||
FilterByTags,
|
||||
SearchContextProvider,
|
||||
SearchKeywordsField,
|
||||
SearchSortWidget,
|
||||
useSearchContext,
|
||||
} from '../../search-manager';
|
||||
import { useContentLibrary } from '../data/apiHooks';
|
||||
import { LibraryContext } from '../common/context';
|
||||
import messages from './messages';
|
||||
import { LibrarySidebar } from '../library-sidebar';
|
||||
import LibraryCollectionComponents from './LibraryCollectionComponents';
|
||||
|
||||
const HeaderActions = ({ canEditLibrary }: { canEditLibrary: boolean; }) => {
|
||||
const intl = useIntl();
|
||||
const {
|
||||
openAddContentSidebar,
|
||||
} = useContext(LibraryContext);
|
||||
|
||||
if (!canEditLibrary) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="header-actions">
|
||||
<Button
|
||||
className="ml-1"
|
||||
iconBefore={Add}
|
||||
variant="primary rounded-0"
|
||||
onClick={openAddContentSidebar}
|
||||
disabled={!canEditLibrary}
|
||||
>
|
||||
{intl.formatMessage(messages.newContentButton)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const SubHeaderTitle = ({
|
||||
title,
|
||||
canEditLibrary,
|
||||
infoClickHandler,
|
||||
}: {
|
||||
title: string;
|
||||
canEditLibrary: boolean;
|
||||
infoClickHandler: () => void;
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
return (
|
||||
<Stack direction="vertical">
|
||||
<Stack direction="horizontal" gap={2}>
|
||||
{title}
|
||||
<IconButton
|
||||
src={InfoOutline}
|
||||
iconAs={Icon}
|
||||
alt={intl.formatMessage(messages.collectionInfoButton)}
|
||||
onClick={infoClickHandler}
|
||||
variant="primary"
|
||||
/>
|
||||
</Stack>
|
||||
{ !canEditLibrary && (
|
||||
<div>
|
||||
<Badge variant="primary" style={{ fontSize: '50%' }}>
|
||||
{intl.formatMessage(messages.readOnlyBadge)}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const LibraryCollectionPageInner = ({ libraryId }: { libraryId: string }) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const {
|
||||
sidebarBodyComponent,
|
||||
openCollectionInfoSidebar,
|
||||
} = useContext(LibraryContext);
|
||||
const { collectionHits: [collectionData], isFetching } = useSearchContext();
|
||||
|
||||
useEffect(() => {
|
||||
openCollectionInfoSidebar();
|
||||
}, []);
|
||||
|
||||
const { data: libraryData, isLoading: isLibLoading } = useContentLibrary(libraryId);
|
||||
|
||||
// Only show loading if collection data is not fetched from index yet
|
||||
// Loading info for search results will be handled by LibraryCollectionComponents component.
|
||||
if (isLibLoading || (!collectionData && isFetching)) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
if (!libraryData || !collectionData) {
|
||||
return <NotFoundAlert />;
|
||||
}
|
||||
|
||||
const breadcrumbs = [
|
||||
{
|
||||
label: libraryData.title,
|
||||
to: `/library/${libraryId}`,
|
||||
},
|
||||
{
|
||||
label: intl.formatMessage(messages.allCollections),
|
||||
to: `/library/${libraryId}/collections`,
|
||||
},
|
||||
// Adding empty breadcrumb to add the last `>` spacer.
|
||||
{
|
||||
label: '',
|
||||
to: '',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="d-flex">
|
||||
<div className="flex-grow-1">
|
||||
<Header
|
||||
number={libraryData.slug}
|
||||
title={libraryData.title}
|
||||
org={libraryData.org}
|
||||
contextId={libraryId}
|
||||
isLibrary
|
||||
/>
|
||||
<Container size="xl" className="px-4 mt-4 mb-5 library-authoring-page">
|
||||
<SubHeader
|
||||
title={(
|
||||
<SubHeaderTitle
|
||||
title={collectionData.displayName}
|
||||
canEditLibrary={libraryData.canEditLibrary}
|
||||
infoClickHandler={openCollectionInfoSidebar}
|
||||
/>
|
||||
)}
|
||||
breadcrumbs={(
|
||||
<Breadcrumb
|
||||
ariaLabel={intl.formatMessage(messages.breadcrumbsAriaLabel)}
|
||||
links={breadcrumbs}
|
||||
linkAs={Link}
|
||||
/>
|
||||
)}
|
||||
headerActions={<HeaderActions canEditLibrary={libraryData.canEditLibrary} />}
|
||||
/>
|
||||
<SearchKeywordsField className="w-50" placeholder={intl.formatMessage(messages.searchPlaceholder)} />
|
||||
<div className="d-flex mt-3 mb-4 align-items-center">
|
||||
<FilterByTags />
|
||||
<FilterByBlockType />
|
||||
<ClearFiltersButton />
|
||||
<div className="flex-grow-1" />
|
||||
<SearchSortWidget />
|
||||
</div>
|
||||
<LibraryCollectionComponents libraryId={libraryId} />
|
||||
</Container>
|
||||
<StudioFooter />
|
||||
</div>
|
||||
{ !!sidebarBodyComponent && (
|
||||
<div className="library-authoring-sidebar box-shadow-left-1 bg-white" data-testid="library-sidebar">
|
||||
<LibrarySidebar library={libraryData} collection={collectionData} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const LibraryCollectionPage = () => {
|
||||
const { libraryId, collectionId } = useParams();
|
||||
|
||||
if (!collectionId || !libraryId) {
|
||||
// istanbul ignore next - This shouldn't be possible; it's just here to satisfy the type checker.
|
||||
throw new Error('Rendered without collectionId or libraryId URL parameter');
|
||||
}
|
||||
|
||||
const collectionQuery: SearchParams = {
|
||||
filter: ['type = "collection"', `context_key = "${libraryId}"`, `block_id = "${collectionId}"`],
|
||||
limit: 1,
|
||||
};
|
||||
|
||||
return (
|
||||
<SearchContextProvider
|
||||
extraFilter={[`context_key = "${libraryId}"`, `collections.key = "${collectionId}"`]}
|
||||
overrideQueries={{ collections: collectionQuery }}
|
||||
>
|
||||
<LibraryCollectionPageInner libraryId={libraryId} />
|
||||
</SearchContextProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default LibraryCollectionPage;
|
||||
@@ -1,8 +1,12 @@
|
||||
import { useLoadOnScroll } from '../hooks';
|
||||
import { useSearchContext } from '../search-manager';
|
||||
import { NoComponents, NoSearchResults } from './EmptyStates';
|
||||
import CollectionCard from './components/CollectionCard';
|
||||
import { LIBRARY_SECTION_PREVIEW_LIMIT } from './components/LibrarySection';
|
||||
import { useContext } from 'react';
|
||||
|
||||
import { useLoadOnScroll } from '../../hooks';
|
||||
import { useSearchContext } from '../../search-manager';
|
||||
import { NoComponents, NoSearchResults } from '../EmptyStates';
|
||||
import CollectionCard from '../components/CollectionCard';
|
||||
import { LIBRARY_SECTION_PREVIEW_LIMIT } from '../components/LibrarySection';
|
||||
import messages from './messages';
|
||||
import { LibraryContext } from '../common/context';
|
||||
|
||||
type LibraryCollectionsProps = {
|
||||
variant: 'full' | 'preview',
|
||||
@@ -25,6 +29,8 @@ const LibraryCollections = ({ variant }: LibraryCollectionsProps) => {
|
||||
isFiltered,
|
||||
} = useSearchContext();
|
||||
|
||||
const { openCreateCollectionModal } = useContext(LibraryContext);
|
||||
|
||||
const collectionList = variant === 'preview' ? collectionHits.slice(0, LIBRARY_SECTION_PREVIEW_LIMIT) : collectionHits;
|
||||
|
||||
useLoadOnScroll(
|
||||
@@ -35,17 +41,25 @@ const LibraryCollections = ({ variant }: LibraryCollectionsProps) => {
|
||||
);
|
||||
|
||||
if (totalCollectionHits === 0) {
|
||||
return isFiltered ? <NoSearchResults searchType="collection" /> : <NoComponents searchType="collection" />;
|
||||
return isFiltered
|
||||
? <NoSearchResults infoText={messages.noSearchResultsCollections} />
|
||||
: (
|
||||
<NoComponents
|
||||
infoText={messages.noCollections}
|
||||
addBtnText={messages.addCollection}
|
||||
handleBtnClick={openCreateCollectionModal}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="library-cards-grid">
|
||||
{ collectionList.map((collectionHit) => (
|
||||
{collectionList.map((collectionHit) => (
|
||||
<CollectionCard
|
||||
key={collectionHit.id}
|
||||
collectionHit={collectionHit}
|
||||
/>
|
||||
)) }
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
2
src/library-authoring/collections/index.tsx
Normal file
2
src/library-authoring/collections/index.tsx
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as CollectionInfo } from './CollectionInfo';
|
||||
export { default as CollectionInfoHeader } from './CollectionInfoHeader';
|
||||
76
src/library-authoring/collections/messages.ts
Normal file
76
src/library-authoring/collections/messages.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
manageTabTitle: {
|
||||
id: 'course-authoring.library-authoring.collections-sidebar.manage-tab.title',
|
||||
defaultMessage: 'Manage',
|
||||
description: 'Title for manage tab',
|
||||
},
|
||||
detailsTabTitle: {
|
||||
id: 'course-authoring.library-authoring.collections-sidebar.details-tab.title',
|
||||
defaultMessage: 'Details',
|
||||
description: 'Title for details tab',
|
||||
},
|
||||
noComponentsInCollection: {
|
||||
id: 'course-authoring.library-authoring.collections-pag.no-components.text',
|
||||
defaultMessage: 'This collection is currently empty.',
|
||||
description: 'Text to display when collection has no associated components',
|
||||
},
|
||||
addComponentsInCollection: {
|
||||
id: 'course-authoring.library-authoring.collections-pag.add-components.btn-text',
|
||||
defaultMessage: 'New',
|
||||
description: 'Text to display in new button if no components in collection is found',
|
||||
},
|
||||
noSearchResultsInCollection: {
|
||||
id: 'course-authoring.library-authoring.collections-pag.no-search-results.text',
|
||||
defaultMessage: 'No matching components found in this collections.',
|
||||
description: 'Message displayed when no matching components are found in collection',
|
||||
},
|
||||
newContentButton: {
|
||||
id: 'course-authoring.library-authoring.collections.buttons.new-content.text',
|
||||
defaultMessage: 'New',
|
||||
description: 'Text of button to open "Add content drawer" in collections page',
|
||||
},
|
||||
collectionInfoButton: {
|
||||
id: 'course-authoring.library-authoring.buttons.collection-info.alt-text',
|
||||
defaultMessage: 'Collection Info',
|
||||
description: 'Alt text for collection info button besides the collection title',
|
||||
},
|
||||
readOnlyBadge: {
|
||||
id: 'course-authoring.library-authoring.collections.badge.read-only',
|
||||
defaultMessage: 'Read Only',
|
||||
description: 'Text in badge when the user has read only access in collections page',
|
||||
},
|
||||
allCollections: {
|
||||
id: 'course-authoring.library-authoring.all-collections.text',
|
||||
defaultMessage: 'All Collections',
|
||||
description: 'Breadcrumbs text to navigate back to all collections',
|
||||
},
|
||||
breadcrumbsAriaLabel: {
|
||||
id: 'course-authoring.library-authoring.breadcrumbs.label.text',
|
||||
defaultMessage: 'Navigation breadcrumbs',
|
||||
description: 'Aria label for navigation breadcrumbs',
|
||||
},
|
||||
searchPlaceholder: {
|
||||
id: 'course-authoring.library-authoring.search.placeholder.text',
|
||||
defaultMessage: 'Search Collection',
|
||||
description: 'Search placeholder text in collections page.',
|
||||
},
|
||||
noSearchResultsCollections: {
|
||||
id: 'course-authoring.library-authoring.no-search-results-collections',
|
||||
defaultMessage: 'No matching collections found in this library.',
|
||||
description: 'Message displayed when no matching collections are found',
|
||||
},
|
||||
noCollections: {
|
||||
id: 'course-authoring.library-authoring.no-collections',
|
||||
defaultMessage: 'You have not added any collection to this library yet.',
|
||||
description: 'Message displayed when the library has no collections',
|
||||
},
|
||||
addCollection: {
|
||||
id: 'course-authoring.library-authoring.add-collection',
|
||||
defaultMessage: 'Add collection',
|
||||
description: 'Button text to add a new collection',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -5,6 +5,7 @@ export enum SidebarBodyComponentId {
|
||||
AddContent = 'add-content',
|
||||
Info = 'info',
|
||||
ComponentInfo = 'component-info',
|
||||
CollectionInfo = 'collection-info',
|
||||
}
|
||||
|
||||
export interface LibraryContextData {
|
||||
@@ -17,6 +18,7 @@ export interface LibraryContextData {
|
||||
isCreateCollectionModalOpen: boolean;
|
||||
openCreateCollectionModal: () => void;
|
||||
closeCreateCollectionModal: () => void;
|
||||
openCollectionInfoSidebar: () => void;
|
||||
}
|
||||
|
||||
export const LibraryContext = React.createContext({
|
||||
@@ -28,6 +30,7 @@ export const LibraryContext = React.createContext({
|
||||
isCreateCollectionModalOpen: false,
|
||||
openCreateCollectionModal: () => {},
|
||||
closeCreateCollectionModal: () => {},
|
||||
openCollectionInfoSidebar: () => {},
|
||||
} as LibraryContextData);
|
||||
|
||||
/**
|
||||
@@ -57,6 +60,10 @@ export const LibraryProvider = (props: { children?: React.ReactNode }) => {
|
||||
},
|
||||
[],
|
||||
);
|
||||
const openCollectionInfoSidebar = React.useCallback(() => {
|
||||
setCurrentComponentUsageKey(undefined);
|
||||
setSidebarBodyComponent(SidebarBodyComponentId.CollectionInfo);
|
||||
}, []);
|
||||
|
||||
const context = React.useMemo(() => ({
|
||||
sidebarBodyComponent,
|
||||
@@ -68,6 +75,7 @@ export const LibraryProvider = (props: { children?: React.ReactNode }) => {
|
||||
isCreateCollectionModalOpen,
|
||||
openCreateCollectionModal,
|
||||
closeCreateCollectionModal,
|
||||
openCollectionInfoSidebar,
|
||||
}), [
|
||||
sidebarBodyComponent,
|
||||
closeLibrarySidebar,
|
||||
@@ -78,6 +86,7 @@ export const LibraryProvider = (props: { children?: React.ReactNode }) => {
|
||||
isCreateCollectionModalOpen,
|
||||
openCreateCollectionModal,
|
||||
closeCreateCollectionModal,
|
||||
openCollectionInfoSidebar,
|
||||
]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import React, { useContext, useMemo } from 'react';
|
||||
|
||||
import { useLoadOnScroll } from '../../hooks';
|
||||
import { useSearchContext } from '../../search-manager';
|
||||
@@ -6,6 +6,7 @@ import { NoComponents, NoSearchResults } from '../EmptyStates';
|
||||
import { useLibraryBlockTypes } from '../data/apiHooks';
|
||||
import ComponentCard from './ComponentCard';
|
||||
import { LIBRARY_SECTION_PREVIEW_LIMIT } from './LibrarySection';
|
||||
import { LibraryContext } from '../common/context';
|
||||
|
||||
type LibraryComponentsProps = {
|
||||
libraryId: string,
|
||||
@@ -28,6 +29,7 @@ const LibraryComponents = ({ libraryId, variant }: LibraryComponentsProps) => {
|
||||
fetchNextPage,
|
||||
isFiltered,
|
||||
} = useSearchContext();
|
||||
const { openAddContentSidebar } = useContext(LibraryContext);
|
||||
|
||||
const componentList = variant === 'preview' ? hits.slice(0, LIBRARY_SECTION_PREVIEW_LIMIT) : hits;
|
||||
|
||||
@@ -51,7 +53,7 @@ const LibraryComponents = ({ libraryId, variant }: LibraryComponentsProps) => {
|
||||
);
|
||||
|
||||
if (componentCount === 0) {
|
||||
return isFiltered ? <NoSearchResults /> : <NoComponents />;
|
||||
return isFiltered ? <NoSearchResults /> : <NoComponents handleBtnClick={openAddContentSidebar} />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
Form,
|
||||
ModalDialog,
|
||||
} from '@openedx/paragon';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Formik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
@@ -17,6 +17,7 @@ import { ToastContext } from '../../generic/toast-context';
|
||||
|
||||
const CreateCollectionModal = () => {
|
||||
const intl = useIntl();
|
||||
const navigate = useNavigate();
|
||||
const { libraryId } = useParams();
|
||||
if (!libraryId) {
|
||||
throw new Error('Rendered without libraryId URL parameter');
|
||||
@@ -29,8 +30,9 @@ const CreateCollectionModal = () => {
|
||||
const { showToast } = React.useContext(ToastContext);
|
||||
|
||||
const handleCreate = React.useCallback((values) => {
|
||||
create.mutateAsync(values).then(() => {
|
||||
create.mutateAsync(values).then((data) => {
|
||||
closeCreateCollectionModal();
|
||||
navigate(`/library/${libraryId}/collection/${data.key}`);
|
||||
showToast(intl.formatMessage(messages.createCollectionSuccess));
|
||||
}).catch(() => {
|
||||
showToast(intl.formatMessage(messages.createCollectionError));
|
||||
|
||||
@@ -50,6 +50,14 @@ export const getXBlockOLXApiUrl = (usageKey: string) => `${getApiBaseUrl()}/api/
|
||||
* Get the URL for the Library Collections API.
|
||||
*/
|
||||
export const getLibraryCollectionsApiUrl = (libraryId: string) => `${getApiBaseUrl()}/api/libraries/v2/${libraryId}/collections/`;
|
||||
/**
|
||||
* Get the URL for the collection API.
|
||||
*/
|
||||
export const getLibraryCollectionApiUrl = (libraryId: string, collectionId: string) => `${getLibraryCollectionsApiUrl(libraryId)}${collectionId}/`;
|
||||
/**
|
||||
* Get the URL for the collection API.
|
||||
*/
|
||||
export const getLibraryCollectionComponentApiUrl = (libraryId: string, collectionId: string) => `${getLibraryCollectionApiUrl(libraryId, collectionId)}components/`;
|
||||
|
||||
export interface ContentLibrary {
|
||||
id: string;
|
||||
@@ -75,6 +83,18 @@ export interface ContentLibrary {
|
||||
updated: string | null;
|
||||
}
|
||||
|
||||
export interface Collection {
|
||||
id: number;
|
||||
key: string;
|
||||
title: string;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
createdBy: string | null;
|
||||
created: string;
|
||||
modified: string;
|
||||
learningPackage: number;
|
||||
}
|
||||
|
||||
export interface LibraryBlockType {
|
||||
blockType: string;
|
||||
displayName: string;
|
||||
@@ -294,3 +314,12 @@ export async function getXBlockOLX(usageKey: string): Promise<string> {
|
||||
const { data } = await getAuthenticatedHttpClient().get(getXBlockOLXApiUrl(usageKey));
|
||||
return data.olx;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update collection components.
|
||||
*/
|
||||
export async function updateCollectionComponents(libraryId:string, collectionId: string, usageKeys: string[]) {
|
||||
await getAuthenticatedHttpClient().patch(getLibraryCollectionComponentApiUrl(libraryId, collectionId), {
|
||||
usage_keys: usageKeys,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,12 +5,18 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { renderHook } from '@testing-library/react-hooks';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { getCommitLibraryChangesUrl, getCreateLibraryBlockUrl, getLibraryCollectionsApiUrl } from './api';
|
||||
import {
|
||||
getCommitLibraryChangesUrl,
|
||||
getCreateLibraryBlockUrl,
|
||||
getLibraryCollectionComponentApiUrl,
|
||||
getLibraryCollectionsApiUrl,
|
||||
} from './api';
|
||||
import {
|
||||
useCommitLibraryChanges,
|
||||
useCreateLibraryBlock,
|
||||
useCreateLibraryCollection,
|
||||
useRevertLibraryChanges,
|
||||
useUpdateCollectionComponents,
|
||||
} from './apiHooks';
|
||||
|
||||
let axiosMock;
|
||||
@@ -89,4 +95,15 @@ describe('library api hooks', () => {
|
||||
|
||||
expect(axiosMock.history.post[0].url).toEqual(url);
|
||||
});
|
||||
|
||||
it('should add components to collection', async () => {
|
||||
const libraryId = 'lib:org:1';
|
||||
const collectionId = 'my-first-collection';
|
||||
const url = getLibraryCollectionComponentApiUrl(libraryId, collectionId);
|
||||
axiosMock.onPatch(url).reply(200);
|
||||
const { result } = renderHook(() => useUpdateCollectionComponents(libraryId, collectionId), { wrapper });
|
||||
await result.current.mutateAsync(['some-usage-key']);
|
||||
|
||||
expect(axiosMock.history.patch[0].url).toEqual(url);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
updateXBlockFields,
|
||||
createCollection,
|
||||
getXBlockOLX,
|
||||
updateCollectionComponents,
|
||||
type CreateLibraryCollectionDataRequest,
|
||||
} from './api';
|
||||
|
||||
@@ -61,6 +62,11 @@ export const libraryAuthoringQueryKeys = {
|
||||
'content',
|
||||
'libraryBlockTypes',
|
||||
],
|
||||
collection: (libraryId?: string, collectionId?: string) => [
|
||||
...libraryAuthoringQueryKeys.all,
|
||||
libraryId,
|
||||
collectionId,
|
||||
],
|
||||
};
|
||||
|
||||
export const xblockQueryKeys = {
|
||||
@@ -122,7 +128,7 @@ export const useCreateLibraryBlock = () => {
|
||||
mutationFn: createLibraryBlock,
|
||||
onSettled: (_data, _error, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.contentLibrary(variables.libraryId) });
|
||||
queryClient.invalidateQueries({ queryKey: ['content_search'] });
|
||||
queryClient.invalidateQueries({ predicate: (query) => libraryQueryPredicate(query, variables.libraryId) });
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -270,3 +276,24 @@ export const useXBlockOLX = (usageKey: string) => (
|
||||
enabled: !!usageKey,
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Use this mutation to add components to a collection in a library
|
||||
*/
|
||||
export const useUpdateCollectionComponents = (libraryId?: string, collectionId?: string) => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (usage_keys: string[]) => {
|
||||
if (libraryId !== undefined && collectionId !== undefined) {
|
||||
return updateCollectionComponents(libraryId, collectionId, usage_keys);
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
onSettled: (_data, _error, _variables) => {
|
||||
if (libraryId !== undefined && collectionId !== undefined) {
|
||||
queryClient.invalidateQueries({ predicate: (query) => libraryQueryPredicate(query, libraryId) });
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -12,9 +12,12 @@ import { LibraryContext, SidebarBodyComponentId } from '../common/context';
|
||||
import { LibraryInfo, LibraryInfoHeader } from '../library-info';
|
||||
import { ComponentInfo, ComponentInfoHeader } from '../component-info';
|
||||
import { ContentLibrary } from '../data/api';
|
||||
import { CollectionInfo, CollectionInfoHeader } from '../collections';
|
||||
import { type CollectionHit } from '../../search-manager/data/api';
|
||||
|
||||
type LibrarySidebarProps = {
|
||||
library: ContentLibrary,
|
||||
collection?: CollectionHit,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -26,7 +29,7 @@ type LibrarySidebarProps = {
|
||||
* You can add more components in `bodyComponentMap`.
|
||||
* Use the returned actions to open and close this sidebar.
|
||||
*/
|
||||
const LibrarySidebar = ({ library }: LibrarySidebarProps) => {
|
||||
const LibrarySidebar = ({ library, collection }: LibrarySidebarProps) => {
|
||||
const intl = useIntl();
|
||||
const {
|
||||
sidebarBodyComponent,
|
||||
@@ -40,6 +43,7 @@ const LibrarySidebar = ({ library }: LibrarySidebarProps) => {
|
||||
[SidebarBodyComponentId.ComponentInfo]: (
|
||||
currentComponentUsageKey && <ComponentInfo usageKey={currentComponentUsageKey} />
|
||||
),
|
||||
[SidebarBodyComponentId.CollectionInfo]: <CollectionInfo />,
|
||||
unknown: null,
|
||||
};
|
||||
|
||||
@@ -49,6 +53,7 @@ const LibrarySidebar = ({ library }: LibrarySidebarProps) => {
|
||||
[SidebarBodyComponentId.ComponentInfo]: (
|
||||
currentComponentUsageKey && <ComponentInfoHeader library={library} usageKey={currentComponentUsageKey} />
|
||||
),
|
||||
[SidebarBodyComponentId.CollectionInfo]: <CollectionInfoHeader collection={collection} />,
|
||||
unknown: null,
|
||||
};
|
||||
|
||||
|
||||
@@ -21,31 +21,16 @@ const messages = defineMessages({
|
||||
defaultMessage: 'No matching components found in this library.',
|
||||
description: 'Message displayed when no search results are found',
|
||||
},
|
||||
noSearchResultsCollections: {
|
||||
id: 'course-authoring.library-authoring.no-search-results-collections',
|
||||
defaultMessage: 'No matching collections found in this library.',
|
||||
description: 'Message displayed when no matching collections are found',
|
||||
},
|
||||
noComponents: {
|
||||
id: 'course-authoring.library-authoring.no-components',
|
||||
defaultMessage: 'You have not added any content to this library yet.',
|
||||
description: 'Message displayed when the library is empty',
|
||||
},
|
||||
noCollections: {
|
||||
id: 'course-authoring.library-authoring.no-collections',
|
||||
defaultMessage: 'You have not added any collection to this library yet.',
|
||||
description: 'Message displayed when the library has no collections',
|
||||
},
|
||||
addComponent: {
|
||||
id: 'course-authoring.library-authoring.add-component',
|
||||
defaultMessage: 'Add component',
|
||||
description: 'Button text to add a new component',
|
||||
},
|
||||
addCollection: {
|
||||
id: 'course-authoring.library-authoring.add-collection',
|
||||
defaultMessage: 'Add collection',
|
||||
description: 'Button text to add a new collection',
|
||||
},
|
||||
homeTab: {
|
||||
id: 'course-authoring.library-authoring.home-tab',
|
||||
defaultMessage: 'Home',
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useSearchContext } from './SearchManager';
|
||||
/**
|
||||
* The "main" input field where users type in search keywords. The search happens as they type (no need to press enter).
|
||||
*/
|
||||
const SearchKeywordsField: React.FC<{ className?: string }> = (props) => {
|
||||
const SearchKeywordsField: React.FC<{ className?: string, placeholder?: string }> = (props) => {
|
||||
const intl = useIntl();
|
||||
const { searchKeywords, setSearchKeywords } = useSearchContext();
|
||||
|
||||
@@ -22,7 +22,9 @@ const SearchKeywordsField: React.FC<{ className?: string }> = (props) => {
|
||||
<SearchField.Label />
|
||||
<SearchField.Input
|
||||
autoFocus
|
||||
placeholder={intl.formatMessage(messages.inputPlaceholder)}
|
||||
placeholder={props.placeholder ? props.placeholder : intl.formatMessage(
|
||||
messages.inputPlaceholder,
|
||||
)}
|
||||
/>
|
||||
<SearchField.ClearButton />
|
||||
<SearchField.SubmitButton />
|
||||
|
||||
@@ -7,9 +7,10 @@
|
||||
import React from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { MeiliSearch, type Filter } from 'meilisearch';
|
||||
import { union } from 'lodash';
|
||||
|
||||
import {
|
||||
CollectionHit, ContentHit, SearchSortOption, forceArray,
|
||||
CollectionHit, ContentHit, SearchSortOption, forceArray, OverrideQueries,
|
||||
} from './data/api';
|
||||
import { useContentSearchConnection, useContentSearchResults } from './data/apiHooks';
|
||||
|
||||
@@ -91,12 +92,13 @@ export const SearchContextProvider: React.FC<{
|
||||
overrideSearchSortOrder?: SearchSortOption
|
||||
children: React.ReactNode,
|
||||
closeSearchModal?: () => void,
|
||||
}> = ({ overrideSearchSortOrder, ...props }) => {
|
||||
overrideQueries?: OverrideQueries,
|
||||
}> = ({ overrideSearchSortOrder, overrideQueries, ...props }) => {
|
||||
const [searchKeywords, setSearchKeywords] = React.useState('');
|
||||
const [blockTypesFilter, setBlockTypesFilter] = React.useState<string[]>([]);
|
||||
const [problemTypesFilter, setProblemTypesFilter] = React.useState<string[]>([]);
|
||||
const [tagsFilter, setTagsFilter] = React.useState<string[]>([]);
|
||||
const extraFilter: string[] = forceArray(props.extraFilter);
|
||||
let extraFilter: string[] = forceArray(props.extraFilter);
|
||||
|
||||
// The search sort order can be set via the query string
|
||||
// E.g. ?sort=display_name:desc maps to SearchSortOption.TITLE_ZA.
|
||||
@@ -114,7 +116,7 @@ export const SearchContextProvider: React.FC<{
|
||||
const sort: SearchSortOption[] = (searchSortOrderToUse === SearchSortOption.RELEVANCE ? [] : [searchSortOrderToUse]);
|
||||
// Selecting SearchSortOption.RECENTLY_PUBLISHED also excludes unpublished components.
|
||||
if (searchSortOrderToUse === SearchSortOption.RECENTLY_PUBLISHED) {
|
||||
extraFilter.push('last_published IS NOT NULL');
|
||||
extraFilter = union(extraFilter, ['last_published IS NOT NULL']);
|
||||
}
|
||||
|
||||
const canClearFilters = (
|
||||
@@ -130,14 +132,7 @@ export const SearchContextProvider: React.FC<{
|
||||
}, []);
|
||||
|
||||
// Initialize a connection to Meilisearch:
|
||||
const { data: connectionDetails, isError: hasConnectionError } = useContentSearchConnection();
|
||||
const indexName = connectionDetails?.indexName;
|
||||
const client = React.useMemo(() => {
|
||||
if (connectionDetails?.apiKey === undefined || connectionDetails?.url === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return new MeiliSearch({ host: connectionDetails.url, apiKey: connectionDetails.apiKey });
|
||||
}, [connectionDetails?.apiKey, connectionDetails?.url]);
|
||||
const { client, indexName, hasConnectionError } = useContentSearchConnection();
|
||||
|
||||
// Run the search
|
||||
const result = useContentSearchResults({
|
||||
@@ -149,6 +144,7 @@ export const SearchContextProvider: React.FC<{
|
||||
problemTypesFilter,
|
||||
tagsFilter,
|
||||
sort,
|
||||
overrideQueries,
|
||||
});
|
||||
|
||||
return React.createElement(SearchContext.Provider, {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import type { Filter, MeiliSearch, MultiSearchQuery } from 'meilisearch';
|
||||
import type {
|
||||
Filter, MeiliSearch, MultiSearchQuery, SearchParams,
|
||||
} from 'meilisearch';
|
||||
|
||||
export const getContentSearchConfigUrl = () => new URL(
|
||||
'api/content_search/v2/studio/',
|
||||
@@ -146,13 +148,32 @@ function formatSearchHit(hit: Record<string, any>): ContentHit | CollectionHit {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
const { _formatted, ...newHit } = hit;
|
||||
newHit.formatted = {
|
||||
displayName: _formatted.display_name,
|
||||
content: _formatted.content ?? {},
|
||||
description: _formatted.description,
|
||||
displayName: _formatted?.display_name,
|
||||
content: _formatted?.content ?? {},
|
||||
description: _formatted?.description,
|
||||
};
|
||||
return camelCaseObject(newHit);
|
||||
}
|
||||
|
||||
export interface OverrideQueries {
|
||||
components?: SearchParams,
|
||||
collections?: SearchParams,
|
||||
}
|
||||
|
||||
function applyOverrideQueries(
|
||||
queries: MultiSearchQuery[],
|
||||
overrideQueries?: OverrideQueries,
|
||||
): MultiSearchQuery[] {
|
||||
const newQueries = [...queries];
|
||||
if (overrideQueries?.components) {
|
||||
newQueries[0] = { ...overrideQueries.components, indexUid: queries[0].indexUid };
|
||||
}
|
||||
if (overrideQueries?.collections) {
|
||||
newQueries[2] = { ...overrideQueries.collections, indexUid: queries[2].indexUid };
|
||||
}
|
||||
return newQueries;
|
||||
}
|
||||
|
||||
interface FetchSearchParams {
|
||||
client: MeiliSearch,
|
||||
indexName: string,
|
||||
@@ -165,6 +186,7 @@ interface FetchSearchParams {
|
||||
sort?: SearchSortOption[],
|
||||
/** How many results to skip, e.g. if limit=20 then passing offset=20 gets the second page. */
|
||||
offset?: number,
|
||||
overrideQueries?: OverrideQueries,
|
||||
}
|
||||
|
||||
export async function fetchSearchResults({
|
||||
@@ -176,6 +198,7 @@ export async function fetchSearchResults({
|
||||
tagsFilter,
|
||||
extraFilter,
|
||||
sort,
|
||||
overrideQueries,
|
||||
offset = 0,
|
||||
}: FetchSearchParams): Promise<{
|
||||
hits: ContentHit[],
|
||||
@@ -186,7 +209,7 @@ export async function fetchSearchResults({
|
||||
collectionHits: CollectionHit[],
|
||||
totalCollectionHits: number,
|
||||
}> {
|
||||
const queries: MultiSearchQuery[] = [];
|
||||
let queries: MultiSearchQuery[] = [];
|
||||
|
||||
// Convert 'extraFilter' into an array
|
||||
const extraFilterFormatted = forceArray(extraFilter);
|
||||
@@ -264,15 +287,19 @@ export async function fetchSearchResults({
|
||||
limit,
|
||||
});
|
||||
|
||||
queries = applyOverrideQueries(queries, overrideQueries);
|
||||
|
||||
const { results } = await client.multiSearch(({ queries }));
|
||||
const componentHitLength = results[0].hits.length;
|
||||
const collectionHitLength = results[2].hits.length;
|
||||
return {
|
||||
hits: results[0].hits.map(formatSearchHit) as ContentHit[],
|
||||
totalHits: results[0].totalHits ?? results[0].estimatedTotalHits ?? results[0].hits.length,
|
||||
totalHits: results[0].totalHits ?? results[0].estimatedTotalHits ?? componentHitLength,
|
||||
blockTypes: results[1].facetDistribution?.block_type ?? {},
|
||||
problemTypes: results[1].facetDistribution?.['content.problem_types'] ?? {},
|
||||
nextOffset: results[0].hits.length === limit || results[2].hits.length === limit ? offset + limit : undefined,
|
||||
nextOffset: componentHitLength === limit || collectionHitLength === limit ? offset + limit : undefined,
|
||||
collectionHits: results[2].hits.map(formatSearchHit) as CollectionHit[],
|
||||
totalCollectionHits: results[2].totalHits ?? results[2].estimatedTotalHits ?? results[2].hits.length,
|
||||
totalCollectionHits: results[2].totalHits ?? results[2].estimatedTotalHits ?? collectionHitLength,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -487,3 +514,19 @@ export async function fetchTagsThatMatchKeyword({
|
||||
|
||||
return { matches: Array.from(matches).map((tagPath) => ({ tagPath })), mayBeMissingResults: hits.length === limit };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch single document by its id
|
||||
*/
|
||||
/* istanbul ignore next */
|
||||
export async function fetchDocumentById({ client, indexName, id } : {
|
||||
/** The Meilisearch client instance */
|
||||
client: MeiliSearch;
|
||||
/** Which index to search */
|
||||
indexName: string;
|
||||
/** document id */
|
||||
id: string | number;
|
||||
}): Promise<ContentHit | CollectionHit> {
|
||||
const doc = await client.index(indexName).getDocument(id);
|
||||
return formatSearchHit(doc);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
|
||||
import type { Filter, MeiliSearch } from 'meilisearch';
|
||||
import { type Filter, MeiliSearch } from 'meilisearch';
|
||||
|
||||
import {
|
||||
SearchSortOption,
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
fetchSearchResults,
|
||||
fetchTagsThatMatchKeyword,
|
||||
getContentSearchConfig,
|
||||
fetchDocumentById,
|
||||
OverrideQueries,
|
||||
} from './api';
|
||||
|
||||
/**
|
||||
@@ -16,8 +18,12 @@ import {
|
||||
* to the current user that allows it to search all content he have permission to view.
|
||||
*
|
||||
*/
|
||||
export const useContentSearchConnection = () => (
|
||||
useQuery({
|
||||
export const useContentSearchConnection = (): {
|
||||
client?: MeiliSearch,
|
||||
indexName?: string,
|
||||
hasConnectionError: boolean;
|
||||
} => {
|
||||
const { data: connectionDetails, isError: hasConnectionError } = useQuery({
|
||||
queryKey: ['content_search'],
|
||||
queryFn: getContentSearchConfig,
|
||||
cacheTime: 60 * 60_000, // Even if we're not actively using the search modal, keep it in memory up to an hour
|
||||
@@ -25,8 +31,18 @@ export const useContentSearchConnection = () => (
|
||||
refetchInterval: 60 * 60_000,
|
||||
refetchOnWindowFocus: false, // This doesn't need to be refreshed when the user switches back to this tab.
|
||||
refetchOnMount: false,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
const indexName = connectionDetails?.indexName;
|
||||
const client = React.useMemo(() => {
|
||||
if (connectionDetails?.apiKey === undefined || connectionDetails?.url === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return new MeiliSearch({ host: connectionDetails.url, apiKey: connectionDetails.apiKey });
|
||||
}, [connectionDetails?.apiKey, connectionDetails?.url]);
|
||||
|
||||
return { client, indexName, hasConnectionError };
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the results of a search
|
||||
@@ -40,6 +56,7 @@ export const useContentSearchResults = ({
|
||||
problemTypesFilter = [],
|
||||
tagsFilter = [],
|
||||
sort = [],
|
||||
overrideQueries,
|
||||
}: {
|
||||
/** The Meilisearch API client */
|
||||
client?: MeiliSearch;
|
||||
@@ -57,6 +74,8 @@ export const useContentSearchResults = ({
|
||||
tagsFilter?: string[];
|
||||
/** Sort search results using these options */
|
||||
sort?: SearchSortOption[];
|
||||
/** Set true to fetch collections along with components */
|
||||
overrideQueries?: OverrideQueries,
|
||||
}) => {
|
||||
const query = useInfiniteQuery({
|
||||
enabled: client !== undefined && indexName !== undefined,
|
||||
@@ -72,6 +91,7 @@ export const useContentSearchResults = ({
|
||||
problemTypesFilter,
|
||||
tagsFilter,
|
||||
sort,
|
||||
overrideQueries,
|
||||
],
|
||||
queryFn: ({ pageParam = 0 }) => {
|
||||
if (client === undefined || indexName === undefined) {
|
||||
@@ -89,6 +109,7 @@ export const useContentSearchResults = ({
|
||||
// For infinite pagination of results, we can retrieve additional pages if requested.
|
||||
// Note that if there are 20 results per page, the "second page" has offset=20, not 2.
|
||||
offset: pageParam,
|
||||
overrideQueries,
|
||||
});
|
||||
},
|
||||
getNextPageParam: (lastPage) => lastPage.nextOffset,
|
||||
@@ -221,3 +242,27 @@ export const useTagFilterOptions = (args: {
|
||||
|
||||
return { ...mainQuery, data };
|
||||
};
|
||||
|
||||
/* istanbul ignore next */
|
||||
export const useGetSingleDocument = ({ client, indexName, id }: {
|
||||
client?: MeiliSearch;
|
||||
indexName?: string;
|
||||
id: string | number;
|
||||
}) => (
|
||||
useQuery({
|
||||
enabled: client !== undefined && indexName !== undefined,
|
||||
queryKey: [
|
||||
'content_search',
|
||||
client?.config.apiKey,
|
||||
client?.config.host,
|
||||
indexName,
|
||||
id,
|
||||
],
|
||||
queryFn: () => {
|
||||
if (client === undefined || indexName === undefined) {
|
||||
throw new Error('Required data unexpectedly undefined. Check "enable" condition of useQuery.');
|
||||
}
|
||||
return fetchDocumentById({ client, indexName, id });
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user