feat: "Add content" sidebar on each library home page (#1065)
This commit is contained in:
69
src/generic/toast-context/index.test.tsx
Normal file
69
src/generic/toast-context/index.test.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { ToastContext, ToastProvider } from '.';
|
||||
import initializeStore from '../../store';
|
||||
|
||||
export interface WraperProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const TestComponentToShow = () => {
|
||||
const { showToast } = React.useContext(ToastContext);
|
||||
|
||||
React.useEffect(() => {
|
||||
showToast('This is the toast!');
|
||||
}, [showToast]);
|
||||
|
||||
return <div>Content</div>;
|
||||
};
|
||||
|
||||
const TestComponentToClose = () => {
|
||||
const { showToast, closeToast } = React.useContext(ToastContext);
|
||||
|
||||
React.useEffect(() => {
|
||||
showToast('This is the toast!');
|
||||
closeToast();
|
||||
}, [showToast]);
|
||||
|
||||
return <div>Content</div>;
|
||||
};
|
||||
|
||||
let store;
|
||||
const RootWrapper = ({ children }: WraperProps) => (
|
||||
<AppProvider store={store}>
|
||||
<ToastProvider>
|
||||
{children}
|
||||
</ToastProvider>
|
||||
</AppProvider>
|
||||
);
|
||||
|
||||
describe('<ToastProvider />', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should show toast', async () => {
|
||||
render(<RootWrapper><TestComponentToShow /></RootWrapper>);
|
||||
expect(await screen.findByText('This is the toast!')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should close toast', async () => {
|
||||
render(<RootWrapper><TestComponentToClose /></RootWrapper>);
|
||||
expect(await screen.findByText('Content')).toBeInTheDocument();
|
||||
expect(screen.queryByText('This is the toast!')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
57
src/generic/toast-context/index.tsx
Normal file
57
src/generic/toast-context/index.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import React from 'react';
|
||||
import { Toast } from '@openedx/paragon';
|
||||
|
||||
export interface ToastContextData {
|
||||
toastMessage: string | null;
|
||||
showToast: (message: string) => void;
|
||||
closeToast: () => void;
|
||||
}
|
||||
|
||||
export interface ToastProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Global context to keep track of popup message(s) that appears to user after
|
||||
* they take an action like creating or deleting something.
|
||||
*/
|
||||
export const ToastContext = React.createContext({
|
||||
toastMessage: null,
|
||||
showToast: () => {},
|
||||
closeToast: () => {},
|
||||
} as ToastContextData);
|
||||
|
||||
/**
|
||||
* React component to provide `ToastContext` to the app
|
||||
*/
|
||||
export const ToastProvider = (props: ToastProviderProps) => {
|
||||
// TODO, We can convert this to a queue of messages,
|
||||
// see: https://github.com/open-craft/frontend-app-course-authoring/pull/38#discussion_r1638990647
|
||||
|
||||
const [toastMessage, setToastMessage] = React.useState<string | null>(null);
|
||||
|
||||
React.useEffect(() => () => {
|
||||
// Cleanup function to avoid updating state on unmounted component
|
||||
setToastMessage(null);
|
||||
}, []);
|
||||
|
||||
const showToast = React.useCallback((message) => setToastMessage(message), [setToastMessage]);
|
||||
const closeToast = React.useCallback(() => setToastMessage(null), [setToastMessage]);
|
||||
|
||||
const context = React.useMemo(() => ({
|
||||
toastMessage,
|
||||
showToast,
|
||||
closeToast,
|
||||
}), [toastMessage, showToast, closeToast]);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={context}>
|
||||
{props.children}
|
||||
{ toastMessage && (
|
||||
<Toast show={toastMessage !== null} onClose={closeToast}>
|
||||
{toastMessage}
|
||||
</Toast>
|
||||
)}
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -16,7 +16,7 @@ import { initializeHotjar } from '@edx/frontend-enterprise-hotjar';
|
||||
import { logError } from '@edx/frontend-platform/logging';
|
||||
import messages from './i18n';
|
||||
|
||||
import { CreateLibrary, LibraryAuthoringPage } from './library-authoring';
|
||||
import { CreateLibrary, LibraryLayout } from './library-authoring';
|
||||
import initializeStore from './store';
|
||||
import CourseAuthoringRoutes from './CourseAuthoringRoutes';
|
||||
import Head from './head/Head';
|
||||
@@ -25,6 +25,7 @@ import CourseRerun from './course-rerun';
|
||||
import { TaxonomyLayout, TaxonomyDetailPage, TaxonomyListPage } from './taxonomy';
|
||||
import { ContentTagsDrawer } from './content-tags-drawer';
|
||||
import AccessibilityPage from './accessibility-page';
|
||||
import { ToastProvider } from './generic/toast-context';
|
||||
|
||||
import 'react-datepicker/dist/react-datepicker.css';
|
||||
import './index.scss';
|
||||
@@ -53,7 +54,7 @@ const App = () => {
|
||||
<Route path="/libraries" element={<StudioHome />} />
|
||||
<Route path="/libraries-v1" element={<StudioHome />} />
|
||||
<Route path="/library/create" element={<CreateLibrary />} />
|
||||
<Route path="/library/:libraryId/*" element={<LibraryAuthoringPage />} />
|
||||
<Route path="/library/:libraryId/*" element={<LibraryLayout />} />
|
||||
<Route path="/course/:courseId/*" element={<CourseAuthoringRoutes />} />
|
||||
<Route path="/course_rerun/:courseId" element={<CourseRerun />} />
|
||||
{getConfig().ENABLE_ACCESSIBILITY_PAGE === 'true' && (
|
||||
@@ -82,10 +83,12 @@ const App = () => {
|
||||
|
||||
return (
|
||||
<AppProvider store={initializeStore()} wrapWithRouter={false}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Head />
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
<ToastProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Head />
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</ToastProvider>
|
||||
</AppProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
import React from 'react';
|
||||
import React, { useContext } from 'react';
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Button, Stack,
|
||||
} from '@openedx/paragon';
|
||||
import { Add } from '@openedx/paragon/icons';
|
||||
|
||||
import messages from './messages';
|
||||
import { LibraryContext } from './common/context';
|
||||
|
||||
export const NoComponents = () => (
|
||||
<Stack direction="horizontal" gap={3} className="mt-6 justify-content-center">
|
||||
<FormattedMessage {...messages.noComponents} />
|
||||
<Button iconBefore={Add}>
|
||||
<FormattedMessage {...messages.addComponent} />
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
export const NoComponents = () => {
|
||||
const { openAddContentSidebar } = useContext(LibraryContext);
|
||||
|
||||
return (
|
||||
<Stack direction="horizontal" gap={3} className="mt-6 justify-content-center">
|
||||
<FormattedMessage {...messages.noComponents} />
|
||||
<Button iconBefore={Add} onClick={() => openAddContentSidebar()}>
|
||||
<FormattedMessage {...messages.addComponent} />
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export const NoSearchResults = () => (
|
||||
<div className="d-flex mt-6 justify-content-center">
|
||||
|
||||
@@ -5,15 +5,20 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { fireEvent, render, waitFor } from '@testing-library/react';
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
waitFor,
|
||||
screen,
|
||||
} from '@testing-library/react';
|
||||
import fetchMock from 'fetch-mock-jest';
|
||||
|
||||
import initializeStore from '../store';
|
||||
import { getContentSearchConfigUrl } from '../search-modal/data/api';
|
||||
import mockResult from '../search-modal/__mocks__/search-result.json';
|
||||
import mockEmptyResult from '../search-modal/__mocks__/empty-search-result.json';
|
||||
import LibraryAuthoringPage from './LibraryAuthoringPage';
|
||||
import { getContentLibraryApiUrl, type ContentLibrary } from './data/api';
|
||||
import LibraryLayout from './LibraryLayout';
|
||||
|
||||
let store;
|
||||
const mockUseParams = jest.fn();
|
||||
@@ -61,15 +66,15 @@ const libraryData: ContentLibrary = {
|
||||
allowPublicRead: false,
|
||||
hasUnpublishedChanges: true,
|
||||
hasUnpublishedDeletes: false,
|
||||
canEditLibrary: true,
|
||||
license: '',
|
||||
canEditLibrary: false,
|
||||
};
|
||||
|
||||
const RootWrapper = () => (
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en" messages={{}}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<LibraryAuthoringPage />
|
||||
<LibraryLayout />
|
||||
</QueryClientProvider>
|
||||
</IntlProvider>
|
||||
</AppProvider>
|
||||
@@ -206,6 +211,16 @@ describe('<LibraryAuthoringPage />', () => {
|
||||
expect(getByText('You have not added any content to this library yet.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('show new content button', async () => {
|
||||
mockUseParams.mockReturnValue({ libraryId: libraryData.id });
|
||||
axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData);
|
||||
|
||||
render(<RootWrapper />);
|
||||
|
||||
expect(await screen.findByRole('heading')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /new/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('show library without search results', async () => {
|
||||
mockUseParams.mockReturnValue({ libraryId: libraryData.id });
|
||||
axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData);
|
||||
@@ -234,4 +249,24 @@ describe('<LibraryAuthoringPage />', () => {
|
||||
// This step is necessary to avoid the url change leak to other tests
|
||||
fireEvent.click(getByRole('tab', { name: 'Home' }));
|
||||
});
|
||||
|
||||
it('should open and close new content sidebar', async () => {
|
||||
mockUseParams.mockReturnValue({ libraryId: libraryData.id });
|
||||
axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData);
|
||||
|
||||
render(<RootWrapper />);
|
||||
|
||||
expect(await screen.findByRole('heading')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/add content/i)).not.toBeInTheDocument();
|
||||
|
||||
const newButton = screen.getByRole('button', { name: /new/i });
|
||||
fireEvent.click(newButton);
|
||||
|
||||
expect(screen.getByText(/add content/i)).toBeInTheDocument();
|
||||
|
||||
const closeButton = screen.getByRole('button', { name: /close/i });
|
||||
fireEvent.click(closeButton);
|
||||
|
||||
expect(screen.queryByText(/add content/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useContext } from 'react';
|
||||
import { StudioFooter } from '@edx/frontend-component-footer';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Container, Icon, IconButton, SearchField, Tab, Tabs,
|
||||
Button,
|
||||
Container,
|
||||
Icon,
|
||||
IconButton,
|
||||
SearchField,
|
||||
Tab,
|
||||
Tabs,
|
||||
Row,
|
||||
Col,
|
||||
} from '@openedx/paragon';
|
||||
import { InfoOutline } from '@openedx/paragon/icons';
|
||||
import { Add, InfoOutline } from '@openedx/paragon/icons';
|
||||
import {
|
||||
Routes, Route, useLocation, useNavigate, useParams,
|
||||
} from 'react-router-dom';
|
||||
@@ -18,6 +26,8 @@ import LibraryCollections from './LibraryCollections';
|
||||
import LibraryHome from './LibraryHome';
|
||||
import { useContentLibrary } from './data/apiHooks';
|
||||
import messages from './messages';
|
||||
import { LibrarySidebar } from './library-sidebar';
|
||||
import { LibraryContext } from './common/context';
|
||||
|
||||
enum TabList {
|
||||
home = '',
|
||||
@@ -44,7 +54,7 @@ const LibraryAuthoringPage = () => {
|
||||
const intl = useIntl();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [searchKeywords, setSearchKeywords] = useState('');
|
||||
const [searchKeywords, setSearchKeywords] = React.useState('');
|
||||
|
||||
const { libraryId } = useParams();
|
||||
|
||||
@@ -52,6 +62,7 @@ const LibraryAuthoringPage = () => {
|
||||
|
||||
const currentPath = location.pathname.split('/').pop();
|
||||
const activeKey = (currentPath && currentPath in TabList) ? TabList[currentPath] : TabList.home;
|
||||
const { sidebarBodyComponent, openAddContentSidebar } = useContext(LibraryContext);
|
||||
|
||||
if (isLoading) {
|
||||
return <Loading />;
|
||||
@@ -62,62 +73,80 @@ const LibraryAuthoringPage = () => {
|
||||
}
|
||||
|
||||
const handleTabChange = (key: string) => {
|
||||
// setTabKey(key);
|
||||
navigate(key);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header
|
||||
number={libraryData.slug}
|
||||
title={libraryData.title}
|
||||
org={libraryData.org}
|
||||
contextId={libraryId}
|
||||
isLibrary
|
||||
/>
|
||||
<Container size="xl" className="p-4 mt-3">
|
||||
<SubHeader
|
||||
title={<SubHeaderTitle title={libraryData.title} />}
|
||||
subtitle={intl.formatMessage(messages.headingSubtitle)}
|
||||
/>
|
||||
<SearchField
|
||||
value={searchKeywords}
|
||||
placeholder={intl.formatMessage(messages.searchPlaceholder)}
|
||||
onChange={(value: string) => setSearchKeywords(value)}
|
||||
onSubmit={() => {}}
|
||||
className="w-50"
|
||||
/>
|
||||
<Tabs
|
||||
variant="tabs"
|
||||
activeKey={activeKey}
|
||||
onSelect={handleTabChange}
|
||||
className="my-3"
|
||||
>
|
||||
<Tab eventKey={TabList.home} title={intl.formatMessage(messages.homeTab)} />
|
||||
<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 libraryId={libraryId} filter={{ searchKeywords }} />}
|
||||
<Container>
|
||||
<Row>
|
||||
<Col>
|
||||
<Header
|
||||
number={libraryData.slug}
|
||||
title={libraryData.title}
|
||||
org={libraryData.org}
|
||||
contextId={libraryId}
|
||||
isLibrary
|
||||
/>
|
||||
<Route
|
||||
path={TabList.components}
|
||||
element={<LibraryComponents libraryId={libraryId} filter={{ searchKeywords }} />}
|
||||
/>
|
||||
<Route
|
||||
path={TabList.collections}
|
||||
element={<LibraryCollections />}
|
||||
/>
|
||||
<Route
|
||||
path="*"
|
||||
element={<NotFoundAlert />}
|
||||
/>
|
||||
</Routes>
|
||||
</Container>
|
||||
<StudioFooter />
|
||||
</>
|
||||
<Container size="xl" className="p-4 mt-3">
|
||||
<SubHeader
|
||||
title={<SubHeaderTitle title={libraryData.title} />}
|
||||
subtitle={intl.formatMessage(messages.headingSubtitle)}
|
||||
headerActions={[
|
||||
<Button
|
||||
iconBefore={Add}
|
||||
variant="primary rounded-0"
|
||||
onClick={() => openAddContentSidebar()}
|
||||
disabled={!libraryData.canEditLibrary}
|
||||
>
|
||||
{intl.formatMessage(messages.newContentButton)}
|
||||
</Button>,
|
||||
]}
|
||||
/>
|
||||
<SearchField
|
||||
value={searchKeywords}
|
||||
placeholder={intl.formatMessage(messages.searchPlaceholder)}
|
||||
onChange={(value: string) => setSearchKeywords(value)}
|
||||
onSubmit={() => {}}
|
||||
className="w-50"
|
||||
/>
|
||||
<Tabs
|
||||
variant="tabs"
|
||||
activeKey={activeKey}
|
||||
onSelect={handleTabChange}
|
||||
className="my-3"
|
||||
>
|
||||
<Tab eventKey={TabList.home} title={intl.formatMessage(messages.homeTab)} />
|
||||
<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 libraryId={libraryId} filter={{ searchKeywords }} />}
|
||||
/>
|
||||
<Route
|
||||
path={TabList.components}
|
||||
element={<LibraryComponents libraryId={libraryId} filter={{ searchKeywords }} />}
|
||||
/>
|
||||
<Route
|
||||
path={TabList.collections}
|
||||
element={<LibraryCollections />}
|
||||
/>
|
||||
<Route
|
||||
path="*"
|
||||
element={<NotFoundAlert />}
|
||||
/>
|
||||
</Routes>
|
||||
</Container>
|
||||
<StudioFooter />
|
||||
</Col>
|
||||
{ sidebarBodyComponent !== null && (
|
||||
<Col xs={6} md={4} className="box-shadow-left-1">
|
||||
<LibrarySidebar />
|
||||
</Col>
|
||||
)}
|
||||
</Row>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
11
src/library-authoring/LibraryLayout.tsx
Normal file
11
src/library-authoring/LibraryLayout.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import React from 'react';
|
||||
import LibraryAuthoringPage from './LibraryAuthoringPage';
|
||||
import { LibraryProvider } from './common/context';
|
||||
|
||||
const LibraryLayout = () => (
|
||||
<LibraryProvider>
|
||||
<LibraryAuthoringPage />
|
||||
</LibraryProvider>
|
||||
);
|
||||
|
||||
export default LibraryLayout;
|
||||
@@ -0,0 +1,85 @@
|
||||
import React from 'react';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import {
|
||||
render, screen, fireEvent, waitFor,
|
||||
} from '@testing-library/react';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import AddContentContainer from './AddContentContainer';
|
||||
import initializeStore from '../../store';
|
||||
import { getCreateLibraryBlockUrl } from '../data/api';
|
||||
|
||||
const mockUseParams = jest.fn();
|
||||
let axiosMock;
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'), // use actual for all non-hook parts
|
||||
useParams: () => mockUseParams(),
|
||||
}));
|
||||
|
||||
const libraryId = '1';
|
||||
let store;
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const RootWrapper = () => (
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en" messages={{}}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AddContentContainer />
|
||||
</QueryClientProvider>
|
||||
</IntlProvider>
|
||||
</AppProvider>
|
||||
);
|
||||
|
||||
describe('<AddContentContainer />', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
mockUseParams.mockReturnValue({ libraryId });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render content buttons', () => {
|
||||
render(<RootWrapper />);
|
||||
expect(screen.getByRole('button', { name: /collection/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /text/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /problem/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /open reponse/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /drag drop/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /video/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /advanced \/ other/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should create a content', async () => {
|
||||
const url = getCreateLibraryBlockUrl(libraryId);
|
||||
axiosMock.onPost(url).reply(200);
|
||||
|
||||
render(<RootWrapper />);
|
||||
|
||||
const textButton = screen.getByRole('button', { name: /text/i });
|
||||
fireEvent.click(textButton);
|
||||
|
||||
await waitFor(() => expect(axiosMock.history.post[0].url).toEqual(url));
|
||||
});
|
||||
});
|
||||
108
src/library-authoring/add-content/AddContentContainer.tsx
Normal file
108
src/library-authoring/add-content/AddContentContainer.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import React, { useContext } from 'react';
|
||||
import {
|
||||
Stack,
|
||||
Button,
|
||||
} from '@openedx/paragon';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Article,
|
||||
AutoAwesome,
|
||||
BookOpen,
|
||||
Create,
|
||||
ThumbUpOutline,
|
||||
Question,
|
||||
VideoCamera,
|
||||
} from '@openedx/paragon/icons';
|
||||
import { v4 as uuid4 } from 'uuid';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { ToastContext } from '../../generic/toast-context';
|
||||
import { useCreateLibraryBlock } from '../data/apiHooks';
|
||||
import messages from './messages';
|
||||
|
||||
const AddContentContainer = () => {
|
||||
const intl = useIntl();
|
||||
const { libraryId } = useParams();
|
||||
const createBlockMutation = useCreateLibraryBlock();
|
||||
const { showToast } = useContext(ToastContext);
|
||||
|
||||
const contentTypes = [
|
||||
{
|
||||
name: intl.formatMessage(messages.textTypeButton),
|
||||
disabled: false,
|
||||
icon: Article,
|
||||
blockType: 'html',
|
||||
},
|
||||
{
|
||||
name: intl.formatMessage(messages.problemTypeButton),
|
||||
disabled: false,
|
||||
icon: Question,
|
||||
blockType: 'problem',
|
||||
},
|
||||
{
|
||||
name: intl.formatMessage(messages.openResponseTypeButton),
|
||||
disabled: false,
|
||||
icon: Create,
|
||||
blockType: 'openassessment',
|
||||
},
|
||||
{
|
||||
name: intl.formatMessage(messages.dragDropTypeButton),
|
||||
disabled: false,
|
||||
icon: ThumbUpOutline,
|
||||
blockType: 'drag-and-drop-v2',
|
||||
},
|
||||
{
|
||||
name: intl.formatMessage(messages.videoTypeButton),
|
||||
disabled: false,
|
||||
icon: VideoCamera,
|
||||
blockType: 'video',
|
||||
},
|
||||
{
|
||||
name: intl.formatMessage(messages.otherTypeButton),
|
||||
disabled: true,
|
||||
icon: AutoAwesome,
|
||||
blockType: 'other', // This block doesn't exist yet.
|
||||
},
|
||||
];
|
||||
|
||||
const onCreateContent = (blockType: string) => {
|
||||
if (libraryId) {
|
||||
createBlockMutation.mutateAsync({
|
||||
libraryId,
|
||||
blockType,
|
||||
definitionId: `${uuid4()}`,
|
||||
}).then(() => {
|
||||
showToast(intl.formatMessage(messages.successCreateMessage));
|
||||
}).catch(() => {
|
||||
showToast(intl.formatMessage(messages.errorCreateMessage));
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack direction="vertical">
|
||||
<Button
|
||||
variant="outline-primary"
|
||||
disabled
|
||||
className="m-2 rounded-0"
|
||||
iconBefore={BookOpen}
|
||||
>
|
||||
{intl.formatMessage(messages.collectionButton)}
|
||||
</Button>
|
||||
<hr className="w-100 bg-gray-500" />
|
||||
{contentTypes.map((contentType) => (
|
||||
<Button
|
||||
key={`add-content-${contentType.blockType}`}
|
||||
variant="outline-primary"
|
||||
disabled={contentType.disabled}
|
||||
className="m-2 rounded-0"
|
||||
iconBefore={contentType.icon}
|
||||
onClick={() => onCreateContent(contentType.blockType)}
|
||||
>
|
||||
{contentType.name}
|
||||
</Button>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddContentContainer;
|
||||
2
src/library-authoring/add-content/index.ts
Normal file
2
src/library-authoring/add-content/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export { default as AddContentContainer } from './AddContentContainer';
|
||||
55
src/library-authoring/add-content/messages.ts
Normal file
55
src/library-authoring/add-content/messages.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { defineMessages as _defineMessages } from '@edx/frontend-platform/i18n';
|
||||
import type { defineMessages as defineMessagesType } from 'react-intl';
|
||||
|
||||
// frontend-platform currently doesn't provide types... do it ourselves.
|
||||
const defineMessages = _defineMessages as typeof defineMessagesType;
|
||||
|
||||
const messages = defineMessages({
|
||||
collectionButton: {
|
||||
id: 'course-authoring.library-authoring.add-content.buttons.collection',
|
||||
defaultMessage: 'Collection',
|
||||
description: 'Content of button to create a Collection.',
|
||||
},
|
||||
textTypeButton: {
|
||||
id: 'course-authoring.library-authoring.add-content.buttons.types.text',
|
||||
defaultMessage: 'Text',
|
||||
description: 'Content of button to create a Text component.',
|
||||
},
|
||||
problemTypeButton: {
|
||||
id: 'course-authoring.library-authoring.add-content.buttons.types.problem',
|
||||
defaultMessage: 'Problem',
|
||||
description: 'Content of button to create a Problem component.',
|
||||
},
|
||||
openResponseTypeButton: {
|
||||
id: 'course-authoring.library-authoring.add-content.buttons.types.open-response',
|
||||
defaultMessage: 'Open Reponse',
|
||||
description: 'Content of button to create a Open Response component.',
|
||||
},
|
||||
dragDropTypeButton: {
|
||||
id: 'course-authoring.library-authoring.add-content.buttons.types.drag-drop',
|
||||
defaultMessage: 'Drag Drop',
|
||||
description: 'Content of button to create a Drag Drod component.',
|
||||
},
|
||||
videoTypeButton: {
|
||||
id: 'course-authoring.library-authoring.add-content.buttons.types.video',
|
||||
defaultMessage: 'Video',
|
||||
description: 'Content of button to create a Video component.',
|
||||
},
|
||||
otherTypeButton: {
|
||||
id: 'course-authoring.library-authoring.add-content.buttons.types.other',
|
||||
defaultMessage: 'Advanced / Other',
|
||||
description: 'Content of button to create a Advanced / Other component.',
|
||||
},
|
||||
successCreateMessage: {
|
||||
id: 'course-authoring.library-authoring.add-content.success.text',
|
||||
defaultMessage: 'Content created successfully.',
|
||||
description: 'Message when creation of content in library is success',
|
||||
},
|
||||
errorCreateMessage: {
|
||||
id: 'course-authoring.library-authoring.add-content.error.text',
|
||||
defaultMessage: 'There was an error creating the content.',
|
||||
description: 'Message when creation of content in library is on error',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
40
src/library-authoring/common/context.tsx
Normal file
40
src/library-authoring/common/context.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
/* eslint-disable react/require-default-props */
|
||||
import React from 'react';
|
||||
|
||||
enum SidebarBodyComponentId {
|
||||
AddContent = 'add-content',
|
||||
}
|
||||
|
||||
export interface LibraryContextData {
|
||||
sidebarBodyComponent: SidebarBodyComponentId | null;
|
||||
closeLibrarySidebar: () => void;
|
||||
openAddContentSidebar: () => void;
|
||||
}
|
||||
|
||||
export const LibraryContext = React.createContext({
|
||||
sidebarBodyComponent: null,
|
||||
closeLibrarySidebar: () => {},
|
||||
openAddContentSidebar: () => {},
|
||||
} as LibraryContextData);
|
||||
|
||||
/**
|
||||
* React component to provide `LibraryContext`
|
||||
*/
|
||||
export const LibraryProvider = (props: { children?: React.ReactNode }) => {
|
||||
const [sidebarBodyComponent, setSidebarBodyComponent] = React.useState<SidebarBodyComponentId | null>(null);
|
||||
|
||||
const closeLibrarySidebar = React.useCallback(() => setSidebarBodyComponent(null), []);
|
||||
const openAddContentSidebar = React.useCallback(() => setSidebarBodyComponent(SidebarBodyComponentId.AddContent), []);
|
||||
|
||||
const context = React.useMemo(() => ({
|
||||
sidebarBodyComponent,
|
||||
closeLibrarySidebar,
|
||||
openAddContentSidebar,
|
||||
}), [sidebarBodyComponent, closeLibrarySidebar, openAddContentSidebar]);
|
||||
|
||||
return (
|
||||
<LibraryContext.Provider value={context}>
|
||||
{props.children}
|
||||
</LibraryContext.Provider>
|
||||
);
|
||||
};
|
||||
38
src/library-authoring/data/api.test.ts
Normal file
38
src/library-authoring/data/api.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { createLibraryBlock, getCreateLibraryBlockUrl } from './api';
|
||||
|
||||
let axiosMock;
|
||||
|
||||
describe('library api calls', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should create library block', async () => {
|
||||
const libraryId = 'lib:org:1';
|
||||
const url = getCreateLibraryBlockUrl(libraryId);
|
||||
axiosMock.onPost(url).reply(200);
|
||||
await createLibraryBlock({
|
||||
libraryId,
|
||||
blockType: 'html',
|
||||
definitionId: '1',
|
||||
});
|
||||
|
||||
expect(axiosMock.history.post[0].url).toEqual(url);
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,10 @@ const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
|
||||
* Get the URL for the content library API.
|
||||
*/
|
||||
export const getContentLibraryApiUrl = (libraryId: string) => `${getApiBaseUrl()}/api/libraries/v2/${libraryId}/`;
|
||||
/**
|
||||
* Get the URL for create content in library.
|
||||
*/
|
||||
export const getCreateLibraryBlockUrl = (libraryId: string) => `${getApiBaseUrl()}/api/libraries/v2/${libraryId}/blocks/`;
|
||||
export const getContentLibraryV2ListApiUrl = () => `${getApiBaseUrl()}/api/libraries/v2/`;
|
||||
|
||||
export interface ContentLibrary {
|
||||
@@ -24,20 +28,8 @@ export interface ContentLibrary {
|
||||
allowPublicRead: boolean;
|
||||
hasUnpublishedChanges: boolean;
|
||||
hasUnpublishedDeletes: boolean;
|
||||
license: string;
|
||||
canEditLibrary: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a content library by its ID.
|
||||
*/
|
||||
export async function getContentLibrary(libraryId?: string): Promise<ContentLibrary> {
|
||||
if (!libraryId) {
|
||||
throw new Error('libraryId is required');
|
||||
}
|
||||
|
||||
const { data } = await getAuthenticatedHttpClient().get(getContentLibraryApiUrl(libraryId));
|
||||
return camelCaseObject(data);
|
||||
license: string;
|
||||
}
|
||||
|
||||
export interface LibrariesV2Response {
|
||||
@@ -66,6 +58,49 @@ export interface GetLibrariesV2CustomParams {
|
||||
search?: string,
|
||||
}
|
||||
|
||||
export interface CreateBlockDataRequest {
|
||||
libraryId: string;
|
||||
blockType: string;
|
||||
definitionId: string;
|
||||
}
|
||||
|
||||
export interface CreateBlockDataResponse {
|
||||
id: string;
|
||||
blockType: string;
|
||||
defKey: string | null;
|
||||
displayName: string;
|
||||
hasUnpublishedChanges: boolean;
|
||||
tagsCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a content library by its ID.
|
||||
*/
|
||||
export async function getContentLibrary(libraryId?: string): Promise<ContentLibrary> {
|
||||
if (!libraryId) {
|
||||
throw new Error('libraryId is required');
|
||||
}
|
||||
|
||||
const { data } = await getAuthenticatedHttpClient().get(getContentLibraryApiUrl(libraryId));
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
export async function createLibraryBlock({
|
||||
libraryId,
|
||||
blockType,
|
||||
definitionId,
|
||||
}: CreateBlockDataRequest): Promise<CreateBlockDataResponse> {
|
||||
const client = getAuthenticatedHttpClient();
|
||||
const { data } = await client.post(
|
||||
getCreateLibraryBlockUrl(libraryId),
|
||||
{
|
||||
block_type: blockType,
|
||||
definition_id: definitionId,
|
||||
},
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a list of content libraries.
|
||||
*/
|
||||
|
||||
53
src/library-authoring/data/apiHooks.test.tsx
Normal file
53
src/library-authoring/data/apiHooks.test.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import React from 'react';
|
||||
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { renderHook } from '@testing-library/react-hooks';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { getCreateLibraryBlockUrl } from './api';
|
||||
import { useCreateLibraryBlock } from './apiHooks';
|
||||
|
||||
let axiosMock;
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const wrapper = ({ children }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
describe('library api hooks', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
});
|
||||
|
||||
it('should create library block', async () => {
|
||||
const libraryId = 'lib:org:1';
|
||||
const url = getCreateLibraryBlockUrl(libraryId);
|
||||
axiosMock.onPost(url).reply(200);
|
||||
const { result } = renderHook(() => useCreateLibraryBlock(), { wrapper });
|
||||
await result.current.mutateAsync({
|
||||
libraryId,
|
||||
blockType: 'html',
|
||||
definitionId: '1',
|
||||
});
|
||||
|
||||
expect(axiosMock.history.post[0].url).toEqual(url);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,14 @@
|
||||
import React from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { MeiliSearch } from 'meilisearch';
|
||||
|
||||
import { useContentSearchConnection, useContentSearchResults } from '../../search-modal';
|
||||
import { type GetLibrariesV2CustomParams, getContentLibrary, getContentLibraryV2List } from './api';
|
||||
import {
|
||||
type GetLibrariesV2CustomParams,
|
||||
createLibraryBlock,
|
||||
getContentLibrary,
|
||||
getContentLibraryV2List,
|
||||
} from './api';
|
||||
|
||||
export const libraryAuthoringQueryKeys = {
|
||||
all: ['contentLibrary'],
|
||||
@@ -28,6 +33,20 @@ export const useContentLibrary = (libraryId?: string) => (
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Use this mutation to create a block in a library
|
||||
*/
|
||||
export const useCreateLibraryBlock = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: createLibraryBlock,
|
||||
onSettled: (_data, _error, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.contentLibrary(variables.libraryId) });
|
||||
queryClient.invalidateQueries({ queryKey: ['content_search'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to fetch the count of components and collections in a library.
|
||||
*/
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export { default as LibraryAuthoringPage } from './LibraryAuthoringPage';
|
||||
export { default as LibraryLayout } from './LibraryLayout';
|
||||
export { CreateLibrary } from './create-library';
|
||||
export { libraryAuthoringQueryKeys, useContentLibraryV2List } from './data/apiHooks';
|
||||
|
||||
52
src/library-authoring/library-sidebar/LibrarySidebar.tsx
Normal file
52
src/library-authoring/library-sidebar/LibrarySidebar.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import React, { useContext } from 'react';
|
||||
import {
|
||||
Stack,
|
||||
Icon,
|
||||
IconButton,
|
||||
} from '@openedx/paragon';
|
||||
import { Close } from '@openedx/paragon/icons';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import messages from '../messages';
|
||||
import { AddContentContainer } from '../add-content';
|
||||
import { LibraryContext } from '../common/context';
|
||||
|
||||
/**
|
||||
* Sidebar container for library pages.
|
||||
*
|
||||
* It's designed to "squash" the page when open.
|
||||
* Uses `sidebarBodyComponent` of the `store` to
|
||||
* choose which component is rendered.
|
||||
* You can add more components in `bodyComponentMap`.
|
||||
* Use the slice actions to open and close this sidebar.
|
||||
*/
|
||||
const LibrarySidebar = () => {
|
||||
const intl = useIntl();
|
||||
const { sidebarBodyComponent, closeLibrarySidebar } = useContext(LibraryContext);
|
||||
|
||||
const bodyComponentMap = {
|
||||
'add-content': <AddContentContainer />,
|
||||
unknown: null,
|
||||
};
|
||||
|
||||
const buildBody = () : React.ReactNode | null => bodyComponentMap[sidebarBodyComponent || 'unknown'];
|
||||
|
||||
return (
|
||||
<div className="p-2 vh-100">
|
||||
<Stack direction="horizontal" className="d-flex justify-content-between">
|
||||
<span className="font-weight-bold m-1.5">
|
||||
{intl.formatMessage(messages.addContentTitle)}
|
||||
</span>
|
||||
<IconButton
|
||||
src={Close}
|
||||
iconAs={Icon}
|
||||
alt={intl.formatMessage(messages.closeButtonAlt)}
|
||||
onClick={closeLibrarySidebar}
|
||||
variant="black"
|
||||
/>
|
||||
</Stack>
|
||||
{buildBody()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LibrarySidebar;
|
||||
2
src/library-authoring/library-sidebar/index.ts
Normal file
2
src/library-authoring/library-sidebar/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export { default as LibrarySidebar } from './LibrarySidebar';
|
||||
@@ -80,6 +80,21 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Components ({componentCount})',
|
||||
description: 'Title for the components container',
|
||||
},
|
||||
addContentTitle: {
|
||||
id: 'course-authoring.library-authoring.drawer.title.add-content',
|
||||
defaultMessage: 'Add Content',
|
||||
description: 'Title of add content in library container.',
|
||||
},
|
||||
newContentButton: {
|
||||
id: 'course-authoring.library-authoring.buttons.new-content.text',
|
||||
defaultMessage: 'New',
|
||||
description: 'Text of button to open "Add content drawer"',
|
||||
},
|
||||
closeButtonAlt: {
|
||||
id: 'course-authoring.library-authoring.buttons.close.alt',
|
||||
defaultMessage: 'Close',
|
||||
description: 'Alt text of close button',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
|
||||
Reference in New Issue
Block a user