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..077e00d64f --- /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..d2779cb616 --- /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.ComponentType { + 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.tsx similarity index 60% rename from src/header/Header.jsx rename to src/header/Header.tsx index 7cc1adcb08..d32c4de66a 100644 --- a/src/header/Header.jsx +++ b/src/header/Header.tsx @@ -1,62 +1,71 @@ -// @ts-check +/* eslint-disable react/require-default-props */ import React from 'react'; -import PropTypes from 'prop-types'; import { getConfig } from '@edx/frontend-platform'; 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'; +interface HeaderProps { + contentId?: string, + number?: string, + org?: string, + title?: string, + isHiddenMainMenu?: boolean, + isLibrary?: boolean, +} + const Header = ({ - courseId, - courseOrg, - courseNumber, - courseTitle, - isHiddenMainMenu, -}) => { + contentId = '', + org = '', + number = '', + title = '', + isHiddenMainMenu = false, + isLibrary = false, +}: HeaderProps) => { const intl = useIntl(); const [isShowSearchModalOpen, openSearchModal, closeSearchModal] = useToggle(false); 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 && ( )} @@ -64,20 +73,4 @@ const Header = ({ ); }; -Header.propTypes = { - courseId: PropTypes.string, - courseNumber: PropTypes.string, - courseOrg: PropTypes.string, - courseTitle: PropTypes.string, - isHiddenMainMenu: PropTypes.bool, -}; - -Header.defaultProps = { - courseId: '', - courseNumber: '', - courseOrg: '', - courseTitle: '', - isHiddenMainMenu: 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..db1b1d8ac6 100644 --- a/src/index.scss +++ b/src/index.scss @@ -26,9 +26,11 @@ @import "textbooks/Textbooks"; @import "content-tags-drawer/ContentTagsDropDownSelector"; @import "content-tags-drawer/ContentTagsCollapsible"; -@import "search-modal/SearchModal"; +@import "search-modal"; +@import "search-manager"; @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..928c224bf2 --- /dev/null +++ b/src/library-authoring/LibraryAuthoringPage.test.tsx @@ -0,0 +1,363 @@ +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-manager/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 returnLowNumberResults = (_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. + mockResult.results[0].query = query; + // Limit number of results to just 2 + mockResult.results[0].hits = mockResult.results[0]?.hits.slice(0, 2); + mockResult.results[0].estimatedTotalHits = 2; + // 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; +}; + +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: + // Call 1: To fetch searchable/filterable/sortable library data + // Call 2: To fetch the recently modified components only + await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(2, 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: + // Call 1: To fetch searchable/filterable/sortable library data + // Call 2: To fetch the recently modified components only + await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(2, 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: + // Call 1: To fetch searchable/filterable/sortable library data + // Call 2: To fetch the recently modified components only + await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(2, searchEndpoint, 'post'); }); + + fireEvent.change(getByRole('searchbox'), { target: { value: 'noresults' } }); + + // Ensure the search endpoint is called again, only once more since the recently modified call + // should not be impacted by the search + await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(3, 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' })); + }); + + it('show the "View All" button when viewing library with many components', 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: + // Call 1: To fetch searchable/filterable/sortable library data + // Call 2: To fetch the recently modified components only + await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(2, 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(); + + // There should only be one "View All" button, since the Components count + // are above the preview limit (4) + expect(getByText('View All')).toBeInTheDocument(); + + // Clicking on "View All" button should navigate to the Components tab + fireEvent.click(getByText('View All')); + expect(queryByText('Recently Modified')).not.toBeInTheDocument(); + expect(queryByText('Collections (0)')).not.toBeInTheDocument(); + expect(queryByText('Components (6)')).not.toBeInTheDocument(); + expect(getAllByText('Test HTML Block')[0]).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('should not show the "View All" button when viewing library with low number of components', async () => { + mockUseParams.mockReturnValue({ libraryId: libraryData.id }); + axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData); + fetchMock.post(searchEndpoint, returnLowNumberResults, { overwriteRoutes: true }); + + const { + getByText, queryByText, getAllByText, + } = render(); + + // Ensure the search endpoint is called: + // Call 1: To fetch searchable/filterable/sortable library data + // Call 2: To fetch the recently modified components only + await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(2, 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 (2)')).toBeInTheDocument(); + expect(getAllByText('Test HTML Block')[0]).toBeInTheDocument(); + + // There should not be any "View All" button on page since Components count + // is less than the preview limit (4) + expect(queryByText('View All')).not.toBeInTheDocument(); + }); + + it('sort library components', async () => { + mockUseParams.mockReturnValue({ libraryId: libraryData.id }); + axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData); + fetchMock.post(searchEndpoint, returnEmptyResult, { overwriteRoutes: true }); + + const { findByText, getByText, getByTitle } = render(); + + // Default sorts by relevance + expect(await findByText('Most Relevant')).toBeInTheDocument(); + + const testSortOption = (async (optionText, sortBy) => { + fireEvent.click(getByTitle('Sort search results')); + fireEvent.click(getByText(optionText)); + const bodyText = sortBy ? `"sort":["${sortBy}"]` : '"sort":[]'; + const searchText = sortBy ? `?sort=${encodeURIComponent(sortBy)}` : ''; + await waitFor(() => { + expect(fetchMock).toHaveBeenLastCalledWith(searchEndpoint, { + body: expect.stringContaining(bodyText), + method: 'POST', + headers: expect.anything(), + }); + }); + expect(window.location.search).toEqual(searchText); + }); + + await testSortOption('Title, A-Z', 'display_name:asc'); + await testSortOption('Title, Z-A', 'display_name:desc'); + await testSortOption('Newest', 'created:desc'); + await testSortOption('Oldest', 'created:asc'); + await testSortOption('Recently Published', 'last_published:desc'); + await testSortOption('Recently Modified', 'modified:desc'); + + // Selecting the default sort option clears the url search param + await testSortOption('Most Relevant', ''); + }); +}); diff --git a/src/library-authoring/LibraryAuthoringPage.tsx b/src/library-authoring/LibraryAuthoringPage.tsx new file mode 100644 index 0000000000..cd8262fb4b --- /dev/null +++ b/src/library-authoring/LibraryAuthoringPage.tsx @@ -0,0 +1,153 @@ +import React, { useEffect, useState } from 'react'; +import { StudioFooter } from '@edx/frontend-component-footer'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { + Container, Icon, IconButton, Tab, Tabs, +} from '@openedx/paragon'; +import { InfoOutline } from '@openedx/paragon/icons'; +import { + Routes, Route, useLocation, useNavigate, useParams, useSearchParams, +} 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 { + ClearFiltersButton, + FilterByBlockType, + FilterByTags, + SearchContextProvider, + SearchKeywordsField, + SearchSortWidget, +} from '../search-manager'; +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 { libraryId } = useParams(); + const { data: libraryData, isLoading } = useContentLibrary(libraryId); + + const [searchParams] = useSearchParams(); + + 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({ + pathname: key, + search: searchParams.toString(), + }); + }; + + return ( + <> +
+ + + } + subtitle={intl.formatMessage(messages.headingSubtitle)} + /> + +
+ + + +
+ +
+ + + + + + + + )} + /> + } + /> + } + /> + } + /> + + + + + + ); +}; + +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..11abf861a8 --- /dev/null +++ b/src/library-authoring/LibraryHome.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import { Stack } from '@openedx/paragon'; +import { useIntl } from '@edx/frontend-platform/i18n'; + +import { NoComponents, NoSearchResults } from './EmptyStates'; +import { useSearchContext } from '../search-manager'; +import LibraryCollections from './LibraryCollections'; +import LibraryComponents from './components/LibraryComponents'; +import LibrarySection from './components/LibrarySection'; +import LibraryRecentlyModified from './LibraryRecentlyModified'; +import messages from './messages'; + +type LibraryHomeProps = { + libraryId: string, + tabList: { home: string, components: string, collections: string }, + handleTabChange: (key: string) => void, +}; + +const LibraryHome = ({ libraryId, tabList, handleTabChange } : LibraryHomeProps) => { + const intl = useIntl(); + const { + totalHits: componentCount, + searchKeywords, + } = useSearchContext(); + + const collectionCount = 0; + + const renderEmptyState = () => { + if (componentCount === 0) { + return searchKeywords === '' ? : ; + } + return null; + }; + + return ( + + + { + renderEmptyState() + || ( + <> + + + + handleTabChange(tabList.components)} + > + + + + ) + } + + ); +}; + +export default LibraryHome; diff --git a/src/library-authoring/LibraryRecentlyModified.tsx b/src/library-authoring/LibraryRecentlyModified.tsx new file mode 100644 index 0000000000..56fd16b269 --- /dev/null +++ b/src/library-authoring/LibraryRecentlyModified.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import { useIntl } from '@edx/frontend-platform/i18n'; + +import { SearchContextProvider, useSearchContext } from '../search-manager'; +import { SearchSortOption } from '../search-manager/data/api'; +import LibraryComponents from './components/LibraryComponents'; +import LibrarySection from './components/LibrarySection'; +import messages from './messages'; + +const RecentlyModified: React.FC<{ libraryId: string }> = ({ libraryId }) => { + const intl = useIntl(); + const { totalHits: componentCount } = useSearchContext(); + + return componentCount > 0 + ? ( + + + + ) + : null; +}; + +const LibraryRecentlyModified: React.FC<{ libraryId: string }> = ({ libraryId }) => ( + + + +); + +export default LibraryRecentlyModified; 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..54f10adbe5 --- /dev/null +++ b/src/library-authoring/components/LibraryComponents.test.tsx @@ -0,0 +1,224 @@ +import React from 'react'; +import { AppProvider } from '@edx/frontend-platform/react'; +import { initializeMockApp } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen, fireEvent } from '@testing-library/react'; +import MockAdapter from 'axios-mock-adapter'; +import fetchMock from 'fetch-mock-jest'; +import type { Store } from 'redux'; + +import { getContentSearchConfigUrl } from '../../search-manager/data/api'; +import { SearchContextProvider } from '../../search-manager/SearchManager'; +import mockEmptyResult from '../../search-modal/__mocks__/empty-search-result.json'; +import initializeStore from '../../store'; +import { libraryComponentsMock } from '../__mocks__'; +import LibraryComponents from './LibraryComponents'; + +const searchEndpoint = 'http://mock.meilisearch.local/multi-search'; + +const mockUseLibraryBlockTypes = jest.fn(); +const mockFetchNextPage = jest.fn(); +const mockUseSearchContext = jest.fn(); + +const data = { + totalHits: 1, + hits: [], + isFetching: true, + isFetchingNextPage: false, + hasNextPage: false, + fetchNextPage: mockFetchNextPage, + searchKeywords: '', +}; + +let store: Store; +let axiosMock: MockAdapter; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, +}); + +const returnEmptyResult = (_url: string, 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: any) => { hit._formatted = { ...hit }; }); + return mockEmptyResult; +}; + +const blockTypeData = { + data: [ + { + blockType: 'html', + displayName: 'Text', + }, + { + blockType: 'video', + displayName: 'Video', + }, + { + blockType: 'problem', + displayName: 'Problem', + }, + ], +}; + +jest.mock('../data/apiHook', () => ({ + useLibraryBlockTypes: () => mockUseLibraryBlockTypes(), +})); + +jest.mock('../../search-manager', () => ({ + ...jest.requireActual('../../search-manager'), + useSearchContext: () => mockUseSearchContext(), +})); + +const RootWrapper = (props) => ( + + + + + + + + + +); + +describe('', () => { + beforeEach(() => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: true, + roles: [], + }, + }); + store = initializeStore(); + mockUseLibraryBlockTypes.mockReturnValue(blockTypeData); + mockUseSearchContext.mockReturnValue(data); + + fetchMock.post(searchEndpoint, returnEmptyResult, { overwriteRoutes: true }); + + // 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', + }); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should render empty state', async () => { + mockUseSearchContext.mockReturnValue({ + ...data, + totalHits: 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 () => { + mockUseSearchContext.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 () => { + mockUseSearchContext.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 () => { + mockUseSearchContext.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 () => { + mockUseSearchContext.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 () => { + mockUseSearchContext.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..4b1a3fa054 --- /dev/null +++ b/src/library-authoring/components/LibraryComponents.tsx @@ -0,0 +1,119 @@ +import React, { useEffect, useMemo } from 'react'; +import { CardGrid } from '@openedx/paragon'; + +import { useSearchContext } from '../../search-manager'; +import { NoComponents, NoSearchResults } from '../EmptyStates'; +import { useLibraryBlockTypes } from '../data/apiHook'; +import { ComponentCard, ComponentCardLoading } from './ComponentCard'; +import { LIBRARY_SECTION_PREVIEW_LIMIT } from './LibrarySection'; + +type LibraryComponentsProps = { + libraryId: string, + variant: 'full' | 'preview', +}; + +/** + * 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, + variant, +}: LibraryComponentsProps) => { + const { + hits, + totalHits: componentCount, + isFetching, + isFetchingNextPage, + hasNextPage, + fetchNextPage, + searchKeywords, + } = useSearchContext(); + + const { componentList, tagCounts } = useMemo(() => { + const result = variant === 'preview' ? hits.slice(0, LIBRARY_SECTION_PREVIEW_LIMIT) : 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 = 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 ( + + {componentList.map((component) => ( + + ))} + { showLoading && } + + ); +}; + +export default LibraryComponents; diff --git a/src/library-authoring/components/LibrarySection.tsx b/src/library-authoring/components/LibrarySection.tsx new file mode 100644 index 0000000000..66fe604ac6 --- /dev/null +++ b/src/library-authoring/components/LibrarySection.tsx @@ -0,0 +1,39 @@ +/* eslint-disable react/require-default-props */ +import React from 'react'; +import { Card, ActionRow, Button } from '@openedx/paragon'; + +export const LIBRARY_SECTION_PREVIEW_LIMIT = 4; + +const LibrarySection: React.FC<{ + title: string, + viewAllAction?: () => void, + contentCount: number, + previewLimit?: number, + children: React.ReactNode, +}> = ({ + title, + viewAllAction, + contentCount, + previewLimit = LIBRARY_SECTION_PREVIEW_LIMIT, + children, +}) => ( + + previewLimit + && ( + + + + ) + } + /> + + {children} + + +); + +export default LibrarySection; 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..b7e6b92cf8 --- /dev/null +++ b/src/library-authoring/data/apiHook.ts @@ -0,0 +1,23 @@ +import { useQuery } from '@tanstack/react-query'; + +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), + }) +); 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..c7a39a3410 --- /dev/null +++ b/src/library-authoring/messages.ts @@ -0,0 +1,90 @@ +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({ + 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', + }, + homeTab: { + id: 'course-authoring.library-authoring.home-tab', + defaultMessage: 'Home', + description: 'Tab label for the home tab', + }, + componentsTab: { + id: 'course-authoring.library-authoring.components-tab', + defaultMessage: 'Components', + description: 'Tab label for the components tab', + }, + collectionsTab: { + id: 'course-authoring.library-authoring.collections-tab', + defaultMessage: 'Collections', + description: 'Tab label for the collections tab', + }, + 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.', + }, + recentlyModifiedSectionTitle: { + id: 'course-authoring.library-authoring.recently-modified-section-title', + defaultMessage: 'Recently Modified', + description: 'Title for the Recently Modified section in library home', + }, + collectionsSectionTitle: { + id: 'course-authoring.library-authoring.collections-section-title', + defaultMessage: 'Collections ({collectionCount})', + description: 'Title for the Collections section in library home', + }, + componentsSectionTitle: { + id: 'course-authoring.library-authoring.components-section-title', + defaultMessage: 'Components ({componentCount})', + description: 'Title for the Components section in library home', + }, +}); + +export default messages; diff --git a/src/search-modal/BlockTypeLabel.tsx b/src/search-manager/BlockTypeLabel.tsx similarity index 100% rename from src/search-modal/BlockTypeLabel.tsx rename to src/search-manager/BlockTypeLabel.tsx diff --git a/src/search-modal/ClearFiltersButton.tsx b/src/search-manager/ClearFiltersButton.tsx similarity index 91% rename from src/search-modal/ClearFiltersButton.tsx rename to src/search-manager/ClearFiltersButton.tsx index 7a29e51722..eeae127381 100644 --- a/src/search-modal/ClearFiltersButton.tsx +++ b/src/search-manager/ClearFiltersButton.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { FormattedMessage } from '@edx/frontend-platform/i18n'; import { Button } from '@openedx/paragon'; import messages from './messages'; -import { useSearchContext } from './manager/SearchManager'; +import { useSearchContext } from './SearchManager'; /** * A button that appears when at least one filter is active, and will clear the filters when clicked. diff --git a/src/search-manager/FilterBy.scss b/src/search-manager/FilterBy.scss new file mode 100644 index 0000000000..3caccac691 --- /dev/null +++ b/src/search-manager/FilterBy.scss @@ -0,0 +1,11 @@ +// Options for the "filter by tag/block type" menu +.pgn__menu.filter-by-refinement-menu { + .pgn__menu-item { + // Make the "filter by tag/block type" menu expand to fit the tags hierarchy and longer block type names + width: 100%; + } +} + +.clear-filter-button:hover { + color: $info-900 !important; +} diff --git a/src/search-modal/FilterByBlockType.tsx b/src/search-manager/FilterByBlockType.tsx similarity index 94% rename from src/search-modal/FilterByBlockType.tsx rename to src/search-manager/FilterByBlockType.tsx index 5aba1bc7df..dc65c7ca86 100644 --- a/src/search-modal/FilterByBlockType.tsx +++ b/src/search-manager/FilterByBlockType.tsx @@ -6,10 +6,11 @@ import { Menu, MenuItem, } from '@openedx/paragon'; +import { FilterList } from '@openedx/paragon/icons'; import SearchFilterWidget from './SearchFilterWidget'; import messages from './messages'; import BlockTypeLabel from './BlockTypeLabel'; -import { useSearchContext } from './manager/SearchManager'; +import { useSearchContext } from './SearchManager'; /** * A button with a dropdown that allows filtering the current search by component type (XBlock type) @@ -69,8 +70,10 @@ const FilterByBlockType: React.FC> = () => { ({ label: }))} label={} + clearFilter={() => setBlockTypesFilter([])} + icon={FilterList} > - + -