diff --git a/src/CourseAuthoringPage.jsx b/src/CourseAuthoringPage.jsx index c4281a8c13..eaa16c49c2 100644 --- a/src/CourseAuthoringPage.jsx +++ b/src/CourseAuthoringPage.jsx @@ -15,29 +15,6 @@ import { getCourseAppsApiStatus } from './pages-and-resources/data/selectors'; import { RequestStatus } from './data/constants'; import Loading from './generic/Loading'; -const AppHeader = ({ - courseNumber, courseOrg, courseTitle, courseId, -}) => ( -
-); - -AppHeader.propTypes = { - courseId: PropTypes.string.isRequired, - courseNumber: PropTypes.string, - courseOrg: PropTypes.string, - courseTitle: PropTypes.string.isRequired, -}; - -AppHeader.defaultProps = { - courseNumber: null, - courseOrg: null, -}; - const CourseAuthoringPage = ({ courseId, children }) => { const dispatch = useDispatch(); @@ -74,11 +51,11 @@ const CourseAuthoringPage = ({ courseId, children }) => { This functionality will be removed in TNL-9591 */} {inProgress ? !isEditor && : (!isEditor && ( - ) )} 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..79cfc933b1 100644 --- a/src/course-unit/course-sequence/sequence-navigation/UnitIcon.jsx +++ b/src/course-unit/course-sequence/sequence-navigation/UnitIcon.jsx @@ -2,7 +2,7 @@ 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 { TYPE_ICONS_MAP, UNIT_ICON_TYPES } from '../../../generic/block-type-utils/constants'; const UnitIcon = ({ type }) => { const icon = TYPE_ICONS_MAP[type] || BookOpenIcon; 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..4b9596c3db --- /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 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: TYPE_ICONS_MAP.vertical, + sequential: Folder, + chapter: Folder, +}; + +export const COMPONENT_TYPE_COLOR_MAP = { + [COMPONENT_TYPES.advanced]: 'bg-other', + [COMPONENT_TYPES.discussion]: 'bg-component', + [COMPONENT_TYPES.library]: 'bg-component', + [COMPONENT_TYPES.html]: 'bg-html', + [COMPONENT_TYPES.openassessment]: 'bg-component', + [COMPONENT_TYPES.problem]: 'bg-component', + [COMPONENT_TYPES.video]: 'bg-video', + [COMPONENT_TYPES.dragAndDrop]: 'bg-component', + vertical: 'bg-vertical', + sequential: 'bg-component', + chapter: 'bg-component', + collection: 'bg-collection', +}; diff --git a/src/generic/block-type-utils/index.tsx b/src/generic/block-type-utils/index.tsx new file mode 100644 index 0000000000..9354bcdd91 --- /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_COLOR_MAP, +} from './constants'; + +export function getItemIcon(blockType: string): React.ReactElement { + return STRUCTURAL_TYPE_ICONS[blockType] ?? COMPONENT_TYPE_ICON_MAP[blockType] ?? Article; +} + +export function getComponentColor(blockType: string): string { + return COMPONENT_TYPE_COLOR_MAP[blockType] ?? 'bg-component'; +} diff --git a/src/header/Header.jsx b/src/header/Header.jsx index 7cc1adcb08..e5ba1a4b3c 100644 --- a/src/header/Header.jsx +++ b/src/header/Header.jsx @@ -6,16 +6,17 @@ import { useIntl } from '@edx/frontend-platform/i18n'; import { StudioHeader } from '@edx/frontend-component-header'; import { useToggle } from '@openedx/paragon'; -import SearchModal from '../search-modal/SearchModal'; +import { SearchModal } from '../search-modal'; import { getContentMenuItems, getSettingMenuItems, getToolsMenuItems } from './utils'; import messages from './messages'; const Header = ({ - courseId, - courseOrg, - courseNumber, - courseTitle, + contentId, + org, + number, + title, isHiddenMainMenu, + isLibrary, }) => { const intl = useIntl(); @@ -23,40 +24,40 @@ const Header = ({ const studioBaseUrl = getConfig().STUDIO_BASE_URL; const meiliSearchEnabled = [true, 'true'].includes(getConfig().MEILISEARCH_ENABLED); - const mainMenuDropdowns = [ + const mainMenuDropdowns = !isLibrary ? [ { id: `${intl.formatMessage(messages['header.links.content'])}-dropdown-menu`, buttonTitle: intl.formatMessage(messages['header.links.content']), - items: getContentMenuItems({ studioBaseUrl, courseId, intl }), + items: getContentMenuItems({ studioBaseUrl, courseId: contentId, intl }), }, { id: `${intl.formatMessage(messages['header.links.settings'])}-dropdown-menu`, buttonTitle: intl.formatMessage(messages['header.links.settings']), - items: getSettingMenuItems({ studioBaseUrl, courseId, intl }), + items: getSettingMenuItems({ studioBaseUrl, courseId: contentId, intl }), }, { id: `${intl.formatMessage(messages['header.links.tools'])}-dropdown-menu`, buttonTitle: intl.formatMessage(messages['header.links.tools']), - items: getToolsMenuItems({ studioBaseUrl, courseId, intl }), + items: getToolsMenuItems({ studioBaseUrl, courseId: contentId, intl }), }, - ]; - const outlineLink = `${studioBaseUrl}/course/${courseId}`; + ] : []; + const outlineLink = !isLibrary ? `${studioBaseUrl}/course/${contentId}` : `/course-authoring/library/${contentId}`; return ( <> { meiliSearchEnabled && ( )} @@ -65,19 +66,21 @@ const Header = ({ }; Header.propTypes = { - courseId: PropTypes.string, - courseNumber: PropTypes.string, - courseOrg: PropTypes.string, - courseTitle: PropTypes.string, + contentId: PropTypes.string, + number: PropTypes.string, + org: PropTypes.string, + title: PropTypes.string, isHiddenMainMenu: PropTypes.bool, + isLibrary: PropTypes.bool, }; Header.defaultProps = { - courseId: '', - courseNumber: '', - courseOrg: '', - courseTitle: '', + contentId: '', + number: '', + org: '', + title: '', isHiddenMainMenu: false, + isLibrary: false, }; export default Header; diff --git a/src/index.jsx b/src/index.jsx index f881441df9..bf7ee9c423 100755 --- a/src/index.jsx +++ b/src/index.jsx @@ -19,11 +19,11 @@ 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 initializeStore from './store'; import CourseAuthoringRoutes from './CourseAuthoringRoutes'; import Head from './head/Head'; import { StudioHome } from './studio-home'; -import LibraryV2Placeholder from './studio-home/tabs-section/LibraryV2Placeholder'; import CourseRerun from './course-rerun'; import { TaxonomyLayout, TaxonomyDetailPage, TaxonomyListPage } from './taxonomy'; import { ContentTagsDrawer } from './content-tags-drawer'; @@ -55,7 +55,8 @@ const App = () => { } /> } /> } /> - } /> + } /> + } /> } /> } /> {getConfig().ENABLE_ACCESSIBILITY_PAGE === 'true' && ( 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/CreateLibrary.tsx b/src/library-authoring/CreateLibrary.tsx new file mode 100644 index 0000000000..227f14dbe5 --- /dev/null +++ b/src/library-authoring/CreateLibrary.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { FormattedMessage } from '@edx/frontend-platform/i18n'; +import { Container } from '@openedx/paragon'; + +import Header from '../header'; +import SubHeader from '../generic/sub-header/SubHeader'; + +import messages from './messages'; + +/* istanbul ignore next This is only a placeholder component */ +const CreateLibrary = () => ( + <> +
+ + } + /> +
+ +
+
+ +); + +export default CreateLibrary; diff --git a/src/library-authoring/EmptyStates.tsx b/src/library-authoring/EmptyStates.tsx new file mode 100644 index 0000000000..d7b718c71d --- /dev/null +++ b/src/library-authoring/EmptyStates.tsx @@ -0,0 +1,23 @@ +import React 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'; + +export const NoComponents = () => ( + + + + +); + +export const NoSearchResults = () => ( +
+ +
+); diff --git a/src/library-authoring/LibraryAuthoringPage.test.tsx b/src/library-authoring/LibraryAuthoringPage.test.tsx new file mode 100644 index 0000000000..b14dc19d78 --- /dev/null +++ b/src/library-authoring/LibraryAuthoringPage.test.tsx @@ -0,0 +1,234 @@ +import React from 'react'; +import MockAdapter from 'axios-mock-adapter'; +import { initializeMockApp } from '@edx/frontend-platform'; +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 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 } from './data/api'; + +let store; +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 searchEndpoint = 'http://mock.meilisearch.local/multi-search'; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, +}); + +const returnEmptyResult = (_url, req) => { + const requestData = JSON.parse(req.body?.toString() ?? ''); + const query = requestData?.queries[0]?.q ?? ''; + // We have to replace the query (search keywords) in the mock results with the actual query, + // because otherwise we may have an inconsistent state that causes more queries and unexpected results. + mockEmptyResult.results[0].query = query; + // And fake the required '_formatted' fields; it contains the highlighting ... around matched words + // eslint-disable-next-line no-underscore-dangle, no-param-reassign + mockEmptyResult.results[0]?.hits.forEach((hit) => { hit._formatted = { ...hit }; }); + return mockEmptyResult; +}; + +const libraryData = { + id: 'lib:org1:lib1', + type: 'complex', + org: 'org1', + slug: 'lib1', + title: 'lib1', + description: 'lib1', + numBlocks: 2, + version: 0, + lastPublished: null, + allowLti: false, + allowPublic_learning: false, + allowPublic_read: false, + hasUnpublished_changes: true, + hasUnpublished_deletes: false, + license: '', +}; + +const RootWrapper = () => ( + + + + + + + +); + +describe('', () => { + beforeEach(() => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: true, + roles: [], + }, + }); + store = initializeStore(); + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + mockUseParams.mockReturnValue({ libraryId: '1' }); + + // The API method to get the Meilisearch connection details uses Axios: + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + axiosMock.onGet(getContentSearchConfigUrl()).reply(200, { + url: 'http://mock.meilisearch.local', + index_name: 'studio', + api_key: 'test-key', + }); + + // The Meilisearch client-side API uses fetch, not Axios. + fetchMock.post(searchEndpoint, (_url, req) => { + const requestData = JSON.parse(req.body?.toString() ?? ''); + const query = requestData?.queries[0]?.q ?? ''; + // We have to replace the query (search keywords) in the mock results with the actual query, + // because otherwise Instantsearch will update the UI and change the query, + // leading to unexpected results in the test cases. + mockResult.results[0].query = query; + // And fake the required '_formatted' fields; it contains the highlighting ... around matched words + // eslint-disable-next-line no-underscore-dangle, no-param-reassign + mockResult.results[0]?.hits.forEach((hit) => { hit._formatted = { ...hit }; }); + return mockResult; + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + axiosMock.restore(); + fetchMock.mockReset(); + queryClient.clear(); + }); + + it('shows the spinner before the query is complete', () => { + mockUseParams.mockReturnValue({ libraryId: '1' }); + // @ts-ignore Use unresolved promise to keep the Loading visible + axiosMock.onGet(getContentLibraryApiUrl('1')).reply(() => new Promise()); + const { getByRole } = render(); + const spinner = getByRole('status'); + expect(spinner.textContent).toEqual('Loading...'); + }); + + it('shows an error component if no library returned', async () => { + mockUseParams.mockReturnValue({ libraryId: 'invalid' }); + axiosMock.onGet(getContentLibraryApiUrl('invalid')).reply(400); + + const { findByTestId } = render(); + + expect(await findByTestId('notFoundAlert')).toBeInTheDocument(); + }); + + it('shows an error component if no library param', async () => { + mockUseParams.mockReturnValue({ libraryId: '' }); + + const { findByTestId } = render(); + + expect(await findByTestId('notFoundAlert')).toBeInTheDocument(); + }); + + it('show library data', async () => { + mockUseParams.mockReturnValue({ libraryId: libraryData.id }); + axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData); + + const { + getByRole, getByText, queryByText, getAllByText, + } = render(); + + // Ensure the search endpoint is called + await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); }); + + expect(getByText('Content library')).toBeInTheDocument(); + expect(getByText(libraryData.title)).toBeInTheDocument(); + + expect(queryByText('You have not added any content to this library yet.')).not.toBeInTheDocument(); + + expect(getByText('Recently Modified')).toBeInTheDocument(); + expect(getByText('Collections (0)')).toBeInTheDocument(); + expect(getByText('Components (6)')).toBeInTheDocument(); + expect(getAllByText('Test HTML Block')[0]).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(); + + // Navigate to the collections tab + fireEvent.click(getByRole('tab', { name: 'Collections' })); + expect(queryByText('Recently Modified')).not.toBeInTheDocument(); + expect(queryByText('Collections (0)')).not.toBeInTheDocument(); + expect(queryByText('Components (6)')).not.toBeInTheDocument(); + expect(queryByText('There are 6 components in this library')).not.toBeInTheDocument(); + expect(getByText('Coming soon!')).toBeInTheDocument(); + + // Go back to Home tab + // This step is necessary to avoid the url change leak to other tests + fireEvent.click(getByRole('tab', { name: 'Home' })); + expect(getByText('Recently Modified')).toBeInTheDocument(); + expect(getByText('Collections (0)')).toBeInTheDocument(); + expect(getByText('Components (6)')).toBeInTheDocument(); + }); + + it('show library without components', async () => { + mockUseParams.mockReturnValue({ libraryId: libraryData.id }); + axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData); + fetchMock.post(searchEndpoint, returnEmptyResult, { overwriteRoutes: true }); + + const { findByText, getByText } = render(); + + expect(await findByText('Content library')).toBeInTheDocument(); + expect(await findByText(libraryData.title)).toBeInTheDocument(); + + // Ensure the search endpoint is called + await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); }); + + expect(getByText('You have not added any content to this library yet.')).toBeInTheDocument(); + }); + + it('show library without search results', async () => { + mockUseParams.mockReturnValue({ libraryId: libraryData.id }); + axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData); + fetchMock.post(searchEndpoint, returnEmptyResult, { overwriteRoutes: true }); + + const { findByText, getByRole, getByText } = render(); + + expect(await findByText('Content library')).toBeInTheDocument(); + expect(await findByText(libraryData.title)).toBeInTheDocument(); + + // Ensure the search endpoint is called + await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); }); + + fireEvent.change(getByRole('searchbox'), { target: { value: 'noresults' } }); + + // Ensure the search endpoint is called again + await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(2, searchEndpoint, 'post'); }); + + expect(getByText('No matching components found in this library.')).toBeInTheDocument(); + + // Navigate to the components tab + fireEvent.click(getByRole('tab', { name: 'Components' })); + expect(getByText('No matching components found in this library.')).toBeInTheDocument(); + + // Go back to Home tab + // This step is necessary to avoid the url change leak to other tests + fireEvent.click(getByRole('tab', { name: 'Home' })); + }); +}); diff --git a/src/library-authoring/LibraryAuthoringPage.tsx b/src/library-authoring/LibraryAuthoringPage.tsx new file mode 100644 index 0000000000..5aaa2d2272 --- /dev/null +++ b/src/library-authoring/LibraryAuthoringPage.tsx @@ -0,0 +1,131 @@ +import React, { useEffect, useState } from 'react'; +import { StudioFooter } from '@edx/frontend-component-footer'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { + Container, Icon, IconButton, SearchField, Tab, Tabs, +} from '@openedx/paragon'; +import { InfoOutline } from '@openedx/paragon/icons'; +import { + Routes, Route, useLocation, useNavigate, useParams, +} from 'react-router-dom'; + +import Loading from '../generic/Loading'; +import SubHeader from '../generic/sub-header/SubHeader'; +import Header from '../header'; +import NotFoundAlert from '../generic/NotFoundAlert'; +import LibraryComponents from './components/LibraryComponents'; +import LibraryCollections from './LibraryCollections'; +import LibraryHome from './LibraryHome'; +import { useContentLibrary } from './data/apiHook'; +import messages from './messages'; + +const TAB_LIST = { + home: '', + components: 'components', + collections: 'collections', +}; + +const SubHeaderTitle = ({ title }: { title: string }) => { + const intl = useIntl(); + return ( + <> + {title} + + + ); +}; + +const LibraryAuthoringPage = () => { + const intl = useIntl(); + const location = useLocation(); + const navigate = useNavigate(); + const [tabKey, setTabKey] = useState(TAB_LIST.home); + const [searchKeywords, setSearchKeywords] = useState(''); + + const { libraryId } = useParams(); + + const { data: libraryData, isLoading } = useContentLibrary(libraryId); + + useEffect(() => { + const currentPath = location.pathname.split('/').pop(); + if (currentPath && Object.values(TAB_LIST).includes(currentPath)) { + setTabKey(currentPath); + } else { + setTabKey(TAB_LIST.home); + } + }, [location]); + + if (isLoading) { + return ; + } + + if (!libraryId || !libraryData) { + return ; + } + + const handleTabChange = (key: string) => { + setTabKey(key); + navigate(key); + }; + + return ( + <> +
+ + } + subtitle={intl.formatMessage(messages.headingSubtitle)} + /> + setSearchKeywords(value)} + onSubmit={() => {}} + className="w-50" + /> + + + + + + + } + /> + } + /> + } + /> + } + /> + + + + + ); +}; + +export default LibraryAuthoringPage; diff --git a/src/library-authoring/LibraryCollections.tsx b/src/library-authoring/LibraryCollections.tsx new file mode 100644 index 0000000000..2f1eb8951f --- /dev/null +++ b/src/library-authoring/LibraryCollections.tsx @@ -0,0 +1,14 @@ +import React from 'react'; +import { FormattedMessage } from '@edx/frontend-platform/i18n'; + +import messages from './messages'; + +const LibraryCollections = () => ( +
+ +
+); + +export default LibraryCollections; diff --git a/src/library-authoring/LibraryHome.tsx b/src/library-authoring/LibraryHome.tsx new file mode 100644 index 0000000000..b4013089c0 --- /dev/null +++ b/src/library-authoring/LibraryHome.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { FormattedMessage } from '@edx/frontend-platform/i18n'; +import { + Card, Stack, +} from '@openedx/paragon'; + +import { NoComponents, NoSearchResults } from './EmptyStates'; +import LibraryCollections from './LibraryCollections'; +import LibraryComponents from './components/LibraryComponents'; +import { useLibraryComponentCount } from './data/apiHook'; +import messages from './messages'; + +const Section = ({ title, children } : { title: string, children: React.ReactNode }) => ( + + + + {children} + + +); + +type LibraryHomeProps = { + libraryId: string, + filter: { + searchKeywords: string, + }, +}; + +const LibraryHome = ({ libraryId, filter } : LibraryHomeProps) => { + const { searchKeywords } = filter; + const { componentCount, collectionCount } = useLibraryComponentCount(libraryId, searchKeywords); + + if (componentCount === 0) { + return searchKeywords === '' ? : ; + } + + return ( + +
+ +
+
+ +
+
+ +
+
+ ); +}; + +export default LibraryHome; 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..873d979bf7 --- /dev/null +++ b/src/library-authoring/components/ComponentCard.scss @@ -0,0 +1,61 @@ +.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; + } + + &.bg-component { + background-color: #005C9E; + } + + &.bg-html { + background-color: #9747FF; + } + + &.bg-collection { + background-color: #FFCD29; + } + + &.bg-video { + background-color: #358F0A; + } + + &.bg-vertical { + background-color: #0B8E77; + } + + &.bg-other { + background-color: #666666; + } + } + + .library-component-card-description { + /* + Set overflow to description + I added '-webkit-box' to truncate multiple lines + */ + font-size: 18px; + display: -webkit-box; /* stylelint-disable-line value-no-vendor-prefix */ + -webkit-box-orient: vertical; + overflow: hidden; + max-height: 220px; + -webkit-line-clamp: 3; + } +} diff --git a/src/library-authoring/components/ComponentCard.tsx b/src/library-authoring/components/ComponentCard.tsx new file mode 100644 index 0000000000..42d05119cb --- /dev/null +++ b/src/library-authoring/components/ComponentCard.tsx @@ -0,0 +1,105 @@ +import React 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, getComponentColor } from '../../generic/block-type-utils'; + +type ComponentCardProps = { + title: string, + description: string, + tagCount: number, + blockType: string, + blockTypeDisplayName: string, +}; + +const ComponentCardMenu = () => ( + + + + + + + + + + + + + + +); + +export const ComponentCardLoading = () => ( + + + + + +); + +export const ComponentCard = ({ + title, + description, + tagCount, + blockType, + blockTypeDisplayName, +}: ComponentCardProps) => { + const componentIcon = getItemIcon(blockType); + + return ( + + + + } + actions={( + + + + )} + /> + + + + + + {blockTypeDisplayName} + + + +
+ {title} +
+

+ {description} +

+
+
+
+
+ ); +}; diff --git a/src/library-authoring/components/LibraryComponents.test.tsx b/src/library-authoring/components/LibraryComponents.test.tsx new file mode 100644 index 0000000000..83919d94e7 --- /dev/null +++ b/src/library-authoring/components/LibraryComponents.test.tsx @@ -0,0 +1,187 @@ +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/apiHook', () => ({ + 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 loading', async () => { + render(); + expect((await screen.findAllByTestId('card-loading'))[0]).toBeInTheDocument(); + }); + + 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(); + }); + + it('should render content and loading when fetching next page', async () => { + mockUseLibraryComponents.mockReturnValue({ + ...data, + hits: libraryComponentsMock, + isFetching: true, + isFetchingNextPage: true, + hasNextPage: true, + }); + + render(); + + expect(await screen.findByText('This is a text: ID=1')).toBeInTheDocument(); + expect(screen.getByText('This is a problem: ID=6')).toBeInTheDocument(); + + expect((await screen.findAllByTestId('card-loading'))[0]).toBeInTheDocument(); + }); +}); diff --git a/src/library-authoring/components/LibraryComponents.tsx b/src/library-authoring/components/LibraryComponents.tsx new file mode 100644 index 0000000000..2b632d3df8 --- /dev/null +++ b/src/library-authoring/components/LibraryComponents.tsx @@ -0,0 +1,141 @@ +import React, { useEffect, useMemo } from 'react'; + +import { CardGrid } from '@openedx/paragon'; +import { NoComponents, NoSearchResults } from '../EmptyStates'; +import { useLibraryBlockTypes, useLibraryComponentCount, useLibraryComponents } from '../data/apiHook'; +import { ComponentCard, ComponentCardLoading } 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, + isFetching, + isFetchingNextPage, + hasNextPage, + fetchNextPage, + } = useLibraryComponents(libraryId, searchKeywords); + + const { componentList, tagCounts } = useMemo(() => { + const result = variant === 'preview' ? hits.slice(0, 4) : hits; + const tagsCountsResult = {}; + result.forEach((component) => { + if (!component.tags) { + tagsCountsResult[component.id] = 0; + } else { + tagsCountsResult[component.id] = (component.tags.level0?.length || 0) + + (component.tags.level1?.length || 0) + + (component.tags.level2?.length || 0) + + (component.tags.level3?.length || 0); + } + }); + return { + componentList: result, + tagCounts: tagsCountsResult, + }; + }, [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]); + + const { showLoading, showContent } = useMemo(() => { + let resultShowLoading = false; + let resultShowContent = false; + + if (isFetching && !isFetchingNextPage) { + // First load; show loading but not content. + resultShowLoading = true; + resultShowContent = false; + } else if (isFetchingNextPage) { + // Load next page; show content and loading. + resultShowLoading = true; + resultShowContent = true; + } else if (!isFetching && !isFetchingNextPage) { + // State without loads; show content. + resultShowLoading = false; + resultShowContent = true; + } + return { + showLoading: resultShowLoading, + showContent: resultShowContent, + }; + }, [isFetching, isFetchingNextPage]); + + 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 ( + + { showContent ? componentList.map((component) => ( + + )) : } + { showLoading && } + + ); +}; + +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..e135458fd3 --- /dev/null +++ b/src/library-authoring/components/messages.ts @@ -0,0 +1,21 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +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 new file mode 100644 index 0000000000..dc66d8ad94 --- /dev/null +++ b/src/library-authoring/data/api.ts @@ -0,0 +1,59 @@ +import { camelCaseObject, getConfig } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; + +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/`; + +export interface ContentLibrary { + id: string; + type: string; + org: string; + slug: string; + title: string; + description: string; + numBlocks: number; + version: number; + lastPublished: Date | null; + allowLti: boolean; + allowPublicLearning: boolean; + allowPublicRead: boolean; + hasUnpublishedChanges: boolean; + hasUnpublishedDeletes: boolean; + license: string; +} + +export interface LibraryBlockType { + blockType: string; + displayName: string; +} + +/** + * 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); +} + +/** + * 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); +} diff --git a/src/library-authoring/data/apiHook.ts b/src/library-authoring/data/apiHook.ts new file mode 100644 index 0000000000..f188371863 --- /dev/null +++ b/src/library-authoring/data/apiHook.ts @@ -0,0 +1,82 @@ +import React from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { MeiliSearch } from 'meilisearch'; + +import { useContentSearchConnection, useContentSearchResults } from '../../search-modal'; +import { getContentLibrary, getLibraryBlockTypes } from './api'; + +/** + * Hook to fetch a content library by its ID. + */ +export const useContentLibrary = (libraryId?: string) => ( + useQuery({ + queryKey: ['contentLibrary', libraryId], + queryFn: () => getContentLibrary(libraryId), + }) +); + +/** + * Hook to fetch block types of a library. + */ +export const useLibraryBlockTypes = (libraryId?: string) => ( + useQuery({ + queryKey: ['contentLibrary', 'libraryBlockTypes', 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], + }); +}; + +/** + * 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, + }; +}; 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/index.ts b/src/library-authoring/index.ts new file mode 100644 index 0000000000..40da2db4af --- /dev/null +++ b/src/library-authoring/index.ts @@ -0,0 +1,2 @@ +export { default as LibraryAuthoringPage } from './LibraryAuthoringPage'; +export { default as CreateLibrary } from './CreateLibrary'; diff --git a/src/library-authoring/messages.ts b/src/library-authoring/messages.ts new file mode 100644 index 0000000000..f587a66648 --- /dev/null +++ b/src/library-authoring/messages.ts @@ -0,0 +1,61 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + headingSubtitle: { + id: 'course-authoring.library-authoring.heading-subtitle', + defaultMessage: 'Content library', + description: 'The page heading for the library page.', + }, + headingInfoAlt: { + id: 'course-authoring.library-authoring.heading-info-alt', + defaultMessage: 'Info', + description: 'Alt text for the info icon next to the page heading.', + }, + searchPlaceholder: { + id: 'course-authoring.library-authoring.search', + defaultMessage: 'Search...', + description: 'Placeholder for search field', + }, + noSearchResults: { + id: 'course-authoring.library-authoring.no-search-results', + defaultMessage: 'No matching components found in this library.', + description: 'Message displayed when no search results are found', + }, + noComponents: { + id: 'course-authoring.library-authoring.no-components', + defaultMessage: 'You have not added any content to this library yet.', + description: 'Message displayed when the library is empty', + }, + addComponent: { + id: 'course-authoring.library-authoring.add-component', + defaultMessage: 'Add component', + description: 'Button text to add a new component', + }, + componentsTempPlaceholder: { + id: 'course-authoring.library-authoring.components-temp-placeholder', + defaultMessage: 'There are {componentCount} components in this library', + description: 'Temp placeholder for the component container. This will be replaced with the actual component list.', + }, + collectionsTempPlaceholder: { + id: 'course-authoring.library-authoring.collections-temp-placeholder', + defaultMessage: 'Coming soon!', + description: 'Temp placeholder for the collections container. This will be replaced with the actual collection 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.', + }, + 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.', + }, +}); + +export default messages; diff --git a/src/search-modal/SearchResult.tsx b/src/search-modal/SearchResult.tsx index 77f806be4a..9075fbc389 100644 --- a/src/search-modal/SearchResult.tsx +++ b/src/search-modal/SearchResult.tsx @@ -6,38 +6,24 @@ 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.ReactElement { - 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 */ -function getLibraryHitUrl(hit: ContentHit, libraryAuthoringMfeUrl: string): string { +function getLibraryComponentUrlSuffix(hit: ContentHit) { const { contextKey } = hit; - return constructLibraryAuthoringURL(libraryAuthoringMfeUrl, `library/${contextKey}`); + return `library/${contextKey}`; } /** @@ -117,10 +103,6 @@ const SearchResult: React.FC<{ hit: ContentHit }> = ({ hit }) => { const { closeSearchModal } = useSearchContext(); const { libraryAuthoringMfeUrl, redirectToLibraryAuthoringMfe } = useSelector(getStudioHomeData); - const { usageKey } = hit; - - const noRedirectUrl = usageKey.startsWith('lb:') && !redirectToLibraryAuthoringMfe; - /** * Returns the URL for the context of the hit */ @@ -136,13 +118,19 @@ const SearchResult: React.FC<{ hit: ContentHit }> = ({ hit }) => { return `/${urlSuffix}`; } - if (usageKey.startsWith('lb:')) { - if (redirectToLibraryAuthoringMfe) { - return getLibraryHitUrl(hit, libraryAuthoringMfeUrl); + if (contextKey.startsWith('lib:')) { + const urlSuffix = getLibraryComponentUrlSuffix(hit); + if (redirectToLibraryAuthoringMfe && libraryAuthoringMfeUrl) { + return constructLibraryAuthoringURL(libraryAuthoringMfeUrl, urlSuffix); + } + + if (newWindow) { + return `${getPath(getConfig().PUBLIC_PATH)}${urlSuffix}`; } + return `/${urlSuffix}`; } - // No context URL for this hit (e.g. a library without library authoring mfe) + // istanbul ignore next - This case should never be reached return undefined; }, [libraryAuthoringMfeUrl, redirectToLibraryAuthoringMfe, hit]); @@ -189,12 +177,12 @@ const SearchResult: React.FC<{ hit: ContentHit }> = ({ hit }) => { return ( @@ -213,7 +201,6 @@ const SearchResult: React.FC<{ hit: ContentHit }> = ({ hit }) => { diff --git a/src/search-modal/SearchUI.test.tsx b/src/search-modal/SearchUI.test.tsx index 92f0f244d3..7a62193e6d 100644 --- a/src/search-modal/SearchUI.test.tsx +++ b/src/search-modal/SearchUI.test.tsx @@ -342,9 +342,10 @@ describe('', () => { window.location = location; }); - test('click lib component result doesnt navigates to the context withou libraryAuthoringMfe', async () => { + test('click lib component result navigates to course-authoring/library without libraryAuthoringMfe', async () => { const data = generateGetStudioHomeDataApiResponse(); data.redirectToLibraryAuthoringMfe = false; + data.libraryAuthoringMfeUrl = ''; axiosMock.onGet(getStudioHomeApiUrl()).reply(200, data); await executeThunk(fetchStudioHomeData(), store.dispatch); @@ -354,18 +355,21 @@ describe('', () => { const resultItem = await findByRole('button', { name: /Library Content/ }); // Clicking the "Open in new window" button should open the result in a new window: - const { open, location } = window; + const { open } = window; window.open = jest.fn(); fireEvent.click(within(resultItem).getByRole('button', { name: 'Open in new window' })); - expect(window.open).not.toHaveBeenCalled(); + + expect(window.open).toHaveBeenCalledWith( + '/library/lib:org1:libafter1', + '_blank', + ); window.open = open; - // @ts-ignore - window.location = { href: '' }; // Clicking in the result should navigate to the result's URL: fireEvent.click(resultItem); - expect(window.location.href === location.href); - window.location = location; + expect(mockNavigate).toHaveBeenCalledWith( + '/library/lib:org1:libafter1', + ); }); }); diff --git a/src/search-modal/data/apiHooks.ts b/src/search-modal/data/apiHooks.ts index cfc4454d5f..fe77482285 100644 --- a/src/search-modal/data/apiHooks.ts +++ b/src/search-modal/data/apiHooks.ts @@ -35,8 +35,8 @@ export const useContentSearchResults = ({ indexName, extraFilter, searchKeywords, - blockTypesFilter, - tagsFilter, + blockTypesFilter = [], + tagsFilter = [], }: { /** The Meilisearch API client */ client?: MeiliSearch; @@ -47,9 +47,9 @@ export const useContentSearchResults = ({ /** The keywords that the user is searching for, if any */ searchKeywords: string; /** Only search for these block types (e.g. `["html", "problem"]`) */ - blockTypesFilter: string[]; + blockTypesFilter?: string[]; /** Required tags (all must match), e.g. `["Difficulty > Hard", "Subject > Math"]` */ - tagsFilter: string[]; + tagsFilter?: string[]; }) => { const query = useInfiniteQuery({ enabled: client !== undefined && indexName !== undefined, diff --git a/src/search-modal/index.js b/src/search-modal/index.js new file mode 100644 index 0000000000..190635618d --- /dev/null +++ b/src/search-modal/index.js @@ -0,0 +1,3 @@ +// @ts-check +export { default as SearchModal } from './SearchModal'; +export { useContentSearchConnection, useContentSearchResults } from './data/apiHooks'; diff --git a/src/studio-home/StudioHome.jsx b/src/studio-home/StudioHome.jsx index 4953c6f3ae..4400e54517 100644 --- a/src/studio-home/StudioHome.jsx +++ b/src/studio-home/StudioHome.jsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useCallback } from 'react'; import { Button, Container, @@ -11,6 +11,7 @@ import { Add as AddIcon, Error } from '@openedx/paragon/icons'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { StudioFooter } from '@edx/frontend-component-footer'; import { getConfig, getPath } from '@edx/frontend-platform'; +import { useLocation } from 'react-router-dom'; import { constructLibraryAuthoringURL } from '../utils'; import Loading from '../generic/Loading'; @@ -19,7 +20,7 @@ import Header from '../header'; import SubHeader from '../generic/sub-header/SubHeader'; import HomeSidebar from './home-sidebar'; import TabsSection from './tabs-section'; -import { isMixedOrV2LibrariesMode } from './tabs-section/utils'; +import { isMixedOrV1LibrariesMode, isMixedOrV2LibrariesMode } from './tabs-section/utils'; import OrganizationSection from './organization-section'; import VerifyEmailLayout from './verify-email-layout'; import CreateNewCourseForm from './create-new-course-form'; @@ -28,6 +29,8 @@ import { useStudioHome } from './hooks'; import AlertMessage from '../generic/alert-message'; const StudioHome = ({ intl }) => { + const location = useLocation(); + const isPaginationCoursesEnabled = getConfig().ENABLE_HOME_PAGE_COURSE_API_V2; const { isLoadingPage, @@ -47,6 +50,8 @@ const StudioHome = ({ intl }) => { const libMode = getConfig().LIBRARY_MODE; + const v1LibraryTab = isMixedOrV1LibrariesMode(libMode) && location?.pathname.split('/').pop() === 'libraries-v1'; + const { userIsActive, studioShortName, @@ -55,7 +60,7 @@ const StudioHome = ({ intl }) => { redirectToLibraryAuthoringMfe, } = studioHomeData; - function getHeaderButtons() { + const getHeaderButtons = useCallback(() => { const headerButtons = []; if (isFailedLoadingPage || !userIsActive) { @@ -83,7 +88,7 @@ const StudioHome = ({ intl }) => { } let libraryHref = `${getConfig().STUDIO_BASE_URL}/home_library`; - if (isMixedOrV2LibrariesMode(libMode)) { + if (isMixedOrV2LibrariesMode(libMode) && !v1LibraryTab) { libraryHref = libraryAuthoringMfeUrl && redirectToLibraryAuthoringMfe ? constructLibraryAuthoringURL(libraryAuthoringMfeUrl, 'create') // Redirection to the placeholder is done in the MFE rather than @@ -106,7 +111,7 @@ const StudioHome = ({ intl }) => { ); return headerButtons; - } + }, [location]); const headerButtons = userIsActive ? getHeaderButtons() : []; if (isLoadingPage && !isFiltered) { diff --git a/src/studio-home/tabs-section/LibraryV2Placeholder.jsx b/src/studio-home/tabs-section/LibraryV2Placeholder.jsx deleted file mode 100644 index 6b13853a2c..0000000000 --- a/src/studio-home/tabs-section/LibraryV2Placeholder.jsx +++ /dev/null @@ -1,36 +0,0 @@ -import React from 'react'; -import { Container } from '@openedx/paragon'; -import { StudioFooter } from '@edx/frontend-component-footer'; -import { useIntl } from '@edx/frontend-platform/i18n'; - -import Header from '../../header'; -import SubHeader from '../../generic/sub-header/SubHeader'; -import messages from './messages'; - -/* istanbul ignore next */ -const LibraryV2Placeholder = () => { - const intl = useIntl(); - - return ( - <> -
- -
-
-
- -
-
-
-

{intl.formatMessage(messages.libraryV2PlaceholderBody)}

-
-
-
- - - ); -}; - -export default LibraryV2Placeholder;