From 3d247410620bad9cd0b0bd056db8c536dcd00b64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Chris=20Ch=C3=A1vez?= Date: Wed, 17 Jul 2024 03:15:40 -0500 Subject: [PATCH 1/7] feat: "Add content" sidebar on each library home page (#1065) --- src/generic/toast-context/index.test.tsx | 69 +++++++++ src/generic/toast-context/index.tsx | 57 +++++++ src/index.jsx | 15 +- src/library-authoring/EmptyStates.tsx | 24 +-- .../LibraryAuthoringPage.test.tsx | 43 +++++- .../LibraryAuthoringPage.tsx | 139 +++++++++++------- src/library-authoring/LibraryLayout.tsx | 11 ++ .../add-content/AddContentContainer.test.tsx | 85 +++++++++++ .../add-content/AddContentContainer.tsx | 108 ++++++++++++++ src/library-authoring/add-content/index.ts | 2 + src/library-authoring/add-content/messages.ts | 55 +++++++ src/library-authoring/common/context.tsx | 40 +++++ src/library-authoring/data/api.test.ts | 38 +++++ src/library-authoring/data/api.ts | 61 ++++++-- src/library-authoring/data/apiHooks.test.tsx | 53 +++++++ src/library-authoring/data/apiHooks.ts | 23 ++- src/library-authoring/index.ts | 2 +- .../library-sidebar/LibrarySidebar.tsx | 52 +++++++ .../library-sidebar/index.ts | 2 + src/library-authoring/messages.ts | 15 ++ 20 files changed, 803 insertions(+), 91 deletions(-) create mode 100644 src/generic/toast-context/index.test.tsx create mode 100644 src/generic/toast-context/index.tsx create mode 100644 src/library-authoring/LibraryLayout.tsx create mode 100644 src/library-authoring/add-content/AddContentContainer.test.tsx create mode 100644 src/library-authoring/add-content/AddContentContainer.tsx create mode 100644 src/library-authoring/add-content/index.ts create mode 100644 src/library-authoring/add-content/messages.ts create mode 100644 src/library-authoring/common/context.tsx create mode 100644 src/library-authoring/data/api.test.ts create mode 100644 src/library-authoring/data/apiHooks.test.tsx create mode 100644 src/library-authoring/library-sidebar/LibrarySidebar.tsx create mode 100644 src/library-authoring/library-sidebar/index.ts diff --git a/src/generic/toast-context/index.test.tsx b/src/generic/toast-context/index.test.tsx new file mode 100644 index 0000000000..ea00ba3a1d --- /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 0000000000..3e98407844 --- /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 a0959ac192..9336b9486f 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 d7b718c71d..343b5b947d 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 26073ee238..fcd8c60b97 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 f369025a32..5247372756 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 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/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 ( + + +
+ {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 0000000000..876828e16f --- /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 0000000000..1d13635e5c --- /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 0000000000..241ed67d20 --- /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 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..37eb4eb3de 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 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..d2b4cbd802 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 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-sidebar/LibrarySidebar.tsx b/src/library-authoring/library-sidebar/LibrarySidebar.tsx new file mode 100644 index 0000000000..734379f518 --- /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 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..d127be9ed4 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; From 3a14141a4e3d3fe2fbc5285f04084c12c5725752 Mon Sep 17 00:00:00 2001 From: edX requirements bot <49161187+edx-requirements-bot@users.noreply.github.com> Date: Wed, 17 Jul 2024 04:23:54 -0400 Subject: [PATCH 2/7] chore: update browserslist DB (#1156) Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com> --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index d005279a70..46c0f5c1c2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8004,9 +8004,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001640", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001640.tgz", - "integrity": "sha512-lA4VMpW0PSUrFnkmVuEKBUovSWKhj7puyCg8StBChgu298N1AtuF1sKWEvfDuimSEDbhlb/KqPKC3fs1HbuQUA==", + "version": "1.0.30001642", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001642.tgz", + "integrity": "sha512-3XQ0DoRgLijXJErLSl+bLnJ+Et4KqV1PY6JJBGAFlsNsz31zeAIncyeZfLCabHK/jtSh+671RM9YMldxjUPZtA==", "funding": [ { "type": "opencollective", From 77135cde1d41f48183285f6047a0e05bc9e30145 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Chris=20Ch=C3=A1vez?= Date: Mon, 22 Jul 2024 20:04:57 -0500 Subject: [PATCH 3/7] feat: Library v2 components tab (#1109) --- .../add-component/AddComponent.jsx | 2 +- .../add-component/AddComponent.test.jsx | 2 +- .../add-component-btn/AddComponentIcon.jsx | 2 +- src/course-unit/constants.js | 47 ----- .../sequence-navigation/UnitIcon.jsx | 4 +- .../course-xblock/CourseXBlock.jsx | 2 +- .../course-xblock/CourseXBlock.test.jsx | 3 +- src/generic/block-type-utils/constants.ts | 69 ++++++++ src/generic/block-type-utils/index.scss | 47 +++++ src/generic/block-type-utils/index.tsx | 15 ++ src/generic/styles.scss | 1 + src/index.scss | 1 + .../LibraryAuthoringPage.test.tsx | 9 +- .../LibraryAuthoringPage.tsx | 4 +- src/library-authoring/LibraryComponents.tsx | 32 ---- src/library-authoring/LibraryHome.tsx | 6 +- src/library-authoring/__mocks__/index.js | 2 + .../__mocks__/libraryComponentsMock.js | 74 ++++++++ .../components/ComponentCard.scss | 24 +++ .../components/ComponentCard.tsx | 104 +++++++++++ .../components/LibraryComponents.test.tsx | 165 ++++++++++++++++++ .../components/LibraryComponents.tsx | 96 ++++++++++ src/library-authoring/components/index.ts | 2 + src/library-authoring/components/messages.ts | 25 +++ src/library-authoring/data/api.ts | 20 +++ src/library-authoring/data/apiHooks.ts | 43 ++++- src/library-authoring/index.scss | 1 + src/library-authoring/messages.ts | 18 +- src/search-modal/SearchResult.tsx | 18 +- webpack.dev-tutor.config.js | 0 30 files changed, 721 insertions(+), 117 deletions(-) create mode 100644 src/generic/block-type-utils/constants.ts create mode 100644 src/generic/block-type-utils/index.scss create mode 100644 src/generic/block-type-utils/index.tsx delete mode 100644 src/library-authoring/LibraryComponents.tsx create mode 100644 src/library-authoring/__mocks__/index.js create mode 100644 src/library-authoring/__mocks__/libraryComponentsMock.js create mode 100644 src/library-authoring/components/ComponentCard.scss create mode 100644 src/library-authoring/components/ComponentCard.tsx create mode 100644 src/library-authoring/components/LibraryComponents.test.tsx create mode 100644 src/library-authoring/components/LibraryComponents.tsx create mode 100644 src/library-authoring/components/index.ts create mode 100644 src/library-authoring/components/messages.ts create mode 100644 src/library-authoring/index.scss create mode 100755 webpack.dev-tutor.config.js diff --git a/src/course-unit/add-component/AddComponent.jsx b/src/course-unit/add-component/AddComponent.jsx index b4c151859d..a2c80f8b74 100644 --- a/src/course-unit/add-component/AddComponent.jsx +++ b/src/course-unit/add-component/AddComponent.jsx @@ -5,7 +5,7 @@ import { useIntl } from '@edx/frontend-platform/i18n'; import { useToggle } from '@openedx/paragon'; import { getCourseSectionVertical } from '../data/selectors'; -import { COMPONENT_TYPES } from '../constants'; +import { COMPONENT_TYPES } from '../../generic/block-type-utils/constants'; import ComponentModalView from './add-component-modals/ComponentModalView'; import AddComponentButton from './add-component-btn'; import messages from './messages'; diff --git a/src/course-unit/add-component/AddComponent.test.jsx b/src/course-unit/add-component/AddComponent.test.jsx index 9bd5a5de04..f09378bf09 100644 --- a/src/course-unit/add-component/AddComponent.test.jsx +++ b/src/course-unit/add-component/AddComponent.test.jsx @@ -14,7 +14,7 @@ import { executeThunk } from '../../utils'; import { fetchCourseSectionVerticalData } from '../data/thunk'; import { getCourseSectionVerticalApiUrl } from '../data/api'; import { courseSectionVerticalMock } from '../__mocks__'; -import { COMPONENT_TYPES } from '../constants'; +import { COMPONENT_TYPES } from '../../generic/block-type-utils/constants'; import AddComponent from './AddComponent'; import messages from './messages'; diff --git a/src/course-unit/add-component/add-component-btn/AddComponentIcon.jsx b/src/course-unit/add-component/add-component-btn/AddComponentIcon.jsx index 4ace3ea015..91cc5b09b1 100644 --- a/src/course-unit/add-component/add-component-btn/AddComponentIcon.jsx +++ b/src/course-unit/add-component/add-component-btn/AddComponentIcon.jsx @@ -2,7 +2,7 @@ import PropTypes from 'prop-types'; import { Icon } from '@openedx/paragon'; import { EditNote as EditNoteIcon } from '@openedx/paragon/icons'; -import { COMPONENT_TYPES, COMPONENT_TYPE_ICON_MAP } from '../../constants'; +import { COMPONENT_TYPES, COMPONENT_TYPE_ICON_MAP } from '../../../generic/block-type-utils/constants'; const AddComponentIcon = ({ type }) => { const icon = COMPONENT_TYPE_ICON_MAP[type] || EditNoteIcon; diff --git a/src/course-unit/constants.js b/src/course-unit/constants.js index b7e7bf5c6b..9ff040d63c 100644 --- a/src/course-unit/constants.js +++ b/src/course-unit/constants.js @@ -1,53 +1,6 @@ -import { - BackHand as BackHandIcon, - BookOpen as BookOpenIcon, - Edit as EditIcon, - EditNote as EditNoteIcon, - FormatListBulleted as FormatListBulletedIcon, - HelpOutline as HelpOutlineIcon, - LibraryAdd as LibraryIcon, - Lock as LockIcon, - QuestionAnswerOutline as QuestionAnswerOutlineIcon, - Science as ScienceIcon, - TextFields as TextFieldsIcon, - VideoCamera as VideoCameraIcon, -} from '@openedx/paragon/icons'; - import messages from './sidebar/messages'; import addComponentMessages from './add-component/messages'; -export const UNIT_ICON_TYPES = ['video', 'other', 'vertical', 'problem', 'lock']; - -export const COMPONENT_TYPES = { - advanced: 'advanced', - discussion: 'discussion', - library: 'library', - html: 'html', - openassessment: 'openassessment', - problem: 'problem', - video: 'video', - dragAndDrop: 'drag-and-drop-v2', -}; - -export const TYPE_ICONS_MAP = { - video: VideoCameraIcon, - other: BookOpenIcon, - vertical: FormatListBulletedIcon, - problem: EditIcon, - lock: LockIcon, -}; - -export const COMPONENT_TYPE_ICON_MAP = { - [COMPONENT_TYPES.advanced]: ScienceIcon, - [COMPONENT_TYPES.discussion]: QuestionAnswerOutlineIcon, - [COMPONENT_TYPES.library]: LibraryIcon, - [COMPONENT_TYPES.html]: TextFieldsIcon, - [COMPONENT_TYPES.openassessment]: EditNoteIcon, - [COMPONENT_TYPES.problem]: HelpOutlineIcon, - [COMPONENT_TYPES.video]: VideoCameraIcon, - [COMPONENT_TYPES.dragAndDrop]: BackHandIcon, -}; - export const getUnitReleaseStatus = (intl) => ({ release: intl.formatMessage(messages.releaseStatusTitle), released: intl.formatMessage(messages.releasedStatusTitle), diff --git a/src/course-unit/course-sequence/sequence-navigation/UnitIcon.jsx b/src/course-unit/course-sequence/sequence-navigation/UnitIcon.jsx index 69830e4bde..9294d419f1 100644 --- a/src/course-unit/course-sequence/sequence-navigation/UnitIcon.jsx +++ b/src/course-unit/course-sequence/sequence-navigation/UnitIcon.jsx @@ -2,10 +2,10 @@ import PropTypes from 'prop-types'; import { Icon } from '@openedx/paragon'; import { BookOpen as BookOpenIcon } from '@openedx/paragon/icons'; -import { TYPE_ICONS_MAP, UNIT_ICON_TYPES } from '../../constants'; +import { UNIT_TYPE_ICONS_MAP, UNIT_ICON_TYPES } from '../../../generic/block-type-utils/constants'; const UnitIcon = ({ type }) => { - const icon = TYPE_ICONS_MAP[type] || BookOpenIcon; + const icon = UNIT_TYPE_ICONS_MAP[type] || BookOpenIcon; return ; }; diff --git a/src/course-unit/course-xblock/CourseXBlock.jsx b/src/course-unit/course-xblock/CourseXBlock.jsx index 394fd22e87..2d8f6221e8 100644 --- a/src/course-unit/course-xblock/CourseXBlock.jsx +++ b/src/course-unit/course-xblock/CourseXBlock.jsx @@ -16,7 +16,7 @@ import SortableItem from '../../generic/drag-helper/SortableItem'; import { scrollToElement } from '../../course-outline/utils'; import { COURSE_BLOCK_NAMES } from '../../constants'; import { copyToClipboard } from '../../generic/data/thunks'; -import { COMPONENT_TYPES } from '../constants'; +import { COMPONENT_TYPES } from '../../generic/block-type-utils/constants'; import XBlockMessages from './xblock-messages/XBlockMessages'; import messages from './messages'; diff --git a/src/course-unit/course-xblock/CourseXBlock.test.jsx b/src/course-unit/course-xblock/CourseXBlock.test.jsx index ad8e09184b..0cdf05d4f6 100644 --- a/src/course-unit/course-xblock/CourseXBlock.test.jsx +++ b/src/course-unit/course-xblock/CourseXBlock.test.jsx @@ -16,7 +16,8 @@ import { getCourseSectionVerticalApiUrl, getXBlockBaseApiUrl } from '../data/api import { fetchCourseSectionVerticalData } from '../data/thunk'; import { executeThunk } from '../../utils'; import { getCourseId } from '../data/selectors'; -import { PUBLISH_TYPES, COMPONENT_TYPES } from '../constants'; +import { PUBLISH_TYPES } from '../constants'; +import { COMPONENT_TYPES } from '../../generic/block-type-utils/constants'; import { courseSectionVerticalMock, courseVerticalChildrenMock } from '../__mocks__'; import CourseXBlock from './CourseXBlock'; import messages from './messages'; diff --git a/src/generic/block-type-utils/constants.ts b/src/generic/block-type-utils/constants.ts new file mode 100644 index 0000000000..9b6cee0993 --- /dev/null +++ b/src/generic/block-type-utils/constants.ts @@ -0,0 +1,69 @@ +import React from 'react'; +import { + BackHand as BackHandIcon, + BookOpen as BookOpenIcon, + Edit as EditIcon, + EditNote as EditNoteIcon, + FormatListBulleted as FormatListBulletedIcon, + HelpOutline as HelpOutlineIcon, + LibraryAdd as LibraryIcon, + Lock as LockIcon, + QuestionAnswerOutline as QuestionAnswerOutlineIcon, + Science as ScienceIcon, + TextFields as TextFieldsIcon, + VideoCamera as VideoCameraIcon, + Folder, +} from '@openedx/paragon/icons'; + +export const UNIT_ICON_TYPES = ['video', 'other', 'vertical', 'problem', 'lock']; + +export const COMPONENT_TYPES = { + advanced: 'advanced', + discussion: 'discussion', + library: 'library', + html: 'html', + openassessment: 'openassessment', + problem: 'problem', + video: 'video', + dragAndDrop: 'drag-and-drop-v2', +}; + +export const UNIT_TYPE_ICONS_MAP: Record = { + video: VideoCameraIcon, + other: BookOpenIcon, + vertical: FormatListBulletedIcon, + problem: EditIcon, + lock: LockIcon, +}; + +export const COMPONENT_TYPE_ICON_MAP: Record = { + [COMPONENT_TYPES.advanced]: ScienceIcon, + [COMPONENT_TYPES.discussion]: QuestionAnswerOutlineIcon, + [COMPONENT_TYPES.library]: LibraryIcon, + [COMPONENT_TYPES.html]: TextFieldsIcon, + [COMPONENT_TYPES.openassessment]: EditNoteIcon, + [COMPONENT_TYPES.problem]: HelpOutlineIcon, + [COMPONENT_TYPES.video]: VideoCameraIcon, + [COMPONENT_TYPES.dragAndDrop]: BackHandIcon, +}; + +export const STRUCTURAL_TYPE_ICONS: Record = { + vertical: UNIT_TYPE_ICONS_MAP.vertical, + sequential: Folder, + chapter: Folder, +}; + +export const COMPONENT_TYPE_STYLE_COLOR_MAP = { + [COMPONENT_TYPES.advanced]: 'component-style-other', + [COMPONENT_TYPES.discussion]: 'component-style-default', + [COMPONENT_TYPES.library]: 'component-style-default', + [COMPONENT_TYPES.html]: 'component-style-html', + [COMPONENT_TYPES.openassessment]: 'component-style-default', + [COMPONENT_TYPES.problem]: 'component-style-default', + [COMPONENT_TYPES.video]: 'component-style-video', + [COMPONENT_TYPES.dragAndDrop]: 'component-style-default', + vertical: 'component-style-vertical', + sequential: 'component-style-default', + chapter: 'component-style-default', + collection: 'component-style-collection', +}; diff --git a/src/generic/block-type-utils/index.scss b/src/generic/block-type-utils/index.scss new file mode 100644 index 0000000000..a546d8ca6b --- /dev/null +++ b/src/generic/block-type-utils/index.scss @@ -0,0 +1,47 @@ +.component-style-default { + background-color: #005C9E; + + .pgn__icon { + color: white; + } +} + +.component-style-html { + background-color: #9747FF; + + .pgn__icon { + color: white; + } +} + +.component-style-collection { + background-color: #FFCD29; + + .pgn__icon { + color: black; + } +} + +.component-style-video { + background-color: #358F0A; + + .pgn__icon { + color: white; + } +} + +.component-style-vertical { + background-color: #0B8E77; + + .pgn__icon { + color: white; + } +} + +.component-style-other { + background-color: #646464; + + .pgn__icon { + color: white; + } +} diff --git a/src/generic/block-type-utils/index.tsx b/src/generic/block-type-utils/index.tsx new file mode 100644 index 0000000000..0204b8e016 --- /dev/null +++ b/src/generic/block-type-utils/index.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import { Article } from '@openedx/paragon/icons'; +import { + COMPONENT_TYPE_ICON_MAP, + STRUCTURAL_TYPE_ICONS, + COMPONENT_TYPE_STYLE_COLOR_MAP, +} from './constants'; + +export function getItemIcon(blockType: string): React.ComponentType { + return STRUCTURAL_TYPE_ICONS[blockType] ?? COMPONENT_TYPE_ICON_MAP[blockType] ?? Article; +} + +export function getComponentStyleColor(blockType: string): string { + return COMPONENT_TYPE_STYLE_COLOR_MAP[blockType] ?? 'bg-component'; +} diff --git a/src/generic/styles.scss b/src/generic/styles.scss index 43a9973a41..be2da4fc84 100644 --- a/src/generic/styles.scss +++ b/src/generic/styles.scss @@ -12,3 +12,4 @@ @import "./modal-dropzone/ModalDropzone"; @import "./configure-modal/ConfigureModal"; @import "./drag-helper/SortableItem"; +@import "./block-type-utils"; diff --git a/src/index.scss b/src/index.scss index 912b40933f..381ca17082 100644 --- a/src/index.scss +++ b/src/index.scss @@ -29,6 +29,7 @@ @import "search-modal/SearchModal"; @import "certificates/scss/Certificates"; @import "group-configurations/GroupConfigurations"; +@import "library-authoring"; // To apply the glow effect to the selected Section/Subsection, in the Course Outline div.row:has(> div > div.highlight) { diff --git a/src/library-authoring/LibraryAuthoringPage.test.tsx b/src/library-authoring/LibraryAuthoringPage.test.tsx index fcd8c60b97..0e90e222f6 100644 --- a/src/library-authoring/LibraryAuthoringPage.test.tsx +++ b/src/library-authoring/LibraryAuthoringPage.test.tsx @@ -155,11 +155,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(); @@ -169,14 +170,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' })); @@ -192,7 +192,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 () => { diff --git a/src/library-authoring/LibraryAuthoringPage.tsx b/src/library-authoring/LibraryAuthoringPage.tsx index 5247372756..8d5e2f7313 100644 --- a/src/library-authoring/LibraryAuthoringPage.tsx +++ b/src/library-authoring/LibraryAuthoringPage.tsx @@ -21,7 +21,7 @@ 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'; @@ -126,7 +126,7 @@ const LibraryAuthoringPage = () => { /> } + element={} /> { - 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/__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/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.ts b/src/library-authoring/data/api.ts index 37eb4eb3de..a0129b5c16 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 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. */ @@ -32,6 +36,22 @@ export interface ContentLibrary { license: string; } +export interface LibraryBlockType { + blockType: string; + displayName: string; +} + +/** + * Fetch block types of a library + */ +export async function getLibraryBlockTypes(libraryId?: string): Promise { + if (!libraryId) { + throw new Error('libraryId is required'); + } + + const { data } = await getAuthenticatedHttpClient().get(getLibraryBlockTypesUrl(libraryId)); + return camelCaseObject(data); +} export interface LibrariesV2Response { next: string | null, previous: string | null, diff --git a/src/library-authoring/data/apiHooks.ts b/src/library-authoring/data/apiHooks.ts index d2b4cbd802..fe87357efa 100644 --- a/src/library-authoring/data/apiHooks.ts +++ b/src/library-authoring/data/apiHooks.ts @@ -5,8 +5,9 @@ import { MeiliSearch } from 'meilisearch'; import { useContentSearchConnection, useContentSearchResults } from '../../search-modal'; import { type GetLibrariesV2CustomParams, - createLibraryBlock, getContentLibrary, + getLibraryBlockTypes, + createLibraryBlock, getContentLibraryV2List, } from './api'; @@ -21,6 +22,12 @@ export const libraryAuthoringQueryKeys = { 'list', ...(customParams ? [customParams] : []), ], + contentLibraryBlockTypes: (contentLibraryId?: string) => [ + ...libraryAuthoringQueryKeys.all, + ...libraryAuthoringQueryKeys.contentLibrary(contentLibraryId), + 'content', + 'libraryBlockTypes', + ], }; /** @@ -33,6 +40,40 @@ 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 */ diff --git a/src/library-authoring/index.scss b/src/library-authoring/index.scss new file mode 100644 index 0000000000..87c22f838e --- /dev/null +++ b/src/library-authoring/index.scss @@ -0,0 +1 @@ +@import "library-authoring/components/ComponentCard"; diff --git a/src/library-authoring/messages.ts b/src/library-authoring/messages.ts index d127be9ed4..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,11 @@ 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', 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/webpack.dev-tutor.config.js b/webpack.dev-tutor.config.js new file mode 100755 index 0000000000..e69de29bb2 From 61352f06c30ba19dcd59fea9edac5d0181e9ab92 Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Mon, 1 Jul 2024 12:43:55 +0200 Subject: [PATCH 4/7] feat: LibraryInfo created and added to LibrarySidebar --- .../LibraryAuthoringPage.tsx | 18 +++++++++-- .../add-content/AddContentHeader.tsx | 11 +++++++ src/library-authoring/add-content/index.ts | 1 + src/library-authoring/add-content/messages.ts | 5 +++ src/library-authoring/common/context.tsx | 12 ++++++- .../library-info/LibraryInfo.tsx | 26 ++++++++++++++++ .../library-info/LibraryInfoHeader.tsx | 31 +++++++++++++++++++ src/library-authoring/library-info/index.ts | 3 ++ .../library-info/messages.ts | 16 ++++++++++ .../library-sidebar/LibrarySidebar.tsx | 22 ++++++++++--- 10 files changed, 137 insertions(+), 8 deletions(-) create mode 100644 src/library-authoring/add-content/AddContentHeader.tsx create mode 100644 src/library-authoring/library-info/LibraryInfo.tsx create mode 100644 src/library-authoring/library-info/LibraryInfoHeader.tsx create mode 100644 src/library-authoring/library-info/index.ts create mode 100644 src/library-authoring/library-info/messages.ts diff --git a/src/library-authoring/LibraryAuthoringPage.tsx b/src/library-authoring/LibraryAuthoringPage.tsx index 8d5e2f7313..c1fd9b76bb 100644 --- a/src/library-authoring/LibraryAuthoringPage.tsx +++ b/src/library-authoring/LibraryAuthoringPage.tsx @@ -37,6 +37,8 @@ enum TabList { const SubHeaderTitle = ({ title }: { title: string }) => { const intl = useIntl(); + const { openInfoSidebar } = useContext(LibraryContext); + return ( <> {title} @@ -45,6 +47,7 @@ const SubHeaderTitle = ({ title }: { title: string }) => { iconAs={Icon} alt={intl.formatMessage(messages.headingInfoAlt)} className="mr-2" + onClick={openInfoSidebar} /> ); @@ -62,7 +65,18 @@ const LibraryAuthoringPage = () => { const currentPath = location.pathname.split('/').pop(); const activeKey = (currentPath && currentPath in TabList) ? TabList[currentPath] : TabList.home; - const { sidebarBodyComponent, openAddContentSidebar } = useContext(LibraryContext); + const { + sidebarBodyComponent, + openAddContentSidebar, + openInfoSidebar, + } = useContext(LibraryContext); + + useEffect(() => { + // Open Library Info sidebar by default + if (!isLoading && libraryData) { + openInfoSidebar(); + }; + }, [isLoading, libraryData]); if (isLoading) { return ; @@ -142,7 +156,7 @@ const LibraryAuthoringPage = () => { { sidebarBodyComponent !== null && ( - + )} 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 index 876828e16f..e1495c3bd9 100644 --- a/src/library-authoring/add-content/index.ts +++ b/src/library-authoring/add-content/index.ts @@ -1,2 +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 index 1d13635e5c..6024e144c1 100644 --- a/src/library-authoring/add-content/messages.ts +++ b/src/library-authoring/add-content/messages.ts @@ -50,6 +50,11 @@ const messages = defineMessages({ 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 index 241ed67d20..735708bfce 100644 --- a/src/library-authoring/common/context.tsx +++ b/src/library-authoring/common/context.tsx @@ -3,18 +3,21 @@ 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); /** @@ -25,12 +28,19 @@ export const LibraryProvider = (props: { children?: React.ReactNode }) => { 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, - }), [sidebarBodyComponent, closeLibrarySidebar, openAddContentSidebar]); + openInfoSidebar, + }), [ + sidebarBodyComponent, + closeLibrarySidebar, + openAddContentSidebar, + openInfoSidebar, + ]); return ( diff --git a/src/library-authoring/library-info/LibraryInfo.tsx b/src/library-authoring/library-info/LibraryInfo.tsx new file mode 100644 index 0000000000..0553a10553 --- /dev/null +++ b/src/library-authoring/library-info/LibraryInfo.tsx @@ -0,0 +1,26 @@ +import { Stack } from "@openedx/paragon"; +import { useIntl } from '@edx/frontend-platform/i18n'; +import React from "react"; +import messages from "./messages"; + +const LibraryInfo = () => { + const intl = useIntl(); + + return ( + +
+ Published section +
+
+ + {intl.formatMessage(messages.organizationSectionTitle)} + +
+
+ Library History Section +
+
+ ); +}; + +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..f8ecc0a9ef --- /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"; + +type LibraryInfoHeaderProps = { + displayName: string, + canEditLibrary: boolean, +}; + +const LibraryInfoHeader = ({ displayName, canEditLibrary} : LibraryInfoHeaderProps) => { + const intl = useIntl(); + + return ( + + + {displayName} + + {canEditLibrary && ( + + )} + + ); +}; + +export default LibraryInfoHeader; 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..f1f0400beb --- /dev/null +++ b/src/library-authoring/library-info/messages.ts @@ -0,0 +1,16 @@ +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.' + }, +}); + +export default messages; diff --git a/src/library-authoring/library-sidebar/LibrarySidebar.tsx b/src/library-authoring/library-sidebar/LibrarySidebar.tsx index 734379f518..135b1f6a3a 100644 --- a/src/library-authoring/library-sidebar/LibrarySidebar.tsx +++ b/src/library-authoring/library-sidebar/LibrarySidebar.tsx @@ -7,8 +7,14 @@ import { import { Close } from '@openedx/paragon/icons'; import { useIntl } from '@edx/frontend-platform/i18n'; import messages from '../messages'; -import { AddContentContainer } from '../add-content'; +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. @@ -19,23 +25,29 @@ import { LibraryContext } from '../common/context'; * You can add more components in `bodyComponentMap`. * Use the slice actions to open and close this sidebar. */ -const LibrarySidebar = () => { +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 (
- - {intl.formatMessage(messages.addContentTitle)} - + {buildHeader()} Date: Wed, 3 Jul 2024 18:35:53 +0200 Subject: [PATCH 5/7] feat: Library history section of info sidebar added --- src/library-authoring/data/api.ts | 2 + .../library-info/LibraryInfo.tsx | 43 ++++++++++++++++--- .../library-info/messages.ts | 15 +++++++ .../library-sidebar/LibrarySidebar.tsx | 8 +++- src/utils.js | 15 +++++++ 5 files changed, 74 insertions(+), 9 deletions(-) diff --git a/src/library-authoring/data/api.ts b/src/library-authoring/data/api.ts index a0129b5c16..2280c6305a 100644 --- a/src/library-authoring/data/api.ts +++ b/src/library-authoring/data/api.ts @@ -34,6 +34,8 @@ export interface ContentLibrary { hasUnpublishedDeletes: boolean; canEditLibrary: boolean; license: string; + created: Date; + updated: Date; } export interface LibraryBlockType { diff --git a/src/library-authoring/library-info/LibraryInfo.tsx b/src/library-authoring/library-info/LibraryInfo.tsx index 0553a10553..31f76e59cc 100644 --- a/src/library-authoring/library-info/LibraryInfo.tsx +++ b/src/library-authoring/library-info/LibraryInfo.tsx @@ -2,23 +2,52 @@ import { Stack } from "@openedx/paragon"; import { useIntl } from '@edx/frontend-platform/i18n'; import React from "react"; import messages from "./messages"; +import { convertToStringFromDateAndFormat } from "../../utils"; +import { COMMA_SEPARATED_DATE_FORMAT } from "../../constants"; -const LibraryInfo = () => { +type LibraryInfoProps = { + orgName: string, + createdAt: Date, + updatedAt: Date, +}; + +const LibraryInfo = ({ orgName, createdAt, updatedAt } : LibraryInfoProps) => { const intl = useIntl(); return ( - +
Published section
-
+ {intl.formatMessage(messages.organizationSectionTitle)} -
-
- Library History Section -
+ + {orgName} + +
+ + + {intl.formatMessage(messages.libraryHistorySectionTitle)} + + + + {intl.formatMessage(messages.lastModifiedLabel)} + + + {convertToStringFromDateAndFormat(updatedAt, COMMA_SEPARATED_DATE_FORMAT)} + + + + + {intl.formatMessage(messages.createdLabel)} + + + {convertToStringFromDateAndFormat(createdAt, COMMA_SEPARATED_DATE_FORMAT)} + + +
); }; diff --git a/src/library-authoring/library-info/messages.ts b/src/library-authoring/library-info/messages.ts index f1f0400beb..d5854ff77d 100644 --- a/src/library-authoring/library-info/messages.ts +++ b/src/library-authoring/library-info/messages.ts @@ -11,6 +11,21 @@ const messages = defineMessages({ 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.' + }, }); export default messages; diff --git a/src/library-authoring/library-sidebar/LibrarySidebar.tsx b/src/library-authoring/library-sidebar/LibrarySidebar.tsx index 135b1f6a3a..0ec0b487c6 100644 --- a/src/library-authoring/library-sidebar/LibrarySidebar.tsx +++ b/src/library-authoring/library-sidebar/LibrarySidebar.tsx @@ -31,7 +31,11 @@ const LibrarySidebar = ({library}: LibrarySidebarProps) => { const bodyComponentMap = { 'add-content': , - 'info': , + 'info': , unknown: null, }; @@ -45,7 +49,7 @@ const LibrarySidebar = ({library}: LibrarySidebarProps) => { const buildHeader = (): React.ReactNode | null => headerComponentMap[sidebarBodyComponent || 'unknown']; return ( -
+
{buildHeader()} { 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]; From 1012ef935d453723cafb662a9646e479122ab810 Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Tue, 9 Jul 2024 15:09:37 +0200 Subject: [PATCH 6/7] feat: LibraryPublishStatus component added - Connection with API to publish an revert changes - LibraryPublishStatus component created with draft and publish status - Component create with the feature to publish and revert changes --- src/index.scss | 1 + .../LibraryAuthoringPage.tsx | 9 +- src/library-authoring/data/api.ts | 30 ++++ src/library-authoring/data/apiHook.ts | 98 +++++++++++++ src/library-authoring/index.scss | 1 + .../library-info/LibraryInfo.tsx | 20 ++- .../library-info/LibraryInfoHeader.tsx | 10 +- .../library-info/LibraryPublishStatus.scss | 13 ++ .../library-info/LibraryPublishStatus.tsx | 136 ++++++++++++++++++ .../library-info/messages.ts | 80 ++++++++++- .../library-sidebar/LibrarySidebar.tsx | 8 +- 11 files changed, 373 insertions(+), 33 deletions(-) create mode 100644 src/library-authoring/data/apiHook.ts create mode 100644 src/library-authoring/library-info/LibraryPublishStatus.scss create mode 100644 src/library-authoring/library-info/LibraryPublishStatus.tsx diff --git a/src/index.scss b/src/index.scss index 381ca17082..717e4a7215 100644 --- a/src/index.scss +++ b/src/index.scss @@ -18,6 +18,7 @@ @import "export-page/CourseExportPage"; @import "import-page/CourseImportPage"; @import "taxonomy"; +@import "library-authoring"; @import "files-and-videos"; @import "content-tags-drawer"; @import "course-outline/CourseOutline"; diff --git a/src/library-authoring/LibraryAuthoringPage.tsx b/src/library-authoring/LibraryAuthoringPage.tsx index c1fd9b76bb..42aa3e25e5 100644 --- a/src/library-authoring/LibraryAuthoringPage.tsx +++ b/src/library-authoring/LibraryAuthoringPage.tsx @@ -1,4 +1,4 @@ -import React, { useContext } from 'react'; +import React, { useContext, useEffect } from 'react'; import { StudioFooter } from '@edx/frontend-component-footer'; import { useIntl } from '@edx/frontend-platform/i18n'; import { @@ -72,11 +72,8 @@ const LibraryAuthoringPage = () => { } = useContext(LibraryContext); useEffect(() => { - // Open Library Info sidebar by default - if (!isLoading && libraryData) { - openInfoSidebar(); - }; - }, [isLoading, libraryData]); + openInfoSidebar(); + }, []); if (isLoading) { return ; diff --git a/src/library-authoring/data/api.ts b/src/library-authoring/data/api.ts index 2280c6305a..3288a6a632 100644 --- a/src/library-authoring/data/api.ts +++ b/src/library-authoring/data/api.ts @@ -16,6 +16,11 @@ export const getLibraryBlockTypesUrl = (libraryId: string) => `${getApiBaseUrl() */ 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; @@ -27,6 +32,9 @@ 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; @@ -141,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/apiHook.ts b/src/library-authoring/data/apiHook.ts new file mode 100644 index 0000000000..0b2e24ea23 --- /dev/null +++ b/src/library-authoring/data/apiHook.ts @@ -0,0 +1,98 @@ +import React from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { MeiliSearch } from 'meilisearch'; + +import { useContentSearchConnection, useContentSearchResults } from '../../search-modal'; +import { + createLibraryBlock, + getContentLibrary, + commitLibraryChanges, + revertLibraryChanges +} from './api'; + +export const libraryQueryKeys = { + /** + * Used in all query keys. + * You can use these key to invalidate all queries. + */ + all: ['contentLibrary'], + contentLibrary: (libraryId) => [ + libraryQueryKeys.all, libraryId, + ], +}; + +/** + * Hook to fetch a content library by its ID. + */ +export const useContentLibrary = (libraryId?: string) => ( + useQuery({ + queryKey: libraryQueryKeys.contentLibrary(libraryId), + queryFn: () => getContentLibrary(libraryId), + }) +); + +/** + * 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: libraryQueryKeys.contentLibrary(variables.libraryId) }); + queryClient.invalidateQueries({ queryKey: ['content_search'] }); + }, + }); +}; + +/** + * Hook to fetch the count of components and collections in a library. + */ +export const useLibraryComponentCount = (libraryId: string, searchKeywords: string) => { + // Meilisearch code to get Collection and Component counts + 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}"`; + + const { totalHits: componentCount } = useContentSearchResults({ + client, + indexName, + searchKeywords, + extraFilter: [libFilter], // ToDo: Add filter for components when collection is implemented + }); + + const collectionCount = 0; // ToDo: Implement collections count + + return { + componentCount, + collectionCount, + }; +}; + +export const useCommitLibraryChanges = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: commitLibraryChanges, + onSettled: (_data, _error, libraryId) => { + queryClient.invalidateQueries({ queryKey: libraryQueryKeys.contentLibrary(libraryId) }); + }, + }); +}; + +export const useRevertLibraryChanges = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: revertLibraryChanges, + onSettled: (_data, _error, libraryId) => { + queryClient.invalidateQueries({ queryKey: libraryQueryKeys.contentLibrary(libraryId) }); + }, + }); +}; diff --git a/src/library-authoring/index.scss b/src/library-authoring/index.scss index 87c22f838e..e82ba16ab0 100644 --- a/src/library-authoring/index.scss +++ b/src/library-authoring/index.scss @@ -1 +1,2 @@ @import "library-authoring/components/ComponentCard"; +@import "library-authoring/library-info/LibraryPublishStatus"; diff --git a/src/library-authoring/library-info/LibraryInfo.tsx b/src/library-authoring/library-info/LibraryInfo.tsx index 31f76e59cc..c0e8bac04e 100644 --- a/src/library-authoring/library-info/LibraryInfo.tsx +++ b/src/library-authoring/library-info/LibraryInfo.tsx @@ -1,30 +1,28 @@ +import React from "react"; import { Stack } from "@openedx/paragon"; import { useIntl } from '@edx/frontend-platform/i18n'; -import React from "react"; 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 = { - orgName: string, - createdAt: Date, - updatedAt: Date, + library: ContentLibrary, }; -const LibraryInfo = ({ orgName, createdAt, updatedAt } : LibraryInfoProps) => { +const LibraryInfo = ({ library } : LibraryInfoProps) => { const intl = useIntl(); return ( -
- Published section -
+ {intl.formatMessage(messages.organizationSectionTitle)} - {orgName} + {library.org} @@ -36,7 +34,7 @@ const LibraryInfo = ({ orgName, createdAt, updatedAt } : LibraryInfoProps) => { {intl.formatMessage(messages.lastModifiedLabel)} - {convertToStringFromDateAndFormat(updatedAt, COMMA_SEPARATED_DATE_FORMAT)} + {convertToStringFromDateAndFormat(library.updated, COMMA_SEPARATED_DATE_FORMAT)} @@ -44,7 +42,7 @@ const LibraryInfo = ({ orgName, createdAt, updatedAt } : LibraryInfoProps) => { {intl.formatMessage(messages.createdLabel)} - {convertToStringFromDateAndFormat(createdAt, COMMA_SEPARATED_DATE_FORMAT)} + {convertToStringFromDateAndFormat(library.created, COMMA_SEPARATED_DATE_FORMAT)}
diff --git a/src/library-authoring/library-info/LibraryInfoHeader.tsx b/src/library-authoring/library-info/LibraryInfoHeader.tsx index f8ecc0a9ef..b25e742143 100644 --- a/src/library-authoring/library-info/LibraryInfoHeader.tsx +++ b/src/library-authoring/library-info/LibraryInfoHeader.tsx @@ -3,21 +3,21 @@ 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 = { - displayName: string, - canEditLibrary: boolean, + library: ContentLibrary, }; -const LibraryInfoHeader = ({ displayName, canEditLibrary} : LibraryInfoHeaderProps) => { +const LibraryInfoHeader = ({ library } : LibraryInfoHeaderProps) => { const intl = useIntl(); return ( - {displayName} + {library.title} - {canEditLibrary && ( + {library.canEditLibrary && ( { + 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} + + +
+ +
+
+
+
+ ); +}; + +export default LibraryPublishStatus; diff --git a/src/library-authoring/library-info/messages.ts b/src/library-authoring/library-info/messages.ts index d5854ff77d..2ec7b8db59 100644 --- a/src/library-authoring/library-info/messages.ts +++ b/src/library-authoring/library-info/messages.ts @@ -9,23 +9,93 @@ const messages = defineMessages({ organizationSectionTitle: { id: 'course-authoring.library-authoring.sidebar.info.organization.title', defaultMessage: 'Organization', - description: 'Title for Organization section in Library info sidebar.' + 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.' + 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.' + 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.' - }, + 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 index 0ec0b487c6..48a3ba7efe 100644 --- a/src/library-authoring/library-sidebar/LibrarySidebar.tsx +++ b/src/library-authoring/library-sidebar/LibrarySidebar.tsx @@ -31,17 +31,13 @@ const LibrarySidebar = ({library}: LibrarySidebarProps) => { const bodyComponentMap = { 'add-content': , - 'info': , + 'info': , unknown: null, }; const headerComponentMap = { 'add-content': , - info: , + info: , unknown: null, }; From 5bedf75f9cfd472d3768f323a9b6ab14903b1d27 Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Wed, 24 Jul 2024 10:13:45 -0500 Subject: [PATCH 7/7] chore: Fix rebase issues and nits --- src/index.scss | 1 - .../LibraryAuthoringPage.test.tsx | 5 + src/library-authoring/data/api.ts | 4 +- src/library-authoring/data/apiHook.ts | 98 ------------------- src/library-authoring/data/apiHooks.ts | 23 +++++ .../library-info/LibraryPublishStatus.scss | 1 - .../library-info/LibraryPublishStatus.tsx | 2 +- 7 files changed, 31 insertions(+), 103 deletions(-) delete mode 100644 src/library-authoring/data/apiHook.ts diff --git a/src/index.scss b/src/index.scss index 717e4a7215..595c45146e 100644 --- a/src/index.scss +++ b/src/index.scss @@ -30,7 +30,6 @@ @import "search-modal/SearchModal"; @import "certificates/scss/Certificates"; @import "group-configurations/GroupConfigurations"; -@import "library-authoring"; // To apply the glow effect to the selected Section/Subsection, in the Course Outline div.row:has(> div > div.highlight) { diff --git a/src/library-authoring/LibraryAuthoringPage.test.tsx b/src/library-authoring/LibraryAuthoringPage.test.tsx index 0e90e222f6..cad29ffa9b 100644 --- a/src/library-authoring/LibraryAuthoringPage.test.tsx +++ b/src/library-authoring/LibraryAuthoringPage.test.tsx @@ -61,6 +61,9 @@ const libraryData: ContentLibrary = { numBlocks: 2, version: 0, lastPublished: null, + lastDraftCreated: null, + publishedBy: 'staff', + lastDraftCreatedBy: null, allowLti: false, allowPublicLearning: false, allowPublicRead: false, @@ -68,6 +71,8 @@ const libraryData: ContentLibrary = { hasUnpublishedDeletes: false, canEditLibrary: true, license: '', + created: null, + updated: null, }; const RootWrapper = () => ( diff --git a/src/library-authoring/data/api.ts b/src/library-authoring/data/api.ts index 3288a6a632..b1406b14f4 100644 --- a/src/library-authoring/data/api.ts +++ b/src/library-authoring/data/api.ts @@ -42,8 +42,8 @@ export interface ContentLibrary { hasUnpublishedDeletes: boolean; canEditLibrary: boolean; license: string; - created: Date; - updated: Date; + created: Date | null; + updated: Date | null; } export interface LibraryBlockType { diff --git a/src/library-authoring/data/apiHook.ts b/src/library-authoring/data/apiHook.ts deleted file mode 100644 index 0b2e24ea23..0000000000 --- a/src/library-authoring/data/apiHook.ts +++ /dev/null @@ -1,98 +0,0 @@ -import React from 'react'; -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { MeiliSearch } from 'meilisearch'; - -import { useContentSearchConnection, useContentSearchResults } from '../../search-modal'; -import { - createLibraryBlock, - getContentLibrary, - commitLibraryChanges, - revertLibraryChanges -} from './api'; - -export const libraryQueryKeys = { - /** - * Used in all query keys. - * You can use these key to invalidate all queries. - */ - all: ['contentLibrary'], - contentLibrary: (libraryId) => [ - libraryQueryKeys.all, libraryId, - ], -}; - -/** - * Hook to fetch a content library by its ID. - */ -export const useContentLibrary = (libraryId?: string) => ( - useQuery({ - queryKey: libraryQueryKeys.contentLibrary(libraryId), - queryFn: () => getContentLibrary(libraryId), - }) -); - -/** - * 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: libraryQueryKeys.contentLibrary(variables.libraryId) }); - queryClient.invalidateQueries({ queryKey: ['content_search'] }); - }, - }); -}; - -/** - * Hook to fetch the count of components and collections in a library. - */ -export const useLibraryComponentCount = (libraryId: string, searchKeywords: string) => { - // Meilisearch code to get Collection and Component counts - 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}"`; - - const { totalHits: componentCount } = useContentSearchResults({ - client, - indexName, - searchKeywords, - extraFilter: [libFilter], // ToDo: Add filter for components when collection is implemented - }); - - const collectionCount = 0; // ToDo: Implement collections count - - return { - componentCount, - collectionCount, - }; -}; - -export const useCommitLibraryChanges = () => { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: commitLibraryChanges, - onSettled: (_data, _error, libraryId) => { - queryClient.invalidateQueries({ queryKey: libraryQueryKeys.contentLibrary(libraryId) }); - }, - }); -}; - -export const useRevertLibraryChanges = () => { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: revertLibraryChanges, - onSettled: (_data, _error, libraryId) => { - queryClient.invalidateQueries({ queryKey: libraryQueryKeys.contentLibrary(libraryId) }); - }, - }); -}; diff --git a/src/library-authoring/data/apiHooks.ts b/src/library-authoring/data/apiHooks.ts index fe87357efa..e7eb4d7c5c 100644 --- a/src/library-authoring/data/apiHooks.ts +++ b/src/library-authoring/data/apiHooks.ts @@ -9,6 +9,8 @@ import { getLibraryBlockTypes, createLibraryBlock, getContentLibraryV2List, + commitLibraryChanges, + revertLibraryChanges, } from './api'; export const libraryAuthoringQueryKeys = { @@ -130,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/library-info/LibraryPublishStatus.scss b/src/library-authoring/library-info/LibraryPublishStatus.scss index d505ccddbf..7f94889290 100644 --- a/src/library-authoring/library-info/LibraryPublishStatus.scss +++ b/src/library-authoring/library-info/LibraryPublishStatus.scss @@ -10,4 +10,3 @@ border-top: 4px solid $info-400; } } - diff --git a/src/library-authoring/library-info/LibraryPublishStatus.tsx b/src/library-authoring/library-info/LibraryPublishStatus.tsx index c11c61de57..84997017b3 100644 --- a/src/library-authoring/library-info/LibraryPublishStatus.tsx +++ b/src/library-authoring/library-info/LibraryPublishStatus.tsx @@ -4,7 +4,7 @@ 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/apiHook"; +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";