From c5fdeed32f87e6d93a1397d9bf968f02f4e1b4a5 Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Thu, 23 Oct 2025 19:09:22 -0500 Subject: [PATCH 01/14] feat: Import course modal created --- .env | 1 + .env.development | 1 + .env.test | 1 + src/index.jsx | 1 + .../LibraryAuthoringPage.tsx | 11 +++ .../course-import/ImportStepperModal.tsx | 76 +++++++++++++++++++ .../course-import/messages.ts | 41 ++++++++++ 7 files changed, 132 insertions(+) create mode 100644 src/library-authoring/course-import/ImportStepperModal.tsx create mode 100644 src/library-authoring/course-import/messages.ts 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/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/library-authoring/LibraryAuthoringPage.tsx b/src/library-authoring/LibraryAuthoringPage.tsx index 4f5cde68e3..5bedcb2050 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'); @@ -355,6 +360,11 @@ const LibraryAuthoringPage = ({ extraFilter={extraFilter} overrideTypesFilter={overrideTypesFilter} > + {getConfig().ENABLE_COURSE_IMPORT_IN_LIBRARY === 'true' && ( + + )} } subtitle={!componentPickerMode ? intl.formatMessage(messages.headingSubtitle) : undefined} @@ -398,6 +408,7 @@ const LibraryAuthoringPage = ({ )} + ); }; diff --git a/src/library-authoring/course-import/ImportStepperModal.tsx b/src/library-authoring/course-import/ImportStepperModal.tsx new file mode 100644 index 0000000000..e7aedf72ec --- /dev/null +++ b/src/library-authoring/course-import/ImportStepperModal.tsx @@ -0,0 +1,76 @@ +import { useState } from 'react'; +import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; +import { + ActionRow, Button, ModalDialog, Stepper, +} from '@openedx/paragon'; + +import messages from './messages'; + +type MigrationStep = 'select-course' | 'review-details'; + +export const ImportStepperModal = ({ + isOpen, + onClose, +}: { + isOpen: boolean, + onClose: () => void, +}) => { + const intl = useIntl(); + const [currentStep, setCurrentStep] = useState('select-course'); + + return ( + + + + + + + + + + + 1 + + + 2 + + + + + {currentStep === 'select-course' ? ( + + + + + + + ) : ( + + + + + )} + + + ); +}; diff --git a/src/library-authoring/course-import/messages.ts b/src/library-authoring/course-import/messages.ts new file mode 100644 index 0000000000..b005e2bf9f --- /dev/null +++ b/src/library-authoring/course-import/messages.ts @@ -0,0 +1,41 @@ +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.', + }, +}); + +export default messages; From 0b2bfdaaf19fc2d6ded9b1594020b6e2fae7a480 Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Thu, 23 Oct 2025 20:46:00 -0500 Subject: [PATCH 02/14] feat: Step 2 of course migration created --- .../course-import/ImportStepperModal.tsx | 3 +- .../course-import/ReviewImportDetails.tsx | 64 +++++++++++++++++++ .../course-import/messages.ts | 51 +++++++++++++++ 3 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 src/library-authoring/course-import/ReviewImportDetails.tsx diff --git a/src/library-authoring/course-import/ImportStepperModal.tsx b/src/library-authoring/course-import/ImportStepperModal.tsx index e7aedf72ec..8e34ddd25a 100644 --- a/src/library-authoring/course-import/ImportStepperModal.tsx +++ b/src/library-authoring/course-import/ImportStepperModal.tsx @@ -4,6 +4,7 @@ import { ActionRow, Button, ModalDialog, Stepper, } from '@openedx/paragon'; +import { ReviewImportDetails } from './ReviewImportDetails'; import messages from './messages'; type MigrationStep = 'select-course' | 'review-details'; @@ -46,7 +47,7 @@ export const ImportStepperModal = ({ eventKey="review-details" title={intl.formatMessage(messages.importCourseReviewDetailsStep)} > - 2 + diff --git a/src/library-authoring/course-import/ReviewImportDetails.tsx b/src/library-authoring/course-import/ReviewImportDetails.tsx new file mode 100644 index 0000000000..fcd1f7fad0 --- /dev/null +++ b/src/library-authoring/course-import/ReviewImportDetails.tsx @@ -0,0 +1,64 @@ +import { FormattedMessage } from '@edx/frontend-platform/i18n'; +import { Card, Icon, Stack } from '@openedx/paragon'; +import { LoadingSpinner } from '@src/generic/Loading'; +import { getItemIcon } from '@src/generic/block-type-utils'; +import { Widgets } from '@openedx/paragon/icons'; +import messages from './messages'; + +export const ReviewImportDetails = () => ( + + + +

+

+
+
+

+ + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

+ + + + + + + +); diff --git a/src/library-authoring/course-import/messages.ts b/src/library-authoring/course-import/messages.ts index b005e2bf9f..96e67047f2 100644 --- a/src/library-authoring/course-import/messages.ts +++ b/src/library-authoring/course-import/messages.ts @@ -36,6 +36,57 @@ const messages = defineMessages({ 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; From 2437085e42899caebd8b742104168bc256c671d2 Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Fri, 24 Oct 2025 18:06:04 -0500 Subject: [PATCH 03/14] refactor: Course List to enable select mode --- src/legacy-libraries-migration/index.scss | 4 - .../course-import/ImportStepperModal.tsx | 7 +- src/studio-home/card-item/index.tsx | 118 +++++++-- src/studio-home/scss/StudioHome.scss | 4 + .../tabs-section/courses-tab/index.test.tsx | 67 +++-- .../tabs-section/courses-tab/index.tsx | 241 +++++++++++------- src/studio-home/tabs-section/index.tsx | 42 +-- .../tabs-section/libraries-tab/index.tsx | 1 + .../tabs-section/libraries-v2-tab/index.tsx | 9 +- 9 files changed, 319 insertions(+), 174 deletions(-) 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/course-import/ImportStepperModal.tsx b/src/library-authoring/course-import/ImportStepperModal.tsx index 8e34ddd25a..c82fd3a53e 100644 --- a/src/library-authoring/course-import/ImportStepperModal.tsx +++ b/src/library-authoring/course-import/ImportStepperModal.tsx @@ -4,6 +4,7 @@ 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'; @@ -18,6 +19,7 @@ export const ImportStepperModal = ({ }) => { const intl = useIntl(); const [currentStep, setCurrentStep] = useState('select-course'); + const [selectedCourseId, setSelectedCourseId] = useState(); return ( - 1 + = ({ 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; @@ -142,6 +209,7 @@ interface BaseProps { migratedToTitle?: string; migratedToCollectionKey?: string | null; selectMode?: 'single' | 'multiple'; + selectPosition?: 'card' | 'title'; isSelected?: boolean; itemId?: string; scrollIntoView?: boolean; @@ -162,6 +230,7 @@ type Props = BaseProps & ( */ const CardItem: React.FC = ({ displayName, + onClick, lmsLink = '', rerunLink = '', org, @@ -170,6 +239,7 @@ const CardItem: React.FC = ({ isLibraries = false, courseKey = '', selectMode, + selectPosition, isSelected = false, itemId = '', path, @@ -195,7 +265,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; @@ -232,16 +302,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/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.test.tsx b/src/studio-home/tabs-section/courses-tab/index.test.tsx index 8a069357ad..e2efbb75c7 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,25 +70,41 @@ 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(); }); @@ -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..a3716f4e80 100644 --- a/src/studio-home/tabs-section/courses-tab/index.tsx +++ b/src/studio-home/tabs-section/courses-tab/index.tsx @@ -1,18 +1,19 @@ 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'; @@ -20,48 +21,144 @@ 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 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; } -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, +}: CardListProps) => { + const { + courses, + numPages, + optimizationEnabled, + } = useSelector(getStudioHomeData); + + const isNotFilteringCourses = !isFiltered && !isLoading; + const hasCourses = courses?.length > 0; + + 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} + /> + ), + )} + + {numPages > 1 && ( + + )} + + ) : (!optimizationEnabled && isNotFilteringCourses && ( + + ) + )} + + {isFiltered && !hasCourses && !isLoading && ( + + + + +

+ +

+ +
+ )} + + ); +}; + +interface Props { + showNewCourseContainer?: boolean; + onClickNewCourse?: () => void; + isShowProcessing?: boolean; + selectedCourseId?: string; + handleSelect?: (courseId: string) => void; +} + +export const CoursesList: React.FC = ({ + showNewCourseContainer = false, + onClickNewCourse = () => {}, + isShowProcessing = false, + selectedCourseId, + handleSelect, }) => { 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 +169,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 +197,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 +223,41 @@ 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 = ({ > Date: Mon, 27 Oct 2025 16:03:17 -0500 Subject: [PATCH 04/14] feat: Get course title from course details --- src/course-outline/data/api.ts | 18 ++- src/course-outline/data/apiHooks.ts | 12 +- src/course-outline/data/types.ts | 9 ++ .../course-import/ImportStepperModal.tsx | 2 +- .../course-import/ReviewImportDetails.tsx | 116 ++++++++++-------- 5 files changed, 103 insertions(+), 54 deletions(-) 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..978fdc8b79 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 { 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,11 @@ export const useCourseItemData = (itemId?: string, enabled: boolean = true) => ( enabled: enabled && itemId !== undefined, }) ); + +export const useCourseDetails = (courseId?: string) => ( + useQuery({ + queryKey: courseOutlineQueryKeys.courseDetails(courseId), + queryFn: () => getCourseDetails(courseId!), + enabled: courseId !== undefined, + }) +); diff --git a/src/course-outline/data/types.ts b/src/course-outline/data/types.ts index 7937a45cf4..fb7d73c109 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 { + course_id: string; + title: string; + subtitle?: string; + org: string; + description?: string; +} + export interface CourseOutlineState { loadingStatus: { outlineIndexLoadingStatus: string; diff --git a/src/library-authoring/course-import/ImportStepperModal.tsx b/src/library-authoring/course-import/ImportStepperModal.tsx index c82fd3a53e..92079095d0 100644 --- a/src/library-authoring/course-import/ImportStepperModal.tsx +++ b/src/library-authoring/course-import/ImportStepperModal.tsx @@ -52,7 +52,7 @@ export const ImportStepperModal = ({ eventKey="review-details" title={intl.formatMessage(messages.importCourseReviewDetailsStep)} > - +
diff --git a/src/library-authoring/course-import/ReviewImportDetails.tsx b/src/library-authoring/course-import/ReviewImportDetails.tsx index fcd1f7fad0..04c41b1303 100644 --- a/src/library-authoring/course-import/ReviewImportDetails.tsx +++ b/src/library-authoring/course-import/ReviewImportDetails.tsx @@ -3,62 +3,80 @@ import { Card, Icon, Stack } from '@openedx/paragon'; import { LoadingSpinner } from '@src/generic/Loading'; import { getItemIcon } from '@src/generic/block-type-utils'; import { Widgets } from '@openedx/paragon/icons'; +import { useCourseDetails } from '@src/course-outline/data/apiHooks'; import messages from './messages'; -export const ReviewImportDetails = () => ( - - - -

-

-
-
-

- - - - - +export const ReviewImportDetails = ({ courseId }: { courseId?: string }) => { + const { data } = useCourseDetails(courseId); + + return ( + + + {data ? ( + +

+

+ +

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

+ + + + + - - - - - - +
+ + + + + + - - - - - - + + + + + + - - - - - - + + + + + + + + + + + + + + + +

+ + + + - - -

- - - - - - -
-); + +
+ ); +}; From a6904e19f049ef2ba935ee1a37698a4fbc6c36a3 Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Wed, 29 Oct 2025 20:18:43 -0500 Subject: [PATCH 05/14] feat: Add previously migration chip --- .../LibraryAuthoringPage.tsx | 6 ++- .../course-import/ImportStepperModal.tsx | 3 ++ src/studio-home/card-item/index.tsx | 14 ++++++- src/studio-home/data/api.ts | 22 +++++++++- src/studio-home/data/apiHooks.ts | 11 ++++- .../tabs-section/courses-tab/index.scss | 7 ++++ .../tabs-section/courses-tab/index.test.tsx | 4 +- .../tabs-section/courses-tab/index.tsx | 42 ++++++++++++++++++- src/studio-home/tabs-section/messages.ts | 5 +++ 9 files changed, 106 insertions(+), 8 deletions(-) diff --git a/src/library-authoring/LibraryAuthoringPage.tsx b/src/library-authoring/LibraryAuthoringPage.tsx index 5bedcb2050..c1b8064962 100644 --- a/src/library-authoring/LibraryAuthoringPage.tsx +++ b/src/library-authoring/LibraryAuthoringPage.tsx @@ -408,7 +408,11 @@ const LibraryAuthoringPage = ({
)} - +
); }; diff --git a/src/library-authoring/course-import/ImportStepperModal.tsx b/src/library-authoring/course-import/ImportStepperModal.tsx index 92079095d0..3c7c73a501 100644 --- a/src/library-authoring/course-import/ImportStepperModal.tsx +++ b/src/library-authoring/course-import/ImportStepperModal.tsx @@ -11,9 +11,11 @@ import messages from './messages'; type MigrationStep = 'select-course' | 'review-details'; export const ImportStepperModal = ({ + libraryKey, isOpen, onClose, }: { + libraryKey: string, isOpen: boolean, onClose: () => void, }) => { @@ -46,6 +48,7 @@ export const ImportStepperModal = ({ = ({ migratedToKey, migratedToTitle, migratedToCollectionKey, + subtitleBeforeComponent, scrollIntoView = false, }) => { const intl = useIntl(); @@ -282,6 +286,14 @@ const CardItem: React.FC = ({ /> ); } + if (subtitleBeforeComponent) { + subtitle = ( + + {subtitleBeforeComponent} + {subtitle} + + ); + } return subtitle; }, [isLibraries, org, number, run, migratedToKey, isMigrated]); 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..96a28f5c27 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 { 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,11 @@ export const useLibrariesV1Data = (enabled: boolean = true) => ( enabled, }) ); + +export const useMigrationInfo = (sourcesKeys: string[], enabled: boolean = true) => ( + useQuery({ + queryKey: studioHomeQueryKeys.migrationInfo(sourcesKeys), + queryFn: () => getMigrationInfo(sourcesKeys), + enabled, + }) +); 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 e2efbb75c7..7334f302c5 100644 --- a/src/studio-home/tabs-section/courses-tab/index.test.tsx +++ b/src/studio-home/tabs-section/courses-tab/index.test.tsx @@ -109,7 +109,7 @@ describe('', () => { 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: { @@ -121,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(); }); diff --git a/src/studio-home/tabs-section/courses-tab/index.tsx b/src/studio-home/tabs-section/courses-tab/index.tsx index a3716f4e80..8b41075856 100644 --- a/src/studio-home/tabs-section/courses-tab/index.tsx +++ b/src/studio-home/tabs-section/courses-tab/index.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useCallback, useMemo } from 'react'; import { useLocation } from 'react-router-dom'; import { useDispatch, useSelector } from 'react-redux'; import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; @@ -9,6 +9,7 @@ import { Alert, Button, Form, + Chip, } from '@openedx/paragon'; import { Error } from '@openedx/paragon/icons'; @@ -22,6 +23,7 @@ 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'; @@ -39,6 +41,7 @@ interface CardListProps { onClickNewCourse?: () => void; inSelectMode?: boolean; selectedCourseId?: string; + currentLibraryId?: string; } const CardList = ({ @@ -53,6 +56,7 @@ const CardList = ({ onClickNewCourse = () => {}, inSelectMode = false, selectedCourseId, + currentLibraryId, }: CardListProps) => { const { courses, @@ -60,9 +64,29 @@ const CardList = ({ optimizationEnabled, } = useSelector(getStudioHomeData); + const { + data: migrationInfoData, + } = useMigrationInfo(courses?.map(item => item.courseKey) || [], true); + + 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 ? ( @@ -79,7 +103,8 @@ const CardList = ({ url, }) => ( onClickCard?.(courseKey)} itemId={courseKey} @@ -93,6 +118,16 @@ const CardList = ({ selectMode={inSelectMode ? 'single' : undefined} selectPosition={inSelectMode ? 'card' : undefined} isSelected={inSelectMode && selectedCourseId === courseKey} + subtitleBeforeComponent={isPreviouslyMigrated(courseKey) && ( +
+ + + +
+ )} /> ), )} @@ -139,6 +174,7 @@ interface Props { isShowProcessing?: boolean; selectedCourseId?: string; handleSelect?: (courseId: string) => void; + currentLibraryId?: string; } export const CoursesList: React.FC = ({ @@ -147,6 +183,7 @@ export const CoursesList: React.FC = ({ isShowProcessing = false, selectedCourseId, handleSelect, + currentLibraryId, }) => { const dispatch = useDispatch(); const intl = useIntl(); @@ -243,6 +280,7 @@ export const CoursesList: React.FC = ({ isFiltered={isFiltered || false} inSelectMode selectedCourseId={selectedCourseId} + currentLibraryId={currentLibraryId} /> ) : ( diff --git a/src/studio-home/tabs-section/messages.ts b/src/studio-home/tabs-section/messages.ts index d69b196eb3..3d40d1abb6 100644 --- a/src/studio-home/tabs-section/messages.ts +++ b/src/studio-home/tabs-section/messages.ts @@ -136,6 +136,11 @@ const messages = defineMessages({ defaultMessage: 'Select All', description: 'Button to select all libraries when migrate legacy libraries.', }, + previouslyImported: { + id: 'studio-home.course-list.card.previously-imported.text', + defaultMessage: 'Previously Imported', + description: 'Chip that indicates that the course has been previously imported.', + }, }); export default messages; From 49443824902c4c03fd72b4f8d36621f7f7bbb3cf Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Fri, 31 Oct 2025 09:30:50 -0500 Subject: [PATCH 06/14] test: ImpoertStepperModal.test added --- src/course-outline/data/types.ts | 2 +- .../course-import/ImportStepperModal.test.tsx | 144 ++++++++++++++++++ .../course-import/ImportStepperModal.tsx | 5 +- .../course-import/ReviewImportDetails.tsx | 4 +- src/studio-home/data/api.mocks.ts | 8 +- .../factories/mockApiResponses.tsx | 10 ++ .../tabs-section/courses-tab/index.tsx | 2 +- 7 files changed, 169 insertions(+), 6 deletions(-) create mode 100644 src/library-authoring/course-import/ImportStepperModal.test.tsx diff --git a/src/course-outline/data/types.ts b/src/course-outline/data/types.ts index fb7d73c109..a8e89d64b6 100644 --- a/src/course-outline/data/types.ts +++ b/src/course-outline/data/types.ts @@ -26,7 +26,7 @@ export interface CourseOutline { // TODO: This interface has only basic data, all the rest needs to be added. export interface CourseDetails { - course_id: string; + courseId: string; title: string; subtitle?: string; org: string; 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 index 3c7c73a501..4cd355fd3d 100644 --- a/src/library-authoring/course-import/ImportStepperModal.tsx +++ b/src/library-authoring/course-import/ImportStepperModal.tsx @@ -65,7 +65,10 @@ export const ImportStepperModal = ({ - diff --git a/src/library-authoring/course-import/ReviewImportDetails.tsx b/src/library-authoring/course-import/ReviewImportDetails.tsx index 04c41b1303..d93e4316aa 100644 --- a/src/library-authoring/course-import/ReviewImportDetails.tsx +++ b/src/library-authoring/course-import/ReviewImportDetails.tsx @@ -7,12 +7,12 @@ import { useCourseDetails } from '@src/course-outline/data/apiHooks'; import messages from './messages'; export const ReviewImportDetails = ({ courseId }: { courseId?: string }) => { - const { data } = useCourseDetails(courseId); + const { data, isPending } = useCourseDetails(courseId); return ( - {data ? ( + {data && !isPending ? (

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/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/tabs-section/courses-tab/index.tsx b/src/studio-home/tabs-section/courses-tab/index.tsx index 8b41075856..598d37c21b 100644 --- a/src/studio-home/tabs-section/courses-tab/index.tsx +++ b/src/studio-home/tabs-section/courses-tab/index.tsx @@ -66,7 +66,7 @@ const CardList = ({ const { data: migrationInfoData, - } = useMigrationInfo(courses?.map(item => item.courseKey) || [], true); + } = useMigrationInfo(courses?.map(item => item.courseKey) || [], currentLibraryId !== undefined); const processedMigrationInfo = useMemo(() => { const result = {}; From 404e44d2a6208761ce9246da1053af902bbf5112 Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Sun, 2 Nov 2025 15:39:22 -0500 Subject: [PATCH 07/14] refactor: Extract SymmaryCard --- .../course-import/ReviewImportDetails.tsx | 46 ++--------------- .../course-import/SummaryCard.tsx | 51 +++++++++++++++++++ 2 files changed, 55 insertions(+), 42 deletions(-) create mode 100644 src/library-authoring/course-import/SummaryCard.tsx diff --git a/src/library-authoring/course-import/ReviewImportDetails.tsx b/src/library-authoring/course-import/ReviewImportDetails.tsx index d93e4316aa..dbbb13261f 100644 --- a/src/library-authoring/course-import/ReviewImportDetails.tsx +++ b/src/library-authoring/course-import/ReviewImportDetails.tsx @@ -1,10 +1,10 @@ import { FormattedMessage } from '@edx/frontend-platform/i18n'; -import { Card, Icon, Stack } from '@openedx/paragon'; +import { Card, Stack } from '@openedx/paragon'; import { LoadingSpinner } from '@src/generic/Loading'; -import { getItemIcon } from '@src/generic/block-type-utils'; -import { Widgets } from '@openedx/paragon/icons'; 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); @@ -31,45 +31,7 @@ export const ReviewImportDetails = ({ courseId }: { courseId?: string }) => { )}

- - - - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +

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 = () => ( + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +); From 4e1c291ce0be7d92f048e46fa0339222228a9632 Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Tue, 4 Nov 2025 12:38:49 -0500 Subject: [PATCH 08/14] style: Nits on the code --- src/course-outline/data/apiHooks.ts | 5 ++--- src/studio-home/data/apiHooks.ts | 5 ++--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/course-outline/data/apiHooks.ts b/src/course-outline/data/apiHooks.ts index 978fdc8b79..755a2b0b11 100644 --- a/src/course-outline/data/apiHooks.ts +++ b/src/course-outline/data/apiHooks.ts @@ -1,4 +1,4 @@ -import { useMutation, useQuery } from '@tanstack/react-query'; +import { skipToken, useMutation, useQuery } from '@tanstack/react-query'; import { createCourseXblock } from '@src/course-unit/data/api'; import { getCourseDetails, getCourseItem } from './api'; @@ -37,7 +37,6 @@ export const useCourseItemData = (itemId?: string, enabled: boolean = true) => ( export const useCourseDetails = (courseId?: string) => ( useQuery({ queryKey: courseOutlineQueryKeys.courseDetails(courseId), - queryFn: () => getCourseDetails(courseId!), - enabled: courseId !== undefined, + queryFn: courseId ? () => getCourseDetails(courseId) : skipToken, }) ); diff --git a/src/studio-home/data/apiHooks.ts b/src/studio-home/data/apiHooks.ts index 96a28f5c27..97c009dcbc 100644 --- a/src/studio-home/data/apiHooks.ts +++ b/src/studio-home/data/apiHooks.ts @@ -1,4 +1,4 @@ -import { useQuery } from '@tanstack/react-query'; +import { skipToken, useQuery } from '@tanstack/react-query'; import { getStudioHomeLibraries, getMigrationInfo } from './api'; export const studioHomeQueryKeys = { @@ -21,7 +21,6 @@ export const useLibrariesV1Data = (enabled: boolean = true) => ( export const useMigrationInfo = (sourcesKeys: string[], enabled: boolean = true) => ( useQuery({ queryKey: studioHomeQueryKeys.migrationInfo(sourcesKeys), - queryFn: () => getMigrationInfo(sourcesKeys), - enabled, + queryFn: enabled ? () => getMigrationInfo(sourcesKeys) : skipToken, }) ); From e05cc03afdef8157ce93d657f4d4be27b30870fa Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Fri, 7 Nov 2025 21:22:00 -0500 Subject: [PATCH 09/14] refactor: Move the Import Course button to the Import Home page --- .../LibraryAuthoringPage.tsx | 15 --- .../course-import/messages.ts | 92 -------------- .../import-course/CourseImportHomePage.tsx | 113 ++++++++++-------- .../import-course/messages.ts | 86 +++++++++++++ .../stepper}/ImportStepperModal.test.tsx | 0 .../stepper}/ImportStepperModal.tsx | 8 +- .../stepper}/ReviewImportDetails.tsx | 2 +- .../stepper}/SummaryCard.tsx | 2 +- 8 files changed, 160 insertions(+), 158 deletions(-) delete mode 100644 src/library-authoring/course-import/messages.ts rename src/library-authoring/{course-import => import-course/stepper}/ImportStepperModal.test.tsx (100%) rename src/library-authoring/{course-import => import-course/stepper}/ImportStepperModal.tsx (93%) rename src/library-authoring/{course-import => import-course/stepper}/ReviewImportDetails.tsx (97%) rename src/library-authoring/{course-import => import-course/stepper}/SummaryCard.tsx (98%) diff --git a/src/library-authoring/LibraryAuthoringPage.tsx b/src/library-authoring/LibraryAuthoringPage.tsx index c1b8064962..4f5cde68e3 100644 --- a/src/library-authoring/LibraryAuthoringPage.tsx +++ b/src/library-authoring/LibraryAuthoringPage.tsx @@ -7,7 +7,6 @@ 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 { @@ -20,7 +19,6 @@ import { Stack, Tab, Tabs, - useToggle, } from '@openedx/paragon'; import { Add, InfoOutline } from '@openedx/paragon/icons'; import { Link, useLocation, useNavigate } from 'react-router-dom'; @@ -51,10 +49,8 @@ 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(); @@ -151,7 +147,6 @@ 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'); @@ -360,11 +355,6 @@ const LibraryAuthoringPage = ({ extraFilter={extraFilter} overrideTypesFilter={overrideTypesFilter} > - {getConfig().ENABLE_COURSE_IMPORT_IN_LIBRARY === 'true' && ( - - )} } subtitle={!componentPickerMode ? intl.formatMessage(messages.headingSubtitle) : undefined} @@ -408,11 +398,6 @@ const LibraryAuthoringPage = ({
)} -
); }; diff --git a/src/library-authoring/course-import/messages.ts b/src/library-authoring/course-import/messages.ts deleted file mode 100644 index 96e67047f2..0000000000 --- a/src/library-authoring/course-import/messages.ts +++ /dev/null @@ -1,92 +0,0 @@ -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/library-authoring/import-course/CourseImportHomePage.tsx b/src/library-authoring/import-course/CourseImportHomePage.tsx index 041da431d8..ab83634426 100644 --- a/src/library-authoring/import-course/CourseImportHomePage.tsx +++ b/src/library-authoring/import-course/CourseImportHomePage.tsx @@ -1,13 +1,14 @@ +import { Helmet } from 'react-helmet'; import { Button, Card, Container, Layout, Stack, + useToggle, } 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'; @@ -17,6 +18,7 @@ import { useLibraryContext } from '../common/context/LibraryContext'; import { useCourseImports } from '../data/apiHooks'; import { HelpSidebar } from './HelpSidebar'; import { ImportedCourseCard } from './ImportedCourseCard'; +import { ImportStepperModal } from './stepper/ImportStepperModal'; import messages from './messages'; const EmptyState = () => ( @@ -35,6 +37,7 @@ const EmptyState = () => ( export const CourseImportHomePage = () => { const intl = useIntl(); const { libraryId, libraryData, readOnly } = useLibraryContext(); + const [importModalIsOpen, openImportModal, closeImportModal] = useToggle(false); const { data: courseImports } = useCourseImports(libraryId); if (!courseImports || !libraryData) { @@ -42,52 +45,66 @@ export const CourseImportHomePage = () => { } return ( -
-
- - {libraryData.title} | {process.env.SITE_NAME} - -
- -
- -
- - - {courseImports.length ? ( - -

- -

- {courseImports.map((courseImport) => ( - - ))} -
- ) : ()} -
- - - -
-
+ <> +
+
+ + {libraryData.title} | {process.env.SITE_NAME} + +
+ +
+ + {intl.formatMessage(messages.importCourseButton)} + + ) + } + /> +
+ + + {courseImports.length ? ( + +

+ +

+ {courseImports.map((courseImport) => ( + + ))} +
+ ) : ()} +
+ + + +
+
+
-
+ + ); }; diff --git a/src/library-authoring/import-course/messages.ts b/src/library-authoring/import-course/messages.ts index e6a85b951c..c3133eef54 100644 --- a/src/library-authoring/import-course/messages.ts +++ b/src/library-authoring/import-course/messages.ts @@ -73,6 +73,92 @@ const messages = defineMessages({ + '

For additional details you can review the Library Import documentation.

', description: 'Body of the second question in the Help & Support sidebar', }, + importCourseModalTitle: { + id: 'course-authoring.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: '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.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.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.cancel.text', + defaultMessage: 'Cancel', + description: 'Label of the button to cancel the course import.', + }, + importCourseNext: { + id: 'course-authoring.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: 'course-authoring.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: '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.', + }, }); export default messages; diff --git a/src/library-authoring/course-import/ImportStepperModal.test.tsx b/src/library-authoring/import-course/stepper/ImportStepperModal.test.tsx similarity index 100% rename from src/library-authoring/course-import/ImportStepperModal.test.tsx rename to src/library-authoring/import-course/stepper/ImportStepperModal.test.tsx diff --git a/src/library-authoring/course-import/ImportStepperModal.tsx b/src/library-authoring/import-course/stepper/ImportStepperModal.tsx similarity index 93% rename from src/library-authoring/course-import/ImportStepperModal.tsx rename to src/library-authoring/import-course/stepper/ImportStepperModal.tsx index 4cd355fd3d..839bf95943 100644 --- a/src/library-authoring/course-import/ImportStepperModal.tsx +++ b/src/library-authoring/import-course/stepper/ImportStepperModal.tsx @@ -5,8 +5,10 @@ import { } from '@openedx/paragon'; import { CoursesList } from '@src/studio-home/tabs-section/courses-tab'; +import { useStudioHome } from '@src/studio-home/hooks'; + import { ReviewImportDetails } from './ReviewImportDetails'; -import messages from './messages'; +import messages from '../messages'; type MigrationStep = 'select-course' | 'review-details'; @@ -23,6 +25,10 @@ export const ImportStepperModal = ({ const [currentStep, setCurrentStep] = useState('select-course'); const [selectedCourseId, setSelectedCourseId] = useState(); + // Load the courses list + // The loading state is handled in `CoursesList` + useStudioHome(); + return ( { diff --git a/src/library-authoring/course-import/SummaryCard.tsx b/src/library-authoring/import-course/stepper/SummaryCard.tsx similarity index 98% rename from src/library-authoring/course-import/SummaryCard.tsx rename to src/library-authoring/import-course/stepper/SummaryCard.tsx index 529c2294b2..4b3c29057d 100644 --- a/src/library-authoring/course-import/SummaryCard.tsx +++ b/src/library-authoring/import-course/stepper/SummaryCard.tsx @@ -5,7 +5,7 @@ import { Widgets } from '@openedx/paragon/icons'; import { LoadingSpinner } from '@src/generic/Loading'; import { getItemIcon } from '@src/generic/block-type-utils'; -import messages from './messages'; +import messages from '../messages'; // TODO: The SummaryCard is always in loading state export const SummaryCard = () => ( From 4f844835efcaef1eb61d820cb3d05a3f79c143da Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Mon, 10 Nov 2025 18:30:26 -0500 Subject: [PATCH 10/14] refactor: Convert Stepper from Modal to Page --- src/library-authoring/LibraryLayout.tsx | 5 + .../CourseImportHomePage.test.tsx | 2 +- .../import-course/CourseImportHomePage.tsx | 118 ++++++++---------- .../import-course/messages.ts | 14 +-- .../stepper/ImportStepperModal.tsx | 94 -------------- ...al.test.tsx => ImportStepperPage.test.tsx} | 31 +++-- .../stepper/ImportStepperPage.tsx | 115 +++++++++++++++++ src/library-authoring/routes.ts | 2 + .../factories/mockApiResponses.tsx | 3 +- 9 files changed, 209 insertions(+), 175 deletions(-) delete mode 100644 src/library-authoring/import-course/stepper/ImportStepperModal.tsx rename src/library-authoring/import-course/stepper/{ImportStepperModal.test.tsx => ImportStepperPage.test.tsx} (84%) create mode 100644 src/library-authoring/import-course/stepper/ImportStepperPage.tsx diff --git a/src/library-authoring/LibraryLayout.tsx b/src/library-authoring/LibraryLayout.tsx index 7575c19127..3d4f19683b 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/import-course/CourseImportHomePage.test.tsx b/src/library-authoring/import-course/CourseImportHomePage.test.tsx index 0c9dbc4ef3..2e2ddcaa23 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 ab83634426..91f32ac37a 100644 --- a/src/library-authoring/import-course/CourseImportHomePage.tsx +++ b/src/library-authoring/import-course/CourseImportHomePage.tsx @@ -1,3 +1,4 @@ +import { useNavigate } from 'react-router-dom'; import { Helmet } from 'react-helmet'; import { Button, @@ -5,7 +6,6 @@ import { Container, Layout, Stack, - useToggle, } from '@openedx/paragon'; import { Add } from '@openedx/paragon/icons'; import { getConfig } from '@edx/frontend-platform'; @@ -18,7 +18,6 @@ import { useLibraryContext } from '../common/context/LibraryContext'; import { useCourseImports } from '../data/apiHooks'; import { HelpSidebar } from './HelpSidebar'; import { ImportedCourseCard } from './ImportedCourseCard'; -import { ImportStepperModal } from './stepper/ImportStepperModal'; import messages from './messages'; const EmptyState = () => ( @@ -36,8 +35,8 @@ const EmptyState = () => ( export const CourseImportHomePage = () => { const intl = useIntl(); + const navigate = useNavigate(); const { libraryId, libraryData, readOnly } = useLibraryContext(); - const [importModalIsOpen, openImportModal, closeImportModal] = useToggle(false); const { data: courseImports } = useCourseImports(libraryId); if (!courseImports || !libraryData) { @@ -45,66 +44,59 @@ export const CourseImportHomePage = () => { } return ( - <> -
-
- - {libraryData.title} | {process.env.SITE_NAME} - -
- -
- - {intl.formatMessage(messages.importCourseButton)} - - ) - } - /> -
- - - {courseImports.length ? ( - -

- -

- {courseImports.map((courseImport) => ( - - ))} -
- ) : ()} -
- - - -
-
-
+
+
+ + {libraryData.title} | {process.env.SITE_NAME} + +
+ +
+ navigate('courses')}> + {intl.formatMessage(messages.importCourseButton)} + + ) + } + /> +
+ + + {courseImports.length ? ( + +

+ +

+ {courseImports.map((courseImport) => ( + + ))} +
+ ) : ()} +
+ + + +
+
- - +
); }; diff --git a/src/library-authoring/import-course/messages.ts b/src/library-authoring/import-course/messages.ts index c3133eef54..99474e641d 100644 --- a/src/library-authoring/import-course/messages.ts +++ b/src/library-authoring/import-course/messages.ts @@ -73,8 +73,8 @@ const messages = defineMessages({ + '

For additional details you can review the Library Import documentation.

', description: 'Body of the second question in the Help & Support sidebar', }, - importCourseModalTitle: { - id: 'course-authoring.library-authoring.import-course.modal.title', + 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.', }, @@ -84,27 +84,27 @@ const messages = defineMessages({ description: 'Label of the button to open the modal to import a course into a library.', }, importCourseSelectCourseStep: { - id: 'course-authoring.library-authoring.import-course.select-course.title', + 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.review-details.title', + 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.cancel.text', + 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.next.text', + 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.back.text', + 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.', }, diff --git a/src/library-authoring/import-course/stepper/ImportStepperModal.tsx b/src/library-authoring/import-course/stepper/ImportStepperModal.tsx deleted file mode 100644 index 839bf95943..0000000000 --- a/src/library-authoring/import-course/stepper/ImportStepperModal.tsx +++ /dev/null @@ -1,94 +0,0 @@ -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 { useStudioHome } from '@src/studio-home/hooks'; - -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(); - - // Load the courses list - // The loading state is handled in `CoursesList` - useStudioHome(); - - return ( - - - - - - - - - - - - - - - - - - - {currentStep === 'select-course' ? ( - - - - - - - ) : ( - - - - - )} - - - ); -}; diff --git a/src/library-authoring/import-course/stepper/ImportStepperModal.test.tsx b/src/library-authoring/import-course/stepper/ImportStepperPage.test.tsx similarity index 84% rename from src/library-authoring/import-course/stepper/ImportStepperModal.test.tsx rename to src/library-authoring/import-course/stepper/ImportStepperPage.test.tsx index fca39d53d9..6bb6310817 100644 --- a/src/library-authoring/import-course/stepper/ImportStepperModal.test.tsx +++ b/src/library-authoring/import-course/stepper/ImportStepperPage.test.tsx @@ -12,17 +12,25 @@ 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'; +import { LibraryProvider } from '@src/library-authoring/common/context/LibraryContext'; +import { mockContentLibrary } from '@src/library-authoring/data/api.mocks'; +import { ImportStepperPage } from './ImportStepperPage'; let axiosMock; mockGetMigrationInfo.applyMock(); +mockContentLibrary.applyMock(); type StudioHomeState = DeprecatedReduxState['studioHome']; -const libraryKey = 'lib:org:lib1'; -const mockOnClose = jest.fn(); +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 = { @@ -49,11 +57,16 @@ const renderComponent = (studioHomeState: Partial = {}) => { return { ...render( - , + , + { + extraWrapper: ({ children }: { children: React.ReactNode }) => ( + + {children} + + ), + path: '/libraries/:libraryId/import/course', + params: { libraryId: libraryKey }, + }, ), store, }; @@ -83,7 +96,7 @@ describe('', () => { const cancelButon = await screen.findByRole('button', { name: /cancel/i }); await user.click(cancelButon); - expect(mockOnClose).toHaveBeenCalled(); + expect(mockNavigate).toHaveBeenCalled(); }); it('should go to review import details step', async () => { 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 0000000000..aec15c8cdb --- /dev/null +++ b/src/library-authoring/import-course/stepper/ImportStepperPage.tsx @@ -0,0 +1,115 @@ +import { 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, Container, Layout, Stepper, +} from '@openedx/paragon'; + +import { CoursesList } 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 { ReviewImportDetails } from './ReviewImportDetails'; +import messages from '../messages'; +import { HelpSidebar } from '../HelpSidebar'; + +type MigrationStep = 'select-course' | 'review-details'; + +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/routes.ts b/src/library-authoring/routes.ts index e052c6fc4a..99a8c72c34 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/factories/mockApiResponses.tsx b/src/studio-home/factories/mockApiResponses.tsx index c755f086c7..3ee50231a0 100644 --- a/src/studio-home/factories/mockApiResponses.tsx +++ b/src/studio-home/factories/mockApiResponses.tsx @@ -1,5 +1,6 @@ import { type DeprecatedReduxState } from '@src/store'; import { RequestStatus } from '@src/data/constants'; +import { mockContentLibrary } from '@src/library-authoring/data/api.mocks'; export const courseId = 'course'; @@ -172,7 +173,7 @@ export const generateGetMigrationInfo = () => ({ sourceKey: 'course-v1:HarvardX+123+2023', targetCollectionKey: 'ltc:org:coll-1', targetCollectionTitle: 'Collection 1', - targetKey: 'lib:org:lib1', + targetKey: mockContentLibrary.libraryId, targetTitle: 'Library 1', }], }); From 3212a84e8fffa67fedf1ad65fae29fd3392bbeab Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Thu, 13 Nov 2025 13:32:50 -0500 Subject: [PATCH 11/14] feat: Enable button on empty state --- .../import-course/CourseImportHomePage.tsx | 27 +++++++++++-------- src/studio-home/card-item/index.tsx | 6 ++++- 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/src/library-authoring/import-course/CourseImportHomePage.tsx b/src/library-authoring/import-course/CourseImportHomePage.tsx index 91f32ac37a..cfbf101b81 100644 --- a/src/library-authoring/import-course/CourseImportHomePage.tsx +++ b/src/library-authoring/import-course/CourseImportHomePage.tsx @@ -20,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 = () => ( - + @@ -35,7 +47,6 @@ const EmptyState = () => ( export const CourseImportHomePage = () => { const intl = useIntl(); - const navigate = useNavigate(); const { libraryId, libraryData, readOnly } = useLibraryContext(); const { data: courseImports } = useCourseImports(libraryId); @@ -66,13 +77,7 @@ export const CourseImportHomePage = () => { title={intl.formatMessage(messages.pageTitle)} subtitle={intl.formatMessage(messages.pageSubtitle)} hideBorder - headerActions={ - getConfig().ENABLE_COURSE_IMPORT_IN_LIBRARY === 'true' && ( - - ) - } + headerActions={} />
diff --git a/src/studio-home/card-item/index.tsx b/src/studio-home/card-item/index.tsx index 743bc27519..4f8bd174fd 100644 --- a/src/studio-home/card-item/index.tsx +++ b/src/studio-home/card-item/index.tsx @@ -176,15 +176,18 @@ const CardMenu = ({ const SelectAction = ({ itemId, + title, selectMode, }: { itemId: string, + title: string, selectMode: 'single' | 'multiple'; }) => { if (selectMode === 'single') { return ( ); @@ -192,7 +195,7 @@ const SelectAction = ({ // Multiple return ( - + ); }; @@ -339,6 +342,7 @@ const CardItem: React.FC = ({ ) : ( Date: Thu, 13 Nov 2025 15:45:46 -0500 Subject: [PATCH 12/14] refactor: Move out all related to migrate props from CardItem --- src/studio-home/card-item/CardItem.test.tsx | 2 +- src/studio-home/card-item/index.tsx | 99 +++++-------------- src/studio-home/messages.ts | 5 - .../tabs-section/courses-tab/index.tsx | 4 +- .../tabs-section/libraries-tab/index.tsx | 82 +++++++++++---- .../tabs-section/libraries-v2-tab/index.tsx | 2 +- src/studio-home/tabs-section/messages.ts | 5 + 7 files changed, 95 insertions(+), 104 deletions(-) diff --git a/src/studio-home/card-item/CardItem.test.tsx b/src/studio-home/card-item/CardItem.test.tsx index b033f789d3..8fd948e38d 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 4f8bd174fd..bdfea40262 100644 --- a/src/studio-home/card-item/index.tsx +++ b/src/studio-home/card-item/index.tsx @@ -10,19 +10,18 @@ import { IconButton, Stack, } from '@openedx/paragon'; -import { AccessTime, ArrowForward, MoreHoriz } from '@openedx/paragon/icons'; +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 @@ -35,7 +34,7 @@ const PrevToNextName = ({ from, to }: { from: React.ReactNode, to?: React.ReactN ); -const MakeLinkOrSpan = ({ +export const MakeLinkOrSpan = ({ when, to, children, className, }: { when: boolean, @@ -54,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 = ({ @@ -65,10 +62,8 @@ const CardTitle: React.FC = ({ selectMode, destinationUrl, title, + secondaryLink, itemId, - isMigrated, - migratedToTitle, - migratedToKey, }) => { const getTitle = useCallback(() => (
@@ -82,24 +77,12 @@ const CardTitle: React.FC = ({ {title} )} - to={ - isMigrated && migratedToTitle && ( - - {migratedToTitle} - - ) - } + to={secondaryLink} />
), [ readOnlyItem, - isMigrated, destinationUrl, - migratedToTitle, title, selectMode, ]); @@ -209,11 +192,10 @@ interface BaseProps { rerunLink?: string | null; courseKey?: string; isLibraries?: boolean; - isMigrated?: boolean; - migratedToKey?: string; - migratedToTitle?: string; - migratedToCollectionKey?: string | null; - subtitleBeforeComponent?: ReactElement | 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; @@ -234,7 +216,7 @@ 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 = '', @@ -250,14 +232,12 @@ const CardItem: React.FC = ({ itemId = '', path, url, - isMigrated = false, - migratedToKey, - migratedToTitle, - migratedToCollectionKey, - subtitleBeforeComponent, + subtitleWrapper, + subtitleBeforeWidget, + titleSecondaryLink, + cardStatusWidget, scrollIntoView = false, }) => { - const intl = useIntl(); const { allowCourseReruns, courseCreatorStatus, @@ -280,33 +260,19 @@ const CardItem: React.FC = ({ const getSubtitle = useCallback(() => { let subtitle = isLibraries ? <>{org} / {number} : <>{org} / {number} / {run}; - if (isMigrated && migratedToKey) { - const migratedToKeyObj = parseLibraryKey(migratedToKey); - subtitle = ( - {migratedToKeyObj.org} / {migratedToKeyObj.lib}} - /> - ); + if (subtitleWrapper) { + subtitle = subtitleWrapper(subtitle); } - if (subtitleBeforeComponent) { + if (subtitleBeforeWidget) { subtitle = ( - {subtitleBeforeComponent} + {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 */ @@ -332,9 +298,7 @@ const CardItem: React.FC = ({ destinationUrl={destinationUrl} title={title} itemId={itemId} - isMigrated={isMigrated} - migratedToTitle={migratedToTitle} - migratedToKey={migratedToKey} + secondaryLink={titleSecondaryLink} /> )} subtitle={getSubtitle()} @@ -353,27 +317,12 @@ const CardItem: React.FC = ({ /> )} /> - {isMigrated && migratedToKey - && ( + {cardStatusWidget && ( - - - {intl.formatMessage(messages.libraryMigrationStatusText)} - - - {migratedToTitle} - - - + {cardStatusWidget} - )} + )}
); }; - -export default CardItem; diff --git a/src/studio-home/messages.ts b/src/studio-home/messages.ts index a4de63ae77..52978fbee1 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/tabs-section/courses-tab/index.tsx b/src/studio-home/tabs-section/courses-tab/index.tsx index 598d37c21b..c996286469 100644 --- a/src/studio-home/tabs-section/courses-tab/index.tsx +++ b/src/studio-home/tabs-section/courses-tab/index.tsx @@ -17,7 +17,7 @@ import { COURSE_CREATOR_STATES } from '@src/constants'; 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'; @@ -118,7 +118,7 @@ const CardList = ({ selectMode={inSelectMode ? 'single' : undefined} selectPosition={inSelectMode ? 'card' : undefined} isSelected={inSelectMode && selectedCourseId === courseKey} - subtitleBeforeComponent={isPreviouslyMigrated(courseKey) && ( + subtitleBeforeWidget={isPreviouslyMigrated(courseKey) && (
( - - )) + }) => { + 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 918a3d5171..0f3b0c9571 100644 --- a/src/studio-home/tabs-section/libraries-v2-tab/index.tsx +++ b/src/studio-home/tabs-section/libraries-v2-tab/index.tsx @@ -17,7 +17,7 @@ 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'; diff --git a/src/studio-home/tabs-section/messages.ts b/src/studio-home/tabs-section/messages.ts index 3d40d1abb6..03f2d4dd41 100644 --- a/src/studio-home/tabs-section/messages.ts +++ b/src/studio-home/tabs-section/messages.ts @@ -141,6 +141,11 @@ const messages = defineMessages({ defaultMessage: 'Previously Imported', description: 'Chip that indicates that the course has been previously imported.', }, + 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; From 4304af1c71330c1c17bb1f2d24274b882568d0a2 Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Thu, 13 Nov 2025 17:15:54 -0500 Subject: [PATCH 13/14] refactor: Extract MigrationStatus Chip from CardList --- src/library-authoring/data/api.mocks.ts | 14 +++++ src/library-authoring/data/api.ts | 21 +++++++ src/library-authoring/data/apiHooks.ts | 15 +++++ .../import-course/messages.ts | 5 ++ .../stepper/ImportStepperPage.test.tsx | 3 +- .../stepper/ImportStepperPage.tsx | 51 +++++++++++++++-- src/studio-home/data/api.mocks.ts | 8 +-- src/studio-home/data/api.ts | 21 ------- src/studio-home/data/apiHooks.ts | 12 +--- .../factories/mockApiResponses.tsx | 11 ---- .../tabs-section/courses-tab/index.tsx | 56 ++++++------------- src/studio-home/tabs-section/messages.ts | 5 -- 12 files changed, 123 insertions(+), 99 deletions(-) diff --git a/src/library-authoring/data/api.mocks.ts b/src/library-authoring/data/api.mocks.ts index a7739b191a..35410e53ed 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 f1883a9c25..0945bac2fa 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 e72ac1cf41..cd312d23e6 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/messages.ts b/src/library-authoring/import-course/messages.ts index 99474e641d..7d675a39f9 100644 --- a/src/library-authoring/import-course/messages.ts +++ b/src/library-authoring/import-course/messages.ts @@ -159,6 +159,11 @@ const messages = defineMessages({ 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 index 6bb6310817..53b7ffbb42 100644 --- a/src/library-authoring/import-course/stepper/ImportStepperPage.test.tsx +++ b/src/library-authoring/import-course/stepper/ImportStepperPage.test.tsx @@ -10,10 +10,9 @@ 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 { LibraryProvider } from '@src/library-authoring/common/context/LibraryContext'; -import { mockContentLibrary } from '@src/library-authoring/data/api.mocks'; +import { mockContentLibrary, mockGetMigrationInfo } from '@src/library-authoring/data/api.mocks'; import { ImportStepperPage } from './ImportStepperPage'; let axiosMock; diff --git a/src/library-authoring/import-course/stepper/ImportStepperPage.tsx b/src/library-authoring/import-course/stepper/ImportStepperPage.tsx index aec15c8cdb..0cecc44e4e 100644 --- a/src/library-authoring/import-course/stepper/ImportStepperPage.tsx +++ b/src/library-authoring/import-course/stepper/ImportStepperPage.tsx @@ -1,24 +1,67 @@ -import { useState } from 'react'; +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, Container, Layout, Stepper, + ActionRow, Button, Chip, Container, Layout, Stepper, } from '@openedx/paragon'; -import { CoursesList } from '@src/studio-home/tabs-section/courses-tab'; +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(); @@ -69,7 +112,7 @@ export const ImportStepperPage = () => { jest.spyOn(api, 'getMigrationInfo').mockResolvedValue( - camelCaseObject(generateGetMigrationInfo()), - ), -}; diff --git a/src/studio-home/data/api.ts b/src/studio-home/data/api.ts index 39230153ba..c86769075e 100644 --- a/src/studio-home/data/api.ts +++ b/src/studio-home/data/api.ts @@ -49,14 +49,6 @@ 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); @@ -77,16 +69,3 @@ 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 97c009dcbc..d1ab2a22eb 100644 --- a/src/studio-home/data/apiHooks.ts +++ b/src/studio-home/data/apiHooks.ts @@ -1,5 +1,5 @@ -import { skipToken, useQuery } from '@tanstack/react-query'; -import { getStudioHomeLibraries, getMigrationInfo } from './api'; +import { useQuery } from '@tanstack/react-query'; +import { getStudioHomeLibraries } from './api'; export const studioHomeQueryKeys = { all: ['studioHome'], @@ -7,7 +7,6 @@ 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) => ( @@ -17,10 +16,3 @@ 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 3ee50231a0..295971f7d0 100644 --- a/src/studio-home/factories/mockApiResponses.tsx +++ b/src/studio-home/factories/mockApiResponses.tsx @@ -1,6 +1,5 @@ import { type DeprecatedReduxState } from '@src/store'; import { RequestStatus } from '@src/data/constants'; -import { mockContentLibrary } from '@src/library-authoring/data/api.mocks'; export const courseId = 'course'; @@ -167,13 +166,3 @@ 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: mockContentLibrary.libraryId, - targetTitle: 'Library 1', - }], -}); diff --git a/src/studio-home/tabs-section/courses-tab/index.tsx b/src/studio-home/tabs-section/courses-tab/index.tsx index c996286469..94c9e438ae 100644 --- a/src/studio-home/tabs-section/courses-tab/index.tsx +++ b/src/studio-home/tabs-section/courses-tab/index.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo } from 'react'; +import React from 'react'; import { useLocation } from 'react-router-dom'; import { useDispatch, useSelector } from 'react-redux'; import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; @@ -9,7 +9,6 @@ import { Alert, Button, Form, - Chip, } from '@openedx/paragon'; import { Error } from '@openedx/paragon/icons'; @@ -23,12 +22,16 @@ 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'; +export interface MigrationStatusProps { + courseId: string; + allVisibleCourseIds: string[]; +} + interface CardListProps { currentPage: number; handlePageSelected: (page: any) => void; @@ -41,7 +44,7 @@ interface CardListProps { onClickNewCourse?: () => void; inSelectMode?: boolean; selectedCourseId?: string; - currentLibraryId?: string; + migrationStatusWidget?: React.ComponentType | null; } const CardList = ({ @@ -56,7 +59,7 @@ const CardList = ({ onClickNewCourse = () => {}, inSelectMode = false, selectedCourseId, - currentLibraryId, + migrationStatusWidget, }: CardListProps) => { const { courses, @@ -64,28 +67,9 @@ const CardList = ({ 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]); + const MigrationStatusWidget = migrationStatusWidget; return ( <> @@ -103,8 +87,6 @@ const CardList = ({ url, }) => ( onClickCard?.(courseKey)} itemId={courseKey} @@ -118,15 +100,11 @@ const CardList = ({ selectMode={inSelectMode ? 'single' : undefined} selectPosition={inSelectMode ? 'card' : undefined} isSelected={inSelectMode && selectedCourseId === courseKey} - subtitleBeforeWidget={isPreviouslyMigrated(courseKey) && ( -
- - - -
+ subtitleBeforeWidget={MigrationStatusWidget && ( + item.courseKey) || []} + /> )} /> ), @@ -174,7 +152,7 @@ interface Props { isShowProcessing?: boolean; selectedCourseId?: string; handleSelect?: (courseId: string) => void; - currentLibraryId?: string; + cardMigrationStatusWidget?: React.ComponentType | null; } export const CoursesList: React.FC = ({ @@ -183,7 +161,7 @@ export const CoursesList: React.FC = ({ isShowProcessing = false, selectedCourseId, handleSelect, - currentLibraryId, + cardMigrationStatusWidget, }) => { const dispatch = useDispatch(); const intl = useIntl(); @@ -280,7 +258,7 @@ export const CoursesList: React.FC = ({ isFiltered={isFiltered || false} inSelectMode selectedCourseId={selectedCourseId} - currentLibraryId={currentLibraryId} + migrationStatusWidget={cardMigrationStatusWidget} /> ) : ( diff --git a/src/studio-home/tabs-section/messages.ts b/src/studio-home/tabs-section/messages.ts index 03f2d4dd41..763a384187 100644 --- a/src/studio-home/tabs-section/messages.ts +++ b/src/studio-home/tabs-section/messages.ts @@ -136,11 +136,6 @@ const messages = defineMessages({ defaultMessage: 'Select All', description: 'Button to select all libraries when migrate legacy libraries.', }, - previouslyImported: { - id: 'studio-home.course-list.card.previously-imported.text', - defaultMessage: 'Previously Imported', - description: 'Chip that indicates that the course has been previously imported.', - }, 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', From a1aedf5ca6505a41234f7a166f9dfda20264d269 Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Thu, 13 Nov 2025 19:09:11 -0500 Subject: [PATCH 14/14] style: Nits on the code --- src/studio-home/tabs-section/courses-tab/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/studio-home/tabs-section/courses-tab/index.tsx b/src/studio-home/tabs-section/courses-tab/index.tsx index 94c9e438ae..d14c36c17b 100644 --- a/src/studio-home/tabs-section/courses-tab/index.tsx +++ b/src/studio-home/tabs-section/courses-tab/index.tsx @@ -44,7 +44,7 @@ interface CardListProps { onClickNewCourse?: () => void; inSelectMode?: boolean; selectedCourseId?: string; - migrationStatusWidget?: React.ComponentType | null; + migrationStatusWidget?: React.ComponentType; } const CardList = ({ @@ -152,7 +152,7 @@ interface Props { isShowProcessing?: boolean; selectedCourseId?: string; handleSelect?: (courseId: string) => void; - cardMigrationStatusWidget?: React.ComponentType | null; + cardMigrationStatusWidget?: React.ComponentType; } export const CoursesList: React.FC = ({