diff --git a/src/library-authoring/LibraryAuthoringPage.test.tsx b/src/library-authoring/LibraryAuthoringPage.test.tsx
index 26073ee238..cad29ffa9b 100644
--- a/src/library-authoring/LibraryAuthoringPage.test.tsx
+++ b/src/library-authoring/LibraryAuthoringPage.test.tsx
@@ -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();
@@ -56,20 +61,25 @@ const libraryData: ContentLibrary = {
numBlocks: 2,
version: 0,
lastPublished: null,
+ lastDraftCreated: null,
+ publishedBy: 'staff',
+ lastDraftCreatedBy: null,
allowLti: false,
allowPublicLearning: false,
allowPublicRead: false,
hasUnpublishedChanges: true,
hasUnpublishedDeletes: false,
+ canEditLibrary: true,
license: '',
- canEditLibrary: false,
+ created: null,
+ updated: null,
};
const RootWrapper = () => (
-
+
@@ -150,11 +160,12 @@ describe('
', () => {
axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData);
const {
- getByRole, getByText, queryByText,
+ getByRole, getByText, queryByText, findByText,
} = render(
);
// Ensure the search endpoint is called
- await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); });
+ // One called for LibraryComponents and another called for components count
+ await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(2, searchEndpoint, 'post'); });
expect(getByText('Content library')).toBeInTheDocument();
expect(getByText(libraryData.title)).toBeInTheDocument();
@@ -164,14 +175,13 @@ describe('
', () => {
expect(getByText('Recently Modified')).toBeInTheDocument();
expect(getByText('Collections (0)')).toBeInTheDocument();
expect(getByText('Components (6)')).toBeInTheDocument();
- expect(getByText('There are 6 components in this library')).toBeInTheDocument();
+ expect(await findByText('Test HTML Block')).toBeInTheDocument();
// Navigate to the components tab
fireEvent.click(getByRole('tab', { name: 'Components' }));
expect(queryByText('Recently Modified')).not.toBeInTheDocument();
expect(queryByText('Collections (0)')).not.toBeInTheDocument();
expect(queryByText('Components (6)')).not.toBeInTheDocument();
- expect(getByText('There are 6 components in this library')).toBeInTheDocument();
// Navigate to the collections tab
fireEvent.click(getByRole('tab', { name: 'Collections' }));
@@ -187,7 +197,6 @@ describe('
', () => {
expect(getByText('Recently Modified')).toBeInTheDocument();
expect(getByText('Collections (0)')).toBeInTheDocument();
expect(getByText('Components (6)')).toBeInTheDocument();
- expect(getByText('There are 6 components in this library')).toBeInTheDocument();
});
it('show library without components', async () => {
@@ -206,6 +215,16 @@ describe('
', () => {
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(
);
+
+ 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 +253,24 @@ describe('
', () => {
// 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(
);
+
+ 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();
+ });
});
diff --git a/src/library-authoring/LibraryAuthoringPage.tsx b/src/library-authoring/LibraryAuthoringPage.tsx
index f369025a32..42aa3e25e5 100644
--- a/src/library-authoring/LibraryAuthoringPage.tsx
+++ b/src/library-authoring/LibraryAuthoringPage.tsx
@@ -1,10 +1,18 @@
-import React, { useState } from 'react';
+import React, { useContext, useEffect } 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';
@@ -13,11 +21,13 @@ import Loading from '../generic/Loading';
import SubHeader from '../generic/sub-header/SubHeader';
import Header from '../header';
import NotFoundAlert from '../generic/NotFoundAlert';
-import LibraryComponents from './LibraryComponents';
+import LibraryComponents from './components/LibraryComponents';
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 = '',
@@ -27,6 +37,8 @@ enum TabList {
const SubHeaderTitle = ({ title }: { title: string }) => {
const intl = useIntl();
+ const { openInfoSidebar } = useContext(LibraryContext);
+
return (
<>
{title}
@@ -35,6 +47,7 @@ const SubHeaderTitle = ({ title }: { title: string }) => {
iconAs={Icon}
alt={intl.formatMessage(messages.headingInfoAlt)}
className="mr-2"
+ onClick={openInfoSidebar}
/>
>
);
@@ -44,7 +57,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 +65,15 @@ const LibraryAuthoringPage = () => {
const currentPath = location.pathname.split('/').pop();
const activeKey = (currentPath && currentPath in TabList) ? TabList[currentPath] : TabList.home;
+ const {
+ sidebarBodyComponent,
+ openAddContentSidebar,
+ openInfoSidebar,
+ } = useContext(LibraryContext);
+
+ useEffect(() => {
+ openInfoSidebar();
+ }, []);
if (isLoading) {
return
;
@@ -62,62 +84,80 @@ const LibraryAuthoringPage = () => {
}
const handleTabChange = (key: string) => {
- // setTabKey(key);
navigate(key);
};
return (
- <>
-
-
- }
- subtitle={intl.formatMessage(messages.headingSubtitle)}
- />
- setSearchKeywords(value)}
- onSubmit={() => {}}
- className="w-50"
- />
-
-
-
-
-
-
- }
+
+
+
+
- }
- />
- }
- />
- }
- />
-
-
-
- >
+
+ }
+ subtitle={intl.formatMessage(messages.headingSubtitle)}
+ headerActions={[
+ openAddContentSidebar()}
+ disabled={!libraryData.canEditLibrary}
+ >
+ {intl.formatMessage(messages.newContentButton)}
+ ,
+ ]}
+ />
+ setSearchKeywords(value)}
+ onSubmit={() => {}}
+ className="w-50"
+ />
+
+
+
+
+
+
+ }
+ />
+ }
+ />
+ }
+ />
+ }
+ />
+
+
+
+
+ { sidebarBodyComponent !== null && (
+
+
+
+ )}
+
+
);
};
diff --git a/src/library-authoring/LibraryComponents.tsx b/src/library-authoring/LibraryComponents.tsx
deleted file mode 100644
index a8d2cd281b..0000000000
--- a/src/library-authoring/LibraryComponents.tsx
+++ /dev/null
@@ -1,32 +0,0 @@
-import React from 'react';
-import { FormattedMessage } from '@edx/frontend-platform/i18n';
-
-import { NoComponents, NoSearchResults } from './EmptyStates';
-import { useLibraryComponentCount } from './data/apiHooks';
-import messages from './messages';
-
-type LibraryComponentsProps = {
- libraryId: string;
- filter: {
- searchKeywords: string;
- };
-};
-
-const LibraryComponents = ({ libraryId, filter: { searchKeywords } }: LibraryComponentsProps) => {
- const { componentCount } = useLibraryComponentCount(libraryId, searchKeywords);
-
- if (componentCount === 0) {
- return searchKeywords === '' ? : ;
- }
-
- return (
-
-
-
- );
-};
-
-export default LibraryComponents;
diff --git a/src/library-authoring/LibraryHome.tsx b/src/library-authoring/LibraryHome.tsx
index 1a79c05cf0..0c202b2cdc 100644
--- a/src/library-authoring/LibraryHome.tsx
+++ b/src/library-authoring/LibraryHome.tsx
@@ -6,9 +6,9 @@ import {
import { NoComponents, NoSearchResults } from './EmptyStates';
import LibraryCollections from './LibraryCollections';
-import LibraryComponents from './LibraryComponents';
import { useLibraryComponentCount } from './data/apiHooks';
import messages from './messages';
+import { LibraryComponents } from './components';
const Section = ({ title, children } : { title: string, children: React.ReactNode }) => (
@@ -45,8 +45,8 @@ const LibraryHome = ({ libraryId, filter } : LibraryHomeProps) => {
-
-
+
);
diff --git a/src/library-authoring/LibraryLayout.tsx b/src/library-authoring/LibraryLayout.tsx
new file mode 100644
index 0000000000..95d829606f
--- /dev/null
+++ b/src/library-authoring/LibraryLayout.tsx
@@ -0,0 +1,11 @@
+import React from 'react';
+import LibraryAuthoringPage from './LibraryAuthoringPage';
+import { LibraryProvider } from './common/context';
+
+const LibraryLayout = () => (
+
+
+
+);
+
+export default LibraryLayout;
diff --git a/src/library-authoring/__mocks__/index.js b/src/library-authoring/__mocks__/index.js
new file mode 100644
index 0000000000..6d72558350
--- /dev/null
+++ b/src/library-authoring/__mocks__/index.js
@@ -0,0 +1,2 @@
+// eslint-disable-next-line import/prefer-default-export
+export { default as libraryComponentsMock } from './libraryComponentsMock';
diff --git a/src/library-authoring/__mocks__/libraryComponentsMock.js b/src/library-authoring/__mocks__/libraryComponentsMock.js
new file mode 100644
index 0000000000..8f3dfa2a7f
--- /dev/null
+++ b/src/library-authoring/__mocks__/libraryComponentsMock.js
@@ -0,0 +1,74 @@
+module.exports = [
+ {
+ id: '1',
+ displayName: 'Text',
+ formatted: {
+ content: {
+ htmlContent: 'This is a text: ID=1',
+ },
+ },
+ tags: {
+ level0: ['1', '2', '3'],
+ },
+ blockType: 'text',
+ },
+ {
+ id: '2',
+ displayName: 'Text',
+ formatted: {
+ content: {
+ htmlContent: 'This is a text: ID=2',
+ },
+ },
+ tags: {
+ level0: ['1', '2', '3'],
+ },
+ blockType: 'text',
+ },
+ {
+ id: '3',
+ displayName: 'Video',
+ formatted: {
+ content: {
+ htmlContent: 'This is a video: ID=3',
+ },
+ },
+ tags: {
+ level0: ['1', '2'],
+ },
+ blockType: 'video',
+ },
+ {
+ id: '4',
+ displayName: 'Video',
+ formatted: {
+ content: {
+ htmlContent: 'This is a video: ID=4',
+ },
+ },
+ tags: {
+ level0: ['1', '2'],
+ },
+ blockType: 'text',
+ },
+ {
+ id: '5',
+ displayName: 'Problem',
+ formatted: {
+ content: {
+ htmlContent: 'This is a problem: ID=5',
+ },
+ },
+ blockType: 'problem',
+ },
+ {
+ id: '6',
+ displayName: 'Problem',
+ formatted: {
+ content: {
+ htmlContent: 'This is a problem: ID=6',
+ },
+ },
+ blockType: 'problem',
+ },
+];
diff --git a/src/library-authoring/add-content/AddContentContainer.test.tsx b/src/library-authoring/add-content/AddContentContainer.test.tsx
new file mode 100644
index 0000000000..51b07843c9
--- /dev/null
+++ b/src/library-authoring/add-content/AddContentContainer.test.tsx
@@ -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 = () => (
+
+
+
+
+
+
+
+);
+
+describe(' ', () => {
+ 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( );
+ 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( );
+
+ const textButton = screen.getByRole('button', { name: /text/i });
+ fireEvent.click(textButton);
+
+ await waitFor(() => expect(axiosMock.history.post[0].url).toEqual(url));
+ });
+});
diff --git a/src/library-authoring/add-content/AddContentContainer.tsx b/src/library-authoring/add-content/AddContentContainer.tsx
new file mode 100644
index 0000000000..9af31593cb
--- /dev/null
+++ b/src/library-authoring/add-content/AddContentContainer.tsx
@@ -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 (
+
+
+ {intl.formatMessage(messages.collectionButton)}
+
+
+ {contentTypes.map((contentType) => (
+ onCreateContent(contentType.blockType)}
+ >
+ {contentType.name}
+
+ ))}
+
+ );
+};
+
+export default AddContentContainer;
diff --git a/src/library-authoring/add-content/AddContentHeader.tsx b/src/library-authoring/add-content/AddContentHeader.tsx
new file mode 100644
index 0000000000..7b5e537b19
--- /dev/null
+++ b/src/library-authoring/add-content/AddContentHeader.tsx
@@ -0,0 +1,11 @@
+import React from "react";
+import { FormattedMessage } from '@edx/frontend-platform/i18n';
+import messages from "./messages";
+
+const AddContentHeader = () => (
+
+
+
+);
+
+export default AddContentHeader;
diff --git a/src/library-authoring/add-content/index.ts b/src/library-authoring/add-content/index.ts
new file mode 100644
index 0000000000..e1495c3bd9
--- /dev/null
+++ b/src/library-authoring/add-content/index.ts
@@ -0,0 +1,3 @@
+// eslint-disable-next-line import/prefer-default-export
+export { default as AddContentContainer } from './AddContentContainer';
+export { default as AddContentHeader } from './AddContentHeader';
diff --git a/src/library-authoring/add-content/messages.ts b/src/library-authoring/add-content/messages.ts
new file mode 100644
index 0000000000..6024e144c1
--- /dev/null
+++ b/src/library-authoring/add-content/messages.ts
@@ -0,0 +1,60 @@
+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',
+ },
+ addContentTitle: {
+ id: 'course-authoring.library-authoring.sidebar.title.add-content',
+ defaultMessage: 'Add Content',
+ description: 'Title of add content in library container.',
+ },
+});
+
+export default messages;
diff --git a/src/library-authoring/common/context.tsx b/src/library-authoring/common/context.tsx
new file mode 100644
index 0000000000..735708bfce
--- /dev/null
+++ b/src/library-authoring/common/context.tsx
@@ -0,0 +1,50 @@
+/* eslint-disable react/require-default-props */
+import React from 'react';
+
+enum SidebarBodyComponentId {
+ AddContent = 'add-content',
+ Info = 'info',
+}
+
+export interface LibraryContextData {
+ sidebarBodyComponent: SidebarBodyComponentId | null;
+ closeLibrarySidebar: () => void;
+ openAddContentSidebar: () => void;
+ openInfoSidebar: () => void;
+}
+
+export const LibraryContext = React.createContext({
+ sidebarBodyComponent: null,
+ closeLibrarySidebar: () => {},
+ openAddContentSidebar: () => {},
+ openInfoSidebar: () => {},
+} as LibraryContextData);
+
+/**
+ * React component to provide `LibraryContext`
+ */
+export const LibraryProvider = (props: { children?: React.ReactNode }) => {
+ const [sidebarBodyComponent, setSidebarBodyComponent] = React.useState(null);
+
+ const closeLibrarySidebar = React.useCallback(() => setSidebarBodyComponent(null), []);
+ const openAddContentSidebar = React.useCallback(() => setSidebarBodyComponent(SidebarBodyComponentId.AddContent), []);
+ const openInfoSidebar = React.useCallback(() => setSidebarBodyComponent(SidebarBodyComponentId.Info), []);
+
+ const context = React.useMemo(() => ({
+ sidebarBodyComponent,
+ closeLibrarySidebar,
+ openAddContentSidebar,
+ openInfoSidebar,
+ }), [
+ sidebarBodyComponent,
+ closeLibrarySidebar,
+ openAddContentSidebar,
+ openInfoSidebar,
+ ]);
+
+ return (
+
+ {props.children}
+
+ );
+};
diff --git a/src/library-authoring/components/ComponentCard.scss b/src/library-authoring/components/ComponentCard.scss
new file mode 100644
index 0000000000..cd39a690e5
--- /dev/null
+++ b/src/library-authoring/components/ComponentCard.scss
@@ -0,0 +1,24 @@
+.library-component-card {
+ .pgn__card {
+ height: 100%;
+ }
+
+ .library-component-header {
+ border-top-left-radius: .375rem;
+ border-top-right-radius: .375rem;
+ padding: 0 .5rem 0 1.25rem;
+
+ .library-component-header-icon {
+ width: 2.3rem;
+ height: 2.3rem;
+ }
+
+ .pgn__card-header-content {
+ margin-top: .55rem;
+ }
+
+ .pgn__card-header-actions {
+ margin: .25rem 0 .25rem 1rem;
+ }
+ }
+}
diff --git a/src/library-authoring/components/ComponentCard.tsx b/src/library-authoring/components/ComponentCard.tsx
new file mode 100644
index 0000000000..0789354491
--- /dev/null
+++ b/src/library-authoring/components/ComponentCard.tsx
@@ -0,0 +1,104 @@
+import React, { useMemo } from 'react';
+import {
+ ActionRow,
+ Card,
+ Container,
+ Icon,
+ IconButton,
+ Dropdown,
+ Stack,
+} from '@openedx/paragon';
+import { MoreVert } from '@openedx/paragon/icons';
+import { FormattedMessage } from '@edx/frontend-platform/i18n';
+import messages from './messages';
+import TagCount from '../../generic/tag-count';
+import { getItemIcon, getComponentStyleColor } from '../../generic/block-type-utils';
+import { ContentHit } from '../../search-modal/data/api';
+import Highlight from '../../search-modal/Highlight';
+
+type ComponentCardProps = {
+ contentHit: ContentHit,
+ blockTypeDisplayName: string,
+};
+
+const ComponentCardMenu = () => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+);
+
+const ComponentCard = ({ contentHit, blockTypeDisplayName } : ComponentCardProps) => {
+ const {
+ blockType,
+ formatted,
+ tags,
+ } = contentHit;
+ const description = formatted?.content?.htmlContent ?? '';
+ const displayName = formatted?.displayName ?? '';
+ const tagCount = useMemo(() => {
+ if (!tags) {
+ return 0;
+ }
+ return (tags.level0?.length || 0) + (tags.level1?.length || 0)
+ + (tags.level2?.length || 0) + (tags.level3?.length || 0);
+ }, [tags]);
+
+ const componentIcon = getItemIcon(blockType);
+
+ return (
+
+
+
+ }
+ actions={(
+
+
+
+ )}
+ />
+
+
+
+
+
+ {blockTypeDisplayName}
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default ComponentCard;
diff --git a/src/library-authoring/components/LibraryComponents.test.tsx b/src/library-authoring/components/LibraryComponents.test.tsx
new file mode 100644
index 0000000000..13687a2c09
--- /dev/null
+++ b/src/library-authoring/components/LibraryComponents.test.tsx
@@ -0,0 +1,165 @@
+import React from 'react';
+import { AppProvider } from '@edx/frontend-platform/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 } from '@testing-library/react';
+import LibraryComponents from './LibraryComponents';
+
+import initializeStore from '../../store';
+import { libraryComponentsMock } from '../__mocks__';
+
+const mockUseLibraryComponents = jest.fn();
+const mockUseLibraryComponentCount = jest.fn();
+const mockUseLibraryBlockTypes = jest.fn();
+const mockFetchNextPage = jest.fn();
+let store;
+const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ retry: false,
+ },
+ },
+});
+
+const data = {
+ hits: [],
+ isFetching: true,
+ isFetchingNextPage: false,
+ hasNextPage: false,
+ fetchNextPage: mockFetchNextPage,
+};
+const countData = {
+ componentCount: 1,
+ collectionCount: 0,
+};
+const blockTypeData = {
+ data: [
+ {
+ blockType: 'html',
+ displayName: 'Text',
+ },
+ {
+ blockType: 'video',
+ displayName: 'Video',
+ },
+ {
+ blockType: 'problem',
+ displayName: 'Problem',
+ },
+ ],
+};
+
+jest.mock('../data/apiHooks', () => ({
+ useLibraryComponents: () => mockUseLibraryComponents(),
+ useLibraryComponentCount: () => mockUseLibraryComponentCount(),
+ useLibraryBlockTypes: () => mockUseLibraryBlockTypes(),
+}));
+
+const RootWrapper = (props) => (
+
+
+
+
+
+
+
+);
+
+describe(' ', () => {
+ beforeEach(() => {
+ initializeMockApp({
+ authenticatedUser: {
+ userId: 3,
+ username: 'abc123',
+ administrator: true,
+ roles: [],
+ },
+ });
+ store = initializeStore();
+ mockUseLibraryComponents.mockReturnValue(data);
+ mockUseLibraryComponentCount.mockReturnValue(countData);
+ mockUseLibraryBlockTypes.mockReturnValue(blockTypeData);
+ });
+
+ afterEach(() => {
+ jest.resetAllMocks();
+ });
+
+ it('should render empty state', async () => {
+ mockUseLibraryComponentCount.mockReturnValueOnce({
+ ...countData,
+ componentCount: 0,
+ });
+ render( );
+ expect(await screen.findByText(/you have not added any content to this library yet\./i));
+ });
+
+ it('should render components in full variant', async () => {
+ mockUseLibraryComponents.mockReturnValue({
+ ...data,
+ hits: libraryComponentsMock,
+ isFetching: false,
+ });
+ render( );
+
+ expect(await screen.findByText('This is a text: ID=1')).toBeInTheDocument();
+ expect(screen.getByText('This is a text: ID=2')).toBeInTheDocument();
+ expect(screen.getByText('This is a video: ID=3')).toBeInTheDocument();
+ expect(screen.getByText('This is a video: ID=4')).toBeInTheDocument();
+ expect(screen.getByText('This is a problem: ID=5')).toBeInTheDocument();
+ expect(screen.getByText('This is a problem: ID=6')).toBeInTheDocument();
+ });
+
+ it('should render components in preview variant', async () => {
+ mockUseLibraryComponents.mockReturnValue({
+ ...data,
+ hits: libraryComponentsMock,
+ isFetching: false,
+ });
+ render( );
+
+ expect(await screen.findByText('This is a text: ID=1')).toBeInTheDocument();
+ expect(screen.getByText('This is a text: ID=2')).toBeInTheDocument();
+ expect(screen.getByText('This is a video: ID=3')).toBeInTheDocument();
+ expect(screen.getByText('This is a video: ID=4')).toBeInTheDocument();
+ expect(screen.queryByText('This is a problem: ID=5')).not.toBeInTheDocument();
+ expect(screen.queryByText('This is a problem: ID=6')).not.toBeInTheDocument();
+ });
+
+ it('should call `fetchNextPage` on scroll to bottom in full variant', async () => {
+ mockUseLibraryComponents.mockReturnValue({
+ ...data,
+ hits: libraryComponentsMock,
+ isFetching: false,
+ hasNextPage: true,
+ });
+
+ render( );
+
+ Object.defineProperty(window, 'innerHeight', { value: 800 });
+ Object.defineProperty(document.body, 'scrollHeight', { value: 1600 });
+
+ fireEvent.scroll(window, { target: { scrollY: 1000 } });
+
+ expect(mockFetchNextPage).toHaveBeenCalled();
+ });
+
+ it('should not call `fetchNextPage` on croll to bottom in preview variant', async () => {
+ mockUseLibraryComponents.mockReturnValue({
+ ...data,
+ hits: libraryComponentsMock,
+ isFetching: false,
+ hasNextPage: true,
+ });
+
+ render( );
+
+ Object.defineProperty(window, 'innerHeight', { value: 800 });
+ Object.defineProperty(document.body, 'scrollHeight', { value: 1600 });
+
+ fireEvent.scroll(window, { target: { scrollY: 1000 } });
+
+ expect(mockFetchNextPage).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/library-authoring/components/LibraryComponents.tsx b/src/library-authoring/components/LibraryComponents.tsx
new file mode 100644
index 0000000000..b2e7ed68b1
--- /dev/null
+++ b/src/library-authoring/components/LibraryComponents.tsx
@@ -0,0 +1,96 @@
+import React, { useEffect, useMemo } from 'react';
+
+import { CardGrid } from '@openedx/paragon';
+import { NoComponents, NoSearchResults } from '../EmptyStates';
+import { useLibraryBlockTypes, useLibraryComponentCount, useLibraryComponents } from '../data/apiHooks';
+import ComponentCard from './ComponentCard';
+
+type LibraryComponentsProps = {
+ libraryId: string,
+ filter: {
+ searchKeywords: string,
+ },
+ variant: string,
+};
+
+/**
+ * Library Components to show components grid
+ *
+ * Use style to:
+ * - 'full': Show all components with Infinite scroll pagination.
+ * - 'preview': Show first 4 components without pagination.
+ */
+const LibraryComponents = ({
+ libraryId,
+ filter: { searchKeywords },
+ variant,
+}: LibraryComponentsProps) => {
+ const { componentCount } = useLibraryComponentCount(libraryId, searchKeywords);
+ const {
+ hits,
+ isFetchingNextPage,
+ hasNextPage,
+ fetchNextPage,
+ } = useLibraryComponents(libraryId, searchKeywords);
+
+ const componentList = variant === 'preview' ? hits.slice(0, 4) : hits;
+
+ // TODO add this to LibraryContext
+ const { data: blockTypesData } = useLibraryBlockTypes(libraryId);
+ const blockTypes = useMemo(() => {
+ const result = {};
+ if (blockTypesData) {
+ blockTypesData.forEach(blockType => {
+ result[blockType.blockType] = blockType;
+ });
+ }
+ return result;
+ }, [blockTypesData]);
+
+ useEffect(() => {
+ if (variant === 'full') {
+ const onscroll = () => {
+ // Verify the position of the scroll to implementa a infinite scroll.
+ // Used `loadLimit` to fetch next page before reach the end of the screen.
+ const loadLimit = 300;
+ const scrolledTo = window.scrollY + window.innerHeight;
+ const scrollDiff = document.body.scrollHeight - scrolledTo;
+ const isNearToBottom = scrollDiff <= loadLimit;
+ if (isNearToBottom && hasNextPage && !isFetchingNextPage) {
+ fetchNextPage();
+ }
+ };
+ window.addEventListener('scroll', onscroll);
+ return () => {
+ window.removeEventListener('scroll', onscroll);
+ };
+ }
+ return () => {};
+ }, [hasNextPage, isFetchingNextPage, fetchNextPage]);
+
+ if (componentCount === 0) {
+ return searchKeywords === '' ? : ;
+ }
+
+ return (
+
+ { componentList.map((contentHit) => (
+
+ )) }
+
+ );
+};
+
+export default LibraryComponents;
diff --git a/src/library-authoring/components/index.ts b/src/library-authoring/components/index.ts
new file mode 100644
index 0000000000..63c42720e0
--- /dev/null
+++ b/src/library-authoring/components/index.ts
@@ -0,0 +1,2 @@
+// eslint-disable-next-line import/prefer-default-export
+export { default as LibraryComponents } from './LibraryComponents';
diff --git a/src/library-authoring/components/messages.ts b/src/library-authoring/components/messages.ts
new file mode 100644
index 0000000000..1e80f26c73
--- /dev/null
+++ b/src/library-authoring/components/messages.ts
@@ -0,0 +1,25 @@
+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({
+ menuEdit: {
+ id: 'course-authoring.library-authoring.component.menu.edit',
+ defaultMessage: 'Edit',
+ description: 'Menu item for edit a component.',
+ },
+ menuCopyToClipboard: {
+ id: 'course-authoring.library-authoring.component.menu.copy',
+ defaultMessage: 'Copy to Clipboard',
+ description: 'Menu item for copy a component.',
+ },
+ menuAddToCollection: {
+ id: 'course-authoring.library-authoring.component.menu.add',
+ defaultMessage: 'Add to Collection',
+ description: 'Menu item for add a component to collection.',
+ },
+});
+
+export default messages;
diff --git a/src/library-authoring/data/api.test.ts b/src/library-authoring/data/api.test.ts
new file mode 100644
index 0000000000..66736ad249
--- /dev/null
+++ b/src/library-authoring/data/api.test.ts
@@ -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);
+ });
+});
diff --git a/src/library-authoring/data/api.ts b/src/library-authoring/data/api.ts
index be3ec564f2..b1406b14f4 100644
--- a/src/library-authoring/data/api.ts
+++ b/src/library-authoring/data/api.ts
@@ -7,7 +7,20 @@ 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 get block types of library.
+ */
+export const getLibraryBlockTypesUrl = (libraryId: string) => `${getApiBaseUrl()}/api/libraries/v2/${libraryId}/block_types/`;
+/**
+ * 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/`;
+/**
+ * Get the URL for commit/revert changes in library.
+ */
+export const getCommitLibraryChangesUrl = (libraryId: string) => `${getApiBaseUrl()}/api/libraries/v2/${libraryId}/commit/`
+
export interface ContentLibrary {
id: string;
@@ -19,27 +32,36 @@ export interface ContentLibrary {
numBlocks: number;
version: number;
lastPublished: Date | null;
+ lastDraftCreated: Date | null;
+ publishedBy: string | null;
+ lastDraftCreatedBy: string | null;
allowLti: boolean;
allowPublicLearning: boolean;
allowPublicRead: boolean;
hasUnpublishedChanges: boolean;
hasUnpublishedDeletes: boolean;
- license: string;
canEditLibrary: boolean;
+ license: string;
+ created: Date | null;
+ updated: Date | null;
+}
+
+export interface LibraryBlockType {
+ blockType: string;
+ displayName: string;
}
/**
- * Fetch a content library by its ID.
+ * Fetch block types of a library
*/
-export async function getContentLibrary(libraryId?: string): Promise {
+export async function getLibraryBlockTypes(libraryId?: string): Promise {
if (!libraryId) {
throw new Error('libraryId is required');
}
- const { data } = await getAuthenticatedHttpClient().get(getContentLibraryApiUrl(libraryId));
+ const { data } = await getAuthenticatedHttpClient().get(getLibraryBlockTypesUrl(libraryId));
return camelCaseObject(data);
}
-
export interface LibrariesV2Response {
next: string | null,
previous: string | null,
@@ -66,6 +88,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 {
+ 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 {
+ const client = getAuthenticatedHttpClient();
+ const { data } = await client.post(
+ getCreateLibraryBlockUrl(libraryId),
+ {
+ block_type: blockType,
+ definition_id: definitionId,
+ },
+ );
+ return data;
+}
+
/**
* Get a list of content libraries.
*/
@@ -84,3 +149,25 @@ export async function getContentLibraryV2List(customParams: GetLibrariesV2Custom
.get(getContentLibraryV2ListApiUrl(), { params: customParamsFormated });
return camelCaseObject(data);
}
+
+/**
+ * Commit library changes.
+ */
+export async function commitLibraryChanges(libraryId: string): Promise {
+ const client = getAuthenticatedHttpClient();
+
+ const { data } = await client.post(getCommitLibraryChangesUrl(libraryId));
+
+ return camelCaseObject(data);
+}
+
+/**
+ * Revert library changes.
+ */
+export async function revertLibraryChanges(libraryId: string): Promise {
+ const client = getAuthenticatedHttpClient();
+
+ const { data } = await client.delete(getCommitLibraryChangesUrl(libraryId));
+
+ return camelCaseObject(data);
+}
diff --git a/src/library-authoring/data/apiHooks.test.tsx b/src/library-authoring/data/apiHooks.test.tsx
new file mode 100644
index 0000000000..6798423767
--- /dev/null
+++ b/src/library-authoring/data/apiHooks.test.tsx
@@ -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 }) => (
+
+ {children}
+
+);
+
+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);
+ });
+});
diff --git a/src/library-authoring/data/apiHooks.ts b/src/library-authoring/data/apiHooks.ts
index 4b887bb4a2..e7eb4d7c5c 100644
--- a/src/library-authoring/data/apiHooks.ts
+++ b/src/library-authoring/data/apiHooks.ts
@@ -1,9 +1,17 @@
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,
+ getContentLibrary,
+ getLibraryBlockTypes,
+ createLibraryBlock,
+ getContentLibraryV2List,
+ commitLibraryChanges,
+ revertLibraryChanges,
+} from './api';
export const libraryAuthoringQueryKeys = {
all: ['contentLibrary'],
@@ -16,6 +24,12 @@ export const libraryAuthoringQueryKeys = {
'list',
...(customParams ? [customParams] : []),
],
+ contentLibraryBlockTypes: (contentLibraryId?: string) => [
+ ...libraryAuthoringQueryKeys.all,
+ ...libraryAuthoringQueryKeys.contentLibrary(contentLibraryId),
+ 'content',
+ 'libraryBlockTypes',
+ ],
};
/**
@@ -28,6 +42,54 @@ export const useContentLibrary = (libraryId?: string) => (
})
);
+/**
+ * Hook to fetch block types of a library.
+ */
+export const useLibraryBlockTypes = (libraryId) => (
+ useQuery({
+ queryKey: libraryAuthoringQueryKeys.contentLibraryBlockTypes(libraryId),
+ queryFn: () => getLibraryBlockTypes(libraryId),
+ })
+);
+
+/**
+ * Hook to fetch components in a library.
+ */
+export const useLibraryComponents = (libraryId: string, searchKeywords: string) => {
+ const { data: connectionDetails } = useContentSearchConnection();
+
+ const indexName = connectionDetails?.indexName;
+ const client = React.useMemo(() => {
+ if (connectionDetails?.apiKey === undefined || connectionDetails?.url === undefined) {
+ return undefined;
+ }
+ return new MeiliSearch({ host: connectionDetails.url, apiKey: connectionDetails.apiKey });
+ }, [connectionDetails?.apiKey, connectionDetails?.url]);
+
+ const libFilter = `context_key = "${libraryId}"`;
+
+ return useContentSearchResults({
+ client,
+ indexName,
+ searchKeywords,
+ extraFilter: [libFilter],
+ });
+};
+
+/**
+ * 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.
*/
@@ -70,3 +132,24 @@ export const useContentLibraryV2List = (customParams: GetLibrariesV2CustomParams
keepPreviousData: true,
})
);
+
+
+export const useCommitLibraryChanges = () => {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: commitLibraryChanges,
+ onSettled: (_data, _error, libraryId) => {
+ queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.contentLibrary(libraryId) });
+ },
+ });
+};
+
+export const useRevertLibraryChanges = () => {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: revertLibraryChanges,
+ onSettled: (_data, _error, libraryId) => {
+ queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.contentLibrary(libraryId) });
+ },
+ });
+};
\ No newline at end of file
diff --git a/src/library-authoring/index.scss b/src/library-authoring/index.scss
new file mode 100644
index 0000000000..e82ba16ab0
--- /dev/null
+++ b/src/library-authoring/index.scss
@@ -0,0 +1,2 @@
+@import "library-authoring/components/ComponentCard";
+@import "library-authoring/library-info/LibraryPublishStatus";
diff --git a/src/library-authoring/index.ts b/src/library-authoring/index.ts
index 817a857375..d541704eec 100644
--- a/src/library-authoring/index.ts
+++ b/src/library-authoring/index.ts
@@ -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';
diff --git a/src/library-authoring/library-info/LibraryInfo.tsx b/src/library-authoring/library-info/LibraryInfo.tsx
new file mode 100644
index 0000000000..c0e8bac04e
--- /dev/null
+++ b/src/library-authoring/library-info/LibraryInfo.tsx
@@ -0,0 +1,53 @@
+import React from "react";
+import { Stack } from "@openedx/paragon";
+import { useIntl } from '@edx/frontend-platform/i18n';
+import messages from "./messages";
+import { convertToStringFromDateAndFormat } from "../../utils";
+import { COMMA_SEPARATED_DATE_FORMAT } from "../../constants";
+import LibraryPublishStatus from "./LibraryPublishStatus";
+import { ContentLibrary } from "../data/api";
+
+type LibraryInfoProps = {
+ library: ContentLibrary,
+};
+
+const LibraryInfo = ({ library } : LibraryInfoProps) => {
+ const intl = useIntl();
+
+ return (
+
+
+
+
+ {intl.formatMessage(messages.organizationSectionTitle)}
+
+
+ {library.org}
+
+
+
+
+ {intl.formatMessage(messages.libraryHistorySectionTitle)}
+
+
+
+ {intl.formatMessage(messages.lastModifiedLabel)}
+
+
+ {convertToStringFromDateAndFormat(library.updated, COMMA_SEPARATED_DATE_FORMAT)}
+
+
+
+
+ {intl.formatMessage(messages.createdLabel)}
+
+
+ {convertToStringFromDateAndFormat(library.created, COMMA_SEPARATED_DATE_FORMAT)}
+
+
+
+
+ );
+};
+
+export default LibraryInfo;
diff --git a/src/library-authoring/library-info/LibraryInfoHeader.tsx b/src/library-authoring/library-info/LibraryInfoHeader.tsx
new file mode 100644
index 0000000000..b25e742143
--- /dev/null
+++ b/src/library-authoring/library-info/LibraryInfoHeader.tsx
@@ -0,0 +1,31 @@
+import React from "react";
+import { Icon, IconButton, Stack } from "@openedx/paragon";
+import { Edit } from '@openedx/paragon/icons';
+import { useIntl } from '@edx/frontend-platform/i18n';
+import messages from "./messages";
+import { ContentLibrary } from "../data/api";
+
+type LibraryInfoHeaderProps = {
+ library: ContentLibrary,
+};
+
+const LibraryInfoHeader = ({ library } : LibraryInfoHeaderProps) => {
+ const intl = useIntl();
+
+ return (
+
+
+ {library.title}
+
+ {library.canEditLibrary && (
+
+ )}
+
+ );
+};
+
+export default LibraryInfoHeader;
diff --git a/src/library-authoring/library-info/LibraryPublishStatus.scss b/src/library-authoring/library-info/LibraryPublishStatus.scss
new file mode 100644
index 0000000000..7f94889290
--- /dev/null
+++ b/src/library-authoring/library-info/LibraryPublishStatus.scss
@@ -0,0 +1,12 @@
+.library-publish-status {
+
+ &.draft-status {
+ background-color: #FDF3E9;
+ border-top: 4px solid #F4B57B;
+ }
+
+ &.published-status {
+ background-color: $info-100;
+ border-top: 4px solid $info-400;
+ }
+}
diff --git a/src/library-authoring/library-info/LibraryPublishStatus.tsx b/src/library-authoring/library-info/LibraryPublishStatus.tsx
new file mode 100644
index 0000000000..84997017b3
--- /dev/null
+++ b/src/library-authoring/library-info/LibraryPublishStatus.tsx
@@ -0,0 +1,136 @@
+import React, { useCallback, useContext, useMemo } from "react";
+import { Button, Container, Stack } from "@openedx/paragon";
+import { ContentLibrary } from "../data/api";
+import { useIntl } from '@edx/frontend-platform/i18n';
+import messages from "./messages";
+import classNames from 'classnames';
+import { useCommitLibraryChanges, useRevertLibraryChanges } from "../data/apiHooks";
+import { ToastContext } from "../../generic/toast-context";
+import { convertToStringFromDateAndFormat } from "../../utils";
+import { COMMA_SEPARATED_DATE_FORMAT, TIME_FORMAT } from "../../constants";
+
+type LibraryPublishStatusProps = {
+ library: ContentLibrary,
+}
+
+const LibraryPublishStatus = ({ library } : LibraryPublishStatusProps) => {
+ const intl = useIntl();
+ 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));
+ });
+ }, []);
+
+ const revert = useCallback(() => {
+ revertLibraryChanges.mutateAsync(library.id)
+ .then(() => {
+ showToast(intl.formatMessage(messages.revertSuccessMsg));
+ }).catch(() => {
+ showToast(intl.formatMessage(messages.revertErrorMsg));
+ });
+ }, []);
+
+ const {
+ isPublished,
+ statusMessage,
+ extraStatusMessage,
+ bodyMessage,
+ } = useMemo(() => {
+ let isPublished : boolean;
+ let statusMessage : string;
+ let extraStatusMessage : string | undefined = undefined;
+ let bodyMessage : string | undefined = undefined;
+ const buildDraftBodyMessage = (() => {
+ if (library.lastDraftCreatedBy) {
+ return intl.formatMessage(messages.lastDraftMsg, {
+ date: {convertToStringFromDateAndFormat(library.lastDraftCreated, COMMA_SEPARATED_DATE_FORMAT)} ,
+ time: {convertToStringFromDateAndFormat(library.lastDraftCreated, TIME_FORMAT)} ,
+ user: {library.lastDraftCreatedBy} ,
+ });
+ } else {
+ return intl.formatMessage(messages.lastDraftMsgWithoutUser, {
+ date: {convertToStringFromDateAndFormat(library.lastDraftCreated, COMMA_SEPARATED_DATE_FORMAT)} ,
+ time: {convertToStringFromDateAndFormat(library.lastDraftCreated, TIME_FORMAT)} ,
+ });
+ }
+ });
+
+ if (!library.lastPublished) {
+ // Library is never published (new)
+ isPublished = false;
+ statusMessage = intl.formatMessage(messages.draftStatusLabel);
+ extraStatusMessage = intl.formatMessage(messages.neverPublishedLabel);
+ bodyMessage = buildDraftBodyMessage();
+ } else if (library.hasUnpublishedChanges || library.hasUnpublishedDeletes) {
+ // Library is on Draft state
+ isPublished = false;
+ statusMessage = intl.formatMessage(messages.draftStatusLabel);
+ extraStatusMessage = intl.formatMessage(messages.unpublishedStatusLabel);
+ bodyMessage = buildDraftBodyMessage();
+ } else {
+ // Library is published
+ isPublished = true;
+ statusMessage = intl.formatMessage(messages.publishedStatusLabel);
+ if (library.publishedBy) {
+ bodyMessage = intl.formatMessage(messages.lastPublishedMsg, {
+ date: {convertToStringFromDateAndFormat(library.lastPublished, COMMA_SEPARATED_DATE_FORMAT)} ,
+ time: {convertToStringFromDateAndFormat(library.lastPublished, TIME_FORMAT)} ,
+ user: {library.publishedBy} ,
+ })
+ } else {
+ bodyMessage = intl.formatMessage(messages.lastPublishedMsgWithoutUser, {
+ date: {convertToStringFromDateAndFormat(library.lastPublished, COMMA_SEPARATED_DATE_FORMAT)} ,
+ time: {convertToStringFromDateAndFormat(library.lastPublished, TIME_FORMAT)} ,
+ })
+ }
+ }
+ return {
+ isPublished,
+ statusMessage,
+ extraStatusMessage,
+ bodyMessage,
+ }
+ }, [library])
+
+ return (
+
+
+
+ {statusMessage}
+
+ { extraStatusMessage && (
+
+ {extraStatusMessage}
+
+ )}
+
+
+
+
+ {bodyMessage}
+
+
+ {intl.formatMessage(messages.publishButtonLabel)}
+
+
+
+ {intl.formatMessage(messages.discardChangesButtonLabel)}
+
+
+
+
+
+ );
+};
+
+export default LibraryPublishStatus;
diff --git a/src/library-authoring/library-info/index.ts b/src/library-authoring/library-info/index.ts
new file mode 100644
index 0000000000..cfe118fdaa
--- /dev/null
+++ b/src/library-authoring/library-info/index.ts
@@ -0,0 +1,3 @@
+// eslint-disable-next-line import/prefer-default-export
+export { default as LibraryInfo } from './LibraryInfo';
+export { default as LibraryInfoHeader } from './LibraryInfoHeader';
diff --git a/src/library-authoring/library-info/messages.ts b/src/library-authoring/library-info/messages.ts
new file mode 100644
index 0000000000..2ec7b8db59
--- /dev/null
+++ b/src/library-authoring/library-info/messages.ts
@@ -0,0 +1,101 @@
+import { defineMessages } from '@edx/frontend-platform/i18n';
+
+const messages = defineMessages({
+ editNameButtonAlt: {
+ id: 'course-authoring.library-authoring.sidebar.info.edit-name.alt',
+ defaultMessage: 'Edit library name',
+ description: 'Alt text for edit library name icon button',
+ },
+ organizationSectionTitle: {
+ id: 'course-authoring.library-authoring.sidebar.info.organization.title',
+ defaultMessage: 'Organization',
+ description: 'Title for Organization section in Library info sidebar.',
+ },
+ libraryHistorySectionTitle: {
+ id: 'course-authoring.library-authoring.sidebar.info.history.title',
+ defaultMessage: 'Library History',
+ description: 'Title for Library History section in Library info sidebar.',
+ },
+ lastModifiedLabel: {
+ id: 'course-authoring.library-authoring.sidebar.info.history.last-modified',
+ defaultMessage: 'Last Modified',
+ description: 'Last Modified label used in Library History section.',
+ },
+ createdLabel: {
+ id: 'course-authoring.library-authoring.sidebar.info.history.created',
+ defaultMessage: 'Created',
+ description: 'Created label used in Library History section.',
+ },
+ draftStatusLabel: {
+ id: 'course-authoring.library-authoring.sidebar.info.publish-status.draft',
+ defaultMessage: 'Draft',
+ description: 'Label in library info sidebar when the library is on draft status',
+ },
+ neverPublishedLabel: {
+ id: 'course-authoring.library-authoring.sidebar.info.publish-status.never',
+ defaultMessage: '(Never Published)',
+ description: 'Label in library info sidebar when the library is never published',
+ },
+ unpublishedStatusLabel: {
+ id: 'course-authoring.library-authoring.sidebar.info.publish-status.unpublished',
+ defaultMessage: '(Unpublished Changes)',
+ description: 'Label in library info sidebar when the library has unpublished changes',
+ },
+ publishedStatusLabel: {
+ id: 'course-authoring.library-authoring.sidebar.info.publish-status.published',
+ defaultMessage: 'Published',
+ description: 'Label in library info sidebar when the library is on published status',
+ },
+ publishButtonLabel: {
+ id: 'course-authoring.library-authoring.sidebar.info.publish-status.publish-button',
+ defaultMessage: 'Publish',
+ description: 'Label of publish button for a library.',
+ },
+ discardChangesButtonLabel: {
+ id: 'course-authoring.library-authoring.sidebar.info.publish-status.discard-button',
+ defaultMessage: 'Discard Changes',
+ description: 'Label of discard changes button for a library.',
+ },
+ lastPublishedMsg: {
+ id: 'course-authoring.library-authoring.sidebar.info.publish-status.last-published',
+ defaultMessage: 'Last published on {date} at {time} UTC by {user}.',
+ description: 'Body meesage of the library info sidebar when library is published.',
+ },
+ lastPublishedMsgWithoutUser: {
+ id: 'course-authoring.library-authoring.sidebar.info.publish-status.last-published-no-user',
+ defaultMessage: 'Last published on {date} at {time} UTC.',
+ description: 'Body meesage of the library info sidebar when library is published.',
+ },
+ lastDraftMsg: {
+ id: 'course-authoring.library-authoring.sidebar.info.publish-status.last-draft',
+ defaultMessage: 'Draft saved on {date} at {time} UTC by {user}.',
+ description: 'Body meesage of the library info sidebar when library is on draft status.',
+ },
+ lastDraftMsgWithoutUser: {
+ id: 'course-authoring.library-authoring.sidebar.info.publish-status.last-draft-no-user',
+ defaultMessage: 'Draft saved on {date} at {time} UTC.',
+ description: 'Body meesage of the library info sidebar when library is on draft status.',
+ },
+ publishSuccessMsg: {
+ id: 'course-authoring.library-authoring.publish.success',
+ defaultMessage: 'Library published successfully',
+ description: 'Message when the library is published successfully.',
+ },
+ publishErrorMsg: {
+ id: 'course-authoring.library-authoring.publish.error',
+ defaultMessage: 'There was an error publishing the library.',
+ description: 'Message when there is an error when publishing the library.',
+ },
+ revertSuccessMsg: {
+ id: 'course-authoring.library-authoring.revert.success',
+ defaultMessage: 'Library changes reverted successfully',
+ description: 'Message when the library changes are reverted successfully.',
+ },
+ revertErrorMsg: {
+ id: 'course-authoring.library-authoring.publish.error',
+ defaultMessage: 'There was an error reverting changes in the library.',
+ description: 'Message when there is an error when reverting changes in the library.',
+ },
+});
+
+export default messages;
diff --git a/src/library-authoring/library-sidebar/LibrarySidebar.tsx b/src/library-authoring/library-sidebar/LibrarySidebar.tsx
new file mode 100644
index 0000000000..48a3ba7efe
--- /dev/null
+++ b/src/library-authoring/library-sidebar/LibrarySidebar.tsx
@@ -0,0 +1,64 @@
+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, AddContentHeader } from '../add-content';
+import { LibraryContext } from '../common/context';
+import { LibraryInfo, LibraryInfoHeader } from '../library-info';
+import { ContentLibrary } from '../data/api';
+
+type LibrarySidebarProps = {
+ library: ContentLibrary,
+}
+
+/**
+ * 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 = ({library}: LibrarySidebarProps) => {
+ const intl = useIntl();
+ const { sidebarBodyComponent, closeLibrarySidebar } = useContext(LibraryContext);
+
+ const bodyComponentMap = {
+ 'add-content': ,
+ 'info': ,
+ unknown: null,
+ };
+
+ const headerComponentMap = {
+ 'add-content': ,
+ info: ,
+ unknown: null,
+ };
+
+ const buildBody = () : React.ReactNode | null => bodyComponentMap[sidebarBodyComponent || 'unknown'];
+ const buildHeader = (): React.ReactNode | null => headerComponentMap[sidebarBodyComponent || 'unknown'];
+
+ return (
+
+
+ {buildHeader()}
+
+
+ {buildBody()}
+
+ );
+};
+
+export default LibrarySidebar;
diff --git a/src/library-authoring/library-sidebar/index.ts b/src/library-authoring/library-sidebar/index.ts
new file mode 100644
index 0000000000..087b1e1d9d
--- /dev/null
+++ b/src/library-authoring/library-sidebar/index.ts
@@ -0,0 +1,2 @@
+// eslint-disable-next-line import/prefer-default-export
+export { default as LibrarySidebar } from './LibrarySidebar';
diff --git a/src/library-authoring/messages.ts b/src/library-authoring/messages.ts
index 0cc3217380..88116c620b 100644
--- a/src/library-authoring/messages.ts
+++ b/src/library-authoring/messages.ts
@@ -60,10 +60,15 @@ const messages = defineMessages({
defaultMessage: 'Coming soon!',
description: 'Temp placeholder for the collections container. This will be replaced with the actual collection list.',
},
- recentComponentsTempPlaceholder: {
- id: 'course-authoring.library-authoring.recent-components-temp-placeholder',
- defaultMessage: 'Recently modified components and collections will be displayed here.',
- description: 'Temp placeholder for the recent components container. This will be replaced with the actual list.',
+ createLibrary: {
+ id: 'course-authoring.library-authoring.create-library',
+ defaultMessage: 'Create library',
+ description: 'Header for the create library form',
+ },
+ createLibraryTempPlaceholder: {
+ id: 'course-authoring.library-authoring.create-library-temp-placeholder',
+ defaultMessage: 'This is a placeholder for the create library form. This will be replaced with the actual form.',
+ description: 'Temp placeholder for the create library container. This will be replaced with the new library form.',
},
recentlyModifiedTitle: {
id: 'course-authoring.library-authoring.recently-modified-title',
@@ -80,6 +85,26 @@ const messages = defineMessages({
defaultMessage: 'Components ({componentCount})',
description: 'Title for the components container',
},
+ recentComponentsTempPlaceholder: {
+ id: 'course-authoring.library-authoring.recent-components-temp-placeholder',
+ defaultMessage: 'Recently modified components and collections will be displayed here.',
+ description: 'Temp placeholder for the recent components container. This will be replaced with the actual list.',
+ },
+ 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;
diff --git a/src/search-modal/SearchResult.tsx b/src/search-modal/SearchResult.tsx
index 1fe86751fe..9075fbc389 100644
--- a/src/search-modal/SearchResult.tsx
+++ b/src/search-modal/SearchResult.tsx
@@ -6,31 +6,17 @@ import {
IconButton,
Stack,
} from '@openedx/paragon';
-import {
- Article,
- Folder,
- OpenInNew,
-} from '@openedx/paragon/icons';
+import { OpenInNew } from '@openedx/paragon/icons';
import { useSelector } from 'react-redux';
import { useNavigate } from 'react-router-dom';
import { constructLibraryAuthoringURL } from '../utils';
-import { COMPONENT_TYPE_ICON_MAP, TYPE_ICONS_MAP } from '../course-unit/constants';
import { getStudioHomeData } from '../studio-home/data/selectors';
import { useSearchContext } from './manager/SearchManager';
import type { ContentHit } from './data/api';
import Highlight from './Highlight';
import messages from './messages';
-
-const STRUCTURAL_TYPE_ICONS: Record = {
- vertical: TYPE_ICONS_MAP.vertical,
- sequential: Folder,
- chapter: Folder,
-};
-
-function getItemIcon(blockType: string): React.ComponentType {
- return STRUCTURAL_TYPE_ICONS[blockType] ?? COMPONENT_TYPE_ICON_MAP[blockType] ?? Article;
-}
+import { getItemIcon } from '../generic/block-type-utils';
/**
* Returns the URL Suffix for library/library component hit
diff --git a/src/utils.js b/src/utils.js
index 2abb63e5be..f1995ee991 100644
--- a/src/utils.js
+++ b/src/utils.js
@@ -284,6 +284,21 @@ export const convertToStringFromDate = (date) => {
return moment(date).format(DATE_TIME_FORMAT);
};
+export const convertToStringFromDateAndFormat = (date, format) => {
+ /**
+ * Convert local time to UTC string from react-datepicker in a format
+ * Note: react-datepicker has a bug where it only interacts with local time
+ * @param {Date} date - date in local time
+ * @param {string} format - format of the date
+ * @return {string} date converted in string in the desired format
+ */
+ if (!date) {
+ return '';
+ }
+
+ return moment(date).format(format);
+};
+
export const isValidDate = (date) => {
const formattedValue = convertToStringFromDate(date).split('T')[0];
diff --git a/webpack.dev-tutor.config.js b/webpack.dev-tutor.config.js
new file mode 100755
index 0000000000..e69de29bb2