feat: add library component picker (#1356)

This commit is contained in:
Rômulo Penido
2024-10-16 14:18:12 -03:00
committed by GitHub
parent 8a4d1f4810
commit b81f611a0e
55 changed files with 1792 additions and 821 deletions

View File

@@ -44,7 +44,7 @@ jest.mock('react-router-dom', () => ({
const renderDrawer = (contentId, drawerParams = {}) => (
render(
<ContentTagsDrawerSheetContext.Provider value={drawerParams}>
<ContentTagsDrawer canTagObject {...drawerParams} />
<ContentTagsDrawer {...drawerParams} />
</ContentTagsDrawerSheetContext.Provider>,
{ path, params: { contentId } },
)
@@ -256,7 +256,7 @@ describe('<ContentTagsDrawer />', () => {
])(
'should hide "$editButton" button on $variant variant if not allowed to tag object',
async ({ variant, editButton }) => {
renderDrawer(stagedTagsId, { variant, canTagObject: false });
renderDrawer(stagedTagsId, { variant, readOnly: true });
expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: editButton })).not.toBeInTheDocument();

View File

@@ -100,10 +100,10 @@ const ContentTagsDrawerTitle = () => {
interface ContentTagsDrawerVariantFooterProps {
onClose: () => void,
canTagObject: boolean,
readOnly: boolean,
}
const ContentTagsDrawerVariantFooter = ({ onClose, canTagObject }: ContentTagsDrawerVariantFooterProps) => {
const ContentTagsDrawerVariantFooter = ({ onClose, readOnly }: ContentTagsDrawerVariantFooterProps) => {
const intl = useIntl();
const {
commitGlobalStagedTagsStatus,
@@ -131,7 +131,7 @@ const ContentTagsDrawerVariantFooter = ({ onClose, canTagObject }: ContentTagsDr
? messages.tagsDrawerCancelButtonText
: messages.tagsDrawerCloseButtonText)}
</Button>
{canTagObject && (
{!readOnly && (
<Button
className="rounded-0"
onClick={isEditMode
@@ -157,7 +157,11 @@ const ContentTagsDrawerVariantFooter = ({ onClose, canTagObject }: ContentTagsDr
);
};
const ContentTagsComponentVariantFooter = ({ canTagObject }: { canTagObject: boolean }) => {
interface ContentTagsComponentVariantFooterProps {
readOnly?: boolean;
}
const ContentTagsComponentVariantFooter = ({ readOnly = false }: ContentTagsComponentVariantFooterProps) => {
const intl = useIntl();
const {
commitGlobalStagedTagsStatus,
@@ -198,16 +202,14 @@ const ContentTagsComponentVariantFooter = ({ canTagObject }: { canTagObject: boo
</div>
)}
</div>
) : (
canTagObject && (
<Button
variant="outline-primary"
onClick={toEditMode}
block
>
{intl.formatMessage(messages.manageTagsButton)}
</Button>
)
) : !readOnly && (
<Button
variant="outline-primary"
onClick={toEditMode}
block
>
{intl.formatMessage(messages.manageTagsButton)}
</Button>
)}
</div>
);
@@ -216,8 +218,8 @@ const ContentTagsComponentVariantFooter = ({ canTagObject }: { canTagObject: boo
interface ContentTagsDrawerProps {
id?: string;
onClose?: () => void;
canTagObject?: boolean;
variant?: 'drawer' | 'component';
readOnly?: boolean;
}
/**
@@ -232,8 +234,8 @@ interface ContentTagsDrawerProps {
const ContentTagsDrawer = ({
id,
onClose,
canTagObject = false,
variant = 'drawer',
readOnly = false,
}: ContentTagsDrawerProps) => {
const intl = useIntl();
// TODO: We can delete 'params' when the iframe is no longer used on edx-platform
@@ -244,7 +246,7 @@ const ContentTagsDrawer = ({
throw new Error('Error: contentId cannot be null.');
}
const context = useContentTagsDrawerContext(contentId, canTagObject);
const context = useContentTagsDrawerContext(contentId, !readOnly);
const { blockingSheet } = useContext(ContentTagsDrawerSheetContext);
const {
@@ -308,9 +310,9 @@ const ContentTagsDrawer = ({
if (isTaxonomyListLoaded && isContentTaxonomyTagsLoaded) {
switch (variant) {
case 'drawer':
return <ContentTagsDrawerVariantFooter onClose={onCloseDrawer} canTagObject={canTagObject} />;
return <ContentTagsDrawerVariantFooter onClose={onCloseDrawer} readOnly={readOnly} />;
case 'component':
return <ContentTagsComponentVariantFooter canTagObject={canTagObject} />;
return <ContentTagsComponentVariantFooter readOnly={readOnly} />;
default:
return null;
}

View File

@@ -14,7 +14,7 @@ const ContentTagsDrawerSheet = ({ id, onClose, showSheet }) => {
// ContentTagsDrawerSheet is only used when editing Courses/Course Units,
// so we assume it's ok to edit the object tags too.
const canTagObject = true;
const readOnly = false;
return (
<ContentTagsDrawerSheetContext.Provider value={context}>
@@ -27,7 +27,7 @@ const ContentTagsDrawerSheet = ({ id, onClose, showSheet }) => {
<ContentTagsDrawer
id={id}
onClose={onClose}
canTagObject={canTagObject}
readOnly={readOnly}
/>
</Sheet>
</ContentTagsDrawerSheetContext.Provider>

View File

@@ -1,6 +1,12 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { act, render, fireEvent } from '@testing-library/react';
import {
act,
render,
fireEvent,
screen,
waitFor,
} from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { AppProvider } from '@edx/frontend-platform/react';
import { initializeMockApp, getConfig } from '@edx/frontend-platform';
@@ -84,7 +90,7 @@ describe('<PageAlerts />', () => {
});
it('renders discussion alerts', async () => {
const { queryByText } = renderComponent({
renderComponent({
...pageAlertsData,
discussionsSettings: {
providerType: 'openedx',
@@ -93,19 +99,21 @@ describe('<PageAlerts />', () => {
discussionsIncontextLearnmoreUrl: 'some-learn-more-url',
});
expect(queryByText(messages.discussionNotificationText.defaultMessage)).toBeInTheDocument();
const learnMoreBtn = queryByText(messages.discussionNotificationLearnMore.defaultMessage);
expect(screen.queryByText(messages.discussionNotificationText.defaultMessage)).toBeInTheDocument();
const learnMoreBtn = screen.queryByText(messages.discussionNotificationLearnMore.defaultMessage);
expect(learnMoreBtn).toBeInTheDocument();
expect(learnMoreBtn).toHaveAttribute('href', 'some-learn-more-url');
const dismissBtn = queryByText('Dismiss');
await act(async () => fireEvent.click(dismissBtn));
const dismissBtn = screen.queryByText('Dismiss');
fireEvent.click(dismissBtn);
const discussionAlertDismissKey = `discussionAlertDismissed-${pageAlertsData.courseId}`;
expect(localStorage.getItem(discussionAlertDismissKey)).toBe('true');
const feedbackLink = queryByText(messages.discussionNotificationFeedback.defaultMessage);
expect(feedbackLink).toBeInTheDocument();
expect(feedbackLink).toHaveAttribute('href', 'some-feedback-url');
await waitFor(() => {
const feedbackLink = screen.queryByText(messages.discussionNotificationFeedback.defaultMessage);
expect(feedbackLink).toBeInTheDocument();
expect(feedbackLink).toHaveAttribute('href', 'some-feedback-url');
});
});
it('renders deprecation warning alerts', async () => {

View File

@@ -1,7 +1,7 @@
.component-style-default {
background-color: #005C9E;
.pgn__icon {
.pgn__icon:not(.btn-icon-before) {
color: white;
}
@@ -10,12 +10,23 @@
background-color: darken(#005C9E, 15%);
}
}
.btn {
background-color: lighten(#005C9E, 10%);
border: 0;
&:hover, &:active, &:focus {
background-color: lighten(#005C9E, 20%);
border: 1px solid $primary;
margin: -1px;
}
}
}
.component-style-html {
background-color: #9747FF;
.pgn__icon {
.pgn__icon:not(.btn-icon-before) {
color: white;
}
@@ -24,12 +35,23 @@
background-color: darken(#9747FF, 15%);
}
}
.btn {
background-color: lighten(#9747FF, 10%);
border: 0;
&:hover, &:active, &:focus {
background-color: lighten(#9747FF, 20%);
border: 1px solid $primary;
margin: -1px;
}
}
}
.component-style-collection {
background-color: #FFCD29;
.pgn__icon {
.pgn__icon:not(.btn-icon-before) {
color: black;
}
@@ -38,12 +60,23 @@
background-color: darken(#FFCD29, 15%);
}
}
.btn {
background-color: lighten(#FFCD29, 10%);
border: 0;
&:hover, &:active, &:focus {
background-color: lighten(#FFCD29, 20%);
border: 1px solid $primary;
margin: -1px;
}
}
}
.component-style-video {
background-color: #358F0A;
.pgn__icon {
.pgn__icon:not(.btn-icon-before) {
color: white;
}
@@ -52,12 +85,23 @@
background-color: darken(#358F0A, 15%);
}
}
.btn {
background-color: lighten(#358F0A, 10%);
border: 0;
&:hover, &:active, &:focus {
background-color: lighten(#358F0A, 20%);
border: 1px solid $primary;
margin: -1px;
}
}
}
.component-style-vertical {
background-color: #0B8E77;
.pgn__icon {
.pgn__icon:not(.btn-icon-before) {
color: white;
}
@@ -66,12 +110,23 @@
background-color: darken(#0B8E77, 15%);
}
}
.btn {
background-color: lighten(#0B8E77, 10%);
border: 0;
&:hover, &:active, &:focus {
background-color: lighten(#0B8E77, 20%);
border: 1px solid $primary;
margin: -1px;
}
}
}
.component-style-other {
background-color: #646464;
.pgn__icon {
.pgn__icon:not(.btn-icon-before) {
color: white;
}
@@ -80,4 +135,15 @@
background-color: darken(#646464, 15%);
}
}
.btn {
background-color: lighten(#646464, 10%);
border: 0;
&:hover, &:active, &:focus {
background-color: lighten(#646464, 20%);
border: 1px solid $primary;
margin: -1px;
}
}
}

View File

@@ -16,7 +16,7 @@ import { initializeHotjar } from '@edx/frontend-enterprise-hotjar';
import { logError } from '@edx/frontend-platform/logging';
import messages from './i18n';
import { CreateLibrary, LibraryLayout } from './library-authoring';
import { ComponentPicker, CreateLibrary, LibraryLayout } from './library-authoring';
import initializeStore from './store';
import CourseAuthoringRoutes from './CourseAuthoringRoutes';
import Head from './head/Head';
@@ -55,6 +55,7 @@ const App = () => {
<Route path="/libraries-v1" element={<StudioHome />} />
<Route path="/library/create" element={<CreateLibrary />} />
<Route path="/library/:libraryId/*" element={<LibraryLayout />} />
<Route path="/component-picker" element={<ComponentPicker />} />
<Route path="/course/:courseId/*" element={<CourseAuthoringRoutes />} />
<Route path="/course_rerun/:courseId" element={<CourseRerun />} />
{getConfig().ENABLE_ACCESSIBILITY_PAGE === 'true' && (

View File

@@ -6,7 +6,6 @@ import {
import { Add } from '@openedx/paragon/icons';
import { ClearFiltersButton } from '../search-manager';
import messages from './messages';
import { useContentLibrary } from './data/apiHooks';
import { useLibraryContext } from './common/context';
export const NoComponents = ({
@@ -18,14 +17,12 @@ export const NoComponents = ({
addBtnText?: MessageDescriptor;
handleBtnClick: () => void;
}) => {
const { libraryId } = useLibraryContext();
const { data: libraryData } = useContentLibrary(libraryId);
const canEditLibrary = libraryData?.canEditLibrary ?? false;
const { readOnly } = useLibraryContext();
return (
<Stack direction="horizontal" gap={3} className="mt-6 justify-content-center">
<FormattedMessage {...infoText} />
{canEditLibrary && (
{!readOnly && (
<Button iconBefore={Add} onClick={handleBtnClick}>
<FormattedMessage {...addBtnText} />
</Button>

View File

@@ -519,7 +519,7 @@ describe('<LibraryAuthoringPage />', () => {
expect(showProbTypesSubmenuBtn).not.toBeNull();
fireEvent.click(showProbTypesSubmenuBtn!);
const validateSubmenu = async (submenuText : string) => {
const validateSubmenu = async (submenuText: string) => {
const submenu = screen.getByText(submenuText);
expect(submenu).toBeInTheDocument();
fireEvent.click(submenu);

View File

@@ -1,19 +1,24 @@
import React, { useEffect } from 'react';
import { useEffect, useState } from 'react';
import { Helmet } from 'react-helmet';
import classNames from 'classnames';
import { StudioFooter } from '@edx/frontend-component-footer';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
Badge,
Breadcrumb,
Button,
Container,
Icon,
Stack,
Tab,
Tabs,
} from '@openedx/paragon';
import { Add, InfoOutline } from '@openedx/paragon/icons';
import { Add, ArrowBack, InfoOutline } from '@openedx/paragon/icons';
import {
Routes, Route, useLocation, useNavigate, useSearchParams,
Link,
useLocation,
useNavigate,
useSearchParams,
} from 'react-router-dom';
import Loading from '../generic/Loading';
@@ -31,7 +36,6 @@ import {
import LibraryComponents from './components/LibraryComponents';
import LibraryCollections from './collections/LibraryCollections';
import LibraryHome from './LibraryHome';
import { useContentLibrary } from './data/apiHooks';
import { LibrarySidebar } from './library-sidebar';
import { SidebarBodyComponentId, useLibraryContext } from './common/context';
import messages from './messages';
@@ -42,23 +46,33 @@ enum TabList {
collections = 'collections',
}
interface HeaderActionsProps {
canEditLibrary: boolean;
interface TabContentProps {
eventKey: string;
handleTabChange: (key: string) => void;
}
const HeaderActions = ({ canEditLibrary }: HeaderActionsProps) => {
const TabContent = ({ eventKey, handleTabChange }: TabContentProps) => {
switch (eventKey) {
case TabList.components:
return <LibraryComponents variant="full" />;
case TabList.collections:
return <LibraryCollections variant="full" />;
default:
return <LibraryHome tabList={TabList} handleTabChange={handleTabChange} />;
}
};
const HeaderActions = () => {
const intl = useIntl();
const {
componentPickerMode,
openAddContentSidebar,
openInfoSidebar,
closeLibrarySidebar,
sidebarBodyComponent,
readOnly,
} = useLibraryContext();
if (!canEditLibrary) {
return null;
}
const infoSidebarIsOpen = () => (
sidebarBodyComponent === SidebarBodyComponentId.Info
);
@@ -84,26 +98,32 @@ const HeaderActions = ({ canEditLibrary }: HeaderActionsProps) => {
>
{intl.formatMessage(messages.libraryInfoButton)}
</Button>
<Button
className="ml-1"
iconBefore={Add}
variant="primary rounded-0"
onClick={openAddContentSidebar}
disabled={!canEditLibrary}
>
{intl.formatMessage(messages.newContentButton)}
</Button>
{!componentPickerMode && (
<Button
className="ml-1"
iconBefore={Add}
variant="primary rounded-0"
onClick={openAddContentSidebar}
disabled={readOnly}
>
{intl.formatMessage(messages.newContentButton)}
</Button>
)}
</div>
);
};
const SubHeaderTitle = ({ title, canEditLibrary }: { title: string, canEditLibrary: boolean }) => {
const SubHeaderTitle = ({ title }: { title: string }) => {
const intl = useIntl();
const { readOnly, componentPickerMode } = useLibraryContext();
const showReadOnlyBadge = readOnly && !componentPickerMode;
return (
<Stack direction="vertical">
{title}
{ !canEditLibrary && (
{showReadOnlyBadge && (
<div>
<Badge variant="primary" style={{ fontSize: '50%' }}>
{intl.formatMessage(messages.readOnlyBadge)}
@@ -114,64 +134,109 @@ const SubHeaderTitle = ({ title, canEditLibrary }: { title: string, canEditLibra
);
};
const LibraryAuthoringPage = () => {
interface LibraryAuthoringPageProps {
returnToLibrarySelection?: () => void,
}
const LibraryAuthoringPage = ({ returnToLibrarySelection }: LibraryAuthoringPageProps) => {
const intl = useIntl();
const location = useLocation();
const navigate = useNavigate();
const { libraryId } = useLibraryContext();
const { data: libraryData, isLoading } = useContentLibrary(libraryId);
const currentPath = location.pathname.split('/').pop();
const activeKey = (currentPath && currentPath in TabList) ? TabList[currentPath] : TabList.home;
const {
libraryId,
libraryData,
isLoadingLibraryData,
componentPickerMode,
sidebarBodyComponent,
openInfoSidebar,
} = useLibraryContext();
const [activeKey, setActiveKey] = useState<string | undefined>('');
useEffect(() => {
openInfoSidebar();
const currentPath = location.pathname.split('/').pop();
if (componentPickerMode || currentPath === libraryId || currentPath === '') {
setActiveKey(TabList.home);
} else if (currentPath && currentPath in TabList) {
setActiveKey(TabList[currentPath]);
}
}, [location.pathname]);
useEffect(() => {
if (!componentPickerMode) {
openInfoSidebar();
}
}, []);
const [searchParams] = useSearchParams();
if (isLoading) {
if (isLoadingLibraryData) {
return <Loading />;
}
// istanbul ignore if: this should never happen
if (activeKey === undefined) {
return <NotFoundAlert />;
}
if (!libraryData) {
return <NotFoundAlert />;
}
const handleTabChange = (key: string) => {
navigate({
pathname: key,
search: searchParams.toString(),
});
setActiveKey(key);
if (!componentPickerMode) {
navigate({
pathname: key,
search: searchParams.toString(),
});
}
};
const breadcumbs = componentPickerMode ? (
<Breadcrumb
links={[
{
label: '',
to: '',
},
{
label: intl.formatMessage(messages.returnToLibrarySelection),
onClick: returnToLibrarySelection,
},
]}
spacer={<Icon src={ArrowBack} size="sm" />}
linkAs={Link}
/>
) : undefined;
return (
<div className="d-flex">
<div className="flex-grow-1">
<Helmet><title>{libraryData.title} | {process.env.SITE_NAME}</title></Helmet>
<Header
number={libraryData.slug}
title={libraryData.title}
org={libraryData.org}
contextId={libraryId}
isLibrary
containerProps={{
size: undefined,
}}
/>
{!componentPickerMode && (
<Header
number={libraryData.slug}
title={libraryData.title}
org={libraryData.org}
contextId={libraryId}
isLibrary
containerProps={{
size: undefined,
}}
/>
)}
<Container className="px-4 mt-4 mb-5 library-authoring-page">
<SearchContextProvider
extraFilter={`context_key = "${libraryId}"`}
>
<SubHeader
title={<SubHeaderTitle title={libraryData.title} canEditLibrary={libraryData.canEditLibrary} />}
subtitle={intl.formatMessage(messages.headingSubtitle)}
headerActions={<HeaderActions canEditLibrary={libraryData.canEditLibrary} />}
title={<SubHeaderTitle title={libraryData.title} />}
subtitle={!componentPickerMode ? intl.formatMessage(messages.headingSubtitle) : undefined}
breadcrumbs={breadcumbs}
headerActions={<HeaderActions />}
/>
<SearchKeywordsField className="w-50" />
<div className="d-flex mt-3 align-items-center">
@@ -191,33 +256,14 @@ const LibraryAuthoringPage = () => {
<Tab eventKey={TabList.components} title={intl.formatMessage(messages.componentsTab)} />
<Tab eventKey={TabList.collections} title={intl.formatMessage(messages.collectionsTab)} />
</Tabs>
<Routes>
<Route
path={TabList.home}
element={(
<LibraryHome tabList={TabList} handleTabChange={handleTabChange} />
)}
/>
<Route
path={TabList.components}
element={<LibraryComponents variant="full" />}
/>
<Route
path={TabList.collections}
element={<LibraryCollections variant="full" />}
/>
<Route
path="*"
element={<NotFoundAlert />}
/>
</Routes>
<TabContent eventKey={activeKey} handleTabChange={handleTabChange} />
</SearchContextProvider>
</Container>
<StudioFooter containerProps={{ size: undefined }} />
{!componentPickerMode && <StudioFooter containerProps={{ size: undefined }} />}
</div>
{ !!sidebarBodyComponent && (
{!!sidebarBodyComponent && (
<div className="library-authoring-sidebar box-shadow-left-1 bg-white" data-testid="library-sidebar">
<LibrarySidebar library={libraryData} />
<LibrarySidebar />
</div>
)}
</div>

View File

@@ -2,6 +2,7 @@ import {
Route,
Routes,
useParams,
useMatch,
} from 'react-router-dom';
import LibraryAuthoringPage from './LibraryAuthoringPage';
@@ -14,13 +15,17 @@ import { ComponentEditorModal } from './components/ComponentEditorModal';
const LibraryLayout = () => {
const { libraryId } = useParams();
const match = useMatch('/library/:libraryId/collection/:collectionId');
const collectionId = match?.params.collectionId;
if (libraryId === undefined) {
// istanbul ignore next - This shouldn't be possible; it's just here to satisfy the type checker.
throw new Error('Error: route is missing libraryId.');
}
return (
<LibraryProvider libraryId={libraryId}>
<LibraryProvider key={collectionId} libraryId={libraryId} collectionId={collectionId}>
<Routes>
<Route
path="collection/:collectionId"

View File

@@ -5,11 +5,12 @@ import { mockContentSearchConfig, mockGetBlockTypes } from '../../search-manager
import {
initializeMocks,
fireEvent,
render,
render as baseRender,
screen,
waitFor,
within,
} from '../../testUtils';
import { LibraryProvider } from '../common/context';
import * as api from '../data/api';
import { mockContentLibrary, mockGetCollectionMetadata } from '../data/api.mocks';
import CollectionDetails from './CollectionDetails';
@@ -17,6 +18,7 @@ import CollectionDetails from './CollectionDetails';
let axiosMock: MockAdapter;
let mockShowToast: (message: string) => void;
mockContentLibrary.applyMock();
mockGetCollectionMetadata.applyMock();
mockContentSearchConfig.applyMock();
mockGetBlockTypes.applyMock();
@@ -26,6 +28,14 @@ const { description: originalDescription } = mockGetCollectionMetadata.collectio
const library = mockContentLibrary.libraryData;
const render = () => baseRender(<CollectionDetails />, {
extraWrapper: ({ children }) => (
<LibraryProvider libraryId={library.id} initialSidebarCollectionId={collectionId}>
{ children }
</LibraryProvider>
),
});
describe('<CollectionDetails />', () => {
beforeEach(() => {
const mocks = initializeMocks();
@@ -38,7 +48,7 @@ describe('<CollectionDetails />', () => {
});
it('should render Collection Details', async () => {
render(<CollectionDetails library={library} collectionId={collectionId} />);
render();
// Collection Description
expect(await screen.findByText('Description / Card Preview Text')).toBeInTheDocument();
@@ -53,7 +63,7 @@ describe('<CollectionDetails />', () => {
});
it('should allow modifying the description', async () => {
render(<CollectionDetails library={library} collectionId={collectionId} />);
render();
expect(await screen.findByText('Description / Card Preview Text')).toBeInTheDocument();
expect(screen.getByText(originalDescription)).toBeInTheDocument();
@@ -87,7 +97,7 @@ describe('<CollectionDetails />', () => {
});
it('should show error while modifing the description', async () => {
render(<CollectionDetails library={library} collectionId={collectionId} />);
render();
expect(await screen.findByText('Description / Card Preview Text')).toBeInTheDocument();
expect(screen.getByText(originalDescription)).toBeInTheDocument();
@@ -112,7 +122,7 @@ describe('<CollectionDetails />', () => {
it('should render Collection stats', async () => {
mockGetBlockTypes('someBlocks');
render(<CollectionDetails library={library} collectionId={collectionId} />);
render();
expect(await screen.findByText('Description / Card Preview Text')).toBeInTheDocument();
expect(screen.getByText('Collection Stats')).toBeInTheDocument();
@@ -131,7 +141,7 @@ describe('<CollectionDetails />', () => {
it('should render Collection stats for empty collection', async () => {
mockGetBlockTypes('noBlocks');
render(<CollectionDetails library={library} collectionId={collectionId} />);
render();
expect(await screen.findByText('Description / Card Preview Text')).toBeInTheDocument();
expect(screen.getByText('Collection Stats')).toBeInTheDocument();
@@ -140,7 +150,7 @@ describe('<CollectionDetails />', () => {
it('should render Collection stats for big collection', async () => {
mockGetBlockTypes('moreBlocks');
render(<CollectionDetails library={library} collectionId={collectionId} />);
render();
expect(await screen.findByText('Description / Card Preview Text')).toBeInTheDocument();
expect(screen.getByText('Collection Stats')).toBeInTheDocument();

View File

@@ -6,7 +6,7 @@ import classNames from 'classnames';
import { getItemIcon } from '../../generic/block-type-utils';
import { ToastContext } from '../../generic/toast-context';
import { BlockTypeLabel, useGetBlockTypes } from '../../search-manager';
import type { ContentLibrary } from '../data/api';
import { useLibraryContext } from '../common/context';
import { useCollection, useUpdateCollection } from '../data/apiHooks';
import HistoryWidget from '../generic/history-widget';
import messages from './messages';
@@ -36,12 +36,9 @@ const BlockCount = ({
);
};
interface CollectionStatsWidgetProps {
libraryId: string,
collectionId: string,
}
const CollectionStatsWidget = () => {
const { libraryId, sidebarCollectionId: collectionId } = useLibraryContext();
const CollectionStatsWidget = ({ libraryId, collectionId }: CollectionStatsWidgetProps) => {
const { data: blockTypes } = useGetBlockTypes([
`context_key = "${libraryId}"`,
`collections.key = "${collectionId}"`,
@@ -96,17 +93,22 @@ const CollectionStatsWidget = ({ libraryId, collectionId }: CollectionStatsWidge
);
};
interface CollectionDetailsProps {
library: ContentLibrary,
collectionId: string,
}
const CollectionDetails = ({ library, collectionId }: CollectionDetailsProps) => {
const CollectionDetails = () => {
const intl = useIntl();
const { showToast } = useContext(ToastContext);
const {
libraryId,
sidebarCollectionId: collectionId,
readOnly,
} = useLibraryContext();
const updateMutation = useUpdateCollection(library.id, collectionId);
const { data: collection } = useCollection(library.id, collectionId);
// istanbul ignore next: This should never happen
if (!collectionId) {
throw new Error('collectionId is required');
}
const updateMutation = useUpdateCollection(libraryId, collectionId);
const { data: collection } = useCollection(libraryId, collectionId);
const [description, setDescription] = useState(collection?.description || '');
@@ -142,7 +144,7 @@ const CollectionDetails = ({ library, collectionId }: CollectionDetailsProps) =>
<h3 className="h5">
{intl.formatMessage(messages.detailsTabDescriptionTitle)}
</h3>
{library.canEditLibrary ? (
{!readOnly ? (
<textarea
className="form-control"
value={description}
@@ -155,7 +157,7 @@ const CollectionDetails = ({ library, collectionId }: CollectionDetailsProps) =>
<h3 className="h5">
{intl.formatMessage(messages.detailsTabStatsTitle)}
</h3>
<CollectionStatsWidget libraryId={library.id} collectionId={collectionId} />
<CollectionStatsWidget />
</div>
<hr className="w-100" />
<div>

View File

@@ -5,32 +5,51 @@ import {
Tab,
Tabs,
} from '@openedx/paragon';
import { Link, useMatch } from 'react-router-dom';
import { useCallback } from 'react';
import { useNavigate, useMatch } from 'react-router-dom';
import type { ContentLibrary } from '../data/api';
import { useLibraryContext } from '../common/context';
import CollectionDetails from './CollectionDetails';
import messages from './messages';
interface CollectionInfoProps {
library: ContentLibrary,
collectionId: string,
}
const CollectionInfo = ({ library, collectionId }: CollectionInfoProps) => {
const CollectionInfo = () => {
const intl = useIntl();
const url = `/library/${library.id}/collection/${collectionId}/`;
const navigate = useNavigate();
const {
libraryId,
collectionId,
setCollectionId,
sidebarCollectionId,
componentPickerMode,
} = useLibraryContext();
const url = `/library/${libraryId}/collection/${sidebarCollectionId}/`;
const urlMatch = useMatch(url);
const showOpenCollectionButton = !urlMatch && collectionId !== sidebarCollectionId;
// istanbul ignore if: this should never happen
if (!sidebarCollectionId) {
throw new Error('sidebarCollectionId is required');
}
const handleOpenCollection = useCallback(() => {
if (!componentPickerMode) {
navigate(url);
} else {
setCollectionId(sidebarCollectionId);
}
}, [componentPickerMode, url]);
return (
<Stack>
{!urlMatch && (
{showOpenCollectionButton && (
<div className="d-flex flex-wrap">
<Button
as={Link}
to={url}
onClick={handleOpenCollection}
variant="outline-primary"
className="m-1 text-nowrap flex-grow-1"
disabled={!!urlMatch}
>
{intl.formatMessage(messages.openCollectionButton)}
</Button>
@@ -45,10 +64,7 @@ const CollectionInfo = ({ library, collectionId }: CollectionInfoProps) => {
Manage tab placeholder
</Tab>
<Tab eventKey="details" title={intl.formatMessage(messages.detailsTabTitle)}>
<CollectionDetails
library={library}
collectionId={collectionId}
/>
<CollectionDetails />
</Tab>
</Tabs>
</Stack>

View File

@@ -4,10 +4,11 @@ import userEvent from '@testing-library/user-event';
import {
initializeMocks,
fireEvent,
render,
render as baseRender,
screen,
waitFor,
} from '../../testUtils';
import { LibraryProvider } from '../common/context';
import { mockContentLibrary, mockGetCollectionMetadata } from '../data/api.mocks';
import * as api from '../data/api';
import CollectionInfoHeader from './CollectionInfoHeader';
@@ -16,9 +17,23 @@ let axiosMock: MockAdapter;
let mockShowToast: (message: string) => void;
mockGetCollectionMetadata.applyMock();
mockContentLibrary.applyMock();
const {
libraryId: mockLibraryId,
libraryIdReadOnly,
} = mockContentLibrary;
const { collectionId } = mockGetCollectionMetadata;
const render = (libraryId: string = mockLibraryId) => baseRender(<CollectionInfoHeader />, {
extraWrapper: ({ children }) => (
<LibraryProvider libraryId={libraryId} initialSidebarCollectionId={collectionId}>
{ children }
</LibraryProvider>
),
});
describe('<CollectionInfoHeader />', () => {
beforeEach(() => {
const mocks = initializeMocks();
@@ -32,27 +47,25 @@ describe('<CollectionInfoHeader />', () => {
});
it('should render Collection info Header', async () => {
const library = await mockContentLibrary(mockContentLibrary.libraryId);
render(<CollectionInfoHeader library={library} collectionId={collectionId} />);
render();
expect(await screen.findByText('Test Collection')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /edit collection title/i })).toBeInTheDocument();
});
it('should not render edit title button without permission', async () => {
const readOnlyLibrary = await mockContentLibrary(mockContentLibrary.libraryIdReadOnly);
render(<CollectionInfoHeader library={readOnlyLibrary} collectionId={collectionId} />);
render(libraryIdReadOnly);
expect(await screen.findByText('Test Collection')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /edit collection title/i })).not.toBeInTheDocument();
});
it('should update collection title', async () => {
const library = await mockContentLibrary(mockContentLibrary.libraryId);
render(<CollectionInfoHeader library={library} collectionId={collectionId} />);
render();
expect(await screen.findByText('Test Collection')).toBeInTheDocument();
const url = api.getLibraryCollectionApiUrl(library.id, collectionId);
const url = api.getLibraryCollectionApiUrl(mockLibraryId, collectionId);
axiosMock.onPatch(url).reply(200);
fireEvent.click(screen.getByRole('button', { name: /edit collection title/i }));
@@ -72,11 +85,10 @@ describe('<CollectionInfoHeader />', () => {
});
it('should not update collection title if title is the same', async () => {
const library = await mockContentLibrary(mockContentLibrary.libraryId);
render(<CollectionInfoHeader library={library} collectionId={collectionId} />);
render();
expect(await screen.findByText('Test Collection')).toBeInTheDocument();
const url = api.getLibraryCollectionApiUrl(library.id, collectionId);
const url = api.getLibraryCollectionApiUrl(mockLibraryId, collectionId);
axiosMock.onPatch(url).reply(200);
fireEvent.click(screen.getByRole('button', { name: /edit collection title/i }));
@@ -92,11 +104,10 @@ describe('<CollectionInfoHeader />', () => {
});
it('should not update collection title if title is empty', async () => {
const library = await mockContentLibrary(mockContentLibrary.libraryId);
render(<CollectionInfoHeader library={library} collectionId={collectionId} />);
render();
expect(await screen.findByText('Test Collection')).toBeInTheDocument();
const url = api.getLibraryCollectionApiUrl(library.id, collectionId);
const url = api.getLibraryCollectionApiUrl(mockLibraryId, collectionId);
axiosMock.onPatch(url).reply(200);
fireEvent.click(screen.getByRole('button', { name: /edit collection title/i }));
@@ -112,11 +123,10 @@ describe('<CollectionInfoHeader />', () => {
});
it('should close edit collection title on press Escape', async () => {
const library = await mockContentLibrary(mockContentLibrary.libraryId);
render(<CollectionInfoHeader library={library} collectionId={collectionId} />);
render();
expect(await screen.findByText('Test Collection')).toBeInTheDocument();
const url = api.getLibraryCollectionApiUrl(library.id, collectionId);
const url = api.getLibraryCollectionApiUrl(mockLibraryId, collectionId);
axiosMock.onPatch(url).reply(200);
fireEvent.click(screen.getByRole('button', { name: /edit collection title/i }));
@@ -132,11 +142,10 @@ describe('<CollectionInfoHeader />', () => {
});
it('should show error on edit collection title', async () => {
const library = await mockContentLibrary(mockContentLibrary.libraryId);
render(<CollectionInfoHeader library={library} collectionId={collectionId} />);
render();
expect(await screen.findByText('Test Collection')).toBeInTheDocument();
const url = api.getLibraryCollectionApiUrl(library.id, collectionId);
const url = api.getLibraryCollectionApiUrl(mockLibraryId, collectionId);
axiosMock.onPatch(url).reply(500);
fireEvent.click(screen.getByRole('button', { name: /edit collection title/i }));

View File

@@ -9,22 +9,28 @@ import {
import { Edit } from '@openedx/paragon/icons';
import { ToastContext } from '../../generic/toast-context';
import type { ContentLibrary } from '../data/api';
import { useLibraryContext } from '../common/context';
import { useCollection, useUpdateCollection } from '../data/apiHooks';
import messages from './messages';
interface CollectionInfoHeaderProps {
library: ContentLibrary;
collectionId: string;
}
const CollectionInfoHeader = ({ library, collectionId }: CollectionInfoHeaderProps) => {
const CollectionInfoHeader = () => {
const intl = useIntl();
const [inputIsActive, setIsActive] = useState(false);
const { data: collection } = useCollection(library.id, collectionId);
const {
libraryId,
sidebarCollectionId: collectionId,
readOnly,
} = useLibraryContext();
const updateMutation = useUpdateCollection(library.id, collectionId);
// istanbul ignore if: this should never happen
if (!collectionId) {
throw new Error('collectionId is required');
}
const { data: collection } = useCollection(libraryId, collectionId);
const updateMutation = useUpdateCollection(libraryId, collectionId);
const { showToast } = useContext(ToastContext);
const handleSaveDisplayName = useCallback(
@@ -83,7 +89,7 @@ const CollectionInfoHeader = ({ library, collectionId }: CollectionInfoHeaderPro
<span className="font-weight-bold m-1.5">
{collection.title}
</span>
{library.canEditLibrary && (
{!readOnly && (
<IconButton
src={Edit}
iconAs={Icon}

View File

@@ -10,8 +10,8 @@ import {
IconButton,
Stack,
} from '@openedx/paragon';
import { Add, InfoOutline } from '@openedx/paragon/icons';
import { Link, useParams } from 'react-router-dom';
import { Add, ArrowBack, InfoOutline } from '@openedx/paragon/icons';
import { Link } from 'react-router-dom';
import Loading from '../../generic/Loading';
import ErrorAlert from '../../generic/alert-error';
@@ -32,13 +32,14 @@ import messages from './messages';
import { LibrarySidebar } from '../library-sidebar';
import LibraryCollectionComponents from './LibraryCollectionComponents';
const HeaderActions = ({ canEditLibrary }: { canEditLibrary: boolean; }) => {
const HeaderActions = () => {
const intl = useIntl();
const {
openAddContentSidebar,
readOnly,
} = useLibraryContext();
if (!canEditLibrary) {
if (readOnly) {
return null;
}
@@ -49,7 +50,6 @@ const HeaderActions = ({ canEditLibrary }: { canEditLibrary: boolean; }) => {
iconBefore={Add}
variant="primary rounded-0"
onClick={openAddContentSidebar}
disabled={!canEditLibrary}
>
{intl.formatMessage(messages.newContentButton)}
</Button>
@@ -59,15 +59,17 @@ const HeaderActions = ({ canEditLibrary }: { canEditLibrary: boolean; }) => {
const SubHeaderTitle = ({
title,
canEditLibrary,
infoClickHandler,
}: {
title: string;
canEditLibrary: boolean;
infoClickHandler: () => void;
}) => {
const intl = useIntl();
const { readOnly, componentPickerMode } = useLibraryContext();
const showReadOnlyBadge = readOnly && !componentPickerMode;
return (
<Stack direction="vertical">
<Stack direction="horizontal" gap={2}>
@@ -80,7 +82,7 @@ const SubHeaderTitle = ({
variant="primary"
/>
</Stack>
{ !canEditLibrary && (
{showReadOnlyBadge && (
<div>
<Badge variant="primary" style={{ fontSize: '50%' }}>
{intl.formatMessage(messages.readOnlyBadge)}
@@ -94,7 +96,7 @@ const SubHeaderTitle = ({
const LibraryCollectionPage = () => {
const intl = useIntl();
const { libraryId, collectionId } = useParams();
const { libraryId, collectionId } = useLibraryContext();
if (!collectionId || !libraryId) {
// istanbul ignore next - This shouldn't be possible; it's just here to satisfy the type checker.
@@ -104,6 +106,8 @@ const LibraryCollectionPage = () => {
const {
sidebarBodyComponent,
openCollectionInfoSidebar,
componentPickerMode,
setCollectionId,
} = useLibraryContext();
const {
@@ -133,32 +137,56 @@ const LibraryCollectionPage = () => {
return <ErrorAlert error={error} />;
}
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: '',
},
];
const breadcumbs = !componentPickerMode ? (
<Breadcrumb
ariaLabel={intl.formatMessage(messages.breadcrumbsAriaLabel)}
links={[
{
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: '',
},
]}
linkAs={Link}
/>
) : (
<Breadcrumb
ariaLabel={intl.formatMessage(messages.breadcrumbsAriaLabel)}
links={[
{
label: '',
to: '',
},
{
label: intl.formatMessage(messages.returnToLibrary),
onClick: () => { setCollectionId(undefined); },
},
]}
spacer={<Icon src={ArrowBack} size="sm" />}
linkAs={Link}
/>
);
return (
<div className="d-flex">
<div className="flex-grow-1">
<Header
number={libraryData.slug}
title={libraryData.title}
org={libraryData.org}
contextId={libraryId}
isLibrary
/>
{!componentPickerMode && (
<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">
<SearchContextProvider
extraFilter={[`context_key = "${libraryId}"`, `collections.key = "${collectionId}"`]}
@@ -168,18 +196,11 @@ const LibraryCollectionPage = () => {
title={(
<SubHeaderTitle
title={collectionData.title}
canEditLibrary={libraryData.canEditLibrary}
infoClickHandler={() => openCollectionInfoSidebar(collectionId)}
/>
)}
breadcrumbs={(
<Breadcrumb
ariaLabel={intl.formatMessage(messages.breadcrumbsAriaLabel)}
links={breadcrumbs}
linkAs={Link}
/>
)}
headerActions={<HeaderActions canEditLibrary={libraryData.canEditLibrary} />}
breadcrumbs={breadcumbs}
headerActions={<HeaderActions />}
/>
<SearchKeywordsField className="w-50" placeholder={intl.formatMessage(messages.searchPlaceholder)} />
<div className="d-flex mt-3 mb-4 align-items-center">
@@ -194,9 +215,9 @@ const LibraryCollectionPage = () => {
</Container>
<StudioFooter />
</div>
{ !!sidebarBodyComponent && (
{!!sidebarBodyComponent && (
<div className="library-authoring-sidebar box-shadow-left-1 bg-white" data-testid="library-sidebar">
<LibrarySidebar library={libraryData} />
<LibrarySidebar />
</div>
)}
</div>

View File

@@ -126,6 +126,11 @@ const messages = defineMessages({
defaultMessage: 'Edit collection title',
description: 'Alt text for edit collection title icon button',
},
returnToLibrary: {
id: 'course-authoring.library-authoring.collection.component-picker.return-to-library',
defaultMessage: 'Back to Library',
description: 'Breadcrumbs link to return to library',
},
});
export default messages;

View File

@@ -1,5 +1,13 @@
import { useToggle } from '@openedx/paragon';
import React from 'react';
import React, {
useCallback,
useContext,
useMemo,
useState,
} from 'react';
import type { ContentLibrary } from '../data/api';
import { useContentLibrary } from '../data/apiHooks';
export enum SidebarBodyComponentId {
AddContent = 'add-content',
@@ -11,13 +19,21 @@ export enum SidebarBodyComponentId {
export interface LibraryContextData {
/** The ID of the current library */
libraryId: string;
libraryData?: ContentLibrary;
readOnly: boolean;
isLoadingLibraryData: boolean;
collectionId: string | undefined;
setCollectionId: (collectionId?: string) => void;
// Whether we're in "component picker" mode
componentPickerMode: boolean;
parentLocator?: string;
// Sidebar stuff - only one sidebar is active at any given time:
sidebarBodyComponent: SidebarBodyComponentId | null;
closeLibrarySidebar: () => void;
openAddContentSidebar: () => void;
openInfoSidebar: () => void;
openComponentInfoSidebar: (usageKey: string) => void;
currentComponentUsageKey?: string;
sidebarComponentUsageKey?: string;
// "Library Team" modal
isLibraryTeamModalOpen: boolean;
openLibraryTeamModal: () => void;
@@ -28,7 +44,7 @@ export interface LibraryContextData {
closeCreateCollectionModal: () => void;
// Current collection
openCollectionInfoSidebar: (collectionId: string) => void;
currentCollectionId?: string;
sidebarCollectionId?: string;
// Editor modal - for editing some component
/** If the editor is open and the user is editing some component, this is its usageKey */
componentBeingEdited: string | undefined;
@@ -46,57 +62,95 @@ export interface LibraryContextData {
*/
const LibraryContext = React.createContext<LibraryContextData | undefined>(undefined);
interface LibraryProviderProps {
children?: React.ReactNode;
libraryId: string;
/** The initial collection ID to show */
collectionId?: string;
/** The component picker mode is a special mode where the user is selecting a component to add to a Unit (or another
* XBlock) */
componentPickerMode?: boolean;
/** The parent component locator, if we're in component picker mode */
parentLocator?: string;
/** Only used for testing */
initialSidebarComponentUsageKey?: string;
/** Only used for testing */
initialSidebarCollectionId?: string;
}
/**
* React component to provide `LibraryContext`
*/
export const LibraryProvider = (props: { children?: React.ReactNode, libraryId: string }) => {
const [sidebarBodyComponent, setSidebarBodyComponent] = React.useState<SidebarBodyComponentId | null>(null);
const [currentComponentUsageKey, setCurrentComponentUsageKey] = React.useState<string>();
export const LibraryProvider = ({
children,
libraryId,
collectionId: collectionIdProp,
componentPickerMode = false,
parentLocator,
initialSidebarComponentUsageKey,
initialSidebarCollectionId,
}: LibraryProviderProps) => {
const [collectionId, setCollectionId] = useState(collectionIdProp);
const [sidebarBodyComponent, setSidebarBodyComponent] = useState<SidebarBodyComponentId | null>(null);
const [sidebarComponentUsageKey, setSidebarComponentUsageKey] = useState<string | undefined>(
initialSidebarComponentUsageKey,
);
const [sidebarCollectionId, setSidebarCollectionId] = useState<string | undefined>(initialSidebarCollectionId);
const [isLibraryTeamModalOpen, openLibraryTeamModal, closeLibraryTeamModal] = useToggle(false);
const [currentCollectionId, setcurrentCollectionId] = React.useState<string>();
const [isCreateCollectionModalOpen, openCreateCollectionModal, closeCreateCollectionModal] = useToggle(false);
const [componentBeingEdited, openComponentEditor] = React.useState<string | undefined>();
const closeComponentEditor = React.useCallback(() => openComponentEditor(undefined), []);
const [componentBeingEdited, openComponentEditor] = useState<string | undefined>();
const closeComponentEditor = useCallback(() => openComponentEditor(undefined), []);
const resetSidebar = React.useCallback(() => {
setCurrentComponentUsageKey(undefined);
setcurrentCollectionId(undefined);
const resetSidebar = useCallback(() => {
setSidebarComponentUsageKey(undefined);
setSidebarCollectionId(undefined);
setSidebarBodyComponent(null);
}, []);
const closeLibrarySidebar = React.useCallback(() => {
const closeLibrarySidebar = useCallback(() => {
resetSidebar();
}, []);
const openAddContentSidebar = React.useCallback(() => {
const openAddContentSidebar = useCallback(() => {
resetSidebar();
setSidebarBodyComponent(SidebarBodyComponentId.AddContent);
}, []);
const openInfoSidebar = React.useCallback(() => {
const openInfoSidebar = useCallback(() => {
resetSidebar();
setSidebarBodyComponent(SidebarBodyComponentId.Info);
}, []);
const openComponentInfoSidebar = React.useCallback(
const openComponentInfoSidebar = useCallback(
(usageKey: string) => {
resetSidebar();
setCurrentComponentUsageKey(usageKey);
setSidebarComponentUsageKey(usageKey);
setSidebarBodyComponent(SidebarBodyComponentId.ComponentInfo);
},
[],
);
const openCollectionInfoSidebar = React.useCallback((collectionId: string) => {
const openCollectionInfoSidebar = useCallback((newCollectionId: string) => {
resetSidebar();
setcurrentCollectionId(collectionId);
setSidebarCollectionId(newCollectionId);
setSidebarBodyComponent(SidebarBodyComponentId.CollectionInfo);
}, []);
const context = React.useMemo<LibraryContextData>(() => ({
libraryId: props.libraryId,
const { data: libraryData, isLoading: isLoadingLibraryData } = useContentLibrary(libraryId);
const readOnly = componentPickerMode || !libraryData?.canEditLibrary;
const context = useMemo<LibraryContextData>(() => ({
libraryId,
libraryData,
collectionId,
setCollectionId,
readOnly,
isLoadingLibraryData,
componentPickerMode,
parentLocator,
sidebarBodyComponent,
closeLibrarySidebar,
openAddContentSidebar,
openInfoSidebar,
openComponentInfoSidebar,
currentComponentUsageKey,
sidebarComponentUsageKey,
isLibraryTeamModalOpen,
openLibraryTeamModal,
closeLibraryTeamModal,
@@ -104,18 +158,24 @@ export const LibraryProvider = (props: { children?: React.ReactNode, libraryId:
openCreateCollectionModal,
closeCreateCollectionModal,
openCollectionInfoSidebar,
currentCollectionId,
sidebarCollectionId,
componentBeingEdited,
openComponentEditor,
closeComponentEditor,
}), [
props.libraryId,
libraryId,
collectionId,
setCollectionId,
libraryData,
readOnly,
isLoadingLibraryData,
componentPickerMode,
sidebarBodyComponent,
closeLibrarySidebar,
openAddContentSidebar,
openInfoSidebar,
openComponentInfoSidebar,
currentComponentUsageKey,
sidebarComponentUsageKey,
isLibraryTeamModalOpen,
openLibraryTeamModal,
closeLibraryTeamModal,
@@ -123,7 +183,7 @@ export const LibraryProvider = (props: { children?: React.ReactNode, libraryId:
openCreateCollectionModal,
closeCreateCollectionModal,
openCollectionInfoSidebar,
currentCollectionId,
sidebarCollectionId,
componentBeingEdited,
openComponentEditor,
closeComponentEditor,
@@ -131,13 +191,13 @@ export const LibraryProvider = (props: { children?: React.ReactNode, libraryId:
return (
<LibraryContext.Provider value={context}>
{props.children}
{children}
</LibraryContext.Provider>
);
};
export function useLibraryContext(): LibraryContextData {
const ctx = React.useContext(LibraryContext);
const ctx = useContext(LibraryContext);
if (ctx === undefined) {
/* istanbul ignore next */
throw new Error('useLibraryContext() was used in a component without a <LibraryProvider> ancestor.');

View File

@@ -1,7 +1,7 @@
import {
fireEvent,
initializeMocks,
render,
render as baseRender,
screen,
waitFor,
} from '../../testUtils';
@@ -21,32 +21,41 @@ mockXBlockAssets.applyMock();
mockXBlockOLX.applyMock();
const setOLXspy = mockSetXBlockOLX.applyMock();
const withLibraryId = (libraryId: string = mockContentLibrary.libraryId) => ({
extraWrapper: ({ children }: { children: React.ReactNode }) => (
<LibraryProvider libraryId={libraryId}>{children}</LibraryProvider>
),
});
const render = (
usageKey: string = mockLibraryBlockMetadata.usageKeyPublished,
libraryId: string = mockContentLibrary.libraryId,
) => baseRender(
<ComponentAdvancedInfo />,
{
extraWrapper: ({ children }: { children: React.ReactNode }) => (
<LibraryProvider libraryId={libraryId} initialSidebarComponentUsageKey={usageKey}>
{children}
</LibraryProvider>
),
},
);
describe('<ComponentAdvancedInfo />', () => {
it('should display nothing when collapsed', async () => {
beforeEach(() => {
initializeMocks();
render(<ComponentAdvancedInfo usageKey={mockLibraryBlockMetadata.usageKeyPublished} />, withLibraryId());
});
it('should display nothing when collapsed', async () => {
render();
const expandButton = await screen.findByRole('button', { name: /Advanced details/ });
expect(expandButton).toBeInTheDocument();
expect(screen.queryByText(mockLibraryBlockMetadata.usageKeyPublished)).not.toBeInTheDocument();
});
it('should display the usage key of the block (when expanded)', async () => {
initializeMocks();
render(<ComponentAdvancedInfo usageKey={mockLibraryBlockMetadata.usageKeyPublished} />, withLibraryId());
render();
const expandButton = await screen.findByRole('button', { name: /Advanced details/ });
fireEvent.click(expandButton);
expect(await screen.findByText(mockLibraryBlockMetadata.usageKeyPublished)).toBeInTheDocument();
});
it('should display the static assets of the block (when expanded)', async () => {
initializeMocks();
render(<ComponentAdvancedInfo usageKey={mockLibraryBlockMetadata.usageKeyPublished} />, withLibraryId());
render();
const expandButton = await screen.findByRole('button', { name: /Advanced details/ });
fireEvent.click(expandButton);
expect(await screen.findByText(/static\/image1\.png/)).toBeInTheDocument();
@@ -56,30 +65,24 @@ describe('<ComponentAdvancedInfo />', () => {
});
it('should display the OLX source of the block (when expanded)', async () => {
initializeMocks();
render(<ComponentAdvancedInfo usageKey={mockXBlockOLX.usageKeyHtml} />, withLibraryId());
render(mockXBlockOLX.usageKeyHtml);
const expandButton = await screen.findByRole('button', { name: /Advanced details/ });
fireEvent.click(expandButton);
// Because of syntax highlighting, the OLX will be borken up by many different tags so we need to search for
// just a substring:
const olxPart = /This is a text component which uses/;
expect(await screen.findByText(olxPart)).toBeInTheDocument();
await waitFor(() => expect(screen.getByText(olxPart)).toBeInTheDocument());
});
it('does not display "Edit OLX" button when the library is read-only', async () => {
initializeMocks();
render(
<ComponentAdvancedInfo usageKey={mockXBlockOLX.usageKeyHtml} />,
withLibraryId(mockContentLibrary.libraryIdReadOnly),
);
render(mockXBlockOLX.usageKeyHtml, mockContentLibrary.libraryIdReadOnly);
const expandButton = await screen.findByRole('button', { name: /Advanced details/ });
fireEvent.click(expandButton);
expect(screen.queryByRole('button', { name: /Edit OLX/ })).not.toBeInTheDocument();
});
it('can edit the OLX', async () => {
initializeMocks();
render(<ComponentAdvancedInfo usageKey={mockLibraryBlockMetadata.usageKeyPublished} />, withLibraryId());
render();
const expandButton = await screen.findByRole('button', { name: /Advanced details/ });
fireEvent.click(expandButton);
const editButton = await screen.findByRole('button', { name: /Edit OLX/ });
@@ -94,13 +97,11 @@ describe('<ComponentAdvancedInfo />', () => {
});
it('displays an error if editing the OLX failed', async () => {
initializeMocks();
setOLXspy.mockImplementation(async () => {
throw new Error('Example error - setting OLX failed');
});
render(<ComponentAdvancedInfo usageKey={mockLibraryBlockMetadata.usageKeyPublished} />, withLibraryId());
render(mockLibraryBlockMetadata.usageKeyPublished);
const expandButton = await screen.findByRole('button', { name: /Advanced details/ });
fireEvent.click(expandButton);
const editButton = await screen.findByRole('button', { name: /Edit OLX/ });

View File

@@ -14,22 +14,21 @@ import { LoadingSpinner } from '../../generic/Loading';
import { CodeEditor, EditorAccessor } from '../../generic/CodeEditor';
import { useLibraryContext } from '../common/context';
import {
useContentLibrary,
useUpdateXBlockOLX,
useXBlockAssets,
useXBlockOLX,
} from '../data/apiHooks';
import messages from './messages';
interface Props {
usageKey: string;
}
export const ComponentAdvancedInfo: React.FC<Props> = ({ usageKey }) => {
export const ComponentAdvancedInfo: React.FC<Record<never, never>> = () => {
const intl = useIntl();
const { libraryId } = useLibraryContext();
const { data: library } = useContentLibrary(libraryId);
const canEditLibrary = library?.canEditLibrary ?? false;
const { readOnly, sidebarComponentUsageKey: usageKey } = useLibraryContext();
// istanbul ignore if: this should never happen in production
if (!usageKey) {
throw new Error('sidebarComponentUsageKey is required to render ComponentAdvancedInfo');
}
const { data: olx, isLoading: isOLXLoading } = useXBlockOLX(usageKey);
const { data: assets, isLoading: areAssetsLoading } = useXBlockAssets(usageKey);
const editorRef = React.useRef<EditorAccessor | undefined>(undefined);
@@ -48,6 +47,7 @@ export const ComponentAdvancedInfo: React.FC<Props> = ({ usageKey }) => {
// On error, an <Alert> is shown below. We catch here to avoid the error propagating up.
});
}, [editorRef, olxUpdater, intl]);
return (
<Collapsible
styling="basic"
@@ -83,7 +83,7 @@ export const ComponentAdvancedInfo: React.FC<Props> = ({ usageKey }) => {
<FormattedMessage {...messages.advancedDetailsOLXCancelButton} />
</Button>
</>
) : canEditLibrary ? (
) : !readOnly ? (
<OverlayTrigger
placement="bottom-start"
overlay={(

View File

@@ -1,6 +1,6 @@
import {
initializeMocks,
render,
render as baseRender,
screen,
} from '../../testUtils';
import {
@@ -17,9 +17,13 @@ mockLibraryBlockMetadata.applyMock();
mockXBlockAssets.applyMock();
mockXBlockOLX.applyMock();
const withLibraryId = (libraryId: string = mockContentLibrary.libraryId) => ({
extraWrapper: ({ children }: { children: React.ReactNode }) => (
<LibraryProvider libraryId={libraryId}>{children}</LibraryProvider>
const { libraryId: mockLibraryId } = mockContentLibrary;
const render = (usageKey: string) => baseRender(<ComponentDetails />, {
extraWrapper: ({ children }) => (
<LibraryProvider libraryId={mockLibraryId} initialSidebarComponentUsageKey={usageKey}>
{children}
</LibraryProvider>
),
});
@@ -29,24 +33,24 @@ describe('<ComponentDetails />', () => {
});
it('should render the component details loading', async () => {
render(<ComponentDetails usageKey={mockLibraryBlockMetadata.usageKeyThatNeverLoads} />, withLibraryId());
render(mockLibraryBlockMetadata.usageKeyThatNeverLoads);
expect(await screen.findByText('Loading...')).toBeInTheDocument();
});
it('should render the component details error', async () => {
render(<ComponentDetails usageKey={mockLibraryBlockMetadata.usageKeyError404} />, withLibraryId());
render(mockLibraryBlockMetadata.usageKeyError404);
expect(await screen.findByText(/Mocked request failed with status code 404/)).toBeInTheDocument();
});
it('should render the component usage', async () => {
render(<ComponentDetails usageKey={mockLibraryBlockMetadata.usageKeyNeverPublished} />, withLibraryId());
render(mockLibraryBlockMetadata.usageKeyNeverPublished);
expect(await screen.findByText('Component Usage')).toBeInTheDocument();
// TODO: replace with actual data when implement tag list
// TODO: replace with actual data when implement course list
expect(screen.queryByText('This will show the courses that use this component.')).toBeInTheDocument();
});
it('should render the component history', async () => {
render(<ComponentDetails usageKey={mockLibraryBlockMetadata.usageKeyNeverPublished} />, withLibraryId());
render(mockLibraryBlockMetadata.usageKeyNeverPublished);
// Show created date
expect(await screen.findByText('June 20, 2024')).toBeInTheDocument();
// Show modified date

View File

@@ -3,17 +3,22 @@ import { Stack } from '@openedx/paragon';
import AlertError from '../../generic/alert-error';
import Loading from '../../generic/Loading';
import { useLibraryContext } from '../common/context';
import { useLibraryBlockMetadata } from '../data/apiHooks';
import HistoryWidget from '../generic/history-widget';
import { ComponentAdvancedInfo } from './ComponentAdvancedInfo';
import messages from './messages';
interface ComponentDetailsProps {
usageKey: string;
}
const ComponentDetails = ({ usageKey }: ComponentDetailsProps) => {
const ComponentDetails = () => {
const intl = useIntl();
const { sidebarComponentUsageKey: usageKey } = useLibraryContext();
// istanbul ignore if: this should never happen
if (!usageKey) {
throw new Error('usageKey is required');
}
const {
data: componentMetadata,
isError,
@@ -46,7 +51,7 @@ const ComponentDetails = ({ usageKey }: ComponentDetailsProps) => {
{...componentMetadata}
/>
</div>
<ComponentAdvancedInfo usageKey={usageKey} />
<ComponentAdvancedInfo />
</Stack>
);
};

View File

@@ -21,9 +21,14 @@ jest.mock('./ComponentManagement', () => ({
default: () => <div>Mocked management tab</div>,
}));
const withLibraryId = (libraryId: string) => ({
const withLibraryId = (libraryId: string, sidebarComponentUsageKey: string) => ({
extraWrapper: ({ children }: { children: React.ReactNode }) => (
<LibraryProvider libraryId={libraryId}>{children}</LibraryProvider>
<LibraryProvider
libraryId={libraryId}
initialSidebarComponentUsageKey={sidebarComponentUsageKey}
>
{children}
</LibraryProvider>
),
});
@@ -31,30 +36,29 @@ describe('<ComponentInfo> Sidebar', () => {
it('should show a disabled "Edit" button when the component type is not editable', async () => {
initializeMocks();
render(
<ComponentInfo usageKey={mockLibraryBlockMetadata.usageKeyThirdPartyXBlock} />,
withLibraryId(mockContentLibrary.libraryId),
<ComponentInfo />,
withLibraryId(mockContentLibrary.libraryId, mockLibraryBlockMetadata.usageKeyThirdPartyXBlock),
);
const editButton = await screen.findByRole('button', { name: /Edit component/ });
expect(editButton).toBeDisabled();
});
it('should show a disabled "Edit" button when the library is read-only', async () => {
it('should not show a "Edit" button when the library is read-only', async () => {
initializeMocks();
render(
<ComponentInfo usageKey={mockLibraryBlockMetadata.usageKeyPublished} />,
withLibraryId(mockContentLibrary.libraryIdReadOnly),
<ComponentInfo />,
withLibraryId(mockContentLibrary.libraryIdReadOnly, mockLibraryBlockMetadata.usageKeyPublished),
);
const editButton = await screen.findByRole('button', { name: /Edit component/ });
expect(editButton).toBeDisabled();
expect(screen.queryByRole('button', { name: /Edit component/ })).not.toBeInTheDocument();
});
it('should show a working "Edit" button for a normal component', async () => {
initializeMocks();
render(
<ComponentInfo usageKey={mockLibraryBlockMetadata.usageKeyPublished} />,
withLibraryId(mockContentLibrary.libraryId),
<ComponentInfo />,
withLibraryId(mockContentLibrary.libraryId, mockLibraryBlockMetadata.usageKeyPublished),
);
const editButton = await screen.findByRole('button', { name: /Edit component/ });

View File

@@ -5,54 +5,88 @@ import {
Tabs,
Stack,
} from '@openedx/paragon';
import { useContext } from 'react';
import { ToastContext } from '../../generic/toast-context';
import { useLibraryContext } from '../common/context';
import { ComponentMenu } from '../components';
import { canEditComponent } from '../components/ComponentEditorModal';
import { useAddComponentToCourse } from '../data/apiHooks';
import ComponentDetails from './ComponentDetails';
import ComponentManagement from './ComponentManagement';
import ComponentPreview from './ComponentPreview';
import messages from './messages';
import { canEditComponent } from '../components/ComponentEditorModal';
import { useLibraryContext } from '../common/context';
import { useContentLibrary } from '../data/apiHooks';
interface ComponentInfoProps {
usageKey: string;
}
const ComponentInfo = ({ usageKey }: ComponentInfoProps) => {
const ComponentInfo = () => {
const intl = useIntl();
const { libraryId, openComponentEditor } = useLibraryContext();
const { data: libraryData } = useContentLibrary(libraryId);
const canEdit = libraryData?.canEditLibrary && canEditComponent(usageKey);
const { showToast } = useContext(ToastContext);
const {
sidebarComponentUsageKey: usageKey,
readOnly,
openComponentEditor,
componentPickerMode,
parentLocator,
} = useLibraryContext();
// istanbul ignore if: this should never happen
if (!usageKey) {
throw new Error('usageKey is required');
}
const {
mutateAsync: addComponentToCourse,
reset,
} = useAddComponentToCourse(parentLocator, usageKey);
const canEdit = canEditComponent(usageKey);
const handleAddComponentToCourse = () => {
addComponentToCourse()
.then(() => {
window.parent.postMessage('closeComponentPicker', '*');
})
.catch(() => {
showToast(intl.formatMessage(messages.addComponentToCourseError));
reset();
});
};
return (
<Stack>
<div className="d-flex flex-wrap">
<Button
{...(canEdit ? { onClick: () => openComponentEditor(usageKey) } : { disabled: true })}
variant="outline-primary"
className="m-1 text-nowrap flex-grow-1"
>
{intl.formatMessage(messages.editComponentButtonTitle)}
{!readOnly && (
<div className="d-flex flex-wrap">
<Button
{...(canEdit ? { onClick: () => openComponentEditor(usageKey) } : { disabled: true })}
variant="outline-primary"
className="m-1 text-nowrap flex-grow-1"
>
{intl.formatMessage(messages.editComponentButtonTitle)}
</Button>
<Button disabled variant="outline-primary" className="m-1 text-nowrap flex-grow-1">
{intl.formatMessage(messages.publishComponentButtonTitle)}
</Button>
<ComponentMenu usageKey={usageKey} />
</div>
)}
{componentPickerMode && (
<Button variant="outline-primary" className="m-1 text-nowrap flex-grow-1" onClick={handleAddComponentToCourse}>
{intl.formatMessage(messages.addComponentToCourse)}
</Button>
<Button disabled variant="outline-primary" className="m-1 text-nowrap flex-grow-1">
{intl.formatMessage(messages.publishComponentButtonTitle)}
</Button>
<ComponentMenu usageKey={usageKey} />
</div>
)}
<Tabs
variant="tabs"
className="my-3 d-flex justify-content-around"
defaultActiveKey="preview"
>
<Tab eventKey="preview" title={intl.formatMessage(messages.previewTabTitle)}>
<ComponentPreview usageKey={usageKey} />
<ComponentPreview />
</Tab>
<Tab eventKey="manage" title={intl.formatMessage(messages.manageTabTitle)}>
<ComponentManagement usageKey={usageKey} canEdit={canEdit} />
<ComponentManagement />
</Tab>
<Tab eventKey="details" title={intl.formatMessage(messages.detailsTabTitle)}>
<ComponentDetails usageKey={usageKey} />
<ComponentDetails />
</Tab>
</Tabs>
</Stack>

View File

@@ -1,57 +1,18 @@
import MockAdapter from 'axios-mock-adapter';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { AppProvider } from '@edx/frontend-platform/react';
import { initializeMockApp } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type MockAdapter from 'axios-mock-adapter';
import {
render,
fireEvent,
render as baseRender,
screen,
waitFor,
} from '@testing-library/react';
import { ContentLibrary, getXBlockFieldsApiUrl } from '../data/api';
import initializeStore from '../../store';
import { ToastProvider } from '../../generic/toast-context';
initializeMocks,
} from '../../testUtils';
import { mockContentLibrary } from '../data/api.mocks';
import { getXBlockFieldsApiUrl } from '../data/api';
import { LibraryProvider } from '../common/context';
import ComponentInfoHeader from './ComponentInfoHeader';
let store;
let axiosMock;
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
const libraryData: ContentLibrary = {
id: 'lib:org1:lib1',
type: 'complex',
org: 'org1',
slug: 'lib1',
title: 'lib1',
description: 'lib1',
numBlocks: 2,
version: 0,
lastPublished: null,
lastDraftCreated: '2024-07-22',
publishedBy: 'staff',
lastDraftCreatedBy: 'staff',
allowLti: false,
allowPublicLearning: false,
allowPublicRead: false,
hasUnpublishedChanges: true,
hasUnpublishedDeletes: false,
canEditLibrary: true,
license: '',
created: '2024-06-26',
updated: '2024-07-20',
};
interface WrapperProps {
library?: ContentLibrary,
}
const { libraryId: mockLibraryId, libraryIdReadOnly } = mockContentLibrary;
const usageKey = 'lb:org1:library:html:a1fa8bdd-dc67-4976-9bf5-0ea75a9bca3d';
const xBlockFields = {
@@ -61,31 +22,25 @@ const xBlockFields = {
},
};
const RootWrapper = ({ library } : WrapperProps) => (
<AppProvider store={store}>
<IntlProvider locale="en" messages={{}}>
<QueryClientProvider client={queryClient}>
<ToastProvider>
<ComponentInfoHeader library={library || libraryData} usageKey={usageKey} />
</ToastProvider>
</QueryClientProvider>
</IntlProvider>
</AppProvider>
);
const render = (libraryId: string = mockLibraryId) => baseRender(<ComponentInfoHeader />, {
extraWrapper: ({ children }) => (
<LibraryProvider libraryId={libraryId} initialSidebarComponentUsageKey={usageKey}>
{children}
</LibraryProvider>
),
});
let axiosMock: MockAdapter;
let mockShowToast: (message: string) => void;
mockContentLibrary.applyMock();
describe('<ComponentInfoHeader />', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
const mocks = initializeMocks();
axiosMock = mocks.axiosMock;
axiosMock.onGet(getXBlockFieldsApiUrl(usageKey)).reply(200, xBlockFields);
mockShowToast = mocks.mockShowToast;
});
afterEach(() => {
@@ -94,19 +49,17 @@ describe('<ComponentInfoHeader />', () => {
});
it('should render component info Header', async () => {
render(<RootWrapper />);
render();
expect(await screen.findByText('Test HTML Block')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /edit component name/i })).toBeInTheDocument();
});
it('should not render edit title button without permission', () => {
const library = {
...libraryData,
canEditLibrary: false,
};
it('should not render edit title button without permission', async () => {
render(libraryIdReadOnly);
render(<RootWrapper library={library} />);
expect(await screen.findByText('Test HTML Block')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /edit component name/i })).not.toBeInTheDocument();
});
@@ -114,7 +67,9 @@ describe('<ComponentInfoHeader />', () => {
it('should edit component title', async () => {
const url = getXBlockFieldsApiUrl(usageKey);
axiosMock.onPost(url).reply(200);
render(<RootWrapper />);
render();
expect(await screen.findByText('Test HTML Block')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /edit component name/i }));
@@ -131,14 +86,16 @@ describe('<ComponentInfoHeader />', () => {
expect(axiosMock.history.post[0].data).toStrictEqual(JSON.stringify({
metadata: { display_name: 'New component name' },
}));
expect(screen.getByText('Component updated successfully.')).toBeInTheDocument();
expect(mockShowToast).toHaveBeenCalledWith('Component updated successfully.');
});
});
it('should close edit library title on press Escape', async () => {
const url = getXBlockFieldsApiUrl(usageKey);
axiosMock.onPost(url).reply(200);
render(<RootWrapper />);
render();
expect(await screen.findByText('Test HTML Block')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /edit component name/i }));
@@ -155,8 +112,9 @@ describe('<ComponentInfoHeader />', () => {
it('should show error on edit library tittle', async () => {
const url = getXBlockFieldsApiUrl(usageKey);
axiosMock.onPatch(url).reply(500);
render();
render(<RootWrapper />);
expect(await screen.findByText('Test HTML Block')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /edit component name/i }));
@@ -172,7 +130,7 @@ describe('<ComponentInfoHeader />', () => {
metadata: { display_name: 'New component name' },
}));
expect(screen.getByText('There was an error updating the component.')).toBeInTheDocument();
expect(mockShowToast).toHaveBeenCalledWith('There was an error updating the component.');
});
});
});

View File

@@ -9,19 +9,23 @@ import {
import { Edit } from '@openedx/paragon/icons';
import { ToastContext } from '../../generic/toast-context';
import type { ContentLibrary } from '../data/api';
import { useLibraryContext } from '../common/context';
import { useUpdateXBlockFields, useXBlockFields } from '../data/apiHooks';
import messages from './messages';
interface ComponentInfoHeaderProps {
library: ContentLibrary;
usageKey: string;
}
const ComponentInfoHeader = ({ library, usageKey }: ComponentInfoHeaderProps) => {
const ComponentInfoHeader = () => {
const intl = useIntl();
const [inputIsActive, setIsActive] = useState(false);
const {
sidebarComponentUsageKey: usageKey,
readOnly,
} = useLibraryContext();
// istanbul ignore next
if (!usageKey) {
throw new Error('usageKey is required');
}
const {
data: xblockFields,
} = useXBlockFields(usageKey);
@@ -80,7 +84,7 @@ const ComponentInfoHeader = ({ library, usageKey }: ComponentInfoHeaderProps) =>
<span className="font-weight-bold m-1.5">
{xblockFields?.displayName}
</span>
{library.canEditLibrary && (
{!readOnly && (
<IconButton
src={Edit}
iconAs={Icon}

View File

@@ -1,21 +1,26 @@
import { setConfig, getConfig } from '@edx/frontend-platform';
import { mockContentTaxonomyTagsData } from '../../content-tags-drawer/data/api.mocks';
import {
initializeMocks,
render as baseRender,
screen,
waitFor,
} from '../../testUtils';
import { mockLibraryBlockMetadata } from '../data/api.mocks';
import ComponentManagement from './ComponentManagement';
import { mockContentTaxonomyTagsData } from '../../content-tags-drawer/data/api.mocks';
import { LibraryProvider } from '../common/context';
import { mockContentLibrary, mockLibraryBlockMetadata } from '../data/api.mocks';
import ComponentManagement from './ComponentManagement';
jest.mock('../../content-tags-drawer', () => ({
ContentTagsDrawer: ({ canTagObject }: { canTagObject: boolean }) => (
<div>Mocked {canTagObject ? 'editable' : 'read-only'} ContentTagsDrawer</div>
ContentTagsDrawer: ({ readOnly }: { readOnly: boolean }) => (
<div>Mocked {readOnly ? 'read-only' : 'editable'} ContentTagsDrawer</div>
),
}));
mockContentLibrary.applyMock();
mockLibraryBlockMetadata.applyMock();
mockContentTaxonomyTagsData.applyMock();
/*
* This function is used to get the inner text of an element.
* https://stackoverflow.com/questions/47902335/innertext-is-undefined-in-jest-test
@@ -30,27 +35,28 @@ const matchInnerText = (nodeName: string, textToMatch: string) => (_: string, el
element.nodeName === nodeName && getInnerText(element) === textToMatch
);
const render = (ui: React.ReactElement) => baseRender(ui, {
extraWrapper: ({ children }) => <LibraryProvider libraryId="lib:OpenedX:CSPROB2">{ children }</LibraryProvider>,
const render = (usageKey: string, libraryId?: string) => baseRender(<ComponentManagement />, {
extraWrapper: ({ children }) => (
<LibraryProvider libraryId={libraryId || mockContentLibrary.libraryId} initialSidebarComponentUsageKey={usageKey}>
{children}
</LibraryProvider>
),
});
mockLibraryBlockMetadata.applyMock();
mockContentTaxonomyTagsData.applyMock();
describe('<ComponentManagement />', () => {
beforeEach(() => {
initializeMocks();
});
it('should render draft status', async () => {
render(<ComponentManagement usageKey={mockLibraryBlockMetadata.usageKeyNeverPublished} />);
render(mockLibraryBlockMetadata.usageKeyNeverPublished);
expect(await screen.findByText('Draft')).toBeInTheDocument();
expect(await screen.findByText('(Never Published)')).toBeInTheDocument();
expect(screen.getByText(matchInnerText('SPAN', 'Draft saved on June 20, 2024 at 13:54.'))).toBeInTheDocument();
});
it('should render published status', async () => {
render(<ComponentManagement usageKey={mockLibraryBlockMetadata.usageKeyPublished} />);
render(mockLibraryBlockMetadata.usageKeyPublished);
expect(await screen.findByText('Published')).toBeInTheDocument();
expect(screen.getByText('Published')).toBeInTheDocument();
expect(
@@ -60,23 +66,24 @@ describe('<ComponentManagement />', () => {
test.each([
{
canEdit: true,
libraryId: mockContentLibrary.libraryId,
expected: 'editable',
},
{
canEdit: false,
libraryId: mockContentLibrary.libraryIdReadOnly,
expected: 'read-only',
},
])(
'should render the tagging info as $expected',
async ({ canEdit, expected }) => {
async ({ libraryId, expected }) => {
setConfig({
...getConfig(),
ENABLE_TAGGING_TAXONOMY_PAGES: 'true',
});
render(<ComponentManagement usageKey={mockLibraryBlockMetadata.usageKeyNeverPublished} canEdit={canEdit} />);
expect(await screen.findByText('Tags (0)')).toBeInTheDocument();
expect(screen.queryByText(`Mocked ${expected} ContentTagsDrawer`)).toBeInTheDocument();
render(mockLibraryBlockMetadata.usageKeyForTags, libraryId);
await waitFor(() => {
expect(screen.getByText(`Mocked ${expected} ContentTagsDrawer`)).toBeInTheDocument();
});
},
);
@@ -85,7 +92,7 @@ describe('<ComponentManagement />', () => {
...getConfig(),
ENABLE_TAGGING_TAXONOMY_PAGES: 'false',
});
render(<ComponentManagement usageKey={mockLibraryBlockMetadata.usageKeyNeverPublished} />);
render(mockLibraryBlockMetadata.usageKeyNeverPublished);
expect(await screen.findByText('Draft')).toBeInTheDocument();
expect(screen.queryByText('Tags')).not.toBeInTheDocument();
});
@@ -95,12 +102,12 @@ describe('<ComponentManagement />', () => {
...getConfig(),
ENABLE_TAGGING_TAXONOMY_PAGES: 'true',
});
render(<ComponentManagement usageKey={mockLibraryBlockMetadata.usageKeyForTags} />);
render(mockLibraryBlockMetadata.usageKeyForTags);
expect(await screen.findByText('Tags (6)')).toBeInTheDocument();
});
it('should render collection count in collection info section', async () => {
render(<ComponentManagement usageKey={mockLibraryBlockMetadata.usageKeyWithCollections} />);
render(mockLibraryBlockMetadata.usageKeyWithCollections);
expect(await screen.findByText('Collections (1)')).toBeInTheDocument();
});
});

View File

@@ -4,6 +4,7 @@ import { useIntl } from '@edx/frontend-platform/i18n';
import { Collapsible, Icon, Stack } from '@openedx/paragon';
import { BookOpen, Tag } from '@openedx/paragon/icons';
import { useLibraryContext } from '../common/context';
import { useLibraryBlockMetadata } from '../data/apiHooks';
import StatusWidget from '../generic/status-widget';
import messages from './messages';
@@ -11,13 +12,15 @@ import { ContentTagsDrawer } from '../../content-tags-drawer';
import { useContentTaxonomyTagsData } from '../../content-tags-drawer/data/apiHooks';
import ManageCollections from './ManageCollections';
interface ComponentManagementProps {
usageKey: string;
canEdit?: boolean;
}
const ComponentManagement = ({ usageKey, canEdit = false }: ComponentManagementProps) => {
const ComponentManagement = () => {
const intl = useIntl();
const { sidebarComponentUsageKey: usageKey, readOnly, isLoadingLibraryData } = useLibraryContext();
// istanbul ignore if: this should never happen
if (!usageKey) {
throw new Error('usageKey is required');
}
const { data: componentMetadata } = useLibraryBlockMetadata(usageKey);
const { data: componentTags } = useContentTaxonomyTagsData(usageKey);
@@ -41,6 +44,11 @@ const ComponentManagement = ({ usageKey, canEdit = false }: ComponentManagementP
return result;
}, [componentTags]);
// istanbul ignore if: this should never happen
if (isLoadingLibraryData) {
return null;
}
// istanbul ignore if: this should never happen
if (!componentMetadata) {
return null;
@@ -66,7 +74,7 @@ const ComponentManagement = ({ usageKey, canEdit = false }: ComponentManagementP
<ContentTagsDrawer
id={usageKey}
variant="component"
canTagObject={canEdit}
readOnly={readOnly}
/>
</Collapsible>
)}

View File

@@ -1,27 +1,44 @@
import {
fireEvent,
initializeMocks,
render,
render as baseRender,
screen,
} from '../../testUtils';
import { mockLibraryBlockMetadata } from '../data/api.mocks';
import { LibraryProvider } from '../common/context';
import { mockContentLibrary, mockLibraryBlockMetadata } from '../data/api.mocks';
import ComponentPreview from './ComponentPreview';
mockLibraryBlockMetadata.applyMock();
mockContentLibrary.applyMock();
const {
libraryId,
} = mockContentLibrary;
const usageKey = mockLibraryBlockMetadata.usageKeyPublished;
const render = () => baseRender(<ComponentPreview />, {
extraWrapper: ({ children }) => (
<LibraryProvider
libraryId={libraryId}
initialSidebarComponentUsageKey={usageKey}
>
{ children }
</LibraryProvider>
),
});
describe('<ComponentPreview />', () => {
it('renders a preview of the component', async () => {
initializeMocks();
const usageKey = mockLibraryBlockMetadata.usageKeyPublished;
render(<ComponentPreview usageKey={usageKey} />);
render();
const iframe = (await screen.findByTitle('Preview')) as HTMLIFrameElement;
expect(iframe.src).toEqual(`http://localhost:18010/xblocks/v2/${usageKey}/embed/student_view/`);
});
it('shows an expanded preview of the component', async () => {
initializeMocks();
const usageKey = mockLibraryBlockMetadata.usageKeyPublished;
render(<ComponentPreview usageKey={usageKey} />);
render();
await screen.findByTitle('Preview'); // Wait for the preview to appear
const expandButton = screen.getByRole('button', { name: /Expand/ });
fireEvent.click(expandButton);

View File

@@ -1,8 +1,8 @@
import React from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Button, StandardModal, useToggle } from '@openedx/paragon';
import { OpenInFull } from '@openedx/paragon/icons';
import { useLibraryContext } from '../common/context';
import { LibraryBlock } from '../LibraryBlock';
import messages from './messages';
import { useLibraryBlockMetadata } from '../data/apiHooks';
@@ -29,14 +29,17 @@ const ModalComponentPreview = ({ isOpen, close, usageKey }: ModalComponentPrevie
);
};
interface ComponentPreviewProps {
usageKey: string;
}
const ComponentPreview = ({ usageKey }: ComponentPreviewProps) => {
const ComponentPreview = () => {
const intl = useIntl();
const [isModalOpen, openModal, closeModal] = useToggle();
const { sidebarComponentUsageKey: usageKey } = useLibraryContext();
// istanbul ignore if: this should never happen
if (!usageKey) {
throw new Error('usageKey is required');
}
const { data: componentMetadata } = useLibraryBlockMetadata(usageKey);
return (

View File

@@ -10,20 +10,24 @@ import {
} from '../../testUtils';
import mockCollectionsResults from '../__mocks__/collection-search.json';
import { mockContentSearchConfig } from '../../search-manager/data/api.mock';
import { mockLibraryBlockMetadata } from '../data/api.mocks';
import { mockContentLibrary, mockLibraryBlockMetadata } from '../data/api.mocks';
import ManageCollections from './ManageCollections';
import { LibraryProvider } from '../common/context';
import { getLibraryBlockCollectionsUrl } from '../data/api';
const render = (ui: React.ReactElement) => baseRender(ui, {
extraWrapper: ({ children }) => <LibraryProvider libraryId="lib:OpenedX:CSPROB2">{ children }</LibraryProvider>,
});
let axiosMock: MockAdapter;
let mockShowToast;
mockContentLibrary.applyMock();
mockLibraryBlockMetadata.applyMock();
mockContentSearchConfig.applyMock();
const render = (ui: React.ReactElement) => baseRender(ui, {
extraWrapper: ({ children }) => (
<LibraryProvider libraryId={mockContentLibrary.libraryId}>{children}</LibraryProvider>
),
});
const searchEndpoint = 'http://mock.meilisearch.local/multi-search';
describe('<ManageCollections />', () => {
@@ -79,6 +83,7 @@ describe('<ManageCollections />', () => {
usageKey={mockLibraryBlockMetadata.usageKeyWithCollections}
collections={[]}
/>);
screen.logTestingPlaygroundURL();
const manageBtn = await screen.findByRole('button', { name: 'Add to Collection' });
userEvent.click(manageBtn);
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); });

View File

@@ -147,6 +147,7 @@ const ComponentCollections = ({ collections, onManageClick }: {
onManageClick: () => void;
}) => {
const intl = useIntl();
const { readOnly } = useLibraryContext();
if (!collections?.length) {
return (
@@ -154,12 +155,14 @@ const ComponentCollections = ({ collections, onManageClick }: {
<span className="border-bottom pb-3 border-gray-100">
<FormattedMessage {...messages.componentNotOrganizedIntoCollection} />
</span>
<Button
onClick={onManageClick}
variant="primary"
>
{intl.formatMessage(messages.manageCollectionsAddBtnText)}
</Button>
{!readOnly && (
<Button
onClick={onManageClick}
variant="primary"
>
{intl.formatMessage(messages.manageCollectionsAddBtnText)}
</Button>
)}
</Stack>
);
}
@@ -177,12 +180,14 @@ const ComponentCollections = ({ collections, onManageClick }: {
<span>{collection}</span>
</Stack>
))}
<Button
onClick={onManageClick}
variant="outline-primary"
>
{intl.formatMessage(messages.manageCollectionsText)}
</Button>
{!readOnly && (
<Button
onClick={onManageClick}
variant="outline-primary"
>
{intl.formatMessage(messages.manageCollectionsText)}
</Button>
)}
</Stack>
);
};

View File

@@ -166,6 +166,16 @@ const messages = defineMessages({
defaultMessage: 'This component is not organized into any collection.',
description: 'Message to display in manage collections section when component is not part of any collection.',
},
addComponentToCourse: {
id: 'course-authoring.library-authoring.component.add-to-course',
defaultMessage: 'Add to Course',
description: 'Button to add component to course',
},
addComponentToCourseError: {
id: 'course-authoring.library-authoring.component.add-to-course-error',
defaultMessage: 'Failed to add component to course',
description: 'Error message when adding component to course fails',
},
});
export default messages;

View File

@@ -0,0 +1,268 @@
import type MockAdapter from 'axios-mock-adapter';
import { mockContentSearchConfig, mockSearchResult } from '../../search-manager/data/api.mock';
import {
initializeMocks,
fireEvent,
render,
screen,
waitFor,
within,
} from '../../testUtils';
import mockResult from '../__mocks__/library-search.json';
import mockCollectionResult from '../__mocks__/collection-search.json';
import {
mockContentLibrary,
mockGetCollectionMetadata,
mockGetContentLibraryV2List,
mockLibraryBlockMetadata,
} from '../data/api.mocks';
import { getXBlockBaseApiUrl } from '../data/api';
import { ComponentPicker } from './ComponentPicker';
mockContentLibrary.applyMock();
mockContentSearchConfig.applyMock();
mockGetCollectionMetadata.applyMock();
mockGetContentLibraryV2List.applyMock();
mockLibraryBlockMetadata.applyMock();
let axiosMock: MockAdapter;
let mockShowToast: (message: string) => void;
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useSearchParams: () => {
const [params] = [new URLSearchParams({
parentLocator: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical1',
})];
return [
params,
];
},
}));
describe('<ComponentPicker />', () => {
beforeEach(() => {
const mocks = initializeMocks();
axiosMock = mocks.axiosMock;
mockShowToast = mocks.mockShowToast;
axiosMock.onPost(getXBlockBaseApiUrl()).reply(200, {});
mockSearchResult(mockResult);
});
it('should pick component using the component card button', async () => {
render(<ComponentPicker />);
expect(await screen.findByText('Test Library 1')).toBeInTheDocument();
fireEvent.click(screen.getByDisplayValue(/lib:sampletaxonomyorg1:tl1/i));
fireEvent.click(screen.getByText('Next'));
// Wait for the content library to load
await screen.findByText(/Change Library/i);
expect(await screen.findByText('Test Library 1')).toBeInTheDocument();
// Click the add component from the component card
fireEvent.click(screen.queryAllByRole('button', { name: 'Add' })[0]);
await waitFor(() => {
expect(axiosMock.history.post.length).toBe(1);
expect(axiosMock.history.post[0].url).toBe(getXBlockBaseApiUrl());
expect(axiosMock.history.post[0].data).toBe(JSON.stringify({
parent_locator: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical1',
library_content_key: 'lb:Axim:TEST:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd',
}));
});
});
it('should show toast if error on api call from the component card button', async () => {
axiosMock.onPost(getXBlockBaseApiUrl()).reply(500, {});
render(<ComponentPicker />);
expect(await screen.findByText('Test Library 1')).toBeInTheDocument();
fireEvent.click(screen.getByDisplayValue(/lib:sampletaxonomyorg1:tl1/i));
fireEvent.click(screen.getByText('Next'));
// Wait for the content library to load
await screen.findByText(/Change Library/i);
expect(await screen.findByText('Test Library 1')).toBeInTheDocument();
// Click the add component from the component card
fireEvent.click(screen.queryAllByRole('button', { name: 'Add' })[0]);
await waitFor(() => {
expect(axiosMock.history.post.length).toBe(1);
expect(axiosMock.history.post[0].url).toBe(getXBlockBaseApiUrl());
expect(axiosMock.history.post[0].data).toBe(JSON.stringify({
parent_locator: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical1',
library_content_key: 'lb:Axim:TEST:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd',
}));
expect(mockShowToast).toHaveBeenCalledWith('Failed to add component to course');
});
});
it('should pick component using the component sidebar', async () => {
render(<ComponentPicker />);
expect(await screen.findByText('Test Library 1')).toBeInTheDocument();
fireEvent.click(screen.getByDisplayValue(/lib:sampletaxonomyorg1:tl1/i));
fireEvent.click(screen.getByText('Next'));
// Wait for the content library to load
await screen.findByText(/Change Library/i);
expect(await screen.findByText('Test Library 1')).toBeInTheDocument();
// Click on the component card to open the sidebar
fireEvent.click(screen.queryAllByText('Introduction to Testing')[0]);
const sidebar = await screen.findByTestId('library-sidebar');
// Click the add component from the component sidebar
fireEvent.click(within(sidebar).getByRole('button', { name: 'Add to Course' }));
await waitFor(() => {
expect(axiosMock.history.post.length).toBe(1);
expect(axiosMock.history.post[0].url).toBe(getXBlockBaseApiUrl());
expect(axiosMock.history.post[0].data).toBe(JSON.stringify({
parent_locator: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical1',
library_content_key: 'lb:Axim:TEST:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd',
}));
});
});
it('should show toast if error on api call from the component sidebar button', async () => {
axiosMock.onPost(getXBlockBaseApiUrl()).reply(500, {});
render(<ComponentPicker />);
expect(await screen.findByText('Test Library 1')).toBeInTheDocument();
fireEvent.click(screen.getByDisplayValue(/lib:sampletaxonomyorg1:tl1/i));
fireEvent.click(screen.getByText('Next'));
// Wait for the content library to load
await screen.findByText(/Change Library/i);
expect(await screen.findByText('Test Library 1')).toBeInTheDocument();
// Click on the component card to open the sidebar
fireEvent.click(screen.queryAllByText('Introduction to Testing')[0]);
const sidebar = await screen.findByTestId('library-sidebar');
// Click the add component from the component sidebar
fireEvent.click(within(sidebar).getByRole('button', { name: 'Add to Course' }));
await waitFor(() => {
expect(axiosMock.history.post.length).toBe(1);
expect(axiosMock.history.post[0].url).toBe(getXBlockBaseApiUrl());
expect(axiosMock.history.post[0].data).toBe(JSON.stringify({
parent_locator: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical1',
library_content_key: 'lb:Axim:TEST:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd',
}));
expect(mockShowToast).toHaveBeenCalledWith('Failed to add component to course');
});
});
it('should pick component inside a collection using the card', async () => {
render(<ComponentPicker />);
expect(await screen.findByText('Test Library 1')).toBeInTheDocument();
fireEvent.click(screen.getByDisplayValue(/lib:sampletaxonomyorg1:tl1/i));
fireEvent.click(screen.getByText('Next'));
// Wait for the content library to load
await screen.findByText(/Change Library/i);
expect(await screen.findByText('Test Library 1')).toBeInTheDocument();
// Click on the collection card to open the sidebar
fireEvent.click(screen.queryAllByText('Collection 1')[0]);
const sidebar = await screen.findByTestId('library-sidebar');
// Mock the collection search result
mockSearchResult(mockCollectionResult);
// Click the add component from the component card
fireEvent.click(within(sidebar).getByRole('button', { name: 'Open' }));
// Wait for the collection to load
await screen.findByText(/Back to Library/i);
await screen.findByText('Introduction to Testing');
// Click the add component from the component card
fireEvent.click(screen.queryAllByRole('button', { name: 'Add' })[0]);
await waitFor(() => {
expect(axiosMock.history.post.length).toBe(1);
expect(axiosMock.history.post[0].url).toBe(getXBlockBaseApiUrl());
expect(axiosMock.history.post[0].data).toBe(JSON.stringify({
parent_locator: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical1',
library_content_key: 'lb:Axim:TEST:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd',
}));
});
});
it('should pick component inside a collection using the sidebar', async () => {
render(<ComponentPicker />);
expect(await screen.findByText('Test Library 1')).toBeInTheDocument();
fireEvent.click(screen.getByDisplayValue(/lib:sampletaxonomyorg1:tl1/i));
fireEvent.click(screen.getByText('Next'));
// Wait for the content library to load
await screen.findByText(/Change Library/i);
expect(await screen.findByText('Test Library 1')).toBeInTheDocument();
// Click on the collection card to open the sidebar
fireEvent.click(screen.queryAllByText('Collection 1')[0]);
const sidebar = await screen.findByTestId('library-sidebar');
// Mock the collection search result
mockSearchResult(mockCollectionResult);
// Click the add component from the component card
fireEvent.click(within(sidebar).getByRole('button', { name: 'Open' }));
// Wait for the collection to load
await screen.findByText(/Back to Library/i);
await screen.findByText('Introduction to Testing');
// Click on the collection card to open the sidebar
fireEvent.click(screen.getByText('Introduction to Testing'));
const collectionSidebar = await screen.findByTestId('library-sidebar');
// Click the add component from the collection sidebar
fireEvent.click(within(collectionSidebar).getByRole('button', { name: 'Add to Course' }));
await waitFor(() => {
expect(axiosMock.history.post.length).toBe(1);
expect(axiosMock.history.post[0].url).toBe(getXBlockBaseApiUrl());
expect(axiosMock.history.post[0].data).toBe(JSON.stringify({
parent_locator: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical1',
library_content_key: 'lb:Axim:TEST:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd',
}));
});
});
it('should return to library selection', async () => {
render(<ComponentPicker />);
expect(await screen.findByText('Test Library 1')).toBeInTheDocument();
fireEvent.click(screen.getByDisplayValue(/lib:sampletaxonomyorg1:tl1/i));
fireEvent.click(screen.getByText('Next'));
// Wait for the content library to load
await screen.findByText(/Change Library/i);
fireEvent.click(screen.getByText(/Change Library/i));
await screen.findByText('Select which Library would you like to reference components from.');
});
});

View File

@@ -0,0 +1,71 @@
import React, { useState } from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Button, Stepper } from '@openedx/paragon';
import { useSearchParams } from 'react-router-dom';
import { LibraryProvider, useLibraryContext } from '../common/context';
import LibraryAuthoringPage from '../LibraryAuthoringPage';
import LibraryCollectionPage from '../collections/LibraryCollectionPage';
import SelectLibrary from './SelectLibrary';
import messages from './messages';
interface LibraryComponentPickerProps {
returnToLibrarySelection: () => void;
}
const InnerComponentPicker: React.FC<LibraryComponentPickerProps> = ({ returnToLibrarySelection }) => {
const { collectionId } = useLibraryContext();
if (collectionId) {
return <LibraryCollectionPage />;
}
return <LibraryAuthoringPage returnToLibrarySelection={returnToLibrarySelection} />;
};
// eslint-disable-next-line import/prefer-default-export
export const ComponentPicker = () => {
const intl = useIntl();
const [searchParams] = useSearchParams();
let parentLocator = searchParams.get('parentLocator');
// istanbul ignore if: this should never happen
if (!parentLocator) {
throw new Error('parentLocator is required');
}
// URLSearchParams decodes '+' to ' ', so we need to convert it back
parentLocator = parentLocator.replaceAll(' ', '+');
const [currentStep, setCurrentStep] = useState('select-library');
const [selectedLibrary, setSelectedLibrary] = useState('');
const returnToLibrarySelection = () => {
setCurrentStep('select-library');
setSelectedLibrary('');
};
return (
<Stepper
activeKey={currentStep}
>
<Stepper.Step eventKey="select-library" title="Select a library">
<SelectLibrary selectedLibrary={selectedLibrary} setSelectedLibrary={setSelectedLibrary} />
</Stepper.Step>
<Stepper.Step eventKey="pick-components" title="Pick some components">
<LibraryProvider libraryId={selectedLibrary} parentLocator={parentLocator} componentPickerMode>
<InnerComponentPicker returnToLibrarySelection={returnToLibrarySelection} />
</LibraryProvider>
</Stepper.Step>
<div className="p-5">
<Stepper.ActionRow eventKey="select-library">
<Stepper.ActionRow.Spacer />
<Button onClick={() => setCurrentStep('pick-components')} disabled={!selectedLibrary}>
{intl.formatMessage(messages.selectLibraryNextButton)}
</Button>
</Stepper.ActionRow>
</div>
</Stepper>
);
};

View File

@@ -0,0 +1,56 @@
import {
initializeMocks,
render,
screen,
} from '../../testUtils';
import {
mockGetContentLibraryV2List,
} from '../data/api.mocks';
import { ComponentPicker } from './ComponentPicker';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useSearchParams: () => {
const [params] = [new URLSearchParams({
parentLocator: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical1',
})];
return [
params,
];
},
}));
describe('<ComponentPicker />', () => {
beforeEach(() => {
initializeMocks();
});
it('should render the library list', async () => {
mockGetContentLibraryV2List.applyMock();
render(<ComponentPicker />);
expect(await screen.findByText('Test Library 1')).toBeInTheDocument();
expect(await screen.findByText('Test Library 1')).toBeInTheDocument();
});
it('should render the loading status', async () => {
mockGetContentLibraryV2List.applyMockLoading();
render(<ComponentPicker />);
expect(await screen.findByText('Loading...')).toBeInTheDocument();
});
it('should render the empty status', async () => {
mockGetContentLibraryV2List.applyMockEmpty();
render(<ComponentPicker />);
expect(await screen.findByText(/there are no libraries with the current filters/i)).toBeInTheDocument();
});
it('should render the error status', async () => {
mockGetContentLibraryV2List.applyMockError();
render(<ComponentPicker />);
expect(await screen.findByText(/mocked request failed with status code 500/i)).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,119 @@
import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n';
import {
Alert,
Card,
Form,
Pagination,
SearchField,
Stack,
} from '@openedx/paragon';
import { useCallback, useEffect, useState } from 'react';
import Loading from '../../generic/Loading';
import AlertError from '../../generic/alert-error';
import { useContentLibraryV2List } from '../data/apiHooks';
import messages from './messages';
const EmptyState = () => (
<Alert className="mt-4 align-self-center">
<Alert.Heading>
<FormattedMessage {...messages.selectLibraryEmptyStateTitle} />
</Alert.Heading>
<p>
<FormattedMessage {...messages.selectLibraryEmptyStateMessage} />
</p>
</Alert>
);
interface SelectLibraryProps {
selectedLibrary: string;
setSelectedLibrary: (libraryKey: string) => void;
}
const SelectLibrary = ({ selectedLibrary, setSelectedLibrary }: SelectLibraryProps) => {
const intl = useIntl();
const [searchQuery, setSearchQuery] = useState('');
const [currentPage, setCurrentPage] = useState(1);
useEffect(() => {
setSelectedLibrary('');
}, [currentPage, searchQuery]);
const handleSearch = useCallback((search: string) => {
setSearchQuery(search);
setCurrentPage(1);
}, []);
const {
data,
isLoading,
isError,
error,
} = useContentLibraryV2List({
page: currentPage,
pageSize: 5,
search: searchQuery,
});
if (isError) {
return <AlertError error={error} />;
}
if (isLoading) {
return <Loading />;
}
return (
<Stack gap={2} className="p-5">
<small className="text-primary-700">
{intl.formatMessage(messages.selectLibraryInfo)}
</small>
<SearchField
onSubmit={handleSearch}
onChange={handleSearch}
value={searchQuery}
placeholder={intl.formatMessage(messages.selectLibrarySearchPlaceholder)}
/>
<div>
{data.results.length === 0 && (<EmptyState />)}
<Form.RadioSet
name="selected-library"
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSelectedLibrary(e.target.value)}
value={selectedLibrary}
>
{data.results.map((library) => (
<Card
key={library.id}
isClickable
onClick={() => setSelectedLibrary(library.id)}
className="card-item"
>
<Card.Header
size="sm"
title={<span className="card-item-title">{library.title}</span>}
subtitle={`${library.org} / ${library.slug}`}
actions={(
<Form.Radio value={library.id} name={`select-library-${library.id}`}>{' '}</Form.Radio>
)}
/>
<Card.Body>
<p>{library.description}</p>
</Card.Body>
</Card>
))}
</Form.RadioSet>
</div>
<Pagination
paginationLabel={intl.formatMessage(messages.selectLibraryPaginationLabel)}
pageCount={data!.numPages}
currentPage={data!.currentPage}
onPageSelect={(page: number) => setCurrentPage(page)}
variant="secondary"
className="align-self-center"
/>
</Stack>
);
};
export default SelectLibrary;

View File

@@ -0,0 +1,2 @@
// eslint-disable-next-line import/prefer-default-export
export { ComponentPicker } from './ComponentPicker';

View File

@@ -0,0 +1,36 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
selectLibraryInfo: {
id: 'course-authoring.library-authoring.pick-components.select-library.info',
defaultMessage: 'Select which Library would you like to reference components from.',
description: 'The info text for the select library component',
},
selectLibrarySearchPlaceholder: {
id: 'course-authoring.library-authoring.pick-components.select-library.search-placeholder',
defaultMessage: 'Search for a library',
description: 'The placeholder text for the search field in the select library component',
},
selectLibraryPaginationLabel: {
id: 'course-authoring.library-authoring.pick-components.select-library.pagination-label',
defaultMessage: 'Library pagination',
description: 'The pagination label for the select library component',
},
selectLibraryEmptyStateTitle: {
id: 'course-authoring.library-authoring.pick-components.select-library.empty-state.title',
defaultMessage: 'We could not find any result',
description: 'The title for the empty state in the select library component',
},
selectLibraryEmptyStateMessage: {
id: 'course-authoring.library-authoring.pick-components.select-library.empty-state.message',
defaultMessage: 'There are no libraries with the current filters.',
description: 'The message for the empty state in the select library component',
},
selectLibraryNextButton: {
id: 'course-authoring.library-authoring.pick-components.select-library.next-button',
defaultMessage: 'Next',
description: 'The text for the next button in the select library component',
},
});
export default messages;

View File

@@ -8,12 +8,11 @@ import {
import { getItemIcon, getComponentStyleColor } from '../../generic/block-type-utils';
import TagCount from '../../generic/tag-count';
import { BlockTypeLabel, ContentHitTags, Highlight } from '../../search-manager';
import { BlockTypeLabel, type ContentHitTags, Highlight } from '../../search-manager';
type BaseComponentCardProps = {
componentType: string,
displayName: string,
description: string,
displayName: string, description: string,
numChildren?: number,
tags: ContentHitTags,
actions: React.ReactNode,
@@ -55,7 +54,12 @@ const BaseComponentCard = ({
title={
<Icon src={componentIcon} className="library-component-header-icon" />
}
actions={actions}
actions={
// Wrap the actions in a div to prevent the card from being clicked when the actions are clicked
/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,
jsx-a11y/no-static-element-interactions */
<div onClick={(e) => e.stopPropagation()}>{actions}</div>
}
/>
<Card.Body>
<Card.Section>

View File

@@ -27,7 +27,7 @@ const CollectionMenu = ({ collectionHit } : CollectionMenuProps) => {
const { showToast } = useContext(ToastContext);
const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useToggle(false);
const [confirmBtnState, setConfirmBtnState] = useState('default');
const { closeLibrarySidebar, currentCollectionId } = useLibraryContext();
const { closeLibrarySidebar, sidebarCollectionId } = useLibraryContext();
const restoreCollectionMutation = useRestoreCollection(collectionHit.contextKey, collectionHit.blockId);
const restoreCollection = useCallback(() => {
@@ -42,7 +42,7 @@ const CollectionMenu = ({ collectionHit } : CollectionMenuProps) => {
const deleteCollectionMutation = useDeleteCollection(collectionHit.contextKey, collectionHit.blockId);
const deleteCollection = useCallback(() => {
setConfirmBtnState('pending');
if (currentCollectionId === collectionHit.blockId) {
if (sidebarCollectionId === collectionHit.blockId) {
// Close sidebar if current collection is open to avoid displaying
// deleted collection in sidebar
closeLibrarySidebar();
@@ -62,11 +62,11 @@ const CollectionMenu = ({ collectionHit } : CollectionMenuProps) => {
setConfirmBtnState('default');
closeDeleteModal();
});
}, [currentCollectionId]);
}, [sidebarCollectionId]);
return (
<>
<Dropdown id="collection-card-dropdown" onClick={(e) => e.stopPropagation()}>
<Dropdown id="collection-card-dropdown">
<Dropdown.Toggle
id="collection-card-menu-toggle"
as={IconButton}
@@ -110,6 +110,7 @@ type CollectionCardProps = {
const CollectionCard = ({ collectionHit } : CollectionCardProps) => {
const {
openCollectionInfoSidebar,
componentPickerMode,
} = useLibraryContext();
const {
@@ -127,7 +128,7 @@ const CollectionCard = ({ collectionHit } : CollectionCardProps) => {
description={description}
tags={tags}
numChildren={numChildren}
actions={(
actions={!componentPickerMode && (
<ActionRow>
<CollectionMenu collectionHit={collectionHit} />
</ActionRow>

View File

@@ -1,23 +1,23 @@
import React, { useContext, useState } from 'react';
import { useContext, useState } from 'react';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import {
ActionRow,
Button,
Dropdown,
Icon,
IconButton,
Dropdown,
} from '@openedx/paragon';
import { MoreVert } from '@openedx/paragon/icons';
import { AddCircleOutline, MoreVert } from '@openedx/paragon/icons';
import { useParams } from 'react-router';
import { STUDIO_CLIPBOARD_CHANNEL } from '../../constants';
import { updateClipboard } from '../../generic/data/api';
import { ToastContext } from '../../generic/toast-context';
import { type ContentHit } from '../../search-manager';
import { useLibraryContext } from '../common/context';
import messages from './messages';
import { STUDIO_CLIPBOARD_CHANNEL } from '../../constants';
import { useAddComponentToCourse, useRemoveComponentsFromCollection } from '../data/apiHooks';
import BaseComponentCard from './BaseComponentCard';
import { canEditComponent } from './ComponentEditorModal';
import { useRemoveComponentsFromCollection } from '../data/apiHooks';
import messages from './messages';
type ComponentCardProps = {
contentHit: ContentHit,
@@ -27,11 +27,12 @@ export const ComponentMenu = ({ usageKey }: { usageKey: string }) => {
const intl = useIntl();
const {
libraryId,
collectionId,
sidebarComponentUsageKey,
openComponentEditor,
closeLibrarySidebar,
currentComponentUsageKey,
} = useLibraryContext();
const { collectionId } = useParams();
const canEdit = usageKey && canEditComponent(usageKey);
const { showToast } = useContext(ToastContext);
const [clipboardBroadcastChannel] = useState(() => new BroadcastChannel(STUDIO_CLIPBOARD_CHANNEL));
@@ -47,7 +48,7 @@ export const ComponentMenu = ({ usageKey }: { usageKey: string }) => {
const removeFromCollection = () => {
removeComponentsMutation.mutateAsync([usageKey]).then(() => {
if (currentComponentUsageKey === usageKey) {
if (sidebarComponentUsageKey === usageKey) {
// Close sidebar if current component is open
closeLibrarySidebar();
}
@@ -58,7 +59,7 @@ export const ComponentMenu = ({ usageKey }: { usageKey: string }) => {
};
return (
<Dropdown id="component-card-dropdown" onClick={(e) => e.stopPropagation()}>
<Dropdown id="component-card-dropdown">
<Dropdown.Toggle
id="component-card-menu-toggle"
as={IconButton}
@@ -88,10 +89,15 @@ export const ComponentMenu = ({ usageKey }: { usageKey: string }) => {
);
};
const ComponentCard = ({ contentHit } : ComponentCardProps) => {
const ComponentCard = ({ contentHit }: ComponentCardProps) => {
const intl = useIntl();
const {
openComponentInfoSidebar,
componentPickerMode,
parentLocator,
} = useLibraryContext();
const { showToast } = useContext(ToastContext);
const {
blockType,
@@ -101,11 +107,27 @@ const ComponentCard = ({ contentHit } : ComponentCardProps) => {
} = contentHit;
const description: string = (/* eslint-disable */
blockType === 'html' ? formatted?.content?.htmlContent :
blockType === 'problem' ? formatted?.content?.capaContent :
undefined
blockType === 'problem' ? formatted?.content?.capaContent :
undefined
) ?? '';/* eslint-enable */
const displayName = formatted?.displayName ?? '';
const {
mutateAsync: addComponentToCourse,
reset,
} = useAddComponentToCourse(parentLocator, contentHit.usageKey);
const handleAddComponentToCourse = () => {
addComponentToCourse()
.then(() => {
window.parent.postMessage('closeComponentPicker', '*');
})
.catch(() => {
showToast(intl.formatMessage(messages.addComponentToCourseError));
reset();
});
};
return (
<BaseComponentCard
componentType={blockType}
@@ -114,7 +136,17 @@ const ComponentCard = ({ contentHit } : ComponentCardProps) => {
tags={tags}
actions={(
<ActionRow>
<ComponentMenu usageKey={usageKey} />
{componentPickerMode ? (
<Button
variant="outline-primary"
iconBefore={AddCircleOutline}
onClick={handleAddComponentToCourse}
>
<FormattedMessage {...messages.addComponentToCourseButtonTitle} />
</Button>
) : (
<ComponentMenu usageKey={usageKey} />
)}
</ActionRow>
)}
openInfoSidebar={() => openComponentInfoSidebar(usageKey)}

View File

@@ -91,6 +91,16 @@ const messages = defineMessages({
defaultMessage: 'Failed to undo delete collection operation',
description: 'Message to display on failure to undo delete collection',
},
addComponentToCourseButtonTitle: {
id: 'course-authoring.library-authoring.component-picker.button.title',
defaultMessage: 'Add',
description: 'Button title for picking a component',
},
addComponentToCourseError: {
id: 'course-authoring.library-authoring.component-picker.error',
defaultMessage: 'Failed to add component to course',
description: 'Error message for failed to add component to course',
},
});
export default messages;

View File

@@ -1,9 +1,35 @@
/* istanbul ignore file */
import { camelCaseObject } from '@edx/frontend-platform';
import { mockContentTaxonomyTagsData } from '../../content-tags-drawer/data/api.mocks';
import { getBlockType } from '../../generic/key-utils';
import { createAxiosError } from '../../testUtils';
import contentLibrariesListV2 from '../__mocks__/contentLibrariesListV2';
import * as api from './api';
/**
* Mock for `getContentLibraryV2List()`
*/
export const mockGetContentLibraryV2List = {
applyMock: () => jest.spyOn(api, 'getContentLibraryV2List').mockResolvedValue(
camelCaseObject(contentLibrariesListV2),
),
applyMockError: () => jest.spyOn(api, 'getContentLibraryV2List').mockRejectedValue(
createAxiosError({ code: 500, message: 'Internal Error.', path: api.getContentLibraryV2ListApiUrl() }),
),
applyMockLoading: () => jest.spyOn(api, 'getContentLibraryV2List').mockResolvedValue(
new Promise(() => {}),
),
applyMockEmpty: () => jest.spyOn(api, 'getContentLibraryV2List').mockResolvedValue({
next: null,
previous: null,
count: 0,
numPages: 1,
currentPage: 1,
start: 0,
results: [],
}),
};
/**
* Mock for `getContentLibrary()`
*
@@ -30,6 +56,69 @@ export async function mockContentLibrary(libraryId: string): Promise<api.Content
allowPublicRead: true,
canEditLibrary: false,
};
case mockContentLibrary.libraryDraftWithoutUser:
return {
...mockContentLibrary.libraryData,
id: mockContentLibrary.libraryDraftWithoutUser,
slug: 'draftNoUser',
lastDraftCreatedBy: null,
};
case mockContentLibrary.libraryNoDraftDate:
return {
...mockContentLibrary.libraryData,
id: mockContentLibrary.libraryNoDraftDate,
slug: 'noDraftDate',
lastDraftCreated: null,
};
case mockContentLibrary.libraryNoDraftNoCrateDate:
return {
...mockContentLibrary.libraryData,
id: mockContentLibrary.libraryNoDraftNoCrateDate,
slug: 'noDraftNoCreateDate',
lastDraftCreated: null,
created: null,
};
case mockContentLibrary.libraryUnpublishedChanges:
return {
...mockContentLibrary.libraryData,
id: mockContentLibrary.libraryUnpublishedChanges,
slug: 'unpublishedChanges',
lastPublished: '2024-07-26T16:37:42Z',
hasUnpublishedChanges: true,
};
case mockContentLibrary.libraryPublished:
return {
...mockContentLibrary.libraryData,
id: mockContentLibrary.libraryPublished,
slug: 'published',
lastPublished: '2024-07-26T16:37:42Z',
hasUnpublishedChanges: false,
publishedBy: 'staff',
};
case mockContentLibrary.libraryPublishedWithoutUser:
return {
...mockContentLibrary.libraryData,
id: mockContentLibrary.libraryPublishedWithoutUser,
slug: 'publishedWithUser',
lastPublished: '2024-07-26T16:37:42Z',
hasUnpublishedChanges: false,
publishedBy: null,
};
case mockContentLibrary.libraryDraftWithoutChanges:
return {
...mockContentLibrary.libraryData,
id: mockContentLibrary.libraryDraftWithoutChanges,
slug: 'draftNoChanges',
numBlocks: 0,
};
case mockContentLibrary.libraryFromList:
return {
...mockContentLibrary.libraryData,
id: mockContentLibrary.libraryFromList,
slug: 'TL1',
org: 'SampleTaxonomyOrg1',
title: 'Test Library 1',
};
default:
throw new Error(`mockContentLibrary: unknown library ID "${libraryId}"`);
}
@@ -48,7 +137,7 @@ mockContentLibrary.libraryData = {
lastPublished: null, // or e.g. '2024-08-30T16:37:42Z',
publishedBy: null, // or e.g. 'test_author',
lastDraftCreated: '2024-07-22T21:37:49Z',
lastDraftCreatedBy: null,
lastDraftCreatedBy: 'staff',
allowLti: false,
allowPublicLearning: false,
allowPublicRead: false,
@@ -63,6 +152,14 @@ mockContentLibrary.libraryIdReadOnly = 'lib:Axim:readOnly';
mockContentLibrary.libraryIdThatNeverLoads = 'lib:Axim:infiniteLoading';
mockContentLibrary.library404 = 'lib:Axim:error404';
mockContentLibrary.library500 = 'lib:Axim:error500';
mockContentLibrary.libraryDraftWithoutUser = 'lib:Axim:draftNoUser';
mockContentLibrary.libraryNoDraftDate = 'lib:Axim:noDraftDate';
mockContentLibrary.libraryNoDraftNoCrateDate = 'lib:Axim:noDraftNoCreateDate';
mockContentLibrary.libraryUnpublishedChanges = 'lib:Axim:unpublishedChanges';
mockContentLibrary.libraryPublished = 'lib:Axim:published';
mockContentLibrary.libraryPublishedWithoutUser = 'lib:Axim:publishedWithoutUser';
mockContentLibrary.libraryDraftWithoutChanges = 'lib:Axim:draftNoChanges';
mockContentLibrary.libraryFromList = 'lib:SampleTaxonomyOrg1:TL1';
mockContentLibrary.applyMock = () => jest.spyOn(api, 'getContentLibrary').mockImplementation(mockContentLibrary);
/**

View File

@@ -76,6 +76,10 @@ export const getLibraryCollectionComponentApiUrl = (libraryId: string, collectio
* Get the API URL for restoring deleted collection.
*/
export const getLibraryCollectionRestoreApiUrl = (libraryId: string, collectionId: string) => `${getLibraryCollectionApiUrl(libraryId, collectionId)}restore/`;
/**
* Get the URL for the xblock api.
*/
export const getXBlockBaseApiUrl = () => `${getApiBaseUrl()}/xblock/`;
export interface ContentLibrary {
id: string;
@@ -258,7 +262,8 @@ export async function createLibraryBlock({
*/
export async function updateLibraryMetadata(libraryData: UpdateLibraryDataRequest): Promise<ContentLibrary> {
const client = getAuthenticatedHttpClient();
const { data } = await client.patch(getContentLibraryApiUrl(libraryData.id), libraryData);
const { id: libraryId, ...updateData } = libraryData;
const { data } = await client.patch(getContentLibraryApiUrl(libraryId), updateData);
return camelCaseObject(data);
}
@@ -478,3 +483,15 @@ export async function updateComponentCollections(usageKey: string, collectionKey
collection_keys: collectionKeys,
});
}
/**
* Add a component to a course.
*/
// istanbul ignore next
export async function addComponentToCourse(parentLocator: string, componentUsageKey: string) {
const client = getAuthenticatedHttpClient();
await client.post(getXBlockBaseApiUrl(), {
parent_locator: parentLocator,
library_content_key: componentUsageKey,
});
}

View File

@@ -40,6 +40,7 @@ import {
getXBlockAssets,
updateComponentCollections,
removeComponentsFromCollection,
addComponentToCourse,
} from './api';
export const libraryQueryPredicate = (query: Query, libraryId: string): boolean => {
@@ -473,3 +474,18 @@ export const useUpdateComponentCollections = (libraryId: string, usageKey: strin
},
});
};
/**
* Use this mutation to add a component to a course
*/
export const useAddComponentToCourse = (parentLocator: string | undefined, componentUsageKey: string) => (
useMutation({
mutationFn: () => {
// istanbul ignore if: this should never happen
if (!parentLocator) {
throw new Error('parentLocator is required');
}
return addComponentToCourse(parentLocator, componentUsageKey);
},
})
);

View File

@@ -1,3 +1,4 @@
export { default as LibraryLayout } from './LibraryLayout';
export { ComponentPicker } from './component-picker';
export { CreateLibrary } from './create-library';
export { libraryAuthoringQueryKeys, useContentLibraryV2List } from './data/apiHooks';

View File

@@ -1,112 +1,67 @@
import React from 'react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { AppProvider } from '@edx/frontend-platform/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { initializeMockApp } from '@edx/frontend-platform';
import MockAdapter from 'axios-mock-adapter';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import {
render,
screen,
fireEvent,
waitFor,
} from '@testing-library/react';
import LibraryInfo from './LibraryInfo';
import { LibraryProvider } from '../common/context';
import { ToastProvider } from '../../generic/toast-context';
import { ContentLibrary, getCommitLibraryChangesUrl } from '../data/api';
import initializeStore from '../../store';
import type MockAdapter from 'axios-mock-adapter';
let store;
let axiosMock;
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
import {
fireEvent,
render as baseRender,
screen,
waitFor,
initializeMocks,
} from '../../testUtils';
import { mockContentLibrary } from '../data/api.mocks';
import { getCommitLibraryChangesUrl } from '../data/api';
import { LibraryProvider } from '../common/context';
import LibraryInfo from './LibraryInfo';
const {
libraryId: mockLibraryId,
libraryIdReadOnly,
libraryDraftWithoutUser,
libraryNoDraftDate,
libraryNoDraftNoCrateDate,
libraryUnpublishedChanges,
libraryPublished,
libraryPublishedWithoutUser,
libraryDraftWithoutChanges,
libraryData,
} = mockContentLibrary;
const render = (libraryId: string = mockLibraryId) => baseRender(<LibraryInfo />, {
extraWrapper: ({ children }) => <LibraryProvider libraryId={libraryId}>{ children }</LibraryProvider>,
});
const libraryData: ContentLibrary = {
id: 'lib:org1:lib1',
type: 'complex',
org: 'org1',
slug: 'lib1',
title: 'lib1',
description: 'lib1',
numBlocks: 2,
version: 0,
lastPublished: null,
lastDraftCreated: '2024-07-22',
publishedBy: 'staff',
lastDraftCreatedBy: 'staff',
allowLti: false,
allowPublicLearning: false,
allowPublicRead: false,
hasUnpublishedChanges: true,
hasUnpublishedDeletes: false,
canEditLibrary: true,
license: '',
created: '2024-06-26',
updated: '2024-07-20',
};
let axiosMock: MockAdapter;
let mockShowToast: (message: string) => void;
interface WrapperProps {
data: ContentLibrary,
}
const RootWrapper = ({ data } : WrapperProps) => (
<AppProvider store={store}>
<IntlProvider locale="en" messages={{}}>
<QueryClientProvider client={queryClient}>
<ToastProvider>
<LibraryProvider libraryId={data.id}>
<LibraryInfo library={data} />
</LibraryProvider>
</ToastProvider>
</QueryClientProvider>
</IntlProvider>
</AppProvider>
);
mockContentLibrary.applyMock();
describe('<LibraryInfo />', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
const mocks = initializeMocks();
axiosMock = mocks.axiosMock;
mockShowToast = mocks.mockShowToast;
});
afterEach(() => {
jest.clearAllMocks();
axiosMock.restore();
});
it('should render Library info sidebar', () => {
render(<RootWrapper data={libraryData} />);
it('should render Library info sidebar', async () => {
render();
expect(await screen.findByText(libraryData.org)).toBeInTheDocument();
expect(screen.getByText('Draft')).toBeInTheDocument();
expect(screen.getByText('(Never Published)')).toBeInTheDocument();
expect(screen.getByText('July 22, 2024')).toBeInTheDocument();
expect(screen.getByText('staff')).toBeInTheDocument();
expect(screen.getByText(libraryData.org)).toBeInTheDocument();
expect(screen.getByText('July 20, 2024')).toBeInTheDocument();
expect(screen.getByText('June 26, 2024')).toBeInTheDocument();
});
it('should render Library info in draft state without user', () => {
const data = {
...libraryData,
lastDraftCreatedBy: null,
};
it('should render Library info in draft state without user', async () => {
render(libraryDraftWithoutUser);
render(<RootWrapper data={data} />);
expect(await screen.findByText(libraryData.org)).toBeInTheDocument();
expect(screen.getByText('Draft')).toBeInTheDocument();
expect(screen.getByText('(Never Published)')).toBeInTheDocument();
@@ -114,13 +69,10 @@ describe('<LibraryInfo />', () => {
expect(screen.queryByText('staff')).not.toBeInTheDocument();
});
it('should render Library creation date if last draft created date is null', () => {
const data = {
...libraryData,
lastDraftCreated: null,
};
it('should render Library creation date if last draft created date is null', async () => {
render(libraryNoDraftDate);
render(<RootWrapper data={data} />);
expect(await screen.findByText(libraryData.org)).toBeInTheDocument();
expect(screen.getByText('Draft')).toBeInTheDocument();
expect(screen.getByText('(Never Published)')).toBeInTheDocument();
@@ -128,26 +80,19 @@ describe('<LibraryInfo />', () => {
expect(screen.getAllByText('June 26, 2024')[1]).toBeInTheDocument();
});
it('should render library info in draft state without date', () => {
const data = {
...libraryData,
lastDraftCreated: null,
created: null,
};
it('should render library info in draft state without date', async () => {
render(libraryNoDraftNoCrateDate);
render(<RootWrapper data={data} />);
expect(await screen.findByText(libraryData.org)).toBeInTheDocument();
expect(screen.getByText('Draft')).toBeInTheDocument();
expect(screen.getByText('(Never Published)')).toBeInTheDocument();
});
it('should render draft library info sidebar', () => {
const data = {
...libraryData,
lastPublished: '2024-07-26',
};
it('should render library info with unpublished changes', async () => {
render(libraryUnpublishedChanges);
render(<RootWrapper data={data} />);
expect(await screen.findByText(libraryData.org)).toBeInTheDocument();
expect(screen.getByText('Draft')).toBeInTheDocument();
expect(screen.queryByText('(Never Published)')).not.toBeInTheDocument();
@@ -155,28 +100,21 @@ describe('<LibraryInfo />', () => {
expect(screen.getByText('staff')).toBeInTheDocument();
});
it('should render published library info sidebar', () => {
const data = {
...libraryData,
lastPublished: '2024-07-26',
hasUnpublishedChanges: false,
};
it('should render published library info sidebar', async () => {
render(libraryPublished);
expect(await screen.findByText(libraryData.org)).toBeInTheDocument();
render(<RootWrapper data={data} />);
expect(screen.getByText('Published')).toBeInTheDocument();
expect(screen.getByText('July 26, 2024')).toBeInTheDocument();
expect(screen.getByText('staff')).toBeInTheDocument();
});
it('should render published library info without user', () => {
const data = {
...libraryData,
lastPublished: '2024-07-26',
hasUnpublishedChanges: false,
publishedBy: null,
};
it('should render published library info without user', async () => {
render(libraryPublishedWithoutUser);
expect(await screen.findByText(libraryData.org)).toBeInTheDocument();
render(<RootWrapper data={data} />);
expect(screen.getByText('Published')).toBeInTheDocument();
expect(screen.getByText('July 26, 2024')).toBeInTheDocument();
expect(screen.queryByText('staff')).not.toBeInTheDocument();
@@ -185,64 +123,99 @@ describe('<LibraryInfo />', () => {
it('should publish library', async () => {
const url = getCommitLibraryChangesUrl(libraryData.id);
axiosMock.onPost(url).reply(200);
render(<RootWrapper data={libraryData} />);
render();
expect(await screen.findByText(libraryData.org)).toBeInTheDocument();
const publishButton = screen.getByRole('button', { name: /publish/i });
fireEvent.click(publishButton);
expect(await screen.findByText('Library published successfully')).toBeInTheDocument();
await waitFor(() => expect(axiosMock.history.post[0].url).toEqual(url));
await waitFor(() => {
expect(axiosMock.history.post[0].url).toEqual(url);
expect(mockShowToast).toHaveBeenCalledWith('Library published successfully');
});
});
it('should show error on publish library', async () => {
const url = getCommitLibraryChangesUrl(libraryData.id);
axiosMock.onPost(url).reply(500);
render(<RootWrapper data={libraryData} />);
render();
expect(await screen.findByText(libraryData.org)).toBeInTheDocument();
const publishButton = screen.getByRole('button', { name: /publish/i });
fireEvent.click(publishButton);
expect(await screen.findByText('There was an error publishing the library.')).toBeInTheDocument();
await waitFor(() => expect(axiosMock.history.post[0].url).toEqual(url));
await waitFor(() => {
expect(axiosMock.history.post[0].url).toEqual(url);
expect(mockShowToast).toHaveBeenCalledWith('There was an error publishing the library.');
});
});
it('should discard changes', async () => {
const url = getCommitLibraryChangesUrl(libraryData.id);
axiosMock.onDelete(url).reply(200);
render();
expect(await screen.findByText(libraryData.org)).toBeInTheDocument();
render(<RootWrapper data={libraryData} />);
const discardButton = screen.getByRole('button', { name: /discard changes/i });
fireEvent.click(discardButton);
expect(await screen.findByText('Library changes reverted successfully')).toBeInTheDocument();
await waitFor(() => expect(axiosMock.history.delete[0].url).toEqual(url));
await waitFor(() => {
expect(axiosMock.history.delete[0].url).toEqual(url);
expect(mockShowToast).toHaveBeenCalledWith('Library changes reverted successfully');
});
});
it('should show error on discard changes', async () => {
const url = getCommitLibraryChangesUrl(libraryData.id);
axiosMock.onDelete(url).reply(500);
render();
expect(await screen.findByText(libraryData.org)).toBeInTheDocument();
render(<RootWrapper data={libraryData} />);
const discardButton = screen.getByRole('button', { name: /discard changes/i });
fireEvent.click(discardButton);
expect(await screen.findByText('There was an error reverting changes in the library.')).toBeInTheDocument();
await waitFor(() => expect(axiosMock.history.delete[0].url).toEqual(url));
await waitFor(() => {
expect(axiosMock.history.delete[0].url).toEqual(url);
expect(mockShowToast).toHaveBeenCalledWith('There was an error reverting changes in the library.');
});
});
it('discard changes btn should be disabled for new libraries', async () => {
render(<RootWrapper data={{ ...libraryData, lastPublished: null, numBlocks: 0 }} />);
const discardButton = screen.getByRole('button', { name: /discard changes/i });
render(libraryDraftWithoutChanges);
expect(await screen.findByText(libraryData.org)).toBeInTheDocument();
const discardButton = screen.getByRole('button', { name: /discard changes/i });
expect(discardButton).toBeDisabled();
});
it('discard changes btn should be enabled for new libraries if components are added', async () => {
render();
expect(await screen.findByText(libraryData.org)).toBeInTheDocument();
const discardButton = screen.getByRole('button', { name: /discard changes/i });
expect(discardButton).not.toBeDisabled();
});
it('should render library info in read-only mode', async () => {
render(libraryIdReadOnly);
expect(await screen.findByText(libraryData.org)).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /publish/i })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /discard changes/i })).not.toBeInTheDocument();
});
it('publish and discard changes btns should be enabled for new libraries if components are added', async () => {
render(<RootWrapper data={{ ...libraryData, lastPublished: null, numBlocks: 2 }} />);
render(libraryUnpublishedChanges);
expect(await screen.findByText(libraryData.org)).toBeInTheDocument();
const publishButton = screen.getByRole('button', { name: /publish/i });
const discardButton = screen.getByRole('button', { name: /discard changes/i });
@@ -251,13 +224,10 @@ describe('<LibraryInfo />', () => {
});
it('publish and discard changes btns should be absent for users who cannot edit the library', async () => {
const data = {
...libraryData,
lastPublished: null,
numBlocks: 2,
canEditLibrary: false,
};
render(<RootWrapper data={data} />);
render(libraryIdReadOnly);
expect(await screen.findByText(libraryData.org)).toBeInTheDocument();
const publishButton = screen.queryByRole('button', { name: /publish/i });
const discardButton = screen.queryByRole('button', { name: /discard changes/i });

View File

@@ -1,30 +1,25 @@
import React from 'react';
import { Button, Stack } from '@openedx/paragon';
import { FormattedDate, useIntl } from '@edx/frontend-platform/i18n';
import messages from './messages';
import LibraryPublishStatus from './LibraryPublishStatus';
import { useLibraryContext } from '../common/context';
import { ContentLibrary } from '../data/api';
type LibraryInfoProps = {
library: ContentLibrary,
};
const LibraryInfo = ({ library } : LibraryInfoProps) => {
const LibraryInfo = () => {
const intl = useIntl();
const { openLibraryTeamModal } = useLibraryContext();
const { libraryData, readOnly, openLibraryTeamModal } = useLibraryContext();
return (
<Stack direction="vertical" gap={2.5}>
<LibraryPublishStatus library={library} />
<LibraryPublishStatus />
<Stack gap={3} direction="vertical">
<span className="font-weight-bold">
{intl.formatMessage(messages.organizationSectionTitle)}
</span>
<span>
{library.org}
{libraryData?.org}
</span>
{library.canEditLibrary && (
{!readOnly && (
<Button variant="outline-primary" onClick={openLibraryTeamModal}>
{intl.formatMessage(messages.libraryTeamButtonTitle)}
</Button>
@@ -40,7 +35,7 @@ const LibraryInfo = ({ library } : LibraryInfoProps) => {
</span>
<span className="small">
<FormattedDate
value={library.updated ?? undefined}
value={libraryData?.updated ?? undefined}
year="numeric"
month="long"
day="2-digit"
@@ -53,7 +48,7 @@ const LibraryInfo = ({ library } : LibraryInfoProps) => {
</span>
<span className="small">
<FormattedDate
value={library.created ?? undefined}
value={libraryData?.created ?? undefined}
year="numeric"
month="long"
day="2-digit"

View File

@@ -1,113 +1,59 @@
import React from 'react';
import MockAdapter from 'axios-mock-adapter';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { AppProvider } from '@edx/frontend-platform/react';
import { initializeMockApp } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type MockAdapter from 'axios-mock-adapter';
import {
render,
screen,
fireEvent,
render as baseRender,
screen,
waitFor,
} from '@testing-library/react';
import { ContentLibrary, getContentLibraryApiUrl } from '../data/api';
import initializeStore from '../../store';
import { ToastProvider } from '../../generic/toast-context';
initializeMocks,
} from '../../testUtils';
import { mockContentLibrary } from '../data/api.mocks';
import { getContentLibraryApiUrl } from '../data/api';
import { LibraryProvider } from '../common/context';
import LibraryInfoHeader from './LibraryInfoHeader';
let store;
let axiosMock;
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
const { libraryId: mockLibraryId, libraryIdReadOnly, libraryData } = mockContentLibrary;
const render = (libraryId: string = mockLibraryId) => baseRender(<LibraryInfoHeader />, {
extraWrapper: ({ children }) => <LibraryProvider libraryId={libraryId}>{ children }</LibraryProvider>,
});
const libraryData: ContentLibrary = {
id: 'lib:org1:lib1',
type: 'complex',
org: 'org1',
slug: 'lib1',
title: 'lib1',
description: 'lib1',
numBlocks: 2,
version: 0,
lastPublished: null,
lastDraftCreated: '2024-07-22',
publishedBy: 'staff',
lastDraftCreatedBy: 'staff',
allowLti: false,
allowPublicLearning: false,
allowPublicRead: false,
hasUnpublishedChanges: true,
hasUnpublishedDeletes: false,
canEditLibrary: true,
license: '',
created: '2024-06-26',
updated: '2024-07-20',
};
let axiosMock: MockAdapter;
let mockShowToast: (message: string) => void;
interface WrapperProps {
data: ContentLibrary,
}
const RootWrapper = ({ data } : WrapperProps) => (
<AppProvider store={store}>
<IntlProvider locale="en" messages={{}}>
<QueryClientProvider client={queryClient}>
<ToastProvider>
<LibraryInfoHeader library={data} />
</ToastProvider>
</QueryClientProvider>
</IntlProvider>
</AppProvider>
);
mockContentLibrary.applyMock();
describe('<LibraryInfoHeader />', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
const mocks = initializeMocks();
axiosMock = mocks.axiosMock;
mockShowToast = mocks.mockShowToast;
});
afterEach(() => {
jest.clearAllMocks();
axiosMock.restore();
});
it('should render Library info Header', () => {
render(<RootWrapper data={libraryData} />);
it('should render Library info Header', async () => {
render();
expect(screen.getByText(libraryData.title)).toBeInTheDocument();
expect(await screen.findByText(libraryData.title)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /edit library name/i })).toBeInTheDocument();
});
it('should not render edit title button without permission', () => {
const data = {
...libraryData,
canEditLibrary: false,
};
render(<RootWrapper data={data} />);
it('should not render edit title button without permission', async () => {
render(libraryIdReadOnly);
expect(await screen.findByText(libraryData.title)).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /edit library name/i })).not.toBeInTheDocument();
});
it('should edit library title', async () => {
queryClient.getQueriesData = jest.fn().mockReturnValue([[null, { id: 1, title: 'Old Title' }]]);
const url = getContentLibraryApiUrl(libraryData.id);
axiosMock.onPatch(url).reply(200);
render(<RootWrapper data={libraryData} />);
render();
expect(await screen.findByText(libraryData.title)).toBeInTheDocument();
const editTitleButton = screen.getByRole('button', { name: /edit library name/i });
fireEvent.click(editTitleButton);
@@ -118,15 +64,20 @@ describe('<LibraryInfoHeader />', () => {
fireEvent.keyDown(textBox, { key: 'Enter', code: 'Enter', charCode: 13 });
expect(textBox).not.toBeInTheDocument();
expect(await screen.findByText('Library updated successfully')).toBeInTheDocument();
await waitFor(() => expect(axiosMock.history.patch[0].url).toEqual(url));
await waitFor(() => {
expect(axiosMock.history.patch[0].url).toEqual(url);
expect(axiosMock.history.patch[0].data).toEqual(JSON.stringify({ title: 'New Library Title' }));
expect(mockShowToast).toHaveBeenCalledWith('Library updated successfully');
});
});
it('should close edit library title on press Escape', async () => {
const url = getContentLibraryApiUrl(libraryData.id);
axiosMock.onPatch(url).reply(200);
render(<RootWrapper data={libraryData} />);
render();
expect(await screen.findByText(libraryData.title)).toBeInTheDocument();
const editTitleButton = screen.getByRole('button', { name: /edit library name/i });
fireEvent.click(editTitleButton);
@@ -136,13 +87,17 @@ describe('<LibraryInfoHeader />', () => {
expect(textBox).not.toBeInTheDocument();
await waitFor(() => expect(axiosMock.history.patch.length).toEqual(0));
await waitFor(() => {
expect(axiosMock.history.patch.length).toEqual(0);
});
});
it('should show error on edit library tittle', async () => {
const url = getContentLibraryApiUrl(libraryData.id);
axiosMock.onPatch(url).reply(500);
render(<RootWrapper data={libraryData} />);
render();
expect(await screen.findByText(libraryData.title)).toBeInTheDocument();
const editTitleButton = screen.getByRole('button', { name: /edit library name/i });
fireEvent.click(editTitleButton);
@@ -152,8 +107,12 @@ describe('<LibraryInfoHeader />', () => {
fireEvent.change(textBox, { target: { value: 'New Library Title' } });
fireEvent.keyDown(textBox, { key: 'Enter', code: 'Enter', charCode: 13 });
expect(await screen.findByText('There was an error updating the library')).toBeInTheDocument();
expect(textBox).not.toBeInTheDocument();
await waitFor(() => expect(axiosMock.history.patch[0].url).toEqual(url));
await waitFor(() => {
expect(axiosMock.history.patch[0].url).toEqual(url);
expect(axiosMock.history.patch[0].data).toEqual(JSON.stringify({ title: 'New Library Title' }));
expect(mockShowToast).toHaveBeenCalledWith('There was an error updating the library');
});
});
});

View File

@@ -1,4 +1,4 @@
import React, { useState, useContext } from 'react';
import { useState, useContext } from 'react';
import {
Icon,
IconButton,
@@ -7,20 +7,22 @@ import {
} from '@openedx/paragon';
import { Edit } from '@openedx/paragon/icons';
import { useIntl } from '@edx/frontend-platform/i18n';
import messages from './messages';
import type { ContentLibrary } from '../data/api';
import { useUpdateLibraryMetadata } from '../data/apiHooks';
import { ToastContext } from '../../generic/toast-context';
import { useLibraryContext } from '../common/context';
import { useUpdateLibraryMetadata } from '../data/apiHooks';
import messages from './messages';
type LibraryInfoHeaderProps = {
library: ContentLibrary,
};
const LibraryInfoHeader = ({ library } : LibraryInfoHeaderProps) => {
const LibraryInfoHeader = () => {
const intl = useIntl();
const [inputIsActive, setIsActive] = useState(false);
const updateMutation = useUpdateLibraryMetadata();
const { showToast } = useContext(ToastContext);
const { libraryData: library, readOnly } = useLibraryContext();
if (!library) {
return null;
}
const handleSaveTitle = (event) => {
const newTitle = event.target.value;
@@ -69,7 +71,7 @@ const LibraryInfoHeader = ({ library } : LibraryInfoHeaderProps) => {
<span className="font-weight-bold mt-1.5 ml-1.5">
{library.title}
</span>
{library.canEditLibrary && (
{!readOnly && (
<IconButton
src={Edit}
iconAs={Icon}

View File

@@ -1,45 +1,51 @@
import React, { useCallback, useContext } from 'react';
import { useCallback, useContext } from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { ToastContext } from '../../generic/toast-context';
import StatusWidget from '../generic/status-widget';
import { useLibraryContext } from '../common/context';
import { useCommitLibraryChanges, useRevertLibraryChanges } from '../data/apiHooks';
import { ContentLibrary } from '../data/api';
import StatusWidget from '../generic/status-widget';
import messages from './messages';
type LibraryPublishStatusProps = {
library: ContentLibrary,
};
const LibraryPublishStatus = ({ library } : LibraryPublishStatusProps) => {
const LibraryPublishStatus = () => {
const intl = useIntl();
const { libraryData, readOnly } = useLibraryContext();
const commitLibraryChanges = useCommitLibraryChanges();
const revertLibraryChanges = useRevertLibraryChanges();
const { showToast } = useContext(ToastContext);
const commit = useCallback(() => {
commitLibraryChanges.mutateAsync(library.id)
.then(() => {
showToast(intl.formatMessage(messages.publishSuccessMsg));
}).catch(() => {
showToast(intl.formatMessage(messages.publishErrorMsg));
});
}, []);
if (libraryData) {
commitLibraryChanges.mutateAsync(libraryData.id)
.then(() => {
showToast(intl.formatMessage(messages.publishSuccessMsg));
}).catch(() => {
showToast(intl.formatMessage(messages.publishErrorMsg));
});
}
}, [libraryData]);
const revert = useCallback(() => {
revertLibraryChanges.mutateAsync(library.id)
.then(() => {
showToast(intl.formatMessage(messages.revertSuccessMsg));
}).catch(() => {
showToast(intl.formatMessage(messages.revertErrorMsg));
});
}, []);
if (libraryData) {
revertLibraryChanges.mutateAsync(libraryData.id)
.then(() => {
showToast(intl.formatMessage(messages.revertSuccessMsg));
}).catch(() => {
showToast(intl.formatMessage(messages.revertErrorMsg));
});
}
}, [libraryData]);
if (!libraryData) {
return null;
}
return (
<StatusWidget
{...library}
onCommit={library.canEditLibrary ? commit : undefined}
onRevert={library.canEditLibrary ? revert : undefined}
{...libraryData}
onCommit={!readOnly ? commit : undefined}
onRevert={!readOnly ? revert : undefined}
/>
);
};

View File

@@ -9,16 +9,11 @@ import { useIntl } from '@edx/frontend-platform/i18n';
import { AddContentContainer, AddContentHeader } from '../add-content';
import { CollectionInfo, CollectionInfoHeader } from '../collections';
import { ContentLibrary } from '../data/api';
import { SidebarBodyComponentId, useLibraryContext } from '../common/context';
import { ComponentInfo, ComponentInfoHeader } from '../component-info';
import { LibraryInfo, LibraryInfoHeader } from '../library-info';
import messages from '../messages';
type LibrarySidebarProps = {
library: ContentLibrary,
};
/**
* Sidebar container for library pages.
*
@@ -28,36 +23,26 @@ 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 = () => {
const intl = useIntl();
const {
sidebarBodyComponent,
closeLibrarySidebar,
currentComponentUsageKey,
currentCollectionId,
} = useLibraryContext();
const bodyComponentMap = {
[SidebarBodyComponentId.AddContent]: <AddContentContainer />,
[SidebarBodyComponentId.Info]: <LibraryInfo library={library} />,
[SidebarBodyComponentId.ComponentInfo]: (
currentComponentUsageKey && <ComponentInfo usageKey={currentComponentUsageKey} />
),
[SidebarBodyComponentId.CollectionInfo]: (
currentCollectionId && <CollectionInfo library={library} collectionId={currentCollectionId} />
),
[SidebarBodyComponentId.Info]: <LibraryInfo />,
[SidebarBodyComponentId.ComponentInfo]: <ComponentInfo />,
[SidebarBodyComponentId.CollectionInfo]: <CollectionInfo />,
unknown: null,
};
const headerComponentMap = {
[SidebarBodyComponentId.AddContent]: <AddContentHeader />,
[SidebarBodyComponentId.Info]: <LibraryInfoHeader library={library} />,
[SidebarBodyComponentId.ComponentInfo]: (
currentComponentUsageKey && <ComponentInfoHeader library={library} usageKey={currentComponentUsageKey} />
),
[SidebarBodyComponentId.CollectionInfo]: (
currentCollectionId && <CollectionInfoHeader library={library} collectionId={currentCollectionId} />
),
[SidebarBodyComponentId.Info]: <LibraryInfoHeader />,
[SidebarBodyComponentId.ComponentInfo]: <ComponentInfoHeader />,
[SidebarBodyComponentId.CollectionInfo]: <CollectionInfoHeader />,
unknown: null,
};

View File

@@ -140,7 +140,7 @@ describe('<LibraryTeam />', () => {
await waitFor(() => {
expect(axiosMock.history.patch.length).toEqual(1);
expect(axiosMock.history.patch[0].data).toBe(
`{"id":"${libraryId}","allow_public_read":true}`,
'{"allow_public_read":true}',
);
});
});

View File

@@ -106,6 +106,11 @@ const messages = defineMessages({
defaultMessage: 'Read Only',
description: 'Text in badge when the user has read only access',
},
returnToLibrarySelection: {
id: 'course-authoring.library-authoring.pick-components.return-to-library-selection',
defaultMessage: 'Change Library',
description: 'Breadcrumbs link to return to library selection',
},
});
export default messages;