diff --git a/.env b/.env index c845272403..23fa3de594 100644 --- a/.env +++ b/.env @@ -36,6 +36,7 @@ ENABLE_ASSETS_PAGE=false ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=false ENABLE_TAGGING_TAXONOMY_PAGES=true ENABLE_CERTIFICATE_PAGE=true +ENABLE_COURSE_IMPORT_IN_LIBRARY=false BBB_LEARN_MORE_URL='' HOTJAR_APP_ID='' HOTJAR_VERSION=6 diff --git a/.env.development b/.env.development index 089fcad238..78fc5621d1 100644 --- a/.env.development +++ b/.env.development @@ -37,6 +37,7 @@ ENABLE_UNIT_PAGE=false ENABLE_ASSETS_PAGE=false ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=true ENABLE_CERTIFICATE_PAGE=true +ENABLE_COURSE_IMPORT_IN_LIBRARY=true ENABLE_NEW_VIDEO_UPLOAD_PAGE=true ENABLE_TAGGING_TAXONOMY_PAGES=true BBB_LEARN_MORE_URL='' diff --git a/.env.test b/.env.test index f789114edf..e78d32b327 100644 --- a/.env.test +++ b/.env.test @@ -33,6 +33,7 @@ ENABLE_UNIT_PAGE=true ENABLE_ASSETS_PAGE=false ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=true ENABLE_CERTIFICATE_PAGE=true +ENABLE_COURSE_IMPORT_IN_LIBRARY=true ENABLE_TAGGING_TAXONOMY_PAGES=true BBB_LEARN_MORE_URL='' INVITE_STUDENTS_EMAIL_TO="someone@domain.com" diff --git a/src/course-outline/data/api.ts b/src/course-outline/data/api.ts index d0e6dc17a1..4ef55c9ae5 100644 --- a/src/course-outline/data/api.ts +++ b/src/course-outline/data/api.ts @@ -1,7 +1,7 @@ import { camelCaseObject, getConfig } from '@edx/frontend-platform'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { XBlock } from '@src/data/types'; -import { CourseOutline } from './types'; +import { CourseOutline, CourseDetails } from './types'; const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL; @@ -9,6 +9,8 @@ export const getCourseOutlineIndexApiUrl = ( courseId: string, ) => `${getApiBaseUrl()}/api/contentstore/v1/course_index/${courseId}`; +export const getCourseDetailsApiUrl = (courseId) => `${getApiBaseUrl()}/api/contentstore/v1/course_details/${courseId}`; + export const getCourseBestPracticesApiUrl = ({ courseId, excludeGraded, @@ -46,7 +48,7 @@ export const createDiscussionsTopicsUrl = (courseId: string) => `${getApiBaseUrl /** * Get course outline index. * @param {string} courseId - * @returns {Promise} + * @returns {Promise} */ export async function getCourseOutlineIndex(courseId: string): Promise { const { data } = await getAuthenticatedHttpClient() @@ -55,6 +57,18 @@ export async function getCourseOutlineIndex(courseId: string): Promise} + */ +export async function getCourseDetails(courseId: string): Promise { + const { data } = await getAuthenticatedHttpClient() + .get(getCourseDetailsApiUrl(courseId)); + + return camelCaseObject(data); +} + /** * * @param courseId diff --git a/src/course-outline/data/apiHooks.ts b/src/course-outline/data/apiHooks.ts index e4686f3c6c..755a2b0b11 100644 --- a/src/course-outline/data/apiHooks.ts +++ b/src/course-outline/data/apiHooks.ts @@ -1,6 +1,6 @@ -import { useMutation, useQuery } from '@tanstack/react-query'; +import { skipToken, useMutation, useQuery } from '@tanstack/react-query'; import { createCourseXblock } from '@src/course-unit/data/api'; -import { getCourseItem } from './api'; +import { getCourseDetails, getCourseItem } from './api'; export const courseOutlineQueryKeys = { all: ['courseOutline'], @@ -9,7 +9,7 @@ export const courseOutlineQueryKeys = { */ contentLibrary: (courseId?: string) => [...courseOutlineQueryKeys.all, courseId], courseItemId: (itemId?: string) => [...courseOutlineQueryKeys.all, itemId], - + courseDetails: (courseId?: string) => [...courseOutlineQueryKeys.all, courseId, 'details'], }; /** @@ -33,3 +33,10 @@ export const useCourseItemData = (itemId?: string, enabled: boolean = true) => ( enabled: enabled && itemId !== undefined, }) ); + +export const useCourseDetails = (courseId?: string) => ( + useQuery({ + queryKey: courseOutlineQueryKeys.courseDetails(courseId), + queryFn: courseId ? () => getCourseDetails(courseId) : skipToken, + }) +); diff --git a/src/course-outline/data/types.ts b/src/course-outline/data/types.ts index 7937a45cf4..a8e89d64b6 100644 --- a/src/course-outline/data/types.ts +++ b/src/course-outline/data/types.ts @@ -24,6 +24,15 @@ export interface CourseOutline { rerunNotificationId: null; } +// TODO: This interface has only basic data, all the rest needs to be added. +export interface CourseDetails { + courseId: string; + title: string; + subtitle?: string; + org: string; + description?: string; +} + export interface CourseOutlineState { loadingStatus: { outlineIndexLoadingStatus: string; diff --git a/src/index.jsx b/src/index.jsx index 928be99e02..77b00c3e56 100755 --- a/src/index.jsx +++ b/src/index.jsx @@ -173,6 +173,7 @@ initialize({ ENABLE_ASSETS_PAGE: process.env.ENABLE_ASSETS_PAGE || 'false', ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN: process.env.ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN || 'false', ENABLE_CERTIFICATE_PAGE: process.env.ENABLE_CERTIFICATE_PAGE || 'false', + ENABLE_COURSE_IMPORT_IN_LIBRARY: process.env.ENABLE_COURSE_IMPORT_IN_LIBRARY || 'false', ENABLE_TAGGING_TAXONOMY_PAGES: process.env.ENABLE_TAGGING_TAXONOMY_PAGES || 'false', ENABLE_CHECKLIST_QUALITY: process.env.ENABLE_CHECKLIST_QUALITY || 'true', ENABLE_GRADING_METHOD_IN_PROBLEMS: process.env.ENABLE_GRADING_METHOD_IN_PROBLEMS === 'true', diff --git a/src/legacy-libraries-migration/index.scss b/src/legacy-libraries-migration/index.scss index 454fbd2bc0..980563bd59 100644 --- a/src/legacy-libraries-migration/index.scss +++ b/src/legacy-libraries-migration/index.scss @@ -13,10 +13,6 @@ .card-item { margin: 0 0 16px !important; - - &.selected { - box-shadow: 0 0 0 2px var(--pgn-color-primary-700); - } } } diff --git a/src/library-authoring/LibraryAuthoringPage.tsx b/src/library-authoring/LibraryAuthoringPage.tsx index 63689635f6..ac458e579e 100644 --- a/src/library-authoring/LibraryAuthoringPage.tsx +++ b/src/library-authoring/LibraryAuthoringPage.tsx @@ -7,6 +7,7 @@ import { } from 'react'; import { Helmet } from 'react-helmet'; import classNames from 'classnames'; +import { getConfig } from '@edx/frontend-platform'; import { StudioFooterSlot } from '@edx/frontend-component-footer'; import { useIntl } from '@edx/frontend-platform/i18n'; import { @@ -19,6 +20,7 @@ import { Stack, Tab, Tabs, + useToggle, } from '@openedx/paragon'; import { Add, InfoOutline } from '@openedx/paragon/icons'; import { Link, useLocation, useNavigate } from 'react-router-dom'; @@ -49,8 +51,10 @@ import { useLibraryContext } from './common/context/LibraryContext'; import { SidebarBodyItemId, useSidebarContext } from './common/context/SidebarContext'; import { allLibraryPageTabs, ContentType, useLibraryRoutes } from './routes'; import messages from './messages'; +import tempMessages from './course-import/messages'; import LibraryFilterByPublished from './generic/filter-by-published'; import { libraryQueryPredicate } from './data/apiHooks'; +import { ImportStepperModal } from './course-import/ImportStepperModal'; const HeaderActions = () => { const intl = useIntl(); @@ -147,6 +151,7 @@ const LibraryAuthoringPage = ({ const params = new URLSearchParams(location.search); const { showToast } = useContext(ToastContext); const queryClient = useQueryClient(); + const [importModalIsOpen, openImportModal, closeImportModal] = useToggle(false); // Get migration status every second if applicable const migrationId = params.get('migration_task'); @@ -353,6 +358,11 @@ const LibraryAuthoringPage = ({ extraFilter={extraFilter} overrideTypesFilter={overrideTypesFilter} > + {getConfig().ENABLE_COURSE_IMPORT_IN_LIBRARY === 'true' && ( + + )} } subtitle={!componentPickerMode ? intl.formatMessage(messages.headingSubtitle) : undefined} @@ -396,6 +406,11 @@ const LibraryAuthoringPage = ({ )} + ); }; diff --git a/src/library-authoring/course-import/ImportStepperModal.test.tsx b/src/library-authoring/course-import/ImportStepperModal.test.tsx new file mode 100644 index 0000000000..fca39d53d9 --- /dev/null +++ b/src/library-authoring/course-import/ImportStepperModal.test.tsx @@ -0,0 +1,144 @@ +import userEvent from '@testing-library/user-event'; +import { + initializeMocks, + render, + screen, + fireEvent, + waitFor, +} from '@src/testUtils'; +import { initialState } from '@src/studio-home/factories/mockApiResponses'; +import { RequestStatus } from '@src/data/constants'; +import { type DeprecatedReduxState } from '@src/store'; +import studioHomeMock from '@src/studio-home/__mocks__/studioHomeMock'; +import { mockGetMigrationInfo } from '@src/studio-home/data/api.mocks'; +import { getCourseDetailsApiUrl } from '@src/course-outline/data/api'; +import { ImportStepperModal } from './ImportStepperModal'; + +let axiosMock; +mockGetMigrationInfo.applyMock(); +type StudioHomeState = DeprecatedReduxState['studioHome']; + +const libraryKey = 'lib:org:lib1'; +const mockOnClose = jest.fn(); +const numPages = 1; +const coursesCount = studioHomeMock.courses.length; + +const renderComponent = (studioHomeState: Partial = {}) => { + // Generate a custom initial state based on studioHomeCoursesRequestParams + const customInitialState: Partial = { + ...initialState, + studioHome: { + ...initialState.studioHome, + studioHomeData: { + courses: studioHomeMock.courses, + numPages, + coursesCount, + }, + loadingStatuses: { + ...initialState.studioHome.loadingStatuses, + courseLoadingStatus: RequestStatus.SUCCESSFUL, + }, + ...studioHomeState, + }, + }; + + // Initialize the store with the custom initial state + const newMocks = initializeMocks({ initialState: customInitialState }); + const store = newMocks.reduxStore; + axiosMock = newMocks.axiosMock; + + return { + ...render( + , + ), + store, + }; +}; + +describe('', () => { + it('should render correctly', async () => { + renderComponent(); + // Renders the stepper header + expect(await screen.findByText('Select Course')).toBeInTheDocument(); + expect(await screen.findByText('Review Import Details')).toBeInTheDocument(); + + // Renders the course list and previously imported chip + expect(screen.getByText(/managing risk in the information age/i)).toBeInTheDocument(); + expect(screen.getByText(/run 0/i)).toBeInTheDocument(); + expect(await screen.findByText('Previously Imported')).toBeInTheDocument(); + + // Renders cancel and next step buttons + expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /next step/i })).toBeInTheDocument(); + }); + + it('should cancel the import', async () => { + const user = userEvent.setup(); + renderComponent(); + + const cancelButon = await screen.findByRole('button', { name: /cancel/i }); + await user.click(cancelButon); + + expect(mockOnClose).toHaveBeenCalled(); + }); + + it('should go to review import details step', async () => { + const user = userEvent.setup(); + renderComponent(); + axiosMock.onGet(getCourseDetailsApiUrl('course-v1:HarvardX+123+2023')).reply(200, { + courseId: 'course-v1:HarvardX+123+2023', + title: 'Managing Risk in the Information Age', + subtitle: '', + org: 'HarvardX', + description: 'This is a test course', + }); + + const nextButton = await screen.findByRole('button', { name: /next step/i }); + expect(nextButton).toBeDisabled(); + + // Select a course + const courseCard = screen.getAllByRole('radio')[0]; + await fireEvent.click(courseCard); + expect(courseCard).toBeChecked(); + + // Click next + expect(nextButton).toBeEnabled(); + await user.click(nextButton); + + await waitFor(async () => expect(await screen.findByText( + /managing risk in the information age is being analyzed for review prior to import/i, + )).toBeInTheDocument()); + + expect(screen.getByText('Analysis Summary')).toBeInTheDocument(); + expect(screen.getByText('Import Details')).toBeInTheDocument(); + // The import details is loading + expect(screen.getByText('The selected course is being analyzed for import and review')).toBeInTheDocument(); + }); + + it('the course should remain selected on back', async () => { + const user = userEvent.setup(); + renderComponent(); + + const nextButton = await screen.findByRole('button', { name: /next step/i }); + expect(nextButton).toBeDisabled(); + + // Select a course + const courseCard = screen.getAllByRole('radio')[0]; + await fireEvent.click(courseCard); + expect(courseCard).toBeChecked(); + + // Click next + expect(nextButton).toBeEnabled(); + await user.click(nextButton); + + const backButton = await screen.getByRole('button', { name: /back/i }); + await user.click(backButton); + + expect(screen.getByText(/managing risk in the information age/i)).toBeInTheDocument(); + expect(courseCard).toBeChecked(); + }); +}); diff --git a/src/library-authoring/course-import/ImportStepperModal.tsx b/src/library-authoring/course-import/ImportStepperModal.tsx new file mode 100644 index 0000000000..4cd355fd3d --- /dev/null +++ b/src/library-authoring/course-import/ImportStepperModal.tsx @@ -0,0 +1,88 @@ +import { useState } from 'react'; +import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; +import { + ActionRow, Button, ModalDialog, Stepper, +} from '@openedx/paragon'; + +import { CoursesList } from '@src/studio-home/tabs-section/courses-tab'; +import { ReviewImportDetails } from './ReviewImportDetails'; +import messages from './messages'; + +type MigrationStep = 'select-course' | 'review-details'; + +export const ImportStepperModal = ({ + libraryKey, + isOpen, + onClose, +}: { + libraryKey: string, + isOpen: boolean, + onClose: () => void, +}) => { + const intl = useIntl(); + const [currentStep, setCurrentStep] = useState('select-course'); + const [selectedCourseId, setSelectedCourseId] = useState(); + + return ( + + + + + + + + + + + + + + + + + + + {currentStep === 'select-course' ? ( + + + + + + + ) : ( + + + + + )} + + + ); +}; diff --git a/src/library-authoring/course-import/ReviewImportDetails.tsx b/src/library-authoring/course-import/ReviewImportDetails.tsx new file mode 100644 index 0000000000..dbbb13261f --- /dev/null +++ b/src/library-authoring/course-import/ReviewImportDetails.tsx @@ -0,0 +1,44 @@ +import { FormattedMessage } from '@edx/frontend-platform/i18n'; +import { Card, Stack } from '@openedx/paragon'; +import { LoadingSpinner } from '@src/generic/Loading'; +import { useCourseDetails } from '@src/course-outline/data/apiHooks'; + +import messages from './messages'; +import { SummaryCard } from './SummaryCard'; + +export const ReviewImportDetails = ({ courseId }: { courseId?: string }) => { + const { data, isPending } = useCourseDetails(courseId); + + return ( + + + {data && !isPending ? ( + +

+

+ +

+
+ ) : ( +
+ +
+ )} +
+

+ +

+ + + + + + +
+ ); +}; diff --git a/src/library-authoring/course-import/SummaryCard.tsx b/src/library-authoring/course-import/SummaryCard.tsx new file mode 100644 index 0000000000..529c2294b2 --- /dev/null +++ b/src/library-authoring/course-import/SummaryCard.tsx @@ -0,0 +1,51 @@ +import { FormattedMessage } from '@edx/frontend-platform/i18n'; +import { Card, Icon, Stack } from '@openedx/paragon'; +import { Widgets } from '@openedx/paragon/icons'; + +import { LoadingSpinner } from '@src/generic/Loading'; +import { getItemIcon } from '@src/generic/block-type-utils'; + +import messages from './messages'; + +// TODO: The SummaryCard is always in loading state +export const SummaryCard = () => ( + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +); diff --git a/src/library-authoring/course-import/messages.ts b/src/library-authoring/course-import/messages.ts new file mode 100644 index 0000000000..96e67047f2 --- /dev/null +++ b/src/library-authoring/course-import/messages.ts @@ -0,0 +1,92 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + importCourseModalTitle: { + id: 'library-authoring.import-course.modal.title', + defaultMessage: 'Import Course to Library', + description: 'Title for the modal to import a course into a library.', + }, + importCourseButton: { + id: 'library-authoring.import-course.button.text', + defaultMessage: 'Import Course', + description: 'Label of the button to open the modal to import a course into a library.', + }, + importCourseSelectCourseStep: { + id: 'library-authoring.import-course.select-course.title', + defaultMessage: 'Select Course', + description: 'Title for the step to select course in the modal to import a course into a library.', + }, + importCourseReviewDetailsStep: { + id: 'library-authoring.import-course.review-details.title', + defaultMessage: 'Review Import Details', + description: 'Title for the step to review import details in the modal to import a course into a library.', + }, + importCourseCalcel: { + id: 'library-authoring.import-course.cancel.text', + defaultMessage: 'Cancel', + description: 'Label of the button to cancel the course import.', + }, + importCourseNext: { + id: 'library-authoring.import-course.next.text', + defaultMessage: 'Next step', + description: 'Label of the button go to the next step in the course import modal.', + }, + importCourseBack: { + id: 'library-authoring.import-course.back.text', + defaultMessage: 'Back', + description: 'Label of the button to go to the previous step in the course import modal.', + }, + importCourseInProgressStatusTitle: { + id: 'library-authoring.import-course.review-details.in-progress.title', + defaultMessage: 'Import Analysis in Progress', + description: 'Titile for the info card with the in-progress status in the course import modal.', + }, + importCourseInProgressStatusBody: { + id: 'library-authoring.import-course.review-details.in-progress.body', + defaultMessage: '{courseName} is being analyzed for review prior to import. For large courses, this may take some time.' + + ' Please remain on this page.', + description: 'Body of the info card with the in-progress status in the course import modal.', + }, + importCourseAnalysisSummary: { + id: 'library-authoring.import-course.review-details.analysis-symmary.title', + defaultMessage: 'Analysis Summary', + description: 'Title of the card for the analysis summary of a imported course.', + }, + importCourseTotalBlocks: { + id: 'library-authoring.import-course.review-details.analysis-symmary.total-blocks', + defaultMessage: 'Total Blocks', + description: 'Label title for the total blocks in the analysis summary of a imported course.', + }, + importCourseSections: { + id: 'library-authoring.import-course.review-details.analysis-symmary.sections', + defaultMessage: 'Sections', + description: 'Label title for the number of sections in the analysis summary of a imported course.', + }, + importCourseSubsections: { + id: 'library-authoring.import-course.review-details.analysis-symmary.subsections', + defaultMessage: 'Subsections', + description: 'Label title for the number of subsections in the analysis summary of a imported course.', + }, + importCourseUnits: { + id: 'library-authoring.import-course.review-details.analysis-symmary.units', + defaultMessage: 'Units', + description: 'Label title for the number of units in the analysis summary of a imported course.', + }, + importCourseComponents: { + id: 'library-authoring.import-course.review-details.analysis-symmary.components', + defaultMessage: 'Components', + description: 'Label title for the number of components in the analysis summary of a imported course.', + }, + importCourseDetailsTitle: { + id: 'library-authoring.import-course.review-details.import-details.title', + defaultMessage: 'Import Details', + description: 'Title of the card for the import details of a imported course.', + }, + importCourseDetailsLoadingBody: { + id: 'library-authoring.import-course.review-details.import-details.loading.body', + defaultMessage: 'The selected course is being analyzed for import and review', + description: 'Body of the card in loading state for the import details of a imported course.', + }, +}); + +export default messages; diff --git a/src/studio-home/card-item/index.tsx b/src/studio-home/card-item/index.tsx index 699d9164a2..743bc27519 100644 --- a/src/studio-home/card-item/index.tsx +++ b/src/studio-home/card-item/index.tsx @@ -1,4 +1,6 @@ -import React, { useCallback, useEffect, useRef } from 'react'; +import React, { + ReactElement, useCallback, useEffect, useRef, +} from 'react'; import { useSelector } from 'react-redux'; import { Card, @@ -9,7 +11,7 @@ import { Stack, } from '@openedx/paragon'; import { AccessTime, ArrowForward, MoreHoriz } from '@openedx/paragon/icons'; -import { useIntl } from '@edx/frontend-platform/i18n'; +import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; import { getConfig } from '@edx/frontend-platform'; import { Link } from 'react-router-dom'; @@ -128,8 +130,75 @@ const CardTitle: React.FC = ({ return getTitle(); }; +interface CardMenuProps { + showMenu: boolean; + isShowRerunLink?: boolean; + rerunLink: string | null; + lmsLink: string | null; +} + +const CardMenu = ({ + showMenu, + isShowRerunLink, + rerunLink, + lmsLink, +}: CardMenuProps) => { + const intl = useIntl(); + + if (!showMenu) { + return null; + } + + return ( + + + + {isShowRerunLink && ( + + {messages.btnReRunText.defaultMessage} + + )} + + + + + + ); +}; + +const SelectAction = ({ + itemId, + selectMode, +}: { + itemId: string, + selectMode: 'single' | 'multiple'; +}) => { + if (selectMode === 'single') { + return ( + + ); + } + + // Multiple + return ( + + ); +}; + interface BaseProps { displayName: string; + onClick?: () => void; org: string; number: string; run?: string; @@ -141,7 +210,9 @@ interface BaseProps { migratedToKey?: string; migratedToTitle?: string; migratedToCollectionKey?: string | null; + subtitleBeforeComponent?: ReactElement | null; selectMode?: 'single' | 'multiple'; + selectPosition?: 'card' | 'title'; isSelected?: boolean; itemId?: string; scrollIntoView?: boolean; @@ -162,6 +233,7 @@ type Props = BaseProps & ( */ const CardItem: React.FC = ({ displayName, + onClick, lmsLink = '', rerunLink = '', org, @@ -170,6 +242,7 @@ const CardItem: React.FC = ({ isLibraries = false, courseKey = '', selectMode, + selectPosition, isSelected = false, itemId = '', path, @@ -178,6 +251,7 @@ const CardItem: React.FC = ({ migratedToKey, migratedToTitle, migratedToCollectionKey, + subtitleBeforeComponent, scrollIntoView = false, }) => { const intl = useIntl(); @@ -195,7 +269,7 @@ const CardItem: React.FC = ({ : new URL(url, getConfig().STUDIO_BASE_URL).toString() ); const readOnlyItem = !(lmsLink || rerunLink || url || path); - const showActions = !(readOnlyItem || isLibraries); + const showActionsMenu = !(readOnlyItem || isLibraries || selectMode !== undefined); const isShowRerunLink = allowCourseReruns && rerunCreatorStatus && courseCreatorStatus === COURSE_CREATOR_STATES.granted; @@ -212,6 +286,14 @@ const CardItem: React.FC = ({ /> ); } + if (subtitleBeforeComponent) { + subtitle = ( + + {subtitleBeforeComponent} + {subtitle} + + ); + } return subtitle; }, [isLibraries, org, number, run, migratedToKey, isMigrated]); @@ -232,16 +314,18 @@ const CardItem: React.FC = ({ return (
- = ({ /> )} subtitle={getSubtitle()} - actions={showActions && ( - - + ) : ( + - - {isShowRerunLink && ( - - {messages.btnReRunText.defaultMessage} - - )} - - {intl.formatMessage(messages.viewLiveBtnText)} - - - )} /> {isMigrated && migratedToKey diff --git a/src/studio-home/data/api.mocks.ts b/src/studio-home/data/api.mocks.ts index 3bb7822945..7371a49005 100644 --- a/src/studio-home/data/api.mocks.ts +++ b/src/studio-home/data/api.mocks.ts @@ -2,7 +2,7 @@ import { camelCaseObject } from '@edx/frontend-platform'; import { createAxiosError } from '@src/testUtils'; import * as api from './api'; -import { generateGetStudioHomeLibrariesApiResponse } from '../factories/mockApiResponses'; +import { generateGetStudioHomeLibrariesApiResponse, generateGetMigrationInfo } from '../factories/mockApiResponses'; /** * Mock for `getContentLibraryV2List()` @@ -21,3 +21,9 @@ export const mockGetStudioHomeLibraries = { libraries: [], }), }; + +export const mockGetMigrationInfo = { + applyMock: () => jest.spyOn(api, 'getMigrationInfo').mockResolvedValue( + camelCaseObject(generateGetMigrationInfo()), + ), +}; diff --git a/src/studio-home/data/api.ts b/src/studio-home/data/api.ts index c4e6c7738e..39230153ba 100644 --- a/src/studio-home/data/api.ts +++ b/src/studio-home/data/api.ts @@ -1,4 +1,3 @@ -// @ts-check import { camelCaseObject, snakeCaseObject, getConfig } from '@edx/frontend-platform'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; @@ -50,6 +49,14 @@ export interface LibrariesV1ListData { libraries: LibraryV1Data[]; } +export interface MigrationInfo { + sourceKey: string; + targetCollectionKey: string; + targetCollectionTitle: string; + targetKey: string; + targetTitle: string; +} + export async function getStudioHomeLibraries(): Promise { const { data } = await getAuthenticatedHttpClient().get(`${getStudioHomeApiUrl()}/libraries`); return camelCaseObject(data); @@ -70,3 +77,16 @@ export async function sendRequestForCourseCreator(): Promise { const { data } = await getAuthenticatedHttpClient().post(getRequestCourseCreatorUrl()); return camelCaseObject(data); } + +/** + * Get the migration info data for a list of source keys + */ +export async function getMigrationInfo(sourceKeys: string[]): Promise> { + const client = getAuthenticatedHttpClient(); + + const params = new URLSearchParams(); + sourceKeys.forEach(key => params.append('source_keys', key)); + + const { data } = await client.get(`${getApiBaseUrl()}/api/modulestore_migrator/v1/migration_info/`, { params }); + return camelCaseObject(data); +} diff --git a/src/studio-home/data/apiHooks.ts b/src/studio-home/data/apiHooks.ts index d1ab2a22eb..97c009dcbc 100644 --- a/src/studio-home/data/apiHooks.ts +++ b/src/studio-home/data/apiHooks.ts @@ -1,5 +1,5 @@ -import { useQuery } from '@tanstack/react-query'; -import { getStudioHomeLibraries } from './api'; +import { skipToken, useQuery } from '@tanstack/react-query'; +import { getStudioHomeLibraries, getMigrationInfo } from './api'; export const studioHomeQueryKeys = { all: ['studioHome'], @@ -7,6 +7,7 @@ export const studioHomeQueryKeys = { * Base key for list of v1/legacy libraries */ librariesV1: () => [...studioHomeQueryKeys.all, 'librariesV1'], + migrationInfo: (sourceKeys: string[]) => [...studioHomeQueryKeys.all, 'migrationInfo', ...sourceKeys], }; export const useLibrariesV1Data = (enabled: boolean = true) => ( @@ -16,3 +17,10 @@ export const useLibrariesV1Data = (enabled: boolean = true) => ( enabled, }) ); + +export const useMigrationInfo = (sourcesKeys: string[], enabled: boolean = true) => ( + useQuery({ + queryKey: studioHomeQueryKeys.migrationInfo(sourcesKeys), + queryFn: enabled ? () => getMigrationInfo(sourcesKeys) : skipToken, + }) +); diff --git a/src/studio-home/factories/mockApiResponses.tsx b/src/studio-home/factories/mockApiResponses.tsx index 295971f7d0..c755f086c7 100644 --- a/src/studio-home/factories/mockApiResponses.tsx +++ b/src/studio-home/factories/mockApiResponses.tsx @@ -166,3 +166,13 @@ export const generateNewVideoApiResponse = () => ({ upload_url: 'http://testing.org', }], }); + +export const generateGetMigrationInfo = () => ({ + 'course-v1:HarvardX+123+2023': [{ + sourceKey: 'course-v1:HarvardX+123+2023', + targetCollectionKey: 'ltc:org:coll-1', + targetCollectionTitle: 'Collection 1', + targetKey: 'lib:org:lib1', + targetTitle: 'Library 1', + }], +}); diff --git a/src/studio-home/scss/StudioHome.scss b/src/studio-home/scss/StudioHome.scss index bf0deb79d8..21af67bd93 100644 --- a/src/studio-home/scss/StudioHome.scss +++ b/src/studio-home/scss/StudioHome.scss @@ -58,6 +58,10 @@ .card-item { margin-bottom: 1.5rem; + &.selected { + box-shadow: 0 0 0 2px var(--pgn-color-primary-700); + } + .pgn__card-header { padding: .9375rem 1.25rem; diff --git a/src/studio-home/tabs-section/courses-tab/index.scss b/src/studio-home/tabs-section/courses-tab/index.scss index da6d5f7411..5a889826e7 100644 --- a/src/studio-home/tabs-section/courses-tab/index.scss +++ b/src/studio-home/tabs-section/courses-tab/index.scss @@ -1,3 +1,10 @@ .courses-tab-container { min-height: 80vh; + + .previously-migrated-chip { + .pgn__chip { + border: 0; + background-color: var(--pgn-color-warning-500); + } + } } diff --git a/src/studio-home/tabs-section/courses-tab/index.test.tsx b/src/studio-home/tabs-section/courses-tab/index.test.tsx index 8a069357ad..7334f302c5 100644 --- a/src/studio-home/tabs-section/courses-tab/index.test.tsx +++ b/src/studio-home/tabs-section/courses-tab/index.test.tsx @@ -7,17 +7,16 @@ import { import { COURSE_CREATOR_STATES } from '@src/constants'; import { type DeprecatedReduxState } from '@src/store'; import studioHomeMock from '@src/studio-home/__mocks__/studioHomeMock'; +import { RequestStatus } from '@src/data/constants'; import { initialState } from '../../factories/mockApiResponses'; -import CoursesTab from '.'; +import { CoursesList } from '.'; import { studioHomeCoursesRequestParamsDefault } from '../../data/slice'; type StudioHomeState = DeprecatedReduxState['studioHome']; const onClickNewCourse = jest.fn(); const isShowProcessing = false; -const isLoading = false; -const isFailed = false; const numPages = 1; const coursesCount = studioHomeMock.courses.length; const showNewCourseContainer = true; @@ -28,6 +27,15 @@ const renderComponent = (overrideProps = {}, studioHomeState: Partial, ), @@ -67,30 +70,46 @@ describe('', () => { }); it('should render loading spinner when isLoading is true and isFiltered is false', () => { - const props = { isLoading: true, coursesDataItems: [] }; - const customStoreData = { studioHomeCoursesRequestParams: { currentPage: 1, isFiltered: true } }; - renderComponent(props, customStoreData); + const customStoreData = { + loadingStatuses: { + ...initialState.studioHome.loadingStatuses, + courseLoadingStatus: RequestStatus.IN_PROGRESS, + }, + studioHomeCoursesRequestParams: { currentPage: 1, isFiltered: true }, + }; + renderComponent({}, customStoreData); const loadingSpinner = screen.getByRole('status'); expect(loadingSpinner).toBeInTheDocument(); }); it('should render an error message when something went wrong', () => { - const props = { isFailed: true }; - const customStoreData = { studioHomeCoursesRequestParams: { currentPage: 1, isFiltered: false } }; - renderComponent(props, customStoreData); + const customStoreData = { + loadingStatuses: { + ...initialState.studioHome.loadingStatuses, + courseLoadingStatus: RequestStatus.FAILED, + }, + studioHomeCoursesRequestParams: { currentPage: 1, isFiltered: false }, + }; + renderComponent({}, customStoreData); const alertErrorFailed = screen.queryByTestId('error-failed-message'); expect(alertErrorFailed).toBeInTheDocument(); }); it('should render an alert message when there is not courses found', () => { - const props = { isLoading: false, coursesDataItems: [] }; - const customStoreData = { studioHomeCoursesRequestParams: { currentPage: 1, isFiltered: true } }; - renderComponent(props, customStoreData); + const customStoreData = { + studioHomeData: { + courses: [], + numPages: 0, + coursesCount: 0, + }, + studioHomeCoursesRequestParams: { currentPage: 1, isFiltered: true }, + }; + renderComponent({}, customStoreData); const alertCoursesNotFound = screen.queryByTestId('courses-not-found-alert'); expect(alertCoursesNotFound).toBeInTheDocument(); }); - it('should render processing courses component when isEnabledPagination is false and isShowProcessing is true', () => { + it('should render processing courses component when isEnabledPagination is false and isShowProcessing is true', async () => { const props = { isShowProcessing: true, isEnabledPagination: false }; const customStoreData = { studioHomeData: { @@ -102,7 +121,7 @@ describe('', () => { }, }; renderComponent(props, customStoreData); - const alertCoursesNotFound = screen.queryByTestId('processing-courses-title'); + const alertCoursesNotFound = await screen.findByTestId('processing-courses-title'); expect(alertCoursesNotFound).toBeInTheDocument(); }); @@ -120,9 +139,15 @@ describe('', () => { }); it('should reset filters when in pressed the button to clean them', () => { - const props = { isLoading: false, coursesDataItems: [] }; - const customStoreData = { studioHomeCoursesRequestParams: { currentPage: 1, isFiltered: true } }; - const { store } = renderComponent(props, customStoreData); + const customStoreData = { + studioHomeData: { + courses: [], + numPages: 0, + coursesCount: 0, + }, + studioHomeCoursesRequestParams: { currentPage: 1, isFiltered: true }, + }; + const { store } = renderComponent({}, customStoreData); const cleanFiltersButton = screen.getByRole('button', { name: /clear filters/i }); expect(cleanFiltersButton).toBeInTheDocument(); diff --git a/src/studio-home/tabs-section/courses-tab/index.tsx b/src/studio-home/tabs-section/courses-tab/index.tsx index 214b4e6115..598d37c21b 100644 --- a/src/studio-home/tabs-section/courses-tab/index.tsx +++ b/src/studio-home/tabs-section/courses-tab/index.tsx @@ -1,18 +1,20 @@ -import React from 'react'; +import React, { useCallback, useMemo } from 'react'; import { useLocation } from 'react-router-dom'; import { useDispatch, useSelector } from 'react-redux'; -import { useIntl } from '@edx/frontend-platform/i18n'; +import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; import { Icon, Row, Pagination, Alert, Button, + Form, + Chip, } from '@openedx/paragon'; import { Error } from '@openedx/paragon/icons'; import { COURSE_CREATOR_STATES } from '@src/constants'; -import { getStudioHomeData, getStudioHomeCoursesParams } from '@src/studio-home/data/selectors'; +import { getStudioHomeData, getStudioHomeCoursesParams, getLoadingStatuses } from '@src/studio-home/data/selectors'; import { resetStudioHomeCoursesCustomParams, updateStudioHomeCoursesCustomParams } from '@src/studio-home/data/slice'; import { fetchStudioHomeData } from '@src/studio-home/data/thunks'; import CardItem from '@src/studio-home/card-item'; @@ -20,48 +22,180 @@ import CollapsibleStateWithAction from '@src/studio-home/collapsible-state-with- import ProcessingCourses from '@src/studio-home/processing-courses'; import { LoadingSpinner } from '@src/generic/Loading'; import AlertMessage from '@src/generic/alert-message'; +import { RequestStatus } from '@src/data/constants'; +import { useMigrationInfo } from '@src/studio-home/data/apiHooks'; import messages from '../messages'; import CoursesFilters from './courses-filters'; import ContactAdministrator from './contact-administrator'; import './index.scss'; -interface Props { - coursesDataItems: { - courseKey: string; - displayName: string; - lmsLink: string | null; - number: string; - org: string; - rerunLink: string | null; - run: string; - url: string; - }[]; - showNewCourseContainer: boolean; - onClickNewCourse: () => void; - isShowProcessing: boolean; +interface CardListProps { + currentPage: number; + handlePageSelected: (page: any) => void; + handleCleanFilters: () => void; + onClickCard?: (courseId: string) => void; isLoading: boolean; - isFailed: boolean; - numPages: number; - coursesCount: number; + isFiltered: boolean; + hasAbilityToCreateCourse?: boolean; + showNewCourseContainer?: boolean; + onClickNewCourse?: () => void; + inSelectMode?: boolean; + selectedCourseId?: string; + currentLibraryId?: string; } -const CoursesTab: React.FC = ({ - coursesDataItems, - showNewCourseContainer, - onClickNewCourse, - isShowProcessing, +const CardList = ({ + currentPage, + handlePageSelected, + handleCleanFilters, + onClickCard, isLoading, - isFailed, - numPages = 0, - coursesCount = 0, + isFiltered, + hasAbilityToCreateCourse = false, + showNewCourseContainer = false, + onClickNewCourse = () => {}, + inSelectMode = false, + selectedCourseId, + currentLibraryId, +}: CardListProps) => { + const { + courses, + numPages, + optimizationEnabled, + } = useSelector(getStudioHomeData); + + const { + data: migrationInfoData, + } = useMigrationInfo(courses?.map(item => item.courseKey) || [], currentLibraryId !== undefined); + + const processedMigrationInfo = useMemo(() => { + const result = {}; + if (migrationInfoData) { + for (const libraries of Object.values(migrationInfoData)) { + // The map key in `migrationInfoData` is in camelCase. + // In the processed map, we use the key in its original form. + result[libraries[0].sourceKey] = libraries.map(item => item.targetKey); + } + } + return result; + }, [migrationInfoData]); + + const isNotFilteringCourses = !isFiltered && !isLoading; + const hasCourses = courses?.length > 0; + + const isPreviouslyMigrated = useCallback((courseKey: string) => ( + courseKey in processedMigrationInfo && processedMigrationInfo[courseKey].includes(currentLibraryId) + ), [processedMigrationInfo]); + + return ( + <> + {hasCourses ? ( + <> + {courses.map( + ({ + courseKey, + displayName, + lmsLink, + org, + rerunLink, + number, + run, + url, + }) => ( + onClickCard?.(courseKey)} + itemId={courseKey} + displayName={displayName} + lmsLink={lmsLink} + rerunLink={rerunLink} + org={org} + number={number} + run={run} + url={url} + selectMode={inSelectMode ? 'single' : undefined} + selectPosition={inSelectMode ? 'card' : undefined} + isSelected={inSelectMode && selectedCourseId === courseKey} + subtitleBeforeComponent={isPreviouslyMigrated(courseKey) && ( +
+ + + +
+ )} + /> + ), + )} + + {numPages > 1 && ( + + )} + + ) : (!optimizationEnabled && isNotFilteringCourses && ( + + ) + )} + + {isFiltered && !hasCourses && !isLoading && ( + + + + +

+ +

+ +
+ )} + + ); +}; + +interface Props { + showNewCourseContainer?: boolean; + onClickNewCourse?: () => void; + isShowProcessing?: boolean; + selectedCourseId?: string; + handleSelect?: (courseId: string) => void; + currentLibraryId?: string; +} + +export const CoursesList: React.FC = ({ + showNewCourseContainer = false, + onClickNewCourse = () => {}, + isShowProcessing = false, + selectedCourseId, + handleSelect, + currentLibraryId, }) => { const dispatch = useDispatch(); const intl = useIntl(); const location = useLocation(); const { + courses, + coursesCount, courseCreatorStatus, - optimizationEnabled, } = useSelector(getStudioHomeData); + const { + courseLoadingStatus, + } = useSelector(getLoadingStatuses); const studioHomeCoursesParams = useSelector(getStudioHomeCoursesParams); const { currentPage, isFiltered } = studioHomeCoursesParams; const hasAbilityToCreateCourse = courseCreatorStatus === COURSE_CREATOR_STATES.granted; @@ -72,6 +206,10 @@ const CoursesTab: React.FC = ({ ].includes(courseCreatorStatus as any); const locationValue = location.search ?? ''; + const isLoading = courseLoadingStatus === RequestStatus.IN_PROGRESS; + const isFailed = courseLoadingStatus === RequestStatus.FAILED; + const inSelectMode = handleSelect !== undefined; + const handlePageSelected = (page) => { const { search, @@ -96,9 +234,6 @@ const CoursesTab: React.FC = ({ dispatch(fetchStudioHomeData(locationValue, false, { page: 1, order: 'display_name' })); }; - const isNotFilteringCourses = !isFiltered && !isLoading; - const hasCourses = coursesDataItems?.length > 0; - if (isLoading && !isFiltered) { return ( @@ -125,70 +260,42 @@ const CoursesTab: React.FC = ({

{intl.formatMessage(messages.coursesPaginationInfo, { - length: coursesDataItems.length, + length: courses?.length, total: coursesCount, })}

- {hasCourses ? ( - <> - {coursesDataItems.map( - ({ - courseKey, - displayName, - lmsLink, - org, - rerunLink, - number, - run, - url, - }) => ( - - ), - )} - - {numPages > 1 && ( - - )} - - ) : (!optimizationEnabled && isNotFilteringCourses && ( - handleSelect(e.target.value)} + > + + + ) : ( + - ) )} - {isFiltered && !hasCourses && !isLoading && ( - - - {intl.formatMessage(messages.coursesTabCourseNotFoundAlertTitle)} - -

- {intl.formatMessage(messages.coursesTabCourseNotFoundAlertMessage)} -

- -
- )} {showCollapsible && ( = ({ ) ); }; - -export default CoursesTab; diff --git a/src/studio-home/tabs-section/index.tsx b/src/studio-home/tabs-section/index.tsx index 129eccf335..9a07c7c2c3 100644 --- a/src/studio-home/tabs-section/index.tsx +++ b/src/studio-home/tabs-section/index.tsx @@ -1,6 +1,5 @@ import { useMemo, useState, useEffect } from 'react'; -import { useSelector } from 'react-redux'; -import PropTypes from 'prop-types'; +import { useNavigate, useLocation } from 'react-router-dom'; import { Badge, Stack, @@ -9,23 +8,28 @@ import { } from '@openedx/paragon'; import { getConfig } from '@edx/frontend-platform'; import { useIntl } from '@edx/frontend-platform/i18n'; -import { useNavigate, useLocation } from 'react-router-dom'; -import { RequestStatus } from '@src/data/constants'; -import { getLoadingStatuses, getStudioHomeData } from '../data/selectors'; import messages from './messages'; import { BaseFilterState, Filter, LibrariesList } from './libraries-tab'; import LibrariesV2List from './libraries-v2-tab/index'; -import CoursesTab from './courses-tab'; +import { CoursesList } from './courses-tab'; import { WelcomeLibrariesV2Alert } from './libraries-v2-tab/WelcomeLibrariesV2Alert'; +interface Props { + showNewCourseContainer: boolean; + onClickNewCourse: () => void; + isShowProcessing: boolean; + librariesV1Enabled?: boolean; + librariesV2Enabled?: boolean; +} + const TabsSection = ({ showNewCourseContainer, onClickNewCourse, isShowProcessing, librariesV1Enabled, librariesV2Enabled, -}) => { +}: Props) => { const intl = useIntl(); const navigate = useNavigate(); const { pathname } = useLocation(); @@ -61,13 +65,6 @@ const TabsSection = ({ setTabKey(initTabKeyState(pathname)); }, [pathname]); - const { courses, numPages, coursesCount } = useSelector(getStudioHomeData); - const { - courseLoadingStatus, - } = useSelector(getLoadingStatuses); - const isLoadingCourses = courseLoadingStatus === RequestStatus.IN_PROGRESS; - const isFailedCoursesPage = courseLoadingStatus === RequestStatus.FAILED; - // Controlling the visibility of tabs when using conditional rendering is necessary for // the correct operation of iterating over child elements inside the Paragon Tabs component. const visibleTabs = useMemo(() => { @@ -78,15 +75,10 @@ const TabsSection = ({ eventKey={TABS_LIST.courses} title={intl.formatMessage(messages.coursesTabTitle)} > - , ); @@ -141,7 +133,7 @@ const TabsSection = ({ } return tabs; - }, [showNewCourseContainer, isLoadingCourses, migrationFilter]); + }, [showNewCourseContainer, migrationFilter]); const handleSelectTab = (tab: TabKeyType) => { if (tab === TABS_LIST.courses) { @@ -168,12 +160,4 @@ const TabsSection = ({ ); }; -TabsSection.propTypes = { - showNewCourseContainer: PropTypes.bool.isRequired, - onClickNewCourse: PropTypes.func.isRequired, - isShowProcessing: PropTypes.bool.isRequired, - librariesV1Enabled: PropTypes.bool, - librariesV2Enabled: PropTypes.bool, -}; - export default TabsSection; diff --git a/src/studio-home/tabs-section/libraries-tab/index.tsx b/src/studio-home/tabs-section/libraries-tab/index.tsx index 6fcfa73247..0f3933557f 100644 --- a/src/studio-home/tabs-section/libraries-tab/index.tsx +++ b/src/studio-home/tabs-section/libraries-tab/index.tsx @@ -47,6 +47,7 @@ const CardList = ({ url={url} itemId={libraryKey} selectMode={inSelectMode ? 'multiple' : undefined} + selectPosition={inSelectMode ? 'title' : undefined} isSelected={selectedIds?.includes(libraryKey)} isMigrated={isMigrated} migratedToKey={migratedToKey} diff --git a/src/studio-home/tabs-section/libraries-v2-tab/index.tsx b/src/studio-home/tabs-section/libraries-v2-tab/index.tsx index f09e9115bf..918a3d5171 100644 --- a/src/studio-home/tabs-section/libraries-v2-tab/index.tsx +++ b/src/studio-home/tabs-section/libraries-v2-tab/index.tsx @@ -23,7 +23,7 @@ import LibrariesV2Filters from './libraries-v2-filters'; interface CardListProps { hasV2Libraries: boolean; - selectMode?: 'single' | 'multiple'; + inSelectMode?: boolean; selectedLibraryId?: string; isFiltered: boolean; isLoading: boolean; @@ -34,7 +34,7 @@ interface CardListProps { const CardList: React.FC = ({ hasV2Libraries, - selectMode, + inSelectMode, selectedLibraryId, isFiltered, isLoading, @@ -56,7 +56,8 @@ const CardList: React.FC = ({ org={org} number={slug} path={`/library/${id}`} - selectMode={selectMode} + selectMode={inSelectMode ? 'single' : undefined} + selectPosition={inSelectMode ? 'title' : undefined} isSelected={selectedLibraryId === id} itemId={id} scrollIntoView={scrollIntoView && selectedLibraryId === id} @@ -202,7 +203,7 @@ const LibrariesV2List: React.FC = ({ >