feat: add library component picker (#1356)
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.');
|
||||
|
||||
@@ -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/ });
|
||||
|
||||
@@ -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={(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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/ });
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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'); });
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
268
src/library-authoring/component-picker/ComponentPicker.test.tsx
Normal file
268
src/library-authoring/component-picker/ComponentPicker.test.tsx
Normal 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.');
|
||||
});
|
||||
});
|
||||
71
src/library-authoring/component-picker/ComponentPicker.tsx
Normal file
71
src/library-authoring/component-picker/ComponentPicker.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
119
src/library-authoring/component-picker/SelectLibrary.tsx
Normal file
119
src/library-authoring/component-picker/SelectLibrary.tsx
Normal 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;
|
||||
2
src/library-authoring/component-picker/index.ts
Normal file
2
src/library-authoring/component-picker/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export { ComponentPicker } from './ComponentPicker';
|
||||
36
src/library-authoring/component-picker/messages.ts
Normal file
36
src/library-authoring/component-picker/messages.ts
Normal 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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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}',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user