diff --git a/src/generic/toast-context/index.test.tsx b/src/generic/toast-context/index.test.tsx new file mode 100644 index 000000000..ea00ba3a1 --- /dev/null +++ b/src/generic/toast-context/index.test.tsx @@ -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
Content
; +}; + +const TestComponentToClose = () => { + const { showToast, closeToast } = React.useContext(ToastContext); + + React.useEffect(() => { + showToast('This is the toast!'); + closeToast(); + }, [showToast]); + + return
Content
; +}; + +let store; +const RootWrapper = ({ children }: WraperProps) => ( + + + {children} + + +); + +describe('', () => { + beforeEach(() => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: true, + roles: [], + }, + }); + store = initializeStore(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should show toast', async () => { + render(); + expect(await screen.findByText('This is the toast!')).toBeInTheDocument(); + }); + + it('should close toast', async () => { + render(); + expect(await screen.findByText('Content')).toBeInTheDocument(); + expect(screen.queryByText('This is the toast!')).not.toBeInTheDocument(); + }); +}); diff --git a/src/generic/toast-context/index.tsx b/src/generic/toast-context/index.tsx new file mode 100644 index 000000000..3e9840784 --- /dev/null +++ b/src/generic/toast-context/index.tsx @@ -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(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 ( + + {props.children} + { toastMessage && ( + + {toastMessage} + + )} + + ); +}; diff --git a/src/index.jsx b/src/index.jsx index a0959ac19..9336b9486 100755 --- a/src/index.jsx +++ b/src/index.jsx @@ -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 = () => { } /> } /> } /> - } /> + } /> } /> } /> {getConfig().ENABLE_ACCESSIBILITY_PAGE === 'true' && ( @@ -82,10 +83,12 @@ const App = () => { return ( - - - - + + + + + + ); }; diff --git a/src/library-authoring/EmptyStates.tsx b/src/library-authoring/EmptyStates.tsx index d7b718c71..343b5b947 100644 --- a/src/library-authoring/EmptyStates.tsx +++ b/src/library-authoring/EmptyStates.tsx @@ -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 = () => ( - - - - -); +export const NoComponents = () => { + const { openAddContentSidebar } = useContext(LibraryContext); + + return ( + + + + + ); +}; export const NoSearchResults = () => (
diff --git a/src/library-authoring/LibraryAuthoringPage.test.tsx b/src/library-authoring/LibraryAuthoringPage.test.tsx index 26073ee23..fcd8c60b9 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(); @@ -61,15 +66,15 @@ const libraryData: ContentLibrary = { allowPublicRead: false, hasUnpublishedChanges: true, hasUnpublishedDeletes: false, + canEditLibrary: true, license: '', - canEditLibrary: false, }; const RootWrapper = () => ( - + @@ -206,6 +211,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 +249,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 f369025a3..524737275 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 } 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 ; @@ -62,62 +73,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={[ + , + ]} + /> + setSearchKeywords(value)} + onSubmit={() => {}} + className="w-50" + /> + + + + + + + } + /> + } + /> + } + /> + } + /> + + + + + { sidebarBodyComponent !== null && ( + + + + )} + + ); }; diff --git a/src/library-authoring/LibraryLayout.tsx b/src/library-authoring/LibraryLayout.tsx new file mode 100644 index 000000000..95d829606 --- /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/add-content/AddContentContainer.test.tsx b/src/library-authoring/add-content/AddContentContainer.test.tsx new file mode 100644 index 000000000..51b07843c --- /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 000000000..9af31593c --- /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 ( + + +
+ {contentTypes.map((contentType) => ( + + ))} +
+ ); +}; + +export default AddContentContainer; diff --git a/src/library-authoring/add-content/index.ts b/src/library-authoring/add-content/index.ts new file mode 100644 index 000000000..876828e16 --- /dev/null +++ b/src/library-authoring/add-content/index.ts @@ -0,0 +1,2 @@ +// eslint-disable-next-line import/prefer-default-export +export { default as AddContentContainer } from './AddContentContainer'; diff --git a/src/library-authoring/add-content/messages.ts b/src/library-authoring/add-content/messages.ts new file mode 100644 index 000000000..1d13635e5 --- /dev/null +++ b/src/library-authoring/add-content/messages.ts @@ -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; diff --git a/src/library-authoring/common/context.tsx b/src/library-authoring/common/context.tsx new file mode 100644 index 000000000..241ed67d2 --- /dev/null +++ b/src/library-authoring/common/context.tsx @@ -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(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 ( + + {props.children} + + ); +}; diff --git a/src/library-authoring/data/api.test.ts b/src/library-authoring/data/api.test.ts new file mode 100644 index 000000000..66736ad24 --- /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 be3ec564f..37eb4eb3d 100644 --- a/src/library-authoring/data/api.ts +++ b/src/library-authoring/data/api.ts @@ -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 { - 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 { + 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. */ diff --git a/src/library-authoring/data/apiHooks.test.tsx b/src/library-authoring/data/apiHooks.test.tsx new file mode 100644 index 000000000..679842376 --- /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 4b887bb4a..d2b4cbd80 100644 --- a/src/library-authoring/data/apiHooks.ts +++ b/src/library-authoring/data/apiHooks.ts @@ -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. */ diff --git a/src/library-authoring/index.ts b/src/library-authoring/index.ts index 817a85737..d541704ee 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-sidebar/LibrarySidebar.tsx b/src/library-authoring/library-sidebar/LibrarySidebar.tsx new file mode 100644 index 000000000..734379f51 --- /dev/null +++ b/src/library-authoring/library-sidebar/LibrarySidebar.tsx @@ -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': , + unknown: null, + }; + + const buildBody = () : React.ReactNode | null => bodyComponentMap[sidebarBodyComponent || 'unknown']; + + return ( +
+ + + {intl.formatMessage(messages.addContentTitle)} + + + + {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 000000000..087b1e1d9 --- /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 0cc321738..d127be9ed 100644 --- a/src/library-authoring/messages.ts +++ b/src/library-authoring/messages.ts @@ -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;