feat: "Add content" sidebar on each library home page (#1065)

This commit is contained in:
Chris Chávez
2024-07-17 03:15:40 -05:00
committed by GitHub
parent e087001905
commit 3d24741062
20 changed files with 803 additions and 91 deletions

View 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();
});
});

View 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>
);
};

View File

@@ -16,7 +16,7 @@ import { initializeHotjar } from '@edx/frontend-enterprise-hotjar';
import { logError } from '@edx/frontend-platform/logging';
import messages from './i18n';
import { CreateLibrary, 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>
);
};

View File

@@ -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">

View File

@@ -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();
});
});

View File

@@ -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>
);
};

View 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;

View File

@@ -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));
});
});

View 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;

View File

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

View 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;

View 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>
);
};

View 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);
});
});

View File

@@ -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.
*/

View 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);
});
});

View File

@@ -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.
*/

View File

@@ -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';

View 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;

View File

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

View File

@@ -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;