diff --git a/.env b/.env index c84527240..23fa3de59 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 089fcad23..78fc5621d 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 f789114ed..e78d32b32 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 d0e6dc17a..4ef55c9ae 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 e4686f3c6..755a2b0b1 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 7937a45cf..a8e89d64b 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 928be99e0..77b00c3e5 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 454fbd2bc..980563bd5 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/LibraryLayout.tsx b/src/library-authoring/LibraryLayout.tsx index 7575c1912..3d4f19683 100644 --- a/src/library-authoring/LibraryLayout.tsx +++ b/src/library-authoring/LibraryLayout.tsx @@ -20,6 +20,7 @@ import { ROUTES } from './routes'; import { LibrarySectionPage, LibrarySubsectionPage } from './section-subsections'; import { LibraryUnitPage } from './units'; import { LibraryTeamModal } from './library-team'; +import { ImportStepperPage } from './import-course/stepper/ImportStepperPage'; const LibraryLayoutWrapper: React.FC = ({ children }) => { const { @@ -97,6 +98,10 @@ const LibraryLayout = () => ( path={ROUTES.IMPORT} Component={CourseImportHomePage} /> + ); diff --git a/src/library-authoring/data/api.mocks.ts b/src/library-authoring/data/api.mocks.ts index a7739b191..35410e53e 100644 --- a/src/library-authoring/data/api.mocks.ts +++ b/src/library-authoring/data/api.mocks.ts @@ -1133,3 +1133,17 @@ mockGetCourseImports.applyMock = () => jest.spyOn( api, 'getCourseImports', ).mockImplementation(mockGetCourseImports); + +export const mockGetMigrationInfo = { + applyMock: () => jest.spyOn(api, 'getMigrationInfo').mockResolvedValue( + camelCaseObject({ + 'course-v1:HarvardX+123+2023': [{ + sourceKey: 'course-v1:HarvardX+123+2023', + targetCollectionKey: 'ltc:org:coll-1', + targetCollectionTitle: 'Collection 1', + targetKey: mockContentLibrary.libraryId, + targetTitle: 'Library 1', + }], + }), + ), +}; diff --git a/src/library-authoring/data/api.ts b/src/library-authoring/data/api.ts index f1883a9c2..0945bac2f 100644 --- a/src/library-authoring/data/api.ts +++ b/src/library-authoring/data/api.ts @@ -809,3 +809,24 @@ export async function getCourseImports(libraryId: 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/library-authoring/data/apiHooks.ts b/src/library-authoring/data/apiHooks.ts index e72ac1cf4..cd312d23e 100644 --- a/src/library-authoring/data/apiHooks.ts +++ b/src/library-authoring/data/apiHooks.ts @@ -93,6 +93,11 @@ export const libraryAuthoringQueryKeys = { ...libraryAuthoringQueryKeys.contentLibrary(libraryId), 'courseImports', ], + migrationInfo: (sourceKeys: string[]) => [ + ...libraryAuthoringQueryKeys.all, + 'migrationInfo', + ...sourceKeys, + ], }; export const xblockQueryKeys = { @@ -965,3 +970,13 @@ export const useCourseImports = (libraryId: string) => ( queryFn: () => api.getCourseImports(libraryId), }) ); + +/** + * Returns the migration info of a given source list + */ +export const useMigrationInfo = (sourcesKeys: string[]) => ( + useQuery({ + queryKey: libraryAuthoringQueryKeys.migrationInfo(sourcesKeys), + queryFn: () => api.getMigrationInfo(sourcesKeys), + }) +); diff --git a/src/library-authoring/import-course/CourseImportHomePage.test.tsx b/src/library-authoring/import-course/CourseImportHomePage.test.tsx index 0c9dbc4ef..2e2ddcaa2 100644 --- a/src/library-authoring/import-course/CourseImportHomePage.test.tsx +++ b/src/library-authoring/import-course/CourseImportHomePage.test.tsx @@ -29,7 +29,7 @@ const render = (libraryId: string) => ( {children} ), - path: '/libraries/:libraryId/import-course', + path: '/libraries/:libraryId/import', params: { libraryId }, }, ) diff --git a/src/library-authoring/import-course/CourseImportHomePage.tsx b/src/library-authoring/import-course/CourseImportHomePage.tsx index 041da431d..cfbf101b8 100644 --- a/src/library-authoring/import-course/CourseImportHomePage.tsx +++ b/src/library-authoring/import-course/CourseImportHomePage.tsx @@ -1,3 +1,5 @@ +import { useNavigate } from 'react-router-dom'; +import { Helmet } from 'react-helmet'; import { Button, Card, @@ -6,8 +8,7 @@ import { Stack, } from '@openedx/paragon'; import { Add } from '@openedx/paragon/icons'; -import { Helmet } from 'react-helmet'; - +import { getConfig } from '@edx/frontend-platform'; import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n'; import Loading from '@src/generic/Loading'; import SubHeader from '@src/generic/sub-header/SubHeader'; @@ -19,14 +20,26 @@ import { HelpSidebar } from './HelpSidebar'; import { ImportedCourseCard } from './ImportedCourseCard'; import messages from './messages'; +const ImportCourseButton = () => { + const navigate = useNavigate(); + + if (getConfig().ENABLE_COURSE_IMPORT_IN_LIBRARY === 'true') { + return ( + + ); + } + + return null; +}; + const EmptyState = () => ( - + @@ -64,6 +77,7 @@ export const CourseImportHomePage = () => { title={intl.formatMessage(messages.pageTitle)} subtitle={intl.formatMessage(messages.pageSubtitle)} hideBorder + headerActions={} /> diff --git a/src/library-authoring/import-course/messages.ts b/src/library-authoring/import-course/messages.ts index e6a85b951..7d675a39f 100644 --- a/src/library-authoring/import-course/messages.ts +++ b/src/library-authoring/import-course/messages.ts @@ -73,6 +73,97 @@ const messages = defineMessages({ + '

For additional details you can review the Library Import documentation.

', description: 'Body of the second question in the Help & Support sidebar', }, + importCourseStepperTitle: { + id: 'course-authoring.library-authoring.import-course.stepper.title', + defaultMessage: 'Import Course to Library', + description: 'Title for the modal to import a course into a library.', + }, + importCourseButton: { + id: 'course-authoring.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: 'course-authoring.library-authoring.import-course.stepper.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: 'course-authoring.library-authoring.import-course.stepper.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: 'course-authoring.library-authoring.import-course.stepper.cancel.text', + defaultMessage: 'Cancel', + description: 'Label of the button to cancel the course import.', + }, + importCourseNext: { + id: 'course-authoring.library-authoring.import-course.stepper.next.text', + defaultMessage: 'Next step', + description: 'Label of the button go to the next step in the course import modal.', + }, + importCourseBack: { + id: 'course-authoring.library-authoring.import-course.stepper.back.text', + defaultMessage: 'Back', + description: 'Label of the button to go to the previous step in the course import modal.', + }, + importCourseInProgressStatusTitle: { + id: 'course-authoring.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: 'course-authoring.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: 'course-authoring.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: 'course-authoring.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: 'course-authoring.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: 'course-authoring.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: 'course-authoring.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: 'course-authoring.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: 'course-authoring.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: 'course-authoring.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.', + }, + previouslyImported: { + id: 'course-authoring.library-authoring.import-course.course-list.card.previously-imported.text', + defaultMessage: 'Previously Imported', + description: 'Chip that indicates that the course has been previously imported.', + }, }); export default messages; diff --git a/src/library-authoring/import-course/stepper/ImportStepperPage.test.tsx b/src/library-authoring/import-course/stepper/ImportStepperPage.test.tsx new file mode 100644 index 000000000..53b7ffbb4 --- /dev/null +++ b/src/library-authoring/import-course/stepper/ImportStepperPage.test.tsx @@ -0,0 +1,156 @@ +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 { getCourseDetailsApiUrl } from '@src/course-outline/data/api'; +import { LibraryProvider } from '@src/library-authoring/common/context/LibraryContext'; +import { mockContentLibrary, mockGetMigrationInfo } from '@src/library-authoring/data/api.mocks'; +import { ImportStepperPage } from './ImportStepperPage'; + +let axiosMock; +mockGetMigrationInfo.applyMock(); +mockContentLibrary.applyMock(); +type StudioHomeState = DeprecatedReduxState['studioHome']; + +const libraryKey = mockContentLibrary.libraryId; +const numPages = 1; +const coursesCount = studioHomeMock.courses.length; + +const mockNavigate = jest.fn(); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useNavigate: () => mockNavigate, +})); + +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( + , + { + extraWrapper: ({ children }: { children: React.ReactNode }) => ( + + {children} + + ), + path: '/libraries/:libraryId/import/course', + params: { libraryId: libraryKey }, + }, + ), + 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(mockNavigate).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/import-course/stepper/ImportStepperPage.tsx b/src/library-authoring/import-course/stepper/ImportStepperPage.tsx new file mode 100644 index 000000000..0cecc44e4 --- /dev/null +++ b/src/library-authoring/import-course/stepper/ImportStepperPage.tsx @@ -0,0 +1,158 @@ +import { useMemo, useState } from 'react'; +import { Helmet } from 'react-helmet'; +import { useNavigate } from 'react-router-dom'; +import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; +import { + ActionRow, Button, Chip, Container, Layout, Stepper, +} from '@openedx/paragon'; + +import { CoursesList, MigrationStatusProps } from '@src/studio-home/tabs-section/courses-tab'; +import { useStudioHome } from '@src/studio-home/hooks'; +import { useLibraryContext } from '@src/library-authoring/common/context/LibraryContext'; +import Loading from '@src/generic/Loading'; + +import Header from '@src/header'; +import SubHeader from '@src/generic/sub-header/SubHeader'; +import { useMigrationInfo } from '@src/library-authoring/data/apiHooks'; +import { ReviewImportDetails } from './ReviewImportDetails'; +import messages from '../messages'; +import { HelpSidebar } from '../HelpSidebar'; + +type MigrationStep = 'select-course' | 'review-details'; + +export const MigrationStatus = ({ + courseId, + allVisibleCourseIds, +}: MigrationStatusProps) => { + const { libraryId } = useLibraryContext(); + + const { + data: migrationInfoData, + } = useMigrationInfo(allVisibleCourseIds); + + 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 isPreviouslyMigrated = ( + courseId in processedMigrationInfo && processedMigrationInfo[courseId].includes(libraryId) + ); + + if (!isPreviouslyMigrated) { + return null; + } + + return ( +
+ + + +
+ ); +}; + +export const ImportStepperPage = () => { + const intl = useIntl(); + const navigate = useNavigate(); + const [currentStep, setCurrentStep] = useState('select-course'); + const [selectedCourseId, setSelectedCourseId] = useState(); + const { libraryId, libraryData, readOnly } = useLibraryContext(); + + // Load the courses list + // The loading state is handled in `CoursesList` + useStudioHome(); + + if (!libraryData) { + return ; + } + + return ( +
+
+ + {libraryData.title} | {process.env.SITE_NAME} + +
+ +
+ +
+ + + + + + + + + + + +
+ {currentStep === 'select-course' ? ( + + + + + ) : ( + + + + + )} +
+
+ + + +
+
+
+
+ ); +}; diff --git a/src/library-authoring/import-course/stepper/ReviewImportDetails.tsx b/src/library-authoring/import-course/stepper/ReviewImportDetails.tsx new file mode 100644 index 000000000..b88f79db8 --- /dev/null +++ b/src/library-authoring/import-course/stepper/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/import-course/stepper/SummaryCard.tsx b/src/library-authoring/import-course/stepper/SummaryCard.tsx new file mode 100644 index 000000000..4b3c29057 --- /dev/null +++ b/src/library-authoring/import-course/stepper/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/routes.ts b/src/library-authoring/routes.ts index e052c6fc4..99a8c72c3 100644 --- a/src/library-authoring/routes.ts +++ b/src/library-authoring/routes.ts @@ -49,6 +49,8 @@ export const ROUTES = { BACKUP: '/backup', // LibraryImportPage route: IMPORT: '/import', + // ImportStepperPage route: + IMPORT_COURSE: '/import/courses', }; export enum ContentType { diff --git a/src/studio-home/card-item/CardItem.test.tsx b/src/studio-home/card-item/CardItem.test.tsx index b033f789d..8fd948e38 100644 --- a/src/studio-home/card-item/CardItem.test.tsx +++ b/src/studio-home/card-item/CardItem.test.tsx @@ -10,7 +10,7 @@ import { import studioHomeMock from '@src/studio-home/__mocks__/studioHomeMock'; import messages from '../messages'; import { trimSlashes } from './utils'; -import CardItem from '.'; +import { CardItem } from '.'; jest.spyOn(reactRedux, 'useSelector').mockImplementation(() => studioHomeMock); diff --git a/src/studio-home/card-item/index.tsx b/src/studio-home/card-item/index.tsx index 699d9164a..bdfea4026 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, @@ -8,19 +10,18 @@ import { IconButton, Stack, } from '@openedx/paragon'; -import { AccessTime, ArrowForward, MoreHoriz } from '@openedx/paragon/icons'; -import { useIntl } from '@edx/frontend-platform/i18n'; +import { ArrowForward, MoreHoriz } from '@openedx/paragon/icons'; +import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; import { getConfig } from '@edx/frontend-platform'; import { Link } from 'react-router-dom'; import { useWaffleFlags } from '@src/data/apiHooks'; import { COURSE_CREATOR_STATES } from '@src/constants'; -import { parseLibraryKey } from '@src/generic/key-utils'; import classNames from 'classnames'; import { getStudioHomeData } from '../data/selectors'; import messages from '../messages'; -const PrevToNextName = ({ from, to }: { from: React.ReactNode, to?: React.ReactNode }) => ( +export const PrevToNextName = ({ from, to }: { from: React.ReactNode, to?: React.ReactNode }) => ( {from} {to @@ -33,7 +34,7 @@ const PrevToNextName = ({ from, to }: { from: React.ReactNode, to?: React.ReactN ); -const MakeLinkOrSpan = ({ +export const MakeLinkOrSpan = ({ when, to, children, className, }: { when: boolean, @@ -52,10 +53,8 @@ interface CardTitleProps { selectMode?: 'single' | 'multiple'; destinationUrl: string; title: string; + secondaryLink?: ReactElement | null; itemId?: string; - isMigrated?: boolean; - migratedToKey?: string; - migratedToTitle?: string; } const CardTitle: React.FC = ({ @@ -63,10 +62,8 @@ const CardTitle: React.FC = ({ selectMode, destinationUrl, title, + secondaryLink, itemId, - isMigrated, - migratedToTitle, - migratedToKey, }) => { const getTitle = useCallback(() => (
@@ -80,24 +77,12 @@ const CardTitle: React.FC = ({ {title} )} - to={ - isMigrated && migratedToTitle && ( - - {migratedToTitle} - - ) - } + to={secondaryLink} />
), [ readOnlyItem, - isMigrated, destinationUrl, - migratedToTitle, title, selectMode, ]); @@ -128,8 +113,78 @@ 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, + title, + selectMode, +}: { + itemId: string, + title: string, + selectMode: 'single' | 'multiple'; +}) => { + if (selectMode === 'single') { + return ( + + ); + } + + // Multiple + return ( + + ); +}; + interface BaseProps { displayName: string; + onClick?: () => void; org: string; number: string; run?: string; @@ -137,11 +192,12 @@ interface BaseProps { rerunLink?: string | null; courseKey?: string; isLibraries?: boolean; - isMigrated?: boolean; - migratedToKey?: string; - migratedToTitle?: string; - migratedToCollectionKey?: string | null; + subtitleWrapper?: ((subtitle: JSX.Element) => ReactElement) | null; // Wrapper for the default subtitle element + subtitleBeforeWidget?: ReactElement | null; // Adds a widget before the default subtitle element + cardStatusWidget?: ReactElement | null; + titleSecondaryLink?: ReactElement | null; selectMode?: 'single' | 'multiple'; + selectPosition?: 'card' | 'title'; isSelected?: boolean; itemId?: string; scrollIntoView?: boolean; @@ -160,8 +216,9 @@ type Props = BaseProps & ( /** * A card on the Studio home page that represents a Course or a Library */ -const CardItem: React.FC = ({ +export const CardItem: React.FC = ({ displayName, + onClick, lmsLink = '', rerunLink = '', org, @@ -170,17 +227,17 @@ const CardItem: React.FC = ({ isLibraries = false, courseKey = '', selectMode, + selectPosition, isSelected = false, itemId = '', path, url, - isMigrated = false, - migratedToKey, - migratedToTitle, - migratedToCollectionKey, + subtitleWrapper, + subtitleBeforeWidget, + titleSecondaryLink, + cardStatusWidget, scrollIntoView = false, }) => { - const intl = useIntl(); const { allowCourseReruns, courseCreatorStatus, @@ -195,7 +252,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; @@ -203,25 +260,19 @@ const CardItem: React.FC = ({ const getSubtitle = useCallback(() => { let subtitle = isLibraries ? <>{org} / {number} : <>{org} / {number} / {run}; - if (isMigrated && migratedToKey) { - const migratedToKeyObj = parseLibraryKey(migratedToKey); + if (subtitleWrapper) { + subtitle = subtitleWrapper(subtitle); + } + if (subtitleBeforeWidget) { subtitle = ( - {migratedToKeyObj.org} / {migratedToKeyObj.lib}} - /> + + {subtitleBeforeWidget} + {subtitle} + ); } return subtitle; - }, [isLibraries, org, number, run, migratedToKey, isMigrated]); - - const collectionLink = () => { - let libUrl = `/library/${migratedToKey}`; - if (migratedToCollectionKey) { - libUrl += `/collection/${migratedToCollectionKey}`; - } - return libUrl; - }; + }, [isLibraries, org, number, run]); useEffect(() => { /* istanbul ignore next */ @@ -232,70 +283,46 @@ const CardItem: React.FC = ({ return (
- )} subtitle={getSubtitle()} - actions={showActions && ( - - + ) : ( + - - {isShowRerunLink && ( - - {messages.btnReRunText.defaultMessage} - - )} - - {intl.formatMessage(messages.viewLiveBtnText)} - - - )} /> - {isMigrated && migratedToKey - && ( + {cardStatusWidget && ( - - - {intl.formatMessage(messages.libraryMigrationStatusText)} - - - {migratedToTitle} - - - + {cardStatusWidget} - )} + )}
); }; - -export default CardItem; diff --git a/src/studio-home/data/api.ts b/src/studio-home/data/api.ts index c4e6c7738..c86769075 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'; diff --git a/src/studio-home/messages.ts b/src/studio-home/messages.ts index a4de63ae7..52978fbee 100644 --- a/src/studio-home/messages.ts +++ b/src/studio-home/messages.ts @@ -73,11 +73,6 @@ const messages = defineMessages({ id: 'course-authoring.studio-home.organization.input.no-options', defaultMessage: 'No options', }, - libraryMigrationStatusText: { - id: 'course-authoring.studio-home.library-v1.card.status', - description: 'Status text in v1 library card in studio informing user of its migration status', - defaultMessage: 'Previously migrated library. Any problem bank links were already moved to', - }, }); export default messages; diff --git a/src/studio-home/scss/StudioHome.scss b/src/studio-home/scss/StudioHome.scss index bf0deb79d..21af67bd9 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 da6d5f741..5a889826e 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 8a069357a..7334f302c 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 214b4e611..d14c36c17 100644 --- a/src/studio-home/tabs-section/courses-tab/index.tsx +++ b/src/studio-home/tabs-section/courses-tab/index.tsx @@ -1,67 +1,179 @@ import React 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, } 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'; +import { CardItem } from '@src/studio-home/card-item'; import CollapsibleStateWithAction from '@src/studio-home/collapsible-state-with-action'; 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 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; - isLoading: boolean; - isFailed: boolean; - numPages: number; - coursesCount: number; +export interface MigrationStatusProps { + courseId: string; + allVisibleCourseIds: string[]; } -const CoursesTab: React.FC = ({ - coursesDataItems, - showNewCourseContainer, - onClickNewCourse, - isShowProcessing, +interface CardListProps { + currentPage: number; + handlePageSelected: (page: any) => void; + handleCleanFilters: () => void; + onClickCard?: (courseId: string) => void; + isLoading: boolean; + isFiltered: boolean; + hasAbilityToCreateCourse?: boolean; + showNewCourseContainer?: boolean; + onClickNewCourse?: () => void; + inSelectMode?: boolean; + selectedCourseId?: string; + migrationStatusWidget?: React.ComponentType; +} + +const CardList = ({ + currentPage, + handlePageSelected, + handleCleanFilters, + onClickCard, isLoading, - isFailed, - numPages = 0, - coursesCount = 0, + isFiltered, + hasAbilityToCreateCourse = false, + showNewCourseContainer = false, + onClickNewCourse = () => {}, + inSelectMode = false, + selectedCourseId, + migrationStatusWidget, +}: CardListProps) => { + const { + courses, + numPages, + optimizationEnabled, + } = useSelector(getStudioHomeData); + + const isNotFilteringCourses = !isFiltered && !isLoading; + const hasCourses = courses?.length > 0; + const MigrationStatusWidget = migrationStatusWidget; + + 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} + subtitleBeforeWidget={MigrationStatusWidget && ( + item.courseKey) || []} + /> + )} + /> + ), + )} + + {numPages > 1 && ( + + )} + + ) : (!optimizationEnabled && isNotFilteringCourses && ( + + ) + )} + + {isFiltered && !hasCourses && !isLoading && ( + + + + +

+ +

+ +
+ )} + + ); +}; + +interface Props { + showNewCourseContainer?: boolean; + onClickNewCourse?: () => void; + isShowProcessing?: boolean; + selectedCourseId?: string; + handleSelect?: (courseId: string) => void; + cardMigrationStatusWidget?: React.ComponentType; +} + +export const CoursesList: React.FC = ({ + showNewCourseContainer = false, + onClickNewCourse = () => {}, + isShowProcessing = false, + selectedCourseId, + handleSelect, + cardMigrationStatusWidget, }) => { 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 +184,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 +212,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 +238,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 129eccf33..9a07c7c2c 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 6fcfa7324..06e91f345 100644 --- a/src/studio-home/tabs-section/libraries-tab/index.tsx +++ b/src/studio-home/tabs-section/libraries-tab/index.tsx @@ -2,15 +2,17 @@ import { useCallback, useState } from 'react'; import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; import { ActionRow, Form, Icon, Menu, MenuItem, Pagination, Row, SearchField, + Stack, } from '@openedx/paragon'; -import { Error, FilterList } from '@openedx/paragon/icons'; +import { Error, FilterList, AccessTime } from '@openedx/paragon/icons'; import { LoadingSpinner } from '@src/generic/Loading'; import AlertMessage from '@src/generic/alert-message'; import { useLibrariesV1Data } from '@src/studio-home/data/apiHooks'; -import CardItem from '@src/studio-home/card-item'; +import { CardItem, MakeLinkOrSpan, PrevToNextName } from '@src/studio-home/card-item'; import SearchFilterWidget from '@src/search-manager/SearchFilterWidget'; import type { LibraryV1Data } from '@src/studio-home/data/api'; +import { parseLibraryKey } from '@src/generic/key-utils'; import messages from '../messages'; import { MigrateLegacyLibrariesAlert } from './MigrateLegacyLibrariesAlert'; @@ -37,23 +39,64 @@ const CardList = ({ migratedToTitle, migratedToCollectionKey, libraryKey, - }) => ( - - )) + }) => { + const collectionLink = () => { + let libUrl = `/library/${migratedToKey}`; + if (migratedToCollectionKey) { + libUrl += `/collection/${migratedToCollectionKey}`; + } + return libUrl; + }; + + const migratedToKeyObj = migratedToKey ? parseLibraryKey(migratedToKey) : undefined; + + const subtitleWrapper = (subtitle) => ( + {migratedToKeyObj?.org} / {migratedToKeyObj?.lib}} + /> + ); + + return ( + + {migratedToTitle} + + ) : null} + cardStatusWidget={(isMigrated && migratedToKey) ? ( + + + + + + {migratedToTitle} + + + + ) : null} + /> + ); + }) } ); 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 f09e9115b..0f3b0c957 100644 --- a/src/studio-home/tabs-section/libraries-v2-tab/index.tsx +++ b/src/studio-home/tabs-section/libraries-v2-tab/index.tsx @@ -17,13 +17,13 @@ import { LoadingSpinner } from '@src/generic/Loading'; import AlertMessage from '@src/generic/alert-message'; import type { ContentLibrary, LibrariesV2Response } from '@src/library-authoring/data/api'; -import CardItem from '../../card-item'; +import { CardItem } from '../../card-item'; import messages from '../messages'; 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 = ({ >