From dfebf78308cfbd471911908d7ad4333e146ade11 Mon Sep 17 00:00:00 2001 From: salmannawaz Date: Thu, 4 Jun 2026 15:41:24 +0500 Subject: [PATCH 1/9] feat: remove the legacy library viewing and editing page Removes the legacy library (V1) tab, routes, and associated UI from Studio Home. The migration wizard (/libraries-v1/migrate) is preserved to allow users to migrate existing legacy library content to V2. Co-Authored-By: Claude Sonnet 4.6 --- src/index.jsx | 4 +- src/studio-home/StudioHome.test.tsx | 37 --- src/studio-home/StudioHome.tsx | 14 +- src/studio-home/hooks.tsx | 2 - .../tabs-section/TabsSection.test.tsx | 289 +----------------- src/studio-home/tabs-section/index.tsx | 36 +-- 6 files changed, 9 insertions(+), 373 deletions(-) diff --git a/src/index.jsx b/src/index.jsx index b0247846bd..609e045d92 100755 --- a/src/index.jsx +++ b/src/index.jsx @@ -28,7 +28,6 @@ import messages from './i18n'; import { LibraryAndComponentPicker, CreateLibrary, - CreateLegacyLibrary, LibraryLayout, PreviewChangesEmbed, } from './library-authoring'; @@ -75,9 +74,8 @@ const App = () => { } /> } /> - } /> + } /> - } /> } /> } /> ', () => { }); describe('render new library button', () => { - it('should navigate to legacy library creation when libraries-v2 disabled', async () => { - mockUseSelector.mockReturnValue({ - ...studioHomeMock, - courseCreatorStatus: COURSE_CREATOR_STATES.granted, - librariesV2Enabled: false, - }); - render(, { path: '/home' }); - await waitFor(() => { - const createNewLibraryButton = screen.getByRole('button', { name: 'New library' }); - - fireEvent.click(createNewLibraryButton); - expect(mockNavigate).toHaveBeenCalledWith('/libraries-v1/create'); - }); - }); - it('should navigate to the library authoring page in course authoring', async () => { mockUseSelector.mockReturnValue({ ...studioHomeMock, - librariesV1Enabled: false, }); render(, { path: '/home' }); const createNewLibraryButton = screen.getByRole('button', { name: 'New library' }); @@ -153,26 +136,6 @@ describe('', () => { }); }); - it('does not render new library button for "v1 only" mode if showNewLibraryButton is False', () => { - mockUseSelector.mockReturnValue({ - ...studioHomeMock, - showNewLibraryButton: false, - librariesV2Enabled: false, - }); - render(, { path: '/home' }); - expect(screen.queryByRole('button', { name: 'New library' })).not.toBeInTheDocument(); - }); - - it('render new library button for "v2 only" mode even if showNewLibraryButton is False', () => { - mockUseSelector.mockReturnValue({ - ...studioHomeMock, - showNewLibraryButton: false, - librariesV1Enabled: false, - }); - render(, { path: '/home' }); - expect(screen.queryByRole('button', { name: 'New library' })).toBeInTheDocument(); - }); - it('should render "create new course" container', async () => { mockUseSelector.mockReturnValue({ ...studioHomeMock, diff --git a/src/studio-home/StudioHome.tsx b/src/studio-home/StudioHome.tsx index a2be1984da..973dc30e31 100644 --- a/src/studio-home/StudioHome.tsx +++ b/src/studio-home/StudioHome.tsx @@ -44,20 +44,17 @@ const StudioHome = () => { hasAbilityToCreateNewCourse, isFiltered, setShowNewCourseContainer, - librariesV1Enabled, librariesV2Enabled, } = useStudioHome(); const adminConsoleUrl = `${getConfig().ADMIN_CONSOLE_URL}/authz`; - const v1LibraryTab = librariesV1Enabled && location?.pathname.split('/').pop() === 'libraries-v1'; - const showV2LibraryURL = librariesV2Enabled && !v1LibraryTab; + const showV2LibraryURL = librariesV2Enabled; const { userIsActive, studioShortName, studioRequestEmail, - showNewLibraryButton, showNewLibraryV2Button, } = studioHomeData; @@ -103,13 +100,9 @@ const StudioHome = () => { ); } - if ((showNewLibraryButton && !showV2LibraryURL) || (showV2LibraryURL && showNewLibraryV2Button)) { + if (showV2LibraryURL && showNewLibraryV2Button) { const newLibraryClick = () => { - if (showV2LibraryURL) { - navigate('/library/create'); - } else { - navigate('/libraries-v1/create'); - } + navigate('/library/create'); }; headerButtons.push( @@ -167,7 +160,6 @@ const StudioHome = () => { showNewCourseContainer={showNewCourseContainer} onClickNewCourse={() => setShowNewCourseContainer(true)} isShowProcessing={Boolean(isShowProcessing) && !isFiltered} - librariesV1Enabled={librariesV1Enabled} librariesV2Enabled={librariesV2Enabled} /> diff --git a/src/studio-home/hooks.tsx b/src/studio-home/hooks.tsx index bc5dc249fe..150c714597 100644 --- a/src/studio-home/hooks.tsx +++ b/src/studio-home/hooks.tsx @@ -85,7 +85,6 @@ const useStudioHome = () => { studioRequestEmail, inProcessCourseActions, courseCreatorStatus, - librariesV1Enabled, librariesV2Enabled, } = studioHomeData; @@ -113,7 +112,6 @@ const useStudioHome = () => { hasAbilityToCreateNewCourse, isFiltered, setShowNewCourseContainer, - librariesV1Enabled, librariesV2Enabled, }; }; diff --git a/src/studio-home/tabs-section/TabsSection.test.tsx b/src/studio-home/tabs-section/TabsSection.test.tsx index 017aee9cf1..00739a1394 100644 --- a/src/studio-home/tabs-section/TabsSection.test.tsx +++ b/src/studio-home/tabs-section/TabsSection.test.tsx @@ -12,7 +12,6 @@ import { fireEvent, screen, act, - within, } from '@src/testUtils'; import messages from '../messages'; import tabMessages from './messages'; @@ -21,7 +20,6 @@ import { initialState, generateGetStudioHomeDataApiResponse, generateGetStudioCoursesApiResponseV2, - generateGetStudioHomeLibrariesApiResponse, } from '../factories/mockApiResponses'; import { getApiBaseUrl, getStudioHomeApiUrl } from '../data/api'; import { fetchStudioHomeData } from '../data/thunks'; @@ -31,14 +29,12 @@ const { studioShortName } = studioHomeMock; let axiosMock; let store; const courseApiLinkV2 = `${getApiBaseUrl()}/api/contentstore/v2/home/courses`; -const libraryApiLink = `${getStudioHomeApiUrl()}/libraries`; const tabSectionComponent = (overrideProps) => ( {}} isShowProcessing - librariesV1Enabled librariesV2Enabled {...overrideProps} /> @@ -62,10 +58,6 @@ const render = (overrideProps = {}) => path="/libraries" element={tabSectionComponent(overrideProps)} /> - , @@ -88,42 +80,7 @@ describe('', () => { await executeThunk(fetchStudioHomeData(), store.dispatch); expect(screen.getByRole('tab', { name: tabMessages.coursesTabTitle.defaultMessage })).toBeInTheDocument(); - expect(screen.getByRole('tab', { name: tabMessages.librariesTabTitle.defaultMessage })).toBeInTheDocument(); - - expect(screen.getByRole('tab', { name: tabMessages.legacyLibrariesTabTitle.defaultMessage })).toBeInTheDocument(); - }); - - it('should render only 1 library tab when libraries-v2 disabled', async () => { - const data = generateGetStudioHomeDataApiResponse(); - - render({ librariesV2Enabled: false }); - axiosMock.onGet(getStudioHomeApiUrl()).reply(200, data); - await executeThunk(fetchStudioHomeData(), store.dispatch); - - expect(screen.getByText(tabMessages.librariesTabTitle.defaultMessage)).toBeInTheDocument(); - const librariesTab = screen.getByRole('tab', { name: tabMessages.librariesTabTitle.defaultMessage }); - expect(librariesTab).toBeInTheDocument(); - // Check Tab.eventKey - expect(librariesTab).toHaveAttribute('data-rb-event-key', 'legacyLibraries'); - - expect(screen.queryByText(tabMessages.legacyLibrariesTabTitle.defaultMessage)).not.toBeInTheDocument(); - }); - - it('should render only 1 library tab when libraries-v1 disabled', async () => { - const data = generateGetStudioHomeDataApiResponse(); - - render({ librariesV1Enabled: false }); - axiosMock.onGet(getStudioHomeApiUrl()).reply(200, data); - await executeThunk(fetchStudioHomeData(), store.dispatch); - - expect(screen.getByText(tabMessages.librariesTabTitle.defaultMessage)).toBeInTheDocument(); - const librariesTab = screen.getByRole('tab', { name: tabMessages.librariesTabTitle.defaultMessage }); - expect(librariesTab).toBeInTheDocument(); - // Check Tab.eventKey - expect(librariesTab).toHaveAttribute('data-rb-event-key', 'libraries'); - - expect(screen.queryByText(tabMessages.legacyLibrariesTabTitle.defaultMessage)).not.toBeInTheDocument(); }); describe('course tab', () => { @@ -201,30 +158,24 @@ describe('', () => { it('should set the url path to home when switching away then back to courses tab', async () => { const data = generateGetStudioCoursesApiResponseV2(); data.results.courses = []; - await axiosMock.onGet(libraryApiLink).reply(200, generateGetStudioHomeLibrariesApiResponse()); await axiosMock.onGet(getStudioHomeApiUrl()).reply(200, generateGetStudioHomeDataApiResponse()); await axiosMock.onGet(courseApiLinkV2).reply(200, data); render(); await executeThunk(fetchStudioHomeData(), store.dispatch); - // confirm the url path is initially /home const firstLocationDisplay = await screen.findByTestId('location-display'); expect(firstLocationDisplay).toHaveTextContent('/home'); - // switch to libraries tab - const librariesTab = screen.getByText(tabMessages.legacyLibrariesTabTitle.defaultMessage); + const librariesTab = screen.getByRole('tab', { name: tabMessages.librariesTabTitle.defaultMessage }); fireEvent.click(librariesTab); - // confirm that the url path has changed expect(librariesTab).toHaveClass('active'); const secondLocationDisplay = await screen.findByTestId('location-display'); - expect(secondLocationDisplay).toHaveTextContent('/libraries-v1'); + expect(secondLocationDisplay).toHaveTextContent('/libraries'); - // switch back to courses tab - const coursesTab = screen.getByText(tabMessages.coursesTabTitle.defaultMessage); + const coursesTab = screen.getByRole('tab', { name: tabMessages.coursesTabTitle.defaultMessage }); fireEvent.click(coursesTab); - // confirm that the url path is /home expect(coursesTab).toHaveClass('active'); const thirdLocationDisplay = await screen.findByTestId('location-display'); expect(thirdLocationDisplay).toHaveTextContent('/home'); @@ -268,84 +219,6 @@ describe('', () => { beforeEach(async () => { await axiosMock.onGet(courseApiLinkV2).reply(200, generateGetStudioCoursesApiResponseV2()); }); - it('should switch to Legacy Libraries tab and render - search and filter should work as expected', async () => { - await axiosMock.onGet(getStudioHomeApiUrl()).reply(200, generateGetStudioHomeDataApiResponse()); - await axiosMock.onGet(libraryApiLink).reply(200, generateGetStudioHomeLibrariesApiResponse()); - render(); - const user = userEvent.setup(); - await act(async () => executeThunk(fetchStudioHomeData(), store.dispatch)); - - const librariesTab = await screen.findByText(tabMessages.legacyLibrariesTabTitle.defaultMessage); - await user.click(librariesTab); - - expect(librariesTab).toHaveClass('active'); - const panel = await screen.findByRole('tabpanel', { hidden: false }); - - expect(await screen.findByText(studioHomeMock.libraries[0].displayName)).toBeVisible(); - - expect( - await screen.findByText(`${studioHomeMock.libraries[0].org} / ${studioHomeMock.libraries[0].number}`), - ).toBeVisible(); - - // Migration info should be displayed - const migratedContent = generateGetStudioHomeLibrariesApiResponse().libraries[1]; - expect(await screen.findByText(migratedContent.displayName)).toBeVisible(); - const newTitleElement = await screen.findAllByText(migratedContent.migratedToTitle!); - expect(newTitleElement[0]).toBeVisible(); - expect(newTitleElement[0]).toHaveAttribute('href', `/library/${migratedContent.migratedToKey}`); - expect(newTitleElement[1]).toHaveAttribute( - 'href', - `/library/${migratedContent.migratedToKey}/collection/${migratedContent.migratedToCollectionKey}`, - ); - - // Check total count display - expect(await within(panel).findByText('Showing 3 of 3')).toBeInTheDocument(); - - // Test search functionality - const searchField = await within(panel).findByPlaceholderText('Search'); - - fireEvent.change(searchField, { target: { value: 'Legacy' } }); - // Should only show 1 result i.e. migratedContent.displayName - expect(await within(panel).findByText('Showing 1 of 3')).toBeInTheDocument(); - expect(await within(panel).findByText(migratedContent.displayName)).toBeVisible(); - // Should not show other items. - expect( - within(panel).queryByText( - generateGetStudioHomeLibrariesApiResponse().libraries[0].displayName, - ), - ).not.toBeInTheDocument(); - // reset search - fireEvent.change(searchField, { target: { value: '' } }); - - // Test migration filter - const filter = await within(panel).findByRole('button', { name: 'Any Migration Status' }); - await user.click(filter); - let migratedOption = await within(panel).findByRole('checkbox', { name: 'Migrated' }); - // This should uncheck Migrated option as all options are selected by default - await user.click(migratedOption); - // Should only show 2 result i.e. unmigrated libraries - expect(await within(panel).findByText('Showing 2 of 3')).toBeInTheDocument(); - // test clearing filter - const clearFilter = await within(panel).findByRole('button', { name: 'Clear Filter' }); - await user.click(clearFilter); - // Should show all 3 results - expect(await within(panel).findByText('Showing 3 of 3')).toBeInTheDocument(); - // Open the filter again - await user.click(filter); - // Reload migratedOption as clearing and opening the filter again creates a new modal - migratedOption = await within(panel).findByRole('checkbox', { name: 'Migrated' }); - const unmigratedOption = await within(panel).findByRole('checkbox', { name: 'Unmigrated' }); - // both options should be selected by default - even after clearing - expect(migratedOption).toBeChecked(); - expect(unmigratedOption).toBeChecked(); - // Un-checking both options should reset the state to both checked. - await user.click(unmigratedOption); - await user.click(migratedOption); - expect(migratedOption).toBeChecked(); - expect(unmigratedOption).toBeChecked(); - // Should show all 3 results - expect(await within(panel).findByText('Showing 3 of 3')).toBeInTheDocument(); - }); it('should switch to Libraries tab and render specific v2 library details', async () => { render(); @@ -370,102 +243,6 @@ describe('', () => { )).toBeVisible(); }); - it('should switch to Libraries tab and render specific v1 library details - v1 only mode', async () => { - await axiosMock.onGet(getStudioHomeApiUrl()).reply(200, generateGetStudioHomeDataApiResponse()); - await axiosMock.onGet(libraryApiLink).reply(200, generateGetStudioHomeLibrariesApiResponse()); - render({ librariesV2Enabled: false }); - const user = userEvent.setup(); - await act(async () => executeThunk(fetchStudioHomeData(), store.dispatch)); - - const librariesTab = await screen.findByRole('tab', { name: tabMessages.librariesTabTitle.defaultMessage }); - await user.click(librariesTab); - - expect(librariesTab).toHaveClass('active'); - - expect(await screen.findByText(studioHomeMock.libraries[0].displayName)).toBeVisible(); - - expect( - await screen.findByText(`${studioHomeMock.libraries[0].org} / ${studioHomeMock.libraries[0].number}`), - ).toBeVisible(); - }); - - it('should open migration library page from v1 libraries tab', async () => { - await axiosMock.onGet(getStudioHomeApiUrl()).reply(200, generateGetStudioHomeDataApiResponse()); - await axiosMock.onGet(libraryApiLink).reply(200, generateGetStudioHomeLibrariesApiResponse()); - render({ librariesV2Enabled: false }); - const user = userEvent.setup(); - await act(async () => executeThunk(fetchStudioHomeData(), store.dispatch)); - - const librariesTab = await screen.findByRole('tab', { name: tabMessages.librariesTabTitle.defaultMessage }); - await user.click(librariesTab); - - expect(librariesTab).toHaveClass('active'); - - expect(await screen.findByText(studioHomeMock.libraries[0].displayName)).toBeVisible(); - - const migratorButton = screen.getByRole('button', { name: /review legacy libraries/i }); - expect(migratorButton).toBeInTheDocument(); - await user.click(migratorButton); - - const locationDisplay = await screen.findByTestId('location-display'); - expect(locationDisplay).toHaveTextContent('/migrate'); - }); - - it('should open migration library page from v2 libraries tab', async () => { - const libraries = generateGetStudioHomeLibrariesApiResponse().libraries.map( - library => ({ - ...library, - isMigrated: false, - }), - ); - const user = userEvent.setup(); - await axiosMock.onGet(getStudioHomeApiUrl()).reply(200, generateGetStudioHomeLibrariesApiResponse()); - await axiosMock.onGet(libraryApiLink).reply(200, { libraries }); - render(); - await executeThunk(fetchStudioHomeData(), store.dispatch); - - const librariesTab = await screen.findByRole('tab', { name: tabMessages.librariesTabTitle.defaultMessage }); - await user.click(librariesTab); - - expect(librariesTab).toHaveClass('active'); - - expect(await screen.findByText(/welcome to the new content libraries/i)).toBeVisible(); - - const migratorButton = screen.getByRole('button', { name: /review legacy libraries/i }); - expect(migratorButton).toBeInTheDocument(); - await user.click(migratorButton); - - const locationDisplay = await screen.findByTestId('location-display'); - expect(locationDisplay).toHaveTextContent('/migrate'); - }); - - it('should switch to Libraries tab and render specific v2 library details ("v2 only" mode)', async () => { - render({ librariesV1Enabled: false }); - await axiosMock.onGet(getStudioHomeApiUrl()).reply(200, generateGetStudioHomeDataApiResponse()); - await executeThunk(fetchStudioHomeData(), store.dispatch); - - // Libraries v1 tab should not be shown - expect(screen.queryByText(tabMessages.legacyLibrariesTabTitle.defaultMessage)).toBeNull(); - - const librariesTab = await screen.findByRole('tab', { name: tabMessages.librariesTabTitle.defaultMessage }); - fireEvent.click(librariesTab); - - expect(librariesTab).toHaveClass('active'); - - await screen.findByText('Showing 2 of 2'); - expect(screen.getAllByText('Page 1, Current Page, of 2')[0]).toBeVisible(); - - expect(screen.getByText(contentLibrariesListV2.results[0].title)).toBeVisible(); - expect(screen.getByText( - `${contentLibrariesListV2.results[0].org} / ${contentLibrariesListV2.results[0].slug}`, - )).toBeVisible(); - - expect(screen.getByText(contentLibrariesListV2.results[1].title)).toBeVisible(); - expect(screen.getByText( - `${contentLibrariesListV2.results[1].org} / ${contentLibrariesListV2.results[1].slug}`, - )).toBeVisible(); - }); - it('should show a "not found" message if no v2 libraries were loaded', async () => { mockGetContentLibraryV2List.applyMockEmpty(); render(); @@ -484,35 +261,8 @@ describe('', () => { ).toBeVisible(); }); - it('should hide Libraries tab when libraries are disabled', async () => { - const data = generateGetStudioHomeDataApiResponse(); - - render({ librariesV1Enabled: false }); - await axiosMock.onGet(getStudioHomeApiUrl()).reply(200, data); - await executeThunk(fetchStudioHomeData(), store.dispatch); - - await screen.findByText(tabMessages.coursesTabTitle.defaultMessage); - expect(screen.queryByText(tabMessages.legacyLibrariesTabTitle.defaultMessage)).toBeNull(); - }); - - it('should render legacy libraries fetch failure alert', async () => { - await axiosMock.onGet(getStudioHomeApiUrl()).reply(200, generateGetStudioHomeDataApiResponse()); - await axiosMock.onGet(libraryApiLink).reply(404); - render(); - const user = userEvent.setup(); - await executeThunk(fetchStudioHomeData(), store.dispatch); - - const librariesTab = await screen.findByText(tabMessages.legacyLibrariesTabTitle.defaultMessage); - await user.click(librariesTab); - - expect(librariesTab).toHaveClass('active'); - - expect(await screen.findByText(tabMessages.librariesTabErrorMessage.defaultMessage)).toBeVisible(); - }); - it('should render v2 libraries fetch failure alert', async () => { mockGetContentLibraryV2List.applyMockError(); - await axiosMock.onGet(libraryApiLink).reply(200, generateGetStudioHomeLibrariesApiResponse()); await axiosMock.onGet(getStudioHomeApiUrl()).reply(200, generateGetStudioHomeDataApiResponse()); render(); const user = userEvent.setup(); @@ -529,38 +279,5 @@ describe('', () => { ), ).toBeVisible(); }); - - [true, false].forEach((isMigrated) => { - it(`should render v2 libraries migration alert when the libraries have isMigrated=${isMigrated}`, async () => { - const libraries = generateGetStudioHomeLibrariesApiResponse().libraries.map( - library => ({ - ...library, - isMigrated, - }), - ); - const user = userEvent.setup(); - await axiosMock.onGet(getStudioHomeApiUrl()).reply(200, generateGetStudioHomeLibrariesApiResponse()); - await axiosMock.onGet(libraryApiLink).reply(200, { libraries }); - render(); - await executeThunk(fetchStudioHomeData(), store.dispatch); - - const librariesTab = await screen.findByRole('tab', { name: tabMessages.librariesTabTitle.defaultMessage }); - await user.click(librariesTab); - - expect(librariesTab).toHaveClass('active'); - - expect(await screen.findByText(/welcome to the new content libraries/i)).toBeVisible(); - - const migrationPendingText = /legacy libraries can be migrated using the migration tool/i; - - if (isMigrated) { - expect(screen.queryByText(migrationPendingText)).not.toBeInTheDocument(); - expect(screen.queryByRole('button', { name: 'Review Legacy Libraries' })).not.toBeInTheDocument(); - } else { - expect(screen.getByText(migrationPendingText)).toBeVisible(); - expect(screen.getByRole('button', { name: 'Review Legacy Libraries' })).toBeVisible(); - } - }); - }); }); }); diff --git a/src/studio-home/tabs-section/index.tsx b/src/studio-home/tabs-section/index.tsx index a130f12f25..193cb09ea3 100644 --- a/src/studio-home/tabs-section/index.tsx +++ b/src/studio-home/tabs-section/index.tsx @@ -9,7 +9,6 @@ import { getConfig } from '@edx/frontend-platform'; import { useIntl } from '@edx/frontend-platform/i18n'; import messages from './messages'; -import { BaseFilterState, Filter, LibrariesList } from './libraries-tab'; import LibrariesV2List from './libraries-v2-tab/index'; import { CoursesList } from './courses-tab'; import { WelcomeLibrariesV2Alert } from './libraries-v2-tab/WelcomeLibrariesV2Alert'; @@ -18,7 +17,6 @@ interface Props { showNewCourseContainer: boolean; onClickNewCourse: () => void; isShowProcessing: boolean; - librariesV1Enabled?: boolean; librariesV2Enabled?: boolean; } @@ -26,31 +24,22 @@ const TabsSection = ({ showNewCourseContainer, onClickNewCourse, isShowProcessing, - librariesV1Enabled, librariesV2Enabled, }: Props) => { const intl = useIntl(); const navigate = useNavigate(); const { pathname } = useLocation(); - const [migrationFilter, setMigrationFilter] = useState(BaseFilterState); const TABS_LIST = { courses: 'courses', libraries: 'libraries', - legacyLibraries: 'legacyLibraries', archived: 'archived', taxonomies: 'taxonomies', } as const; type TabKeyType = keyof typeof TABS_LIST; const initTabKeyState = (pname: string) => { - if (pname.includes('/libraries-v1')) { - return TABS_LIST.legacyLibraries; - } - if (pname.includes('/libraries')) { - return librariesV2Enabled - ? TABS_LIST.libraries - : TABS_LIST.legacyLibraries; + return TABS_LIST.libraries; } // Default to courses tab @@ -101,25 +90,6 @@ const TabsSection = ({ ); } - if (librariesV1Enabled) { - tabs.push( - - - , - ); - } - if (getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true') { tabs.push( { if (tab === TABS_LIST.courses) { navigate('/home'); - } else if (tab === TABS_LIST.legacyLibraries) { - navigate('/libraries-v1'); } else if (tab === TABS_LIST.libraries) { navigate('/libraries'); } else if (tab === TABS_LIST.taxonomies) { From 064b4845c789bc714e080eca67c77e6a0c302d6f Mon Sep 17 00:00:00 2001 From: salmannawaz Date: Thu, 4 Jun 2026 15:59:32 +0500 Subject: [PATCH 2/9] fix: redirect to /libraries after exiting migration wizard The /libraries-v1 route was removed when the legacy library viewing and editing page was removed. The migration wizard exit button was still navigating to that removed route, causing a 404 error. Co-Authored-By: Claude Sonnet 4.6 --- .../LegacyLibMigrationPage.test.tsx | 4 ++-- src/legacy-libraries-migration/LegacyLibMigrationPage.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/legacy-libraries-migration/LegacyLibMigrationPage.test.tsx b/src/legacy-libraries-migration/LegacyLibMigrationPage.test.tsx index cc3bdd5918..fd8d433615 100644 --- a/src/legacy-libraries-migration/LegacyLibMigrationPage.test.tsx +++ b/src/legacy-libraries-migration/LegacyLibMigrationPage.test.tsx @@ -76,13 +76,13 @@ describe('', () => { cancelButton.click(); - // Should navigate to legacy libraries tab on studio home + // Should navigate to libraries tab on studio home expect(await screen.findByText('Exit Migration?')).toBeInTheDocument(); const exitButton = screen.getByRole('button', { name: /exit/i }); exitButton.click(); await waitFor(() => { - expect(mockNavigate).toHaveBeenCalledWith('/libraries-v1'); + expect(mockNavigate).toHaveBeenCalledWith('/libraries'); }); }); diff --git a/src/legacy-libraries-migration/LegacyLibMigrationPage.tsx b/src/legacy-libraries-migration/LegacyLibMigrationPage.tsx index c0777d004e..0e106054e8 100644 --- a/src/legacy-libraries-migration/LegacyLibMigrationPage.tsx +++ b/src/legacy-libraries-migration/LegacyLibMigrationPage.tsx @@ -64,7 +64,7 @@ const ExitModal = ({ - From 15b1d874017360fa063ba948adb2fab669029bea Mon Sep 17 00:00:00 2001 From: salmannawaz Date: Thu, 4 Jun 2026 16:23:54 +0500 Subject: [PATCH 3/9] fix: address Copilot review comments on legacy library removal PR - Inline librariesV2Enabled directly instead of showV2LibraryURL alias - Fall back to courses tab in initTabKeyState when librariesV2Enabled is false, preventing blank tab when /libraries is visited with libraries disabled - Add test for librariesV2Enabled: false to prevent regressions Co-Authored-By: Claude Sonnet 4.6 --- src/studio-home/StudioHome.tsx | 4 +--- src/studio-home/tabs-section/TabsSection.test.tsx | 12 ++++++++++++ src/studio-home/tabs-section/index.tsx | 2 +- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/studio-home/StudioHome.tsx b/src/studio-home/StudioHome.tsx index 973dc30e31..d323cd7ec2 100644 --- a/src/studio-home/StudioHome.tsx +++ b/src/studio-home/StudioHome.tsx @@ -49,8 +49,6 @@ const StudioHome = () => { const adminConsoleUrl = `${getConfig().ADMIN_CONSOLE_URL}/authz`; - const showV2LibraryURL = librariesV2Enabled; - const { userIsActive, studioShortName, @@ -100,7 +98,7 @@ const StudioHome = () => { ); } - if (showV2LibraryURL && showNewLibraryV2Button) { + if (librariesV2Enabled && showNewLibraryV2Button) { const newLibraryClick = () => { navigate('/library/create'); }; diff --git a/src/studio-home/tabs-section/TabsSection.test.tsx b/src/studio-home/tabs-section/TabsSection.test.tsx index 00739a1394..4caf385d5c 100644 --- a/src/studio-home/tabs-section/TabsSection.test.tsx +++ b/src/studio-home/tabs-section/TabsSection.test.tsx @@ -83,6 +83,18 @@ describe('', () => { expect(screen.getByRole('tab', { name: tabMessages.librariesTabTitle.defaultMessage })).toBeInTheDocument(); }); + it('should not render libraries tab and default to courses when librariesV2Enabled is false', async () => { + render({ librariesV2Enabled: false }); + axiosMock.onGet(getStudioHomeApiUrl()).reply(200, generateGetStudioHomeDataApiResponse()); + await executeThunk(fetchStudioHomeData(), store.dispatch); + + expect(screen.getByRole('tab', { name: tabMessages.coursesTabTitle.defaultMessage })).toBeInTheDocument(); + expect(screen.queryByRole('tab', { name: tabMessages.librariesTabTitle.defaultMessage })).not.toBeInTheDocument(); + + const locationDisplay = screen.getByTestId('location-display'); + expect(locationDisplay).toHaveTextContent('/home'); + }); + describe('course tab', () => { it('should render specific course details', async () => { render(); diff --git a/src/studio-home/tabs-section/index.tsx b/src/studio-home/tabs-section/index.tsx index 193cb09ea3..497d5829d9 100644 --- a/src/studio-home/tabs-section/index.tsx +++ b/src/studio-home/tabs-section/index.tsx @@ -38,7 +38,7 @@ const TabsSection = ({ type TabKeyType = keyof typeof TABS_LIST; const initTabKeyState = (pname: string) => { - if (pname.includes('/libraries')) { + if (pname.includes('/libraries') && librariesV2Enabled) { return TABS_LIST.libraries; } From 7e05210f97eb36e3332ee2d1da911a66a174e0e9 Mon Sep 17 00:00:00 2001 From: salmannawaz Date: Thu, 11 Jun 2026 13:18:20 +0500 Subject: [PATCH 4/9] style: remove stray blank line in router config Co-Authored-By: Claude Sonnet 4.6 --- src/index.jsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/index.jsx b/src/index.jsx index 609e045d92..63b1c8511a 100755 --- a/src/index.jsx +++ b/src/index.jsx @@ -74,7 +74,6 @@ const App = () => { } /> } /> - } /> } /> } /> From 8381aade84c26d77fbe0e2b6617d64eb346c486c Mon Sep 17 00:00:00 2001 From: salmannawaz Date: Thu, 11 Jun 2026 15:18:12 +0500 Subject: [PATCH 5/9] Revert "style: remove stray blank line in router config" This reverts commit 7e05210f97eb36e3332ee2d1da911a66a174e0e9. --- src/index.jsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/index.jsx b/src/index.jsx index 63b1c8511a..609e045d92 100755 --- a/src/index.jsx +++ b/src/index.jsx @@ -74,6 +74,7 @@ const App = () => { } /> } /> + } /> } /> } /> From d649db6938d6e984d0953eed7bdbee733eebb763 Mon Sep 17 00:00:00 2001 From: salmannawaz Date: Mon, 15 Jun 2026 16:40:41 +0500 Subject: [PATCH 6/9] feat: remove legacy library migration page Co-Authored-By: Claude Sonnet 4.6 --- src/index.jsx | 3 - .../ConfirmationView.tsx | 90 ---- .../LegacyLibMigrationPage.test.tsx | 449 ------------------ .../LegacyLibMigrationPage.tsx | 264 ---------- .../LegacyMigrationHelpSidebar.tsx | 49 -- .../SelectDestinationView.tsx | 33 -- src/legacy-libraries-migration/index.scss | 54 --- src/legacy-libraries-migration/messages.ts | 167 ------- .../LibraryAuthoringPage.test.tsx | 57 --- .../LibraryAuthoringPage.tsx | 59 +-- .../MigrateLegacyLibrariesAlert.tsx | 28 -- .../tabs-section/libraries-tab/index.tsx | 354 -------------- .../WelcomeLibrariesV2Alert.tsx | 45 +- 13 files changed, 7 insertions(+), 1645 deletions(-) delete mode 100644 src/legacy-libraries-migration/ConfirmationView.tsx delete mode 100644 src/legacy-libraries-migration/LegacyLibMigrationPage.test.tsx delete mode 100644 src/legacy-libraries-migration/LegacyLibMigrationPage.tsx delete mode 100644 src/legacy-libraries-migration/LegacyMigrationHelpSidebar.tsx delete mode 100644 src/legacy-libraries-migration/SelectDestinationView.tsx delete mode 100644 src/legacy-libraries-migration/index.scss delete mode 100644 src/legacy-libraries-migration/messages.ts delete mode 100644 src/studio-home/tabs-section/libraries-tab/MigrateLegacyLibrariesAlert.tsx delete mode 100644 src/studio-home/tabs-section/libraries-tab/index.tsx diff --git a/src/index.jsx b/src/index.jsx index 609e045d92..c6818338af 100755 --- a/src/index.jsx +++ b/src/index.jsx @@ -44,8 +44,6 @@ import { ContentType } from './library-authoring/routes'; import 'react-datepicker/dist/react-datepicker.css'; import './index.scss'; -import { LegacyLibMigrationPage } from './legacy-libraries-migration/LegacyLibMigrationPage'; - const queryClient = new QueryClient({ defaultOptions: { queries: { @@ -75,7 +73,6 @@ const App = () => { } /> } /> - } /> } /> } /> ( - - - - {legacyLib.displayName} - - } - subtitle={ - - - {destinationName} - - } - /> - {legacyLib.isMigrated && ( - - - - - - - )} - -); - -interface ConfirmationViewProps { - destination: ContentLibrary; - legacyLibraries: LibraryV1Data[]; -} - -export const ConfirmationView = ({ - destination, - legacyLibraries, -}: ConfirmationViewProps) => ( - - - - - {legacyLibraries.map((legacyLib) => ( - - ))} - -); diff --git a/src/legacy-libraries-migration/LegacyLibMigrationPage.test.tsx b/src/legacy-libraries-migration/LegacyLibMigrationPage.test.tsx deleted file mode 100644 index fd8d433615..0000000000 --- a/src/legacy-libraries-migration/LegacyLibMigrationPage.test.tsx +++ /dev/null @@ -1,449 +0,0 @@ -import type MockAdapter from 'axios-mock-adapter'; -import userEvent from '@testing-library/user-event'; - -import { - initializeMocks, - render, - screen, - waitFor, - within, -} from '@src/testUtils'; -import studioHomeMock from '@src/studio-home/__mocks__/studioHomeMock'; -import { mockGetContentLibraryV2List } from '@src/library-authoring/data/api.mocks'; -import { mockGetStudioHomeLibraries } from '@src/studio-home/data/api.mocks'; -import { getContentLibraryV2CreateApiUrl } from '@src/library-authoring/create-library/data/api'; -import { getStudioHomeApiUrl } from '@src/studio-home/data/api'; - -import { bulkModulestoreMigrateUrl } from '@src/data/api'; -import { LegacyLibMigrationPage } from './LegacyLibMigrationPage'; - -const path = '/libraries-v1/migrate/*'; -let axiosMock: MockAdapter; -let mockShowToast; - -mockGetStudioHomeLibraries.applyMock(); -mockGetContentLibraryV2List.applyMock(); - -const mockNavigate = jest.fn(); -jest.mock('react-router-dom', () => ({ - ...jest.requireActual('react-router-dom'), - useNavigate: () => mockNavigate, -})); - -jest.mock('@src/generic/data/apiHooks', () => ({ - ...jest.requireActual('@src/generic/data/apiHooks'), - useOrganizationListData: () => ({ - data: ['org1', 'org2', 'org3', 'org4', 'org5'], - isLoading: false, - }), -})); - -const renderPage = () => ( - render(, { path }) -); - -describe('', () => { - beforeEach(() => { - const mocks = initializeMocks(); - axiosMock = mocks.axiosMock; - mockShowToast = mocks.mockShowToast; - }); - - it('should render legacy library migration page', async () => { - renderPage(); - // Should render the title - expect(await screen.findByText('Migrate Legacy Libraries')).toBeInTheDocument(); - // Should render the Migration Steps Viewer - expect(screen.getByText('Select Legacy Libraries')).toBeInTheDocument(); - expect(screen.getByText('Select Destination')).toBeInTheDocument(); - expect(screen.getByText('Confirm')).toBeInTheDocument(); - }); - - it('should cancel the migration', async () => { - renderPage(); - expect(await screen.findByText('Migrate Legacy Libraries')).toBeInTheDocument(); - - const cancelButton = screen.getByRole('button', { name: /cancel/i }); - cancelButton.click(); - - // Should show exit confirmation modal - expect(await screen.findByText('Exit Migration?')).toBeInTheDocument(); - - // Close exit confirmation modal - const continueButton = screen.getByRole('button', { name: /continue migrating/i }); - continueButton.click(); - expect(mockNavigate).not.toHaveBeenCalled(); - - cancelButton.click(); - - // Should navigate to libraries tab on studio home - expect(await screen.findByText('Exit Migration?')).toBeInTheDocument(); - const exitButton = screen.getByRole('button', { name: /exit/i }); - exitButton.click(); - - await waitFor(() => { - expect(mockNavigate).toHaveBeenCalledWith('/libraries'); - }); - }); - - it('should select legacy libraries', async () => { - const user = userEvent.setup(); - renderPage(); - expect(await screen.findByText('Migrate Legacy Libraries')).toBeInTheDocument(); - - const nextButton = screen.getByRole('button', { name: /next/i }); - // The next button is disabled - expect(nextButton).toBeDisabled(); - - // The filter is Unmigrated by default - const filterButton = await screen.findByRole('button', { name: /unmigrated/i }); - expect(filterButton).toBeInTheDocument(); - - // Clear filter to show all - await user.click(filterButton); - const clearButton = await screen.findByRole('button', { name: /clear filter/i }); - await user.click(clearButton); - - expect(await screen.findByText('MBA')).toBeInTheDocument(); - expect(await screen.findByText('Legacy library 1')).toBeInTheDocument(); - expect(await screen.findByText('MBA 1')).toBeInTheDocument(); - - const library1 = screen.getByRole('checkbox', { name: 'MBA' }); - const library2 = screen.getByRole('checkbox', { name: /legacy library 1 imported library/i }); - - expect(library1).not.toBeChecked(); - expect(library2).not.toBeChecked(); - - library1.click(); - - expect(library1).toBeChecked(); - expect(library2).not.toBeChecked(); - expect(nextButton).not.toBeDisabled(); - - library2.click(); - expect(library1).toBeChecked(); - expect(library2).toBeChecked(); - expect(nextButton).not.toBeDisabled(); - - library2.click(); - expect(library1).toBeChecked(); - expect(library2).not.toBeChecked(); - expect(nextButton).not.toBeDisabled(); - }); - - it('should select all legacy libraries', async () => { - const user = userEvent.setup(); - renderPage(); - expect(await screen.findByText('Migrate Legacy Libraries')).toBeInTheDocument(); - - // The filter is Unmigrated by default - const filterButton = await screen.findByRole('button', { name: /unmigrated/i }); - expect(filterButton).toBeInTheDocument(); - - // Clear filter to show all - await user.click(filterButton); - const clearButton = await screen.findByRole('button', { name: /clear filter/i }); - await user.click(clearButton); - - const selectAll = screen.getByRole('checkbox', { name: /select all/i }); - await user.click(selectAll); - - const library1 = screen.getByRole('checkbox', { name: 'MBA' }); - const library2 = screen.getByRole('checkbox', { name: /legacy library 1 imported library/i }); - const library3 = screen.getByRole('checkbox', { name: 'MBA 1' }); - - expect(library1).toBeChecked(); - expect(library2).toBeChecked(); - expect(library3).toBeChecked(); - - await user.click(selectAll); - expect(library1).not.toBeChecked(); - expect(library2).not.toBeChecked(); - expect(library3).not.toBeChecked(); - }); - - it('should back to select legacy libraries', async () => { - const user = userEvent.setup(); - renderPage(); - // The filter is Unmigrated by default - const filterButton = await screen.findByRole('button', { name: /unmigrated/i }); - expect(filterButton).toBeInTheDocument(); - - // Clear filter to show all - await user.click(filterButton); - const clearButton = await screen.findByRole('button', { name: /clear filter/i }); - await user.click(clearButton); - - expect(await screen.findByText('MBA')).toBeInTheDocument(); - expect(await screen.findByText('Legacy library 1')).toBeInTheDocument(); - expect(await screen.findByText('MBA 1')).toBeInTheDocument(); - - const legacyLibrary = screen.getByRole('checkbox', { name: 'MBA' }); - legacyLibrary.click(); - - const nextButton = screen.getByRole('button', { name: /next/i }); - nextButton.click(); - - // Should show alert of SelectDestinationView - expect(await screen.findByText(/you selected will be migrated to the Content Library you/)).toBeInTheDocument(); - - const backButton = screen.getByRole('button', { name: /back/i }); - backButton.click(); - - // The selected legacy library remains checked - expect(legacyLibrary).toBeChecked(); - - // The filter remains the same - expect(await screen.findByText('MBA')).toBeInTheDocument(); - expect(await screen.findByText('Legacy library 1')).toBeInTheDocument(); - expect(await screen.findByText('MBA 1')).toBeInTheDocument(); - }); - - it('should select a library destination', async () => { - renderPage(); - expect(await screen.findByText('Migrate Legacy Libraries')).toBeInTheDocument(); - expect(await screen.findByText('MBA')).toBeInTheDocument(); - - const legacyLibrary = screen.getByRole('checkbox', { name: 'MBA' }); - legacyLibrary.click(); - - const nextButton = screen.getByRole('button', { name: /next/i }); - nextButton.click(); - - // Should show alert of SelectDestinationView - expect(await screen.findByText(/you selected will be migrated to the Content Library you/)).toBeInTheDocument(); - - // The next button is disabled - expect(nextButton).toBeDisabled(); - - expect(await screen.findByText('Test Library 1')).toBeInTheDocument(); - const radioButton = screen.getByRole('radio', { name: /test library 1/i }); - radioButton.click(); - - expect(radioButton).toBeChecked(); - expect(nextButton).not.toBeDisabled(); - }); - - it('should back to select library destination', async () => { - const user = userEvent.setup(); - renderPage(); - expect(await screen.findByText('Migrate Legacy Libraries')).toBeInTheDocument(); - expect(await screen.findByText('MBA')).toBeInTheDocument(); - - const legacyLibrary = screen.getByRole('checkbox', { name: 'MBA' }); - await user.click(legacyLibrary); - - const nextButton = await screen.findByRole('button', { name: /next/i }); - await user.click(nextButton); - - // Should show alert of SelectDestinationView - expect(await screen.findByText(/you selected will be migrated to the Content Library you/)).toBeInTheDocument(); - expect(await screen.findByText('Test Library 1')).toBeInTheDocument(); - const radioButton = screen.getByRole('radio', { name: /test library 1/i }); - await user.click(radioButton); - - await user.click(nextButton); - const alert = await screen.findByRole('alert'); - expect( - await within(alert).findByText( - /All content from the legacy library you selected will be migrated to/, - ), - ).toBeInTheDocument(); - - const backButton = screen.getByRole('button', { name: /back/i }); - await user.click(backButton); - - expect(await screen.findByText('Test Library 1')).toBeInTheDocument(); - // The selected v2 library remains checked - expect(radioButton).toBeChecked(); - }); - - it('should open the create new library modal', async () => { - const user = userEvent.setup(); - axiosMock.onGet(getStudioHomeApiUrl()).reply(200, studioHomeMock); - axiosMock.onPost(getContentLibraryV2CreateApiUrl()).reply(200, { - id: 'lib:SampleTaxonomyOrg1:TL1', - }); - - renderPage(); - expect(await screen.findByText('Migrate Legacy Libraries')).toBeInTheDocument(); - expect(await screen.findByText('MBA')).toBeInTheDocument(); - - const legacyLibrary = screen.getByRole('checkbox', { name: 'MBA' }); - legacyLibrary.click(); - - const nextButton = screen.getByRole('button', { name: /next/i }); - nextButton.click(); - - // Should show alert of SelectDestinationView - expect(await screen.findByText(/you selected will be migrated to the Content Library you/)).toBeInTheDocument(); - - const createButton = await screen.findByRole('button', { name: /create new library/i }); - expect(createButton).toBeInTheDocument(); - createButton.click(); - - // Should open the create library modal - expect(await screen.findByText('Create new library')).toBeInTheDocument(); - - // Cancel and close the create library modal - const cancelButton = screen.getByRole('button', { name: /cancel/i }); - cancelButton.click(); - await waitFor(() => { - expect(screen.queryByText('Create new library')).not.toBeInTheDocument(); - }); - - // Open the modal again and create a new library - createButton.click(); - const titleInput = await screen.findByRole('textbox', { name: /library name/i }); - await user.click(titleInput); - await user.type(titleInput, 'Test Library Name'); - - const orgInput = await screen.findByRole('combobox', { name: /organization/i }); - await user.click(orgInput); - await user.type(orgInput, 'org1'); - await user.tab(); - - const slugInput = await screen.findByRole('textbox', { name: /library id/i }); - await user.click(slugInput); - await user.type(slugInput, 'test_library_slug'); - - const confirmButton = await screen.findByRole('button', { name: 'Create' }); - confirmButton.click(); - await waitFor(() => { - expect(axiosMock.history.post.length).toBe(1); - }); - expect(axiosMock.history.post[0].data).toBe( - '{"description":"","title":"Test Library Name","org":"org1","slug":"test_library_slug"}', - ); - - // The library should be checked - expect(screen.getByRole('radio', { name: /test library 1/i })).toBeChecked(); - }); - - it('should confirm migration', async () => { - const user = userEvent.setup(); - axiosMock.onPost(bulkModulestoreMigrateUrl()).reply(200); - renderPage(); - expect(await screen.findByText('Migrate Legacy Libraries')).toBeInTheDocument(); - expect(await screen.findByText('MBA')).toBeInTheDocument(); - - // The filter is 'unmigrated' by default. - // Clear the filter to select all libraries - const filterButton = screen.getByRole('button', { name: /unmigrated/i }); - await user.click(filterButton); - const clearButton = await screen.findByRole('button', { name: /clear filter/i }); - await user.click(clearButton); - - const legacyLibrary1 = screen.getByRole('checkbox', { name: 'MBA' }); - const legacyLibrary2 = screen.getByRole('checkbox', { name: /legacy library 1 imported library/i }); - const legacyLibrary3 = screen.getByRole('checkbox', { name: 'MBA 1' }); - - legacyLibrary1.click(); - legacyLibrary2.click(); - legacyLibrary3.click(); - - const nextButton = screen.getByRole('button', { name: /next/i }); - await user.click(nextButton); - - // Should show alert of SelectDestinationView - expect(await screen.findByText(/you selected will be migrated to the Content Library you/)).toBeInTheDocument(); - expect(await screen.findByText('Test Library 1')).toBeInTheDocument(); - const radioButton = screen.getByRole('radio', { name: /test library 1/i }); - await user.click(radioButton); - - await user.click(nextButton); - - // Should show alert of ConfirmationView - const alert = await screen.findByRole('alert'); - expect( - await within(alert).findByText( - /All content from the 3 legacy libraries you selected will be migrated to/, - ), - ).toBeInTheDocument(); - expect(screen.getByText('MBA')).toBeInTheDocument(); - expect(screen.getByText('Legacy library 1')).toBeInTheDocument(); - expect(screen.getByText('MBA 1')).toBeInTheDocument(); - expect(screen.getByText( - /Previously migrated library. Any problem bank links were already moved will be migrated to/i, - )).toBeInTheDocument(); - - const confirmButton = screen.getByRole('button', { name: /confirm/i }); - confirmButton.click(); - - await waitFor(() => { - expect(axiosMock.history.post.length).toBe(1); - }); - expect(axiosMock.history.post[0].data).toBe( - '{"sources":["library-v1:MBA+123","library-v1:UNIX+LG1","library-v1:MBA+1234"],"target":"lib:SampleTaxonomyOrg1:TL1","create_collections":true,"repeat_handling_strategy":"fork"}', - ); - expect(mockShowToast).toHaveBeenCalledWith('3 legacy libraries are being migrated.'); - }); - - it('should show error when confirm migration', async () => { - const user = userEvent.setup(); - axiosMock.onPost(bulkModulestoreMigrateUrl()).reply(400); - renderPage(); - expect(await screen.findByText('Migrate Legacy Libraries')).toBeInTheDocument(); - expect(await screen.findByText('MBA')).toBeInTheDocument(); - - // The filter is 'unmigrated' by default. - // Clear the filter to select all libraries - const filterButton = screen.getByRole('button', { name: /unmigrated/i }); - await user.click(filterButton); - const clearButton = await screen.findByRole('button', { name: /clear filter/i }); - await user.click(clearButton); - - const legacyLibrary1 = screen.getByRole('checkbox', { name: 'MBA' }); - const legacyLibrary2 = screen.getByRole('checkbox', { name: /legacy library 1 imported library/i }); - const legacyLibrary3 = screen.getByRole('checkbox', { name: 'MBA 1' }); - - legacyLibrary1.click(); - legacyLibrary2.click(); - legacyLibrary3.click(); - - const nextButton = screen.getByRole('button', { name: /next/i }); - await user.click(nextButton); - - // Should show alert of SelectDestinationView - expect(await screen.findByText(/you selected will be migrated to the Content Library you/)).toBeInTheDocument(); - expect(await screen.findByText('Test Library 1')).toBeInTheDocument(); - const radioButton = screen.getByRole('radio', { name: /test library 1/i }); - await user.click(radioButton); - - await user.click(nextButton); - - // Should show alert of ConfirmationView - const alert = await screen.findByRole('alert'); - expect( - await within(alert).findByText( - /All content from the 3 legacy libraries you selected will be migrated to/, - { exact: false }, - ), - ).toBeInTheDocument(); - expect(screen.getByText('MBA')).toBeInTheDocument(); - expect(screen.getByText('Legacy library 1')).toBeInTheDocument(); - expect(screen.getByText('MBA 1')).toBeInTheDocument(); - expect(screen.getByText( - /Previously migrated library. Any problem bank links were already moved will be migrated to/i, - )).toBeInTheDocument(); - - const confirmButton = screen.getByRole('button', { name: /confirm/i }); - confirmButton.click(); - - await waitFor(() => { - expect(axiosMock.history.post.length).toBe(1); - }); - expect(axiosMock.history.post[0].data).toBe( - '{"sources":["library-v1:MBA+123","library-v1:UNIX+LG1","library-v1:MBA+1234"],"target":"lib:SampleTaxonomyOrg1:TL1","create_collections":true,"repeat_handling_strategy":"fork"}', - ); - expect(mockShowToast).toHaveBeenCalledWith('Legacy libraries migration have failed'); - }); - - it('should show help sidebar', async () => { - renderPage(); - expect(await screen.findByText('Help & Support')).toBeInTheDocument(); - expect(screen.getByText('What’s different in the new Content Libraries experience?')).toBeInTheDocument(); - expect(screen.getByText('What happens when I migrate my Legacy Libraries?')).toBeInTheDocument(); - expect(screen.getByText('How do I migrate my Legacy Libraries?')).toBeInTheDocument(); - }); -}); diff --git a/src/legacy-libraries-migration/LegacyLibMigrationPage.tsx b/src/legacy-libraries-migration/LegacyLibMigrationPage.tsx deleted file mode 100644 index 0e106054e8..0000000000 --- a/src/legacy-libraries-migration/LegacyLibMigrationPage.tsx +++ /dev/null @@ -1,264 +0,0 @@ -import { - useCallback, - useContext, - 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, - ModalDialog, - StatefulButton, - Stepper, - useToggle, -} from '@openedx/paragon'; -import Header from '@src/header'; -import SubHeader from '@src/generic/sub-header/SubHeader'; -import type { ContentLibrary } from '@src/library-authoring/data/api'; -import type { LibraryV1Data } from '@src/studio-home/data/api'; -import { ToastContext } from '@src/generic/toast-context'; -import { Filter, LibrariesList } from '@src/studio-home/tabs-section/libraries-tab'; - -import { useBulkModulestoreMigrate } from '@src/data/apiHooks'; -import messages from './messages'; -import { SelectDestinationView } from './SelectDestinationView'; -import { ConfirmationView } from './ConfirmationView'; -import { LegacyMigrationHelpSidebar } from './LegacyMigrationHelpSidebar'; - -export type MigrationStep = 'select-libraries' | 'select-destination' | 'confirmation-view'; - -const ExitModal = ({ - isExitModalOpen, - closeExitModal, -}: { - isExitModalOpen: boolean; - closeExitModal: () => void; -}) => { - const intl = useIntl(); - const navigate = useNavigate(); - - return ( - - - - - - - - - - - - - - - - - - - ); -}; - -export const LegacyLibMigrationPage = () => { - const intl = useIntl(); - const navigate = useNavigate(); - const { showToast } = useContext(ToastContext); - const [currentStep, setCurrentStep] = useState('select-libraries'); - const [isExitModalOpen, openExitModal, closeExitModal] = useToggle(false); - const [legacyLibraries, setLegacyLibraries] = useState([]); - const [migrationFilter, setMigrationFilter] = useState([Filter.unmigrated]); - const [destinationLibrary, setDestination] = useState(); - const [confirmationButtonState, setConfirmationButtonState] = useState('default'); - const migrate = useBulkModulestoreMigrate(); - - const handleMigrate = useCallback(async () => { - if (destinationLibrary) { - try { - const migrationTask = await migrate.mutateAsync({ - sources: legacyLibraries.map((lib) => lib.libraryKey), - target: destinationLibrary.id, - createCollections: true, - repeatHandlingStrategy: 'fork', - }); - showToast(intl.formatMessage(messages.migrationInProgress, { - count: legacyLibraries.length, - })); - navigate(`/library/${destinationLibrary.id}?migration_task=${migrationTask.uuid}`); - } catch { - showToast(intl.formatMessage(messages.migrationFailed)); - } - } - }, [migrate, legacyLibraries, destinationLibrary]); - - const handleNext = useCallback(() => { - switch (currentStep) { - case 'select-libraries': - setCurrentStep('select-destination'); - break; - case 'select-destination': - setCurrentStep('confirmation-view'); - break; - case 'confirmation-view': - setConfirmationButtonState('pending'); - // eslint-disable-next-line @typescript-eslint/no-floating-promises - handleMigrate(); - break; - default: - /* istanbul ignore next */ - break; - } - }, [currentStep, setCurrentStep, handleMigrate]); - - const handleBack = useCallback(() => { - switch (currentStep) { - case 'select-libraries': - openExitModal(); - break; - case 'select-destination': - setCurrentStep('select-libraries'); - break; - case 'confirmation-view': - setCurrentStep('select-destination'); - break; - default: - /* istanbul ignore next */ - break; - } - }, [currentStep, setCurrentStep]); - - const isNextDisabled = useCallback(() => { - switch (currentStep) { - case 'select-libraries': - return legacyLibraries.length === 0; - case 'select-destination': - return destinationLibrary === undefined; - case 'confirmation-view': - /* istanbul ignore next */ - return false; - default: - /* istanbul ignore next */ - return true; - } - }, [legacyLibraries, currentStep, destinationLibrary]); - - const handleUpdateLegacyLibraries = useCallback((library: LibraryV1Data, action: 'add' | 'remove') => { - if (action === 'add') { - setLegacyLibraries([...legacyLibraries, library]); - } else { - setLegacyLibraries(legacyLibraries.filter(item => item.libraryKey !== library.libraryKey)); - } - }, [legacyLibraries, setLegacyLibraries]); - - const legacyLibrariesIds = useMemo(() => legacyLibraries.map(item => item.libraryKey), [legacyLibraries]); - - return ( - <> - - - {intl.formatMessage(messages.siteTitle)} - - -
-
- - -
- -
- - - - - - - - - - - {destinationLibrary && ( - - )} - - -
-
-
- - {currentStep !== 'confirmation-view' ? - ( - - ) : - ( - - )} -
-
-
- - - -
-
- - - ); -}; diff --git a/src/legacy-libraries-migration/LegacyMigrationHelpSidebar.tsx b/src/legacy-libraries-migration/LegacyMigrationHelpSidebar.tsx deleted file mode 100644 index abe5f0ae36..0000000000 --- a/src/legacy-libraries-migration/LegacyMigrationHelpSidebar.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { FormattedMessage } from '@edx/frontend-platform/i18n'; -import { Icon, Stack } from '@openedx/paragon'; -import { Question } from '@openedx/paragon/icons'; -import { Div, Paragraph } from '@src/utils'; - -import messages from './messages'; - -export const LegacyMigrationHelpSidebar = () => ( -
- - - - - - -
- - - - - - - - - -
- - - - - - - - -
- - - - - - - - -
-
-); diff --git a/src/legacy-libraries-migration/SelectDestinationView.tsx b/src/legacy-libraries-migration/SelectDestinationView.tsx deleted file mode 100644 index 04e7cfc952..0000000000 --- a/src/legacy-libraries-migration/SelectDestinationView.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { FormattedMessage } from '@edx/frontend-platform/i18n'; -import { Alert, Container } from '@openedx/paragon'; - -import LibrariesV2List from '@src/studio-home/tabs-section/libraries-v2-tab'; -import type { ContentLibrary } from '@src/library-authoring/data/api'; - -import messages from './messages'; - -interface SelectDestinationViewProps { - destinationId?: string; - setDestinationId: (library: ContentLibrary) => void; - legacyLibCount: number; -} - -export const SelectDestinationView = ({ - destinationId, - setDestinationId, - legacyLibCount, -}: SelectDestinationViewProps) => ( - - - - - - -); diff --git a/src/legacy-libraries-migration/index.scss b/src/legacy-libraries-migration/index.scss deleted file mode 100644 index 980563bd59..0000000000 --- a/src/legacy-libraries-migration/index.scss +++ /dev/null @@ -1,54 +0,0 @@ -.legacy-library-migration-page { - .migration-container { - // Calculate all the screen size subtracting the height of the header and top/bottom margins - min-height: calc(calc(100vh - 60px) - calc(var(--pgn-spacing-spacer-base) * 6)); - - .courses-tab-container { - min-height: auto; - } - - .migration-content { - flex: 1; - } - - .card-item { - margin: 0 0 16px !important; - } - } - - .confirmation-view { - .pgn__card-header-content { - margin-top: calc(var(--pgn-spacing-spacer-base)) !important;; - } - } - - .content-buttons { - width: 100%; - position: sticky; - bottom: 0; - } - - .row { - margin-right: 0; // To avoid create a horizontal scroll using Layout - } - - [class*="col-"] { - // To avoid create an empty gray space between the main content and the sidebar - padding-right: 0; - padding-left: 0; - } -} - -.legacy-libraries-migration-help { - z-index: 1000; // same as header - flex: 350px 0 0; - position: sticky; - top: 0; - right: 0; - height: 100vh; - overflow-y: auto; - - hr { - width: 100%; - } -} diff --git a/src/legacy-libraries-migration/messages.ts b/src/legacy-libraries-migration/messages.ts deleted file mode 100644 index 00e828342a..0000000000 --- a/src/legacy-libraries-migration/messages.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { defineMessages } from '@edx/frontend-platform/i18n'; - -const messages = defineMessages({ - siteTitle: { - id: 'legacy-libraries-migration.site-title', - defaultMessage: 'Migrate Legacy Libraries', - description: 'Title for the page to migrate legacy libraries.', - }, - cancel: { - id: 'legacy-libraries-migration.button.cancel', - defaultMessage: 'Cancel', - description: 'Text of the button to cancel the migration.', - }, - next: { - id: 'legacy-libraries-migration.button.next', - defaultMessage: 'Next', - description: 'Text of the button to go to the next step of the migration.', - }, - back: { - id: 'legacy-libraries-migration.button.back', - defaultMessage: 'Back', - description: 'Text of the button to go back to the previous step of the migration.', - }, - confirm: { - id: 'legacy-libraries-migration.button.confirm', - defaultMessage: 'Confirm', - description: 'Text of the button to confirm the migration.', - }, - selectLegacyLibrariesStepTitle: { - id: 'legacy-libraries-migration.select-legacy-libraries-step.title', - defaultMessage: 'Select Legacy Libraries', - description: 'Title of the Select Legacy Libraries step', - }, - selectDestinationStepTitle: { - id: 'legacy-libraries-migration.select-destination-step.title', - defaultMessage: 'Select Destination', - description: 'Title of the Select Destination step', - }, - confirmStepTitle: { - id: 'legacy-libraries-migration.confirm-step.title', - defaultMessage: 'Confirm', - description: 'Title of the Confirm step', - }, - exitModalTitle: { - id: 'legacy-libraries-migration.exit-modal.title', - defaultMessage: 'Exit Migration?', - description: 'Title of the modal to confirm exit the migration.', - }, - exitModalBodyText: { - id: 'legacy-libraries-migration.exit-modal.body', - defaultMessage: 'By exiting, all changes will be lost and no libraries will be migrated.', - description: 'Body text of the modal to confirm exit the migration.', - }, - exitModalCancelText: { - id: 'legacy-libraries-migration.exit-modal.button.cancel.text', - defaultMessage: 'Continue Migrating', - description: 'Text for the button to close the modal to confirm exit the migration.', - }, - exitModalConfirmText: { - id: 'legacy-libraries-migration.exit-modal.button.confirm.text', - defaultMessage: 'Exit', - description: 'Text for the button to confirm exit the migration.', - }, - selectDestinationAlert: { - id: 'legacy-libraries-migration.select-destination.alert.text', - defaultMessage: 'All content from the' - + ' {count, plural, one {legacy library} other {{count} legacy libraries}} you selected will' - + ' be migrated to the Content Library you select, organized into collections. Legacy library content used' - + ' in courses will continue to work as-is. To receive any future changes to migrated content,' - + ' you must update these references within your course.', - description: 'Alert text in the select destination step of the legacy libraries migration page.', - }, - confirmationViewAlert: { - id: 'legacy-libraries-migration.select-destination.alert.text', - defaultMessage: 'All content from the' - + ' {count, plural, one {legacy library} other {{count} legacy libraries}} you selected will' - + ' be migrated to {libraryName} and organized into collections. Legacy library content used' - + ' in courses will continue to work as-is. To receive any future changes to migrated content,' - + ' you must update these references within your course.', - description: 'Alert text in the confirmation step of the legacy libraries migration page.', - }, - previouslyMigratedAlert: { - id: 'legacy-libraries-migration.confirmation-step.card.previously-migrated.text', - defaultMessage: 'Previously migrated library. Any problem bank links were already' - + ' moved will be migrated to {libraryName}', - description: 'Alert text when the legacy library is already migrated.', - }, - helpAndSupportTitle: { - id: 'legacy-libraries-migration.helpAndSupport.title', - defaultMessage: 'Help & Support', - description: 'Title of the Help & Support sidebar', - }, - helpAndSupportFirstQuestionTitle: { - id: 'legacy-libraries-migration.helpAndSupport.q1.title', - defaultMessage: 'What’s different in the new Content Libraries experience?', - description: 'Title of the first question in the Help & Support sidebar', - }, - helpAndSupportFirstQuestionBody: { - id: 'legacy-libraries-migration.helpAndSupport.q1.body', - defaultMessage: 'In the new Content Libraries experience, you can author sections,' - + ' subsections, units, and many types of components. Library content can be reused across many courses,' - + ' and kept up-to-date. Content libraries now support increased collaboration across authoring teams.', - description: 'Body of the first question in the Help & Support sidebar', - }, - helpAndSupportSecondQuestionTitle: { - id: 'legacy-libraries-migration.helpAndSupport.q2.title', - defaultMessage: 'What happens when I migrate my Legacy Libraries?', - description: 'Title of the second question in the Help & Support sidebar', - }, - helpAndSupportSecondQuestionBody: { - id: 'legacy-libraries-migration.helpAndSupport.q2.body', - defaultMessage: 'All legacy library content is supported in the new experience.' - + ' Content from legacy libraries will be migrated to its own collection in the new Content Libraries experience.' - + ' This collection will have the same name as your original library. Courses that use legacy library content will' - + ' continue to function as usual, linked to the migrated version within the new libraries experience.', - description: 'Body of the second question in the Help & Support sidebar', - }, - helpAndSupportThirdQuestionTitle: { - id: 'legacy-libraries-migration.helpAndSupport.q3.title', - defaultMessage: 'How do I migrate my Legacy Libraries?', - description: 'Title of the third question in the Help & Support sidebar', - }, - helpAndSupportThirdQuestionBody: { - id: 'legacy-libraries-migration.helpAndSupport.q3.body.2', - defaultMessage: '

There are three steps to migrating legacy libraries:

' - + '

1 - Select Legacy Libraries
' - + 'You can select up to 50 legacy libraries for migration in this step. By default, only libraries that have' - + ' not yet been migrated are shown. To see all libraries, remove the filter.' - + ' You can select up to 50 legacy libraries for migration, but only one destination' - + ' v2 Content Library per migration.

' - + '

2 - Select Destination
' - + 'You can migrate legacy libraries to an existing Content Library in the new experience,' - + ' or you can create a new destination. You can only select one v2 Content Library per migration.' - + ' All your content will be migrated, and kept organized in collections.

' - + '

3 - Confirm
' - + 'In this step, review your migration. Once you confirm, migration will begin.' - + ' It may take some time to complete.

', - description: 'Part 2 of the Body of the third question in the Help & Support sidebar', - }, - migrationInProgress: { - id: 'legacy-libraries-migration.confirmation-step.toast.migration-in-progress', - defaultMessage: '{count, plural, one {{count} legacy library is} other {{count} legacy libraries are}} being migrated.', - description: 'Toast message that indicates the legacy libraries are being migrated', - }, - migrationFailed: { - id: 'legacy-libraries-migration.confirmation-step.toast.migration-failed', - defaultMessage: 'Legacy libraries migration have failed', - description: 'Toast message that indicates the migration of legacy libraries is failed', - }, - migrationFailedMultiple: { - id: 'legacy-libraries-migration.confirmation-step.toast.migration-multiple-failed', - defaultMessage: 'Multiple legacy libraries have failed', - description: 'Toast message that indicates the migration of legacy libraries is failed', - }, - migrationFailedOneLibrary: { - id: 'legacy-libraries-migration.confirmation-step.toast.migration-one-failed', - defaultMessage: 'The legacy library with this key has failed: {key}', - description: 'Toast message that indicates that one legacy library has failed in the migration', - }, - migrationSuccess: { - id: 'legacy-libraries-migration.confirmation-step.toast.migration-success', - defaultMessage: 'The migration of legacy libraries has been completed successfully.', - description: 'Toast message that indicates the migration of legacy libraries is finished', - }, -}); - -export default messages; diff --git a/src/library-authoring/LibraryAuthoringPage.test.tsx b/src/library-authoring/LibraryAuthoringPage.test.tsx index 2cc0354e88..16d825af0a 100644 --- a/src/library-authoring/LibraryAuthoringPage.test.tsx +++ b/src/library-authoring/LibraryAuthoringPage.test.tsx @@ -10,7 +10,6 @@ import { within, } from '@src/testUtils'; import studioHomeMock from '@src/studio-home/__mocks__/studioHomeMock'; -import { mockGetMigrationStatus } from '@src/data/api.mocks'; import mockEmptyResult from '@src/search-modal/__mocks__/empty-search-result.json'; import { mockContentSearchConfig } from '@src/search-manager/data/api.mock'; import { getStudioHomeApiUrl } from '@src/studio-home/data/api'; @@ -35,7 +34,6 @@ mockContentSearchConfig.applyMock(); mockContentLibrary.applyMock(); mockGetLibraryTeam.applyMock(); mockXBlockFields.applyMock(); -mockGetMigrationStatus.applyMock(); const searchEndpoint = 'http://mock.meilisearch.local/multi-search'; @@ -1086,59 +1084,4 @@ describe('', () => { ); }); - it('Should show success in migration legacy libraries', async () => { - render(, { - path, - routerProps: { - initialEntries: [ - `/library/${mockContentLibrary.libraryId}?migration_task=${mockGetMigrationStatus.migrationId}`, - ], - }, - }); - - await waitFor(() => - expect(mockShowToast).toHaveBeenCalledWith('The migration of legacy libraries has been completed successfully.') - ); - }); - - it('Should show fail in migration legacy libraries', async () => { - render(, { - path, - routerProps: { - initialEntries: [ - `/library/${mockContentLibrary.libraryId}?migration_task=${mockGetMigrationStatus.migrationIdFailed}`, - ], - }, - }); - - await waitFor(() => expect(mockShowToast).toHaveBeenCalledWith('Legacy libraries migration have failed')); - }); - - it('Should show fail multiple legacy libraries in a migration', async () => { - render(, { - path, - routerProps: { - initialEntries: [ - `/library/${mockContentLibrary.libraryId}?migration_task=${mockGetMigrationStatus.migrationIdMultiple}`, - ], - }, - }); - - await waitFor(() => expect(mockShowToast).toHaveBeenCalledWith('Multiple legacy libraries have failed')); - }); - - it('Should show fail one legacy library in a migration', async () => { - render(, { - path, - routerProps: { - initialEntries: [ - `/library/${mockContentLibrary.libraryId}?migration_task=${mockGetMigrationStatus.migrationIdOneLibrary}`, - ], - }, - }); - - await waitFor(() => - expect(mockShowToast).toHaveBeenCalledWith('The legacy library with this key has failed: legacy-lib-1') - ); - }); }); diff --git a/src/library-authoring/LibraryAuthoringPage.tsx b/src/library-authoring/LibraryAuthoringPage.tsx index 29f71acfaf..9625130d61 100644 --- a/src/library-authoring/LibraryAuthoringPage.tsx +++ b/src/library-authoring/LibraryAuthoringPage.tsx @@ -1,7 +1,6 @@ import { type ReactNode, useCallback, - useContext, useEffect, useState, } from 'react'; @@ -20,10 +19,8 @@ import { Tabs, } from '@openedx/paragon'; import { Add, InfoOutline } from '@openedx/paragon/icons'; -import { Link, useLocation, useNavigate } from 'react-router-dom'; -import { useQueryClient } from '@tanstack/react-query'; +import { Link, useLocation } from 'react-router-dom'; -import { useModulestoreMigrationStatus } from '@src/data/apiHooks'; import Loading from '@src/generic/Loading'; import SubHeader from '@src/generic/sub-header/SubHeader'; import Header from '@src/header'; @@ -33,9 +30,6 @@ import { SearchContextProvider, TypesFilterData, } from '@src/search-manager'; -import { ToastContext } from '@src/generic/toast-context'; -import migrationMessages from '@src/legacy-libraries-migration/messages'; - import { FiltersProps } from '@src/library-authoring/library-filters'; import { MainFilters } from '@src/library-authoring/library-filters/MainFilters'; import { useMultiLibraryContext } from '@src/library-authoring/common/context/MultiLibraryContext'; @@ -47,7 +41,6 @@ import { useOptionalLibraryContext } from './common/context/LibraryContext'; import { SidebarBodyItemId, useSidebarContext } from './common/context/SidebarContext'; import { allLibraryPageTabs, ContentType, useLibraryRoutes } from './routes'; import messages from './messages'; -import { libraryQueryPredicate } from './data/apiHooks'; const HeaderActions = () => { const intl = useIntl(); @@ -142,17 +135,6 @@ const LibraryAuthoringPage = ({ }: LibraryAuthoringPageProps) => { const intl = useIntl(); const location = useLocation(); - const navigate = useNavigate(); - const params = new URLSearchParams(location.search); - const { showToast } = useContext(ToastContext); - const queryClient = useQueryClient(); - - // Get migration status every second if applicable - const migrationId = params.get('migration_task'); - const { - data: migrationStatusData, - } = useModulestoreMigrationStatus(migrationId); - const { isLoadingPage: isLoadingStudioHome, isFailedLoadingPage: isFailedLoadingStudioHome, @@ -226,45 +208,6 @@ const LibraryAuthoringPage = ({ } }, [navigateTo]); - // Verify the migration task status - if (migrationId && libraryId) { - let deleteMigrationIdParam = false; - if (migrationStatusData?.state === 'Succeeded') { - // Check if any library migrations failed. - // A `Succeeded` state means that the bulk migration ended, but some libraries might have failed. - const failedMigrations = migrationStatusData.parameters.filter(item => item.isFailed); - if (failedMigrations.length > 1) { - showToast(intl.formatMessage(migrationMessages.migrationFailedMultiple)); - } else if (failedMigrations.length === 1) { - showToast(intl.formatMessage( - migrationMessages.migrationFailedOneLibrary, - { - key: failedMigrations[0].source, - }, - )); - } else { - showToast(intl.formatMessage(migrationMessages.migrationSuccess)); - } - queryClient.invalidateQueries({ predicate: (query) => libraryQueryPredicate(query, libraryId) }); - deleteMigrationIdParam = true; - } else if (migrationStatusData?.state === 'Failed') { - // A `Failed` state means that the entire bulk migration has failed. - showToast(intl.formatMessage(migrationMessages.migrationFailed)); - deleteMigrationIdParam = true; - } else if (migrationStatusData?.state === 'Canceled') { - /* istanbul ignore next */ - deleteMigrationIdParam = true; - } - - if (deleteMigrationIdParam) { - params.delete('migration_task'); - navigate({ - pathname: location.pathname, - search: params.toString(), - }, { replace: true }); - } - } - if (isLoadingLibraryData) { return ; } diff --git a/src/studio-home/tabs-section/libraries-tab/MigrateLegacyLibrariesAlert.tsx b/src/studio-home/tabs-section/libraries-tab/MigrateLegacyLibrariesAlert.tsx deleted file mode 100644 index a4d72dd15b..0000000000 --- a/src/studio-home/tabs-section/libraries-tab/MigrateLegacyLibrariesAlert.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { useNavigate } from 'react-router-dom'; -import { Alert, Button } from '@openedx/paragon'; -import { Warning } from '@openedx/paragon/icons'; -import { FormattedMessage } from '@edx/frontend-platform/i18n'; - -import messages from '../messages'; - -export const MigrateLegacyLibrariesAlert = () => { - const navigate = useNavigate(); - - return ( - - - - -
-
- -
-
- -
-
-
- ); -}; diff --git a/src/studio-home/tabs-section/libraries-tab/index.tsx b/src/studio-home/tabs-section/libraries-tab/index.tsx deleted file mode 100644 index 35f089b858..0000000000 --- a/src/studio-home/tabs-section/libraries-tab/index.tsx +++ /dev/null @@ -1,354 +0,0 @@ -import { useCallback, useState } from 'react'; -import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; -import { - ActionRow, - Form, - Icon, - Menu, - MenuItem, - Pagination, - Row, - SearchField, - Stack, -} from '@openedx/paragon'; -import { Error, FilterList, AccessTime } from '@openedx/paragon/icons'; - -import { LoadingSpinner } from '@src/generic/Loading'; -import AlertMessage from '@src/generic/alert-message'; -import { useLibrariesV1Data } from '@src/studio-home/data/apiHooks'; -import { CardItem, MakeLinkOrSpan, PrevToNextName } from '@src/studio-home/card-item'; -import SearchFilterWidget from '@src/search-manager/SearchFilterWidget'; -import type { LibraryV1Data } from '@src/studio-home/data/api'; -import { parseLibraryKey } from '@src/generic/key-utils'; - -import messages from '../messages'; -import { MigrateLegacyLibrariesAlert } from './MigrateLegacyLibrariesAlert'; - -const CardList = ({ - data, - inSelectMode, - selectedIds, -}: { - data: LibraryV1Data[]; - inSelectMode: boolean; - selectedIds?: string[]; -}) => ( - // eslint-disable-next-line react/jsx-no-useless-fragment - <> - {data?.map(({ - displayName, - org, - number, - url, - isMigrated, - migratedToKey, - migratedToTitle, - migratedToCollectionKey, - libraryKey, - }) => { - const collectionLink = () => { - let libUrl = `/library/${migratedToKey}`; - if (migratedToCollectionKey) { - libUrl += `/collection/${migratedToCollectionKey}`; - } - return libUrl; - }; - - const migratedToKeyObj = migratedToKey ? parseLibraryKey(migratedToKey) : undefined; - - const subtitleWrapper = (subtitle) => ( - {migratedToKeyObj?.org} / {migratedToKeyObj?.lib}} - /> - ); - - return ( - - {migratedToTitle} - - ) : - null} - cardStatusWidget={(isMigrated && migratedToKey) ? - ( - - - - - - {migratedToTitle} - - - - ) : - null} - /> - ); - })} - -); - -function findInValues(arr: T[] | undefined, searchValue: string) { - return arr?.filter(o => - Object.values(o).some(value => - String(value).toLowerCase().includes( - String(searchValue).toLowerCase().trim(), - ) - ) - ); -} - -export enum Filter { - migrated = 'migrated', - unmigrated = 'unmigrated', -} - -export const BaseFilterState = Object.values(Filter); - -interface MigrationFilterProps { - filters: Filter[]; - setFilters: React.Dispatch>; -} - -const MigrationFilter = ({ filters, setFilters }: MigrationFilterProps) => { - const intl = useIntl(); - const filterLabels = { - [Filter.migrated]: intl.formatMessage(messages.librariesV1TabMigrationFilterMigratedLabel), - [Filter.unmigrated]: intl.formatMessage(messages.librariesV1TabMigrationFilterUnmigratedLabel), - }; - - let label = intl.formatMessage(messages.librariesV1TabMigrationFilterLabel); - // Set appliedFilters to empty list to indicate clear state - let appliedFilters: { label: string; }[] = []; - if (filters.length === 1) { - // Update label to display selected filter item, i.e., Migrated or Unmigrated - label = filterLabels[filters[0]]; - // Only update appliedFilters if a single option is selected else show clear state. - appliedFilters = filters.map(filter => ({ label: filterLabels[filter] })); - } - - const toggleFilter = useCallback((filter: Filter) => { - setFilters((oldList: Filter[]) => { - if (oldList.includes(filter)) { - const newList = oldList.filter(m => m !== filter); - if (newList.length === 0) { - return BaseFilterState; - } - return newList; - } - // istanbul ignore next - return [...oldList, filter]; - }); - }, [setFilters]); - - const menuItems = useCallback(() => - BaseFilterState.map((item) => ( - { - toggleFilter(item); - }} - > - {filterLabels[item]} - - )), [toggleFilter, BaseFilterState]); - - return ( - setFilters(BaseFilterState)} // On clear select both migrated and unmigrated options. - icon={FilterList} - skipLabelUpdate - > - - - - {menuItems()} - - - - - ); -}; - -interface LibrariesListProps { - selectedIds?: string[]; - handleCheck?: (library: LibraryV1Data, action: 'add' | 'remove') => void; - setSelectedLibraries?: (libraries: LibraryV1Data[]) => void; - hideMigationAlert?: boolean; - // We lift `migrationFilter` and `setMigrationFilter` into props - // so that the filter state is maintained consistently across different - // steps of the legacy libraries migration flow, and to allow - // parent components to control and persist the filter context. - migrationFilter: Filter[]; - setMigrationFilter: React.Dispatch>; -} - -export const LibrariesList = ({ - selectedIds, - handleCheck, - setSelectedLibraries, - hideMigationAlert = false, - migrationFilter, - setMigrationFilter, -}: LibrariesListProps) => { - const intl = useIntl(); - const { isPending, data, isError } = useLibrariesV1Data(); - const [currentPage, setCurrentPage] = useState(1); - const [search, setSearch] = useState(''); - - let filteredData = findInValues(data?.libraries, search || '') || []; - if (migrationFilter.length === 1) { - // filter results by migrated status - filteredData = filteredData.filter((obj) => obj.isMigrated === (migrationFilter[0] === Filter.migrated)); - } - const perPage = 10; - const totalPages = Math.ceil(filteredData.length / perPage); - const currentPageData = filteredData.slice((currentPage - 1) * perPage, currentPage * perPage); - const inSelectMode = handleCheck !== undefined; - - const allChecked = filteredData.every(value => selectedIds?.includes(value.libraryKey)); - const someChecked = filteredData.some(value => selectedIds?.includes(value.libraryKey)); - const checkboxIsIndeterminate = someChecked && !allChecked; - - const handleChangeCheckboxSet = useCallback((event) => { - if (handleCheck) { - const libraryId = event.target.value; - const library = currentPageData.find((item) => item.libraryKey === libraryId); - if (library) { - if (event.target.checked) { - handleCheck(library, 'add'); - } else { - handleCheck(library, 'remove'); - } - } - } - }, [handleCheck, currentPageData]); - - const handleSelectAll = useCallback(() => { - if (checkboxIsIndeterminate || selectedIds?.length === 0) { - setSelectedLibraries?.(filteredData); - } else { - setSelectedLibraries?.([]); - } - }, [checkboxIsIndeterminate, selectedIds, filteredData]); - - if (isPending) { - return ( - - - - ); - } - - if (isError) { - return ( - - - {intl.formatMessage(messages.librariesTabErrorMessage)} - - } - /> - ); - } - - return ( - <> - {!hideMigationAlert && } -
- - {inSelectMode && ( - - - - )} - {}} - onChange={setSearch} - value={search} - className="mr-4" - placeholder={intl.formatMessage(messages.librariesV2TabLibrarySearchPlaceholder)} - /> - - - {!isPending && !isError - && ( - <> - {intl.formatMessage(messages.coursesPaginationInfo, { - length: currentPageData?.length, - total: data?.libraries.length, - })} - - )} - - {inSelectMode ? - ( - - - - ) : - ( - - )} - {totalPages > 1 - && ( - - )} -
- - ); -}; diff --git a/src/studio-home/tabs-section/libraries-v2-tab/WelcomeLibrariesV2Alert.tsx b/src/studio-home/tabs-section/libraries-v2-tab/WelcomeLibrariesV2Alert.tsx index 8aacb3de41..eb1270ffeb 100644 --- a/src/studio-home/tabs-section/libraries-v2-tab/WelcomeLibrariesV2Alert.tsx +++ b/src/studio-home/tabs-section/libraries-v2-tab/WelcomeLibrariesV2Alert.tsx @@ -1,9 +1,6 @@ -import { Alert, Button, Hyperlink } from '@openedx/paragon'; +import { Alert, Hyperlink } from '@openedx/paragon'; import { FormattedMessage } from '@edx/frontend-platform/i18n'; import { getExternalLinkUrl } from '@edx/frontend-platform'; -import { useNavigate } from 'react-router-dom'; - -import { useLibrariesV1Data } from '@src/studio-home/data/apiHooks'; import messages from '../messages'; @@ -19,38 +16,8 @@ const libraryDocsLink = ( ); -export const WelcomeLibrariesV2Alert = () => { - const { data, isPending, isError } = useLibrariesV1Data(); - const navigate = useNavigate(); - - // Does not show the alert if we are still loading or if there was an error fetching libraries - if (isPending || isError) { - return null; - } - - const hasPendingV1Migrations = data.libraries.some(library => !library.isMigrated); - return ( - - {hasPendingV1Migrations ? - ( - <> - - - -
-
- - -
-
- -
-
- - ) : - } -
- ); -}; +export const WelcomeLibrariesV2Alert = () => ( + + + +); From c38f78a6bd4dfda7c7de3a5c12c0f5f784294309 Mon Sep 17 00:00:00 2001 From: salmannawaz Date: Tue, 16 Jun 2026 14:45:04 +0500 Subject: [PATCH 7/9] style: fix trailing blank line in LibraryAuthoringPage.test.tsx Co-Authored-By: Claude Sonnet 4.6 --- src/library-authoring/LibraryAuthoringPage.test.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/library-authoring/LibraryAuthoringPage.test.tsx b/src/library-authoring/LibraryAuthoringPage.test.tsx index 16d825af0a..91947dcd4e 100644 --- a/src/library-authoring/LibraryAuthoringPage.test.tsx +++ b/src/library-authoring/LibraryAuthoringPage.test.tsx @@ -1083,5 +1083,4 @@ describe('', () => { 'This page cannot be shown: Libraries v2 are disabled.', ); }); - }); From 721be46444190dc750aea3488f0becd84c79dad7 Mon Sep 17 00:00:00 2001 From: salmannawaz Date: Tue, 16 Jun 2026 15:47:56 +0500 Subject: [PATCH 8/9] fix: remove stale legacy-libraries-migration SCSS import Co-Authored-By: Claude Sonnet 4.6 --- src/index.scss | 1 - 1 file changed, 1 deletion(-) diff --git a/src/index.scss b/src/index.scss index 431cb9cf10..02413a6e66 100644 --- a/src/index.scss +++ b/src/index.scss @@ -30,7 +30,6 @@ @import "certificates/scss/Certificates"; @import "group-configurations/GroupConfigurations"; @import "optimizer-page/scan-results/ScanResults"; -@import "legacy-libraries-migration/"; @import "container-comparison/"; // To apply the glow effect to the selected Section/Subsection, in the Course Outline From 85da54a685f2c8bb6fca447ae0a1c2ac257d6ebc Mon Sep 17 00:00:00 2001 From: salmannawaz Date: Tue, 16 Jun 2026 23:18:48 +0500 Subject: [PATCH 9/9] Revert "feat: remove legacy library migration page" This reverts commit d649db6938d6e984d0953eed7bdbee733eebb763. --- src/index.jsx | 3 + src/index.scss | 1 + .../ConfirmationView.tsx | 90 ++++ .../LegacyLibMigrationPage.test.tsx | 449 ++++++++++++++++++ .../LegacyLibMigrationPage.tsx | 264 ++++++++++ .../LegacyMigrationHelpSidebar.tsx | 49 ++ .../SelectDestinationView.tsx | 33 ++ src/legacy-libraries-migration/index.scss | 54 +++ src/legacy-libraries-migration/messages.ts | 167 +++++++ .../LibraryAuthoringPage.test.tsx | 58 +++ .../LibraryAuthoringPage.tsx | 59 ++- .../MigrateLegacyLibrariesAlert.tsx | 28 ++ .../tabs-section/libraries-tab/index.tsx | 354 ++++++++++++++ .../WelcomeLibrariesV2Alert.tsx | 45 +- 14 files changed, 1647 insertions(+), 7 deletions(-) create mode 100644 src/legacy-libraries-migration/ConfirmationView.tsx create mode 100644 src/legacy-libraries-migration/LegacyLibMigrationPage.test.tsx create mode 100644 src/legacy-libraries-migration/LegacyLibMigrationPage.tsx create mode 100644 src/legacy-libraries-migration/LegacyMigrationHelpSidebar.tsx create mode 100644 src/legacy-libraries-migration/SelectDestinationView.tsx create mode 100644 src/legacy-libraries-migration/index.scss create mode 100644 src/legacy-libraries-migration/messages.ts create mode 100644 src/studio-home/tabs-section/libraries-tab/MigrateLegacyLibrariesAlert.tsx create mode 100644 src/studio-home/tabs-section/libraries-tab/index.tsx diff --git a/src/index.jsx b/src/index.jsx index c6818338af..609e045d92 100755 --- a/src/index.jsx +++ b/src/index.jsx @@ -44,6 +44,8 @@ import { ContentType } from './library-authoring/routes'; import 'react-datepicker/dist/react-datepicker.css'; import './index.scss'; +import { LegacyLibMigrationPage } from './legacy-libraries-migration/LegacyLibMigrationPage'; + const queryClient = new QueryClient({ defaultOptions: { queries: { @@ -73,6 +75,7 @@ const App = () => { } /> } /> + } /> } /> } /> ( + + + + {legacyLib.displayName} + + } + subtitle={ + + + {destinationName} + + } + /> + {legacyLib.isMigrated && ( + + + + + + + )} + +); + +interface ConfirmationViewProps { + destination: ContentLibrary; + legacyLibraries: LibraryV1Data[]; +} + +export const ConfirmationView = ({ + destination, + legacyLibraries, +}: ConfirmationViewProps) => ( + + + + + {legacyLibraries.map((legacyLib) => ( + + ))} + +); diff --git a/src/legacy-libraries-migration/LegacyLibMigrationPage.test.tsx b/src/legacy-libraries-migration/LegacyLibMigrationPage.test.tsx new file mode 100644 index 0000000000..fd8d433615 --- /dev/null +++ b/src/legacy-libraries-migration/LegacyLibMigrationPage.test.tsx @@ -0,0 +1,449 @@ +import type MockAdapter from 'axios-mock-adapter'; +import userEvent from '@testing-library/user-event'; + +import { + initializeMocks, + render, + screen, + waitFor, + within, +} from '@src/testUtils'; +import studioHomeMock from '@src/studio-home/__mocks__/studioHomeMock'; +import { mockGetContentLibraryV2List } from '@src/library-authoring/data/api.mocks'; +import { mockGetStudioHomeLibraries } from '@src/studio-home/data/api.mocks'; +import { getContentLibraryV2CreateApiUrl } from '@src/library-authoring/create-library/data/api'; +import { getStudioHomeApiUrl } from '@src/studio-home/data/api'; + +import { bulkModulestoreMigrateUrl } from '@src/data/api'; +import { LegacyLibMigrationPage } from './LegacyLibMigrationPage'; + +const path = '/libraries-v1/migrate/*'; +let axiosMock: MockAdapter; +let mockShowToast; + +mockGetStudioHomeLibraries.applyMock(); +mockGetContentLibraryV2List.applyMock(); + +const mockNavigate = jest.fn(); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useNavigate: () => mockNavigate, +})); + +jest.mock('@src/generic/data/apiHooks', () => ({ + ...jest.requireActual('@src/generic/data/apiHooks'), + useOrganizationListData: () => ({ + data: ['org1', 'org2', 'org3', 'org4', 'org5'], + isLoading: false, + }), +})); + +const renderPage = () => ( + render(, { path }) +); + +describe('', () => { + beforeEach(() => { + const mocks = initializeMocks(); + axiosMock = mocks.axiosMock; + mockShowToast = mocks.mockShowToast; + }); + + it('should render legacy library migration page', async () => { + renderPage(); + // Should render the title + expect(await screen.findByText('Migrate Legacy Libraries')).toBeInTheDocument(); + // Should render the Migration Steps Viewer + expect(screen.getByText('Select Legacy Libraries')).toBeInTheDocument(); + expect(screen.getByText('Select Destination')).toBeInTheDocument(); + expect(screen.getByText('Confirm')).toBeInTheDocument(); + }); + + it('should cancel the migration', async () => { + renderPage(); + expect(await screen.findByText('Migrate Legacy Libraries')).toBeInTheDocument(); + + const cancelButton = screen.getByRole('button', { name: /cancel/i }); + cancelButton.click(); + + // Should show exit confirmation modal + expect(await screen.findByText('Exit Migration?')).toBeInTheDocument(); + + // Close exit confirmation modal + const continueButton = screen.getByRole('button', { name: /continue migrating/i }); + continueButton.click(); + expect(mockNavigate).not.toHaveBeenCalled(); + + cancelButton.click(); + + // Should navigate to libraries tab on studio home + expect(await screen.findByText('Exit Migration?')).toBeInTheDocument(); + const exitButton = screen.getByRole('button', { name: /exit/i }); + exitButton.click(); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith('/libraries'); + }); + }); + + it('should select legacy libraries', async () => { + const user = userEvent.setup(); + renderPage(); + expect(await screen.findByText('Migrate Legacy Libraries')).toBeInTheDocument(); + + const nextButton = screen.getByRole('button', { name: /next/i }); + // The next button is disabled + expect(nextButton).toBeDisabled(); + + // The filter is Unmigrated by default + const filterButton = await screen.findByRole('button', { name: /unmigrated/i }); + expect(filterButton).toBeInTheDocument(); + + // Clear filter to show all + await user.click(filterButton); + const clearButton = await screen.findByRole('button', { name: /clear filter/i }); + await user.click(clearButton); + + expect(await screen.findByText('MBA')).toBeInTheDocument(); + expect(await screen.findByText('Legacy library 1')).toBeInTheDocument(); + expect(await screen.findByText('MBA 1')).toBeInTheDocument(); + + const library1 = screen.getByRole('checkbox', { name: 'MBA' }); + const library2 = screen.getByRole('checkbox', { name: /legacy library 1 imported library/i }); + + expect(library1).not.toBeChecked(); + expect(library2).not.toBeChecked(); + + library1.click(); + + expect(library1).toBeChecked(); + expect(library2).not.toBeChecked(); + expect(nextButton).not.toBeDisabled(); + + library2.click(); + expect(library1).toBeChecked(); + expect(library2).toBeChecked(); + expect(nextButton).not.toBeDisabled(); + + library2.click(); + expect(library1).toBeChecked(); + expect(library2).not.toBeChecked(); + expect(nextButton).not.toBeDisabled(); + }); + + it('should select all legacy libraries', async () => { + const user = userEvent.setup(); + renderPage(); + expect(await screen.findByText('Migrate Legacy Libraries')).toBeInTheDocument(); + + // The filter is Unmigrated by default + const filterButton = await screen.findByRole('button', { name: /unmigrated/i }); + expect(filterButton).toBeInTheDocument(); + + // Clear filter to show all + await user.click(filterButton); + const clearButton = await screen.findByRole('button', { name: /clear filter/i }); + await user.click(clearButton); + + const selectAll = screen.getByRole('checkbox', { name: /select all/i }); + await user.click(selectAll); + + const library1 = screen.getByRole('checkbox', { name: 'MBA' }); + const library2 = screen.getByRole('checkbox', { name: /legacy library 1 imported library/i }); + const library3 = screen.getByRole('checkbox', { name: 'MBA 1' }); + + expect(library1).toBeChecked(); + expect(library2).toBeChecked(); + expect(library3).toBeChecked(); + + await user.click(selectAll); + expect(library1).not.toBeChecked(); + expect(library2).not.toBeChecked(); + expect(library3).not.toBeChecked(); + }); + + it('should back to select legacy libraries', async () => { + const user = userEvent.setup(); + renderPage(); + // The filter is Unmigrated by default + const filterButton = await screen.findByRole('button', { name: /unmigrated/i }); + expect(filterButton).toBeInTheDocument(); + + // Clear filter to show all + await user.click(filterButton); + const clearButton = await screen.findByRole('button', { name: /clear filter/i }); + await user.click(clearButton); + + expect(await screen.findByText('MBA')).toBeInTheDocument(); + expect(await screen.findByText('Legacy library 1')).toBeInTheDocument(); + expect(await screen.findByText('MBA 1')).toBeInTheDocument(); + + const legacyLibrary = screen.getByRole('checkbox', { name: 'MBA' }); + legacyLibrary.click(); + + const nextButton = screen.getByRole('button', { name: /next/i }); + nextButton.click(); + + // Should show alert of SelectDestinationView + expect(await screen.findByText(/you selected will be migrated to the Content Library you/)).toBeInTheDocument(); + + const backButton = screen.getByRole('button', { name: /back/i }); + backButton.click(); + + // The selected legacy library remains checked + expect(legacyLibrary).toBeChecked(); + + // The filter remains the same + expect(await screen.findByText('MBA')).toBeInTheDocument(); + expect(await screen.findByText('Legacy library 1')).toBeInTheDocument(); + expect(await screen.findByText('MBA 1')).toBeInTheDocument(); + }); + + it('should select a library destination', async () => { + renderPage(); + expect(await screen.findByText('Migrate Legacy Libraries')).toBeInTheDocument(); + expect(await screen.findByText('MBA')).toBeInTheDocument(); + + const legacyLibrary = screen.getByRole('checkbox', { name: 'MBA' }); + legacyLibrary.click(); + + const nextButton = screen.getByRole('button', { name: /next/i }); + nextButton.click(); + + // Should show alert of SelectDestinationView + expect(await screen.findByText(/you selected will be migrated to the Content Library you/)).toBeInTheDocument(); + + // The next button is disabled + expect(nextButton).toBeDisabled(); + + expect(await screen.findByText('Test Library 1')).toBeInTheDocument(); + const radioButton = screen.getByRole('radio', { name: /test library 1/i }); + radioButton.click(); + + expect(radioButton).toBeChecked(); + expect(nextButton).not.toBeDisabled(); + }); + + it('should back to select library destination', async () => { + const user = userEvent.setup(); + renderPage(); + expect(await screen.findByText('Migrate Legacy Libraries')).toBeInTheDocument(); + expect(await screen.findByText('MBA')).toBeInTheDocument(); + + const legacyLibrary = screen.getByRole('checkbox', { name: 'MBA' }); + await user.click(legacyLibrary); + + const nextButton = await screen.findByRole('button', { name: /next/i }); + await user.click(nextButton); + + // Should show alert of SelectDestinationView + expect(await screen.findByText(/you selected will be migrated to the Content Library you/)).toBeInTheDocument(); + expect(await screen.findByText('Test Library 1')).toBeInTheDocument(); + const radioButton = screen.getByRole('radio', { name: /test library 1/i }); + await user.click(radioButton); + + await user.click(nextButton); + const alert = await screen.findByRole('alert'); + expect( + await within(alert).findByText( + /All content from the legacy library you selected will be migrated to/, + ), + ).toBeInTheDocument(); + + const backButton = screen.getByRole('button', { name: /back/i }); + await user.click(backButton); + + expect(await screen.findByText('Test Library 1')).toBeInTheDocument(); + // The selected v2 library remains checked + expect(radioButton).toBeChecked(); + }); + + it('should open the create new library modal', async () => { + const user = userEvent.setup(); + axiosMock.onGet(getStudioHomeApiUrl()).reply(200, studioHomeMock); + axiosMock.onPost(getContentLibraryV2CreateApiUrl()).reply(200, { + id: 'lib:SampleTaxonomyOrg1:TL1', + }); + + renderPage(); + expect(await screen.findByText('Migrate Legacy Libraries')).toBeInTheDocument(); + expect(await screen.findByText('MBA')).toBeInTheDocument(); + + const legacyLibrary = screen.getByRole('checkbox', { name: 'MBA' }); + legacyLibrary.click(); + + const nextButton = screen.getByRole('button', { name: /next/i }); + nextButton.click(); + + // Should show alert of SelectDestinationView + expect(await screen.findByText(/you selected will be migrated to the Content Library you/)).toBeInTheDocument(); + + const createButton = await screen.findByRole('button', { name: /create new library/i }); + expect(createButton).toBeInTheDocument(); + createButton.click(); + + // Should open the create library modal + expect(await screen.findByText('Create new library')).toBeInTheDocument(); + + // Cancel and close the create library modal + const cancelButton = screen.getByRole('button', { name: /cancel/i }); + cancelButton.click(); + await waitFor(() => { + expect(screen.queryByText('Create new library')).not.toBeInTheDocument(); + }); + + // Open the modal again and create a new library + createButton.click(); + const titleInput = await screen.findByRole('textbox', { name: /library name/i }); + await user.click(titleInput); + await user.type(titleInput, 'Test Library Name'); + + const orgInput = await screen.findByRole('combobox', { name: /organization/i }); + await user.click(orgInput); + await user.type(orgInput, 'org1'); + await user.tab(); + + const slugInput = await screen.findByRole('textbox', { name: /library id/i }); + await user.click(slugInput); + await user.type(slugInput, 'test_library_slug'); + + const confirmButton = await screen.findByRole('button', { name: 'Create' }); + confirmButton.click(); + await waitFor(() => { + expect(axiosMock.history.post.length).toBe(1); + }); + expect(axiosMock.history.post[0].data).toBe( + '{"description":"","title":"Test Library Name","org":"org1","slug":"test_library_slug"}', + ); + + // The library should be checked + expect(screen.getByRole('radio', { name: /test library 1/i })).toBeChecked(); + }); + + it('should confirm migration', async () => { + const user = userEvent.setup(); + axiosMock.onPost(bulkModulestoreMigrateUrl()).reply(200); + renderPage(); + expect(await screen.findByText('Migrate Legacy Libraries')).toBeInTheDocument(); + expect(await screen.findByText('MBA')).toBeInTheDocument(); + + // The filter is 'unmigrated' by default. + // Clear the filter to select all libraries + const filterButton = screen.getByRole('button', { name: /unmigrated/i }); + await user.click(filterButton); + const clearButton = await screen.findByRole('button', { name: /clear filter/i }); + await user.click(clearButton); + + const legacyLibrary1 = screen.getByRole('checkbox', { name: 'MBA' }); + const legacyLibrary2 = screen.getByRole('checkbox', { name: /legacy library 1 imported library/i }); + const legacyLibrary3 = screen.getByRole('checkbox', { name: 'MBA 1' }); + + legacyLibrary1.click(); + legacyLibrary2.click(); + legacyLibrary3.click(); + + const nextButton = screen.getByRole('button', { name: /next/i }); + await user.click(nextButton); + + // Should show alert of SelectDestinationView + expect(await screen.findByText(/you selected will be migrated to the Content Library you/)).toBeInTheDocument(); + expect(await screen.findByText('Test Library 1')).toBeInTheDocument(); + const radioButton = screen.getByRole('radio', { name: /test library 1/i }); + await user.click(radioButton); + + await user.click(nextButton); + + // Should show alert of ConfirmationView + const alert = await screen.findByRole('alert'); + expect( + await within(alert).findByText( + /All content from the 3 legacy libraries you selected will be migrated to/, + ), + ).toBeInTheDocument(); + expect(screen.getByText('MBA')).toBeInTheDocument(); + expect(screen.getByText('Legacy library 1')).toBeInTheDocument(); + expect(screen.getByText('MBA 1')).toBeInTheDocument(); + expect(screen.getByText( + /Previously migrated library. Any problem bank links were already moved will be migrated to/i, + )).toBeInTheDocument(); + + const confirmButton = screen.getByRole('button', { name: /confirm/i }); + confirmButton.click(); + + await waitFor(() => { + expect(axiosMock.history.post.length).toBe(1); + }); + expect(axiosMock.history.post[0].data).toBe( + '{"sources":["library-v1:MBA+123","library-v1:UNIX+LG1","library-v1:MBA+1234"],"target":"lib:SampleTaxonomyOrg1:TL1","create_collections":true,"repeat_handling_strategy":"fork"}', + ); + expect(mockShowToast).toHaveBeenCalledWith('3 legacy libraries are being migrated.'); + }); + + it('should show error when confirm migration', async () => { + const user = userEvent.setup(); + axiosMock.onPost(bulkModulestoreMigrateUrl()).reply(400); + renderPage(); + expect(await screen.findByText('Migrate Legacy Libraries')).toBeInTheDocument(); + expect(await screen.findByText('MBA')).toBeInTheDocument(); + + // The filter is 'unmigrated' by default. + // Clear the filter to select all libraries + const filterButton = screen.getByRole('button', { name: /unmigrated/i }); + await user.click(filterButton); + const clearButton = await screen.findByRole('button', { name: /clear filter/i }); + await user.click(clearButton); + + const legacyLibrary1 = screen.getByRole('checkbox', { name: 'MBA' }); + const legacyLibrary2 = screen.getByRole('checkbox', { name: /legacy library 1 imported library/i }); + const legacyLibrary3 = screen.getByRole('checkbox', { name: 'MBA 1' }); + + legacyLibrary1.click(); + legacyLibrary2.click(); + legacyLibrary3.click(); + + const nextButton = screen.getByRole('button', { name: /next/i }); + await user.click(nextButton); + + // Should show alert of SelectDestinationView + expect(await screen.findByText(/you selected will be migrated to the Content Library you/)).toBeInTheDocument(); + expect(await screen.findByText('Test Library 1')).toBeInTheDocument(); + const radioButton = screen.getByRole('radio', { name: /test library 1/i }); + await user.click(radioButton); + + await user.click(nextButton); + + // Should show alert of ConfirmationView + const alert = await screen.findByRole('alert'); + expect( + await within(alert).findByText( + /All content from the 3 legacy libraries you selected will be migrated to/, + { exact: false }, + ), + ).toBeInTheDocument(); + expect(screen.getByText('MBA')).toBeInTheDocument(); + expect(screen.getByText('Legacy library 1')).toBeInTheDocument(); + expect(screen.getByText('MBA 1')).toBeInTheDocument(); + expect(screen.getByText( + /Previously migrated library. Any problem bank links were already moved will be migrated to/i, + )).toBeInTheDocument(); + + const confirmButton = screen.getByRole('button', { name: /confirm/i }); + confirmButton.click(); + + await waitFor(() => { + expect(axiosMock.history.post.length).toBe(1); + }); + expect(axiosMock.history.post[0].data).toBe( + '{"sources":["library-v1:MBA+123","library-v1:UNIX+LG1","library-v1:MBA+1234"],"target":"lib:SampleTaxonomyOrg1:TL1","create_collections":true,"repeat_handling_strategy":"fork"}', + ); + expect(mockShowToast).toHaveBeenCalledWith('Legacy libraries migration have failed'); + }); + + it('should show help sidebar', async () => { + renderPage(); + expect(await screen.findByText('Help & Support')).toBeInTheDocument(); + expect(screen.getByText('What’s different in the new Content Libraries experience?')).toBeInTheDocument(); + expect(screen.getByText('What happens when I migrate my Legacy Libraries?')).toBeInTheDocument(); + expect(screen.getByText('How do I migrate my Legacy Libraries?')).toBeInTheDocument(); + }); +}); diff --git a/src/legacy-libraries-migration/LegacyLibMigrationPage.tsx b/src/legacy-libraries-migration/LegacyLibMigrationPage.tsx new file mode 100644 index 0000000000..0e106054e8 --- /dev/null +++ b/src/legacy-libraries-migration/LegacyLibMigrationPage.tsx @@ -0,0 +1,264 @@ +import { + useCallback, + useContext, + 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, + ModalDialog, + StatefulButton, + Stepper, + useToggle, +} from '@openedx/paragon'; +import Header from '@src/header'; +import SubHeader from '@src/generic/sub-header/SubHeader'; +import type { ContentLibrary } from '@src/library-authoring/data/api'; +import type { LibraryV1Data } from '@src/studio-home/data/api'; +import { ToastContext } from '@src/generic/toast-context'; +import { Filter, LibrariesList } from '@src/studio-home/tabs-section/libraries-tab'; + +import { useBulkModulestoreMigrate } from '@src/data/apiHooks'; +import messages from './messages'; +import { SelectDestinationView } from './SelectDestinationView'; +import { ConfirmationView } from './ConfirmationView'; +import { LegacyMigrationHelpSidebar } from './LegacyMigrationHelpSidebar'; + +export type MigrationStep = 'select-libraries' | 'select-destination' | 'confirmation-view'; + +const ExitModal = ({ + isExitModalOpen, + closeExitModal, +}: { + isExitModalOpen: boolean; + closeExitModal: () => void; +}) => { + const intl = useIntl(); + const navigate = useNavigate(); + + return ( + + + + + + + + + + + + + + + + + + + ); +}; + +export const LegacyLibMigrationPage = () => { + const intl = useIntl(); + const navigate = useNavigate(); + const { showToast } = useContext(ToastContext); + const [currentStep, setCurrentStep] = useState('select-libraries'); + const [isExitModalOpen, openExitModal, closeExitModal] = useToggle(false); + const [legacyLibraries, setLegacyLibraries] = useState([]); + const [migrationFilter, setMigrationFilter] = useState([Filter.unmigrated]); + const [destinationLibrary, setDestination] = useState(); + const [confirmationButtonState, setConfirmationButtonState] = useState('default'); + const migrate = useBulkModulestoreMigrate(); + + const handleMigrate = useCallback(async () => { + if (destinationLibrary) { + try { + const migrationTask = await migrate.mutateAsync({ + sources: legacyLibraries.map((lib) => lib.libraryKey), + target: destinationLibrary.id, + createCollections: true, + repeatHandlingStrategy: 'fork', + }); + showToast(intl.formatMessage(messages.migrationInProgress, { + count: legacyLibraries.length, + })); + navigate(`/library/${destinationLibrary.id}?migration_task=${migrationTask.uuid}`); + } catch { + showToast(intl.formatMessage(messages.migrationFailed)); + } + } + }, [migrate, legacyLibraries, destinationLibrary]); + + const handleNext = useCallback(() => { + switch (currentStep) { + case 'select-libraries': + setCurrentStep('select-destination'); + break; + case 'select-destination': + setCurrentStep('confirmation-view'); + break; + case 'confirmation-view': + setConfirmationButtonState('pending'); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + handleMigrate(); + break; + default: + /* istanbul ignore next */ + break; + } + }, [currentStep, setCurrentStep, handleMigrate]); + + const handleBack = useCallback(() => { + switch (currentStep) { + case 'select-libraries': + openExitModal(); + break; + case 'select-destination': + setCurrentStep('select-libraries'); + break; + case 'confirmation-view': + setCurrentStep('select-destination'); + break; + default: + /* istanbul ignore next */ + break; + } + }, [currentStep, setCurrentStep]); + + const isNextDisabled = useCallback(() => { + switch (currentStep) { + case 'select-libraries': + return legacyLibraries.length === 0; + case 'select-destination': + return destinationLibrary === undefined; + case 'confirmation-view': + /* istanbul ignore next */ + return false; + default: + /* istanbul ignore next */ + return true; + } + }, [legacyLibraries, currentStep, destinationLibrary]); + + const handleUpdateLegacyLibraries = useCallback((library: LibraryV1Data, action: 'add' | 'remove') => { + if (action === 'add') { + setLegacyLibraries([...legacyLibraries, library]); + } else { + setLegacyLibraries(legacyLibraries.filter(item => item.libraryKey !== library.libraryKey)); + } + }, [legacyLibraries, setLegacyLibraries]); + + const legacyLibrariesIds = useMemo(() => legacyLibraries.map(item => item.libraryKey), [legacyLibraries]); + + return ( + <> + + + {intl.formatMessage(messages.siteTitle)} + + +
+
+ + +
+ +
+ + + + + + + + + + + {destinationLibrary && ( + + )} + + +
+
+
+ + {currentStep !== 'confirmation-view' ? + ( + + ) : + ( + + )} +
+
+
+ + + +
+
+ + + ); +}; diff --git a/src/legacy-libraries-migration/LegacyMigrationHelpSidebar.tsx b/src/legacy-libraries-migration/LegacyMigrationHelpSidebar.tsx new file mode 100644 index 0000000000..abe5f0ae36 --- /dev/null +++ b/src/legacy-libraries-migration/LegacyMigrationHelpSidebar.tsx @@ -0,0 +1,49 @@ +import { FormattedMessage } from '@edx/frontend-platform/i18n'; +import { Icon, Stack } from '@openedx/paragon'; +import { Question } from '@openedx/paragon/icons'; +import { Div, Paragraph } from '@src/utils'; + +import messages from './messages'; + +export const LegacyMigrationHelpSidebar = () => ( +
+ + + + + + +
+ + + + + + + + + +
+ + + + + + + + +
+ + + + + + + + +
+
+); diff --git a/src/legacy-libraries-migration/SelectDestinationView.tsx b/src/legacy-libraries-migration/SelectDestinationView.tsx new file mode 100644 index 0000000000..04e7cfc952 --- /dev/null +++ b/src/legacy-libraries-migration/SelectDestinationView.tsx @@ -0,0 +1,33 @@ +import { FormattedMessage } from '@edx/frontend-platform/i18n'; +import { Alert, Container } from '@openedx/paragon'; + +import LibrariesV2List from '@src/studio-home/tabs-section/libraries-v2-tab'; +import type { ContentLibrary } from '@src/library-authoring/data/api'; + +import messages from './messages'; + +interface SelectDestinationViewProps { + destinationId?: string; + setDestinationId: (library: ContentLibrary) => void; + legacyLibCount: number; +} + +export const SelectDestinationView = ({ + destinationId, + setDestinationId, + legacyLibCount, +}: SelectDestinationViewProps) => ( + + + + + + +); diff --git a/src/legacy-libraries-migration/index.scss b/src/legacy-libraries-migration/index.scss new file mode 100644 index 0000000000..980563bd59 --- /dev/null +++ b/src/legacy-libraries-migration/index.scss @@ -0,0 +1,54 @@ +.legacy-library-migration-page { + .migration-container { + // Calculate all the screen size subtracting the height of the header and top/bottom margins + min-height: calc(calc(100vh - 60px) - calc(var(--pgn-spacing-spacer-base) * 6)); + + .courses-tab-container { + min-height: auto; + } + + .migration-content { + flex: 1; + } + + .card-item { + margin: 0 0 16px !important; + } + } + + .confirmation-view { + .pgn__card-header-content { + margin-top: calc(var(--pgn-spacing-spacer-base)) !important;; + } + } + + .content-buttons { + width: 100%; + position: sticky; + bottom: 0; + } + + .row { + margin-right: 0; // To avoid create a horizontal scroll using Layout + } + + [class*="col-"] { + // To avoid create an empty gray space between the main content and the sidebar + padding-right: 0; + padding-left: 0; + } +} + +.legacy-libraries-migration-help { + z-index: 1000; // same as header + flex: 350px 0 0; + position: sticky; + top: 0; + right: 0; + height: 100vh; + overflow-y: auto; + + hr { + width: 100%; + } +} diff --git a/src/legacy-libraries-migration/messages.ts b/src/legacy-libraries-migration/messages.ts new file mode 100644 index 0000000000..00e828342a --- /dev/null +++ b/src/legacy-libraries-migration/messages.ts @@ -0,0 +1,167 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + siteTitle: { + id: 'legacy-libraries-migration.site-title', + defaultMessage: 'Migrate Legacy Libraries', + description: 'Title for the page to migrate legacy libraries.', + }, + cancel: { + id: 'legacy-libraries-migration.button.cancel', + defaultMessage: 'Cancel', + description: 'Text of the button to cancel the migration.', + }, + next: { + id: 'legacy-libraries-migration.button.next', + defaultMessage: 'Next', + description: 'Text of the button to go to the next step of the migration.', + }, + back: { + id: 'legacy-libraries-migration.button.back', + defaultMessage: 'Back', + description: 'Text of the button to go back to the previous step of the migration.', + }, + confirm: { + id: 'legacy-libraries-migration.button.confirm', + defaultMessage: 'Confirm', + description: 'Text of the button to confirm the migration.', + }, + selectLegacyLibrariesStepTitle: { + id: 'legacy-libraries-migration.select-legacy-libraries-step.title', + defaultMessage: 'Select Legacy Libraries', + description: 'Title of the Select Legacy Libraries step', + }, + selectDestinationStepTitle: { + id: 'legacy-libraries-migration.select-destination-step.title', + defaultMessage: 'Select Destination', + description: 'Title of the Select Destination step', + }, + confirmStepTitle: { + id: 'legacy-libraries-migration.confirm-step.title', + defaultMessage: 'Confirm', + description: 'Title of the Confirm step', + }, + exitModalTitle: { + id: 'legacy-libraries-migration.exit-modal.title', + defaultMessage: 'Exit Migration?', + description: 'Title of the modal to confirm exit the migration.', + }, + exitModalBodyText: { + id: 'legacy-libraries-migration.exit-modal.body', + defaultMessage: 'By exiting, all changes will be lost and no libraries will be migrated.', + description: 'Body text of the modal to confirm exit the migration.', + }, + exitModalCancelText: { + id: 'legacy-libraries-migration.exit-modal.button.cancel.text', + defaultMessage: 'Continue Migrating', + description: 'Text for the button to close the modal to confirm exit the migration.', + }, + exitModalConfirmText: { + id: 'legacy-libraries-migration.exit-modal.button.confirm.text', + defaultMessage: 'Exit', + description: 'Text for the button to confirm exit the migration.', + }, + selectDestinationAlert: { + id: 'legacy-libraries-migration.select-destination.alert.text', + defaultMessage: 'All content from the' + + ' {count, plural, one {legacy library} other {{count} legacy libraries}} you selected will' + + ' be migrated to the Content Library you select, organized into collections. Legacy library content used' + + ' in courses will continue to work as-is. To receive any future changes to migrated content,' + + ' you must update these references within your course.', + description: 'Alert text in the select destination step of the legacy libraries migration page.', + }, + confirmationViewAlert: { + id: 'legacy-libraries-migration.select-destination.alert.text', + defaultMessage: 'All content from the' + + ' {count, plural, one {legacy library} other {{count} legacy libraries}} you selected will' + + ' be migrated to {libraryName} and organized into collections. Legacy library content used' + + ' in courses will continue to work as-is. To receive any future changes to migrated content,' + + ' you must update these references within your course.', + description: 'Alert text in the confirmation step of the legacy libraries migration page.', + }, + previouslyMigratedAlert: { + id: 'legacy-libraries-migration.confirmation-step.card.previously-migrated.text', + defaultMessage: 'Previously migrated library. Any problem bank links were already' + + ' moved will be migrated to {libraryName}', + description: 'Alert text when the legacy library is already migrated.', + }, + helpAndSupportTitle: { + id: 'legacy-libraries-migration.helpAndSupport.title', + defaultMessage: 'Help & Support', + description: 'Title of the Help & Support sidebar', + }, + helpAndSupportFirstQuestionTitle: { + id: 'legacy-libraries-migration.helpAndSupport.q1.title', + defaultMessage: 'What’s different in the new Content Libraries experience?', + description: 'Title of the first question in the Help & Support sidebar', + }, + helpAndSupportFirstQuestionBody: { + id: 'legacy-libraries-migration.helpAndSupport.q1.body', + defaultMessage: 'In the new Content Libraries experience, you can author sections,' + + ' subsections, units, and many types of components. Library content can be reused across many courses,' + + ' and kept up-to-date. Content libraries now support increased collaboration across authoring teams.', + description: 'Body of the first question in the Help & Support sidebar', + }, + helpAndSupportSecondQuestionTitle: { + id: 'legacy-libraries-migration.helpAndSupport.q2.title', + defaultMessage: 'What happens when I migrate my Legacy Libraries?', + description: 'Title of the second question in the Help & Support sidebar', + }, + helpAndSupportSecondQuestionBody: { + id: 'legacy-libraries-migration.helpAndSupport.q2.body', + defaultMessage: 'All legacy library content is supported in the new experience.' + + ' Content from legacy libraries will be migrated to its own collection in the new Content Libraries experience.' + + ' This collection will have the same name as your original library. Courses that use legacy library content will' + + ' continue to function as usual, linked to the migrated version within the new libraries experience.', + description: 'Body of the second question in the Help & Support sidebar', + }, + helpAndSupportThirdQuestionTitle: { + id: 'legacy-libraries-migration.helpAndSupport.q3.title', + defaultMessage: 'How do I migrate my Legacy Libraries?', + description: 'Title of the third question in the Help & Support sidebar', + }, + helpAndSupportThirdQuestionBody: { + id: 'legacy-libraries-migration.helpAndSupport.q3.body.2', + defaultMessage: '

There are three steps to migrating legacy libraries:

' + + '

1 - Select Legacy Libraries
' + + 'You can select up to 50 legacy libraries for migration in this step. By default, only libraries that have' + + ' not yet been migrated are shown. To see all libraries, remove the filter.' + + ' You can select up to 50 legacy libraries for migration, but only one destination' + + ' v2 Content Library per migration.

' + + '

2 - Select Destination
' + + 'You can migrate legacy libraries to an existing Content Library in the new experience,' + + ' or you can create a new destination. You can only select one v2 Content Library per migration.' + + ' All your content will be migrated, and kept organized in collections.

' + + '

3 - Confirm
' + + 'In this step, review your migration. Once you confirm, migration will begin.' + + ' It may take some time to complete.

', + description: 'Part 2 of the Body of the third question in the Help & Support sidebar', + }, + migrationInProgress: { + id: 'legacy-libraries-migration.confirmation-step.toast.migration-in-progress', + defaultMessage: '{count, plural, one {{count} legacy library is} other {{count} legacy libraries are}} being migrated.', + description: 'Toast message that indicates the legacy libraries are being migrated', + }, + migrationFailed: { + id: 'legacy-libraries-migration.confirmation-step.toast.migration-failed', + defaultMessage: 'Legacy libraries migration have failed', + description: 'Toast message that indicates the migration of legacy libraries is failed', + }, + migrationFailedMultiple: { + id: 'legacy-libraries-migration.confirmation-step.toast.migration-multiple-failed', + defaultMessage: 'Multiple legacy libraries have failed', + description: 'Toast message that indicates the migration of legacy libraries is failed', + }, + migrationFailedOneLibrary: { + id: 'legacy-libraries-migration.confirmation-step.toast.migration-one-failed', + defaultMessage: 'The legacy library with this key has failed: {key}', + description: 'Toast message that indicates that one legacy library has failed in the migration', + }, + migrationSuccess: { + id: 'legacy-libraries-migration.confirmation-step.toast.migration-success', + defaultMessage: 'The migration of legacy libraries has been completed successfully.', + description: 'Toast message that indicates the migration of legacy libraries is finished', + }, +}); + +export default messages; diff --git a/src/library-authoring/LibraryAuthoringPage.test.tsx b/src/library-authoring/LibraryAuthoringPage.test.tsx index 91947dcd4e..2cc0354e88 100644 --- a/src/library-authoring/LibraryAuthoringPage.test.tsx +++ b/src/library-authoring/LibraryAuthoringPage.test.tsx @@ -10,6 +10,7 @@ import { within, } from '@src/testUtils'; import studioHomeMock from '@src/studio-home/__mocks__/studioHomeMock'; +import { mockGetMigrationStatus } from '@src/data/api.mocks'; import mockEmptyResult from '@src/search-modal/__mocks__/empty-search-result.json'; import { mockContentSearchConfig } from '@src/search-manager/data/api.mock'; import { getStudioHomeApiUrl } from '@src/studio-home/data/api'; @@ -34,6 +35,7 @@ mockContentSearchConfig.applyMock(); mockContentLibrary.applyMock(); mockGetLibraryTeam.applyMock(); mockXBlockFields.applyMock(); +mockGetMigrationStatus.applyMock(); const searchEndpoint = 'http://mock.meilisearch.local/multi-search'; @@ -1083,4 +1085,60 @@ describe('', () => { 'This page cannot be shown: Libraries v2 are disabled.', ); }); + + it('Should show success in migration legacy libraries', async () => { + render(, { + path, + routerProps: { + initialEntries: [ + `/library/${mockContentLibrary.libraryId}?migration_task=${mockGetMigrationStatus.migrationId}`, + ], + }, + }); + + await waitFor(() => + expect(mockShowToast).toHaveBeenCalledWith('The migration of legacy libraries has been completed successfully.') + ); + }); + + it('Should show fail in migration legacy libraries', async () => { + render(, { + path, + routerProps: { + initialEntries: [ + `/library/${mockContentLibrary.libraryId}?migration_task=${mockGetMigrationStatus.migrationIdFailed}`, + ], + }, + }); + + await waitFor(() => expect(mockShowToast).toHaveBeenCalledWith('Legacy libraries migration have failed')); + }); + + it('Should show fail multiple legacy libraries in a migration', async () => { + render(, { + path, + routerProps: { + initialEntries: [ + `/library/${mockContentLibrary.libraryId}?migration_task=${mockGetMigrationStatus.migrationIdMultiple}`, + ], + }, + }); + + await waitFor(() => expect(mockShowToast).toHaveBeenCalledWith('Multiple legacy libraries have failed')); + }); + + it('Should show fail one legacy library in a migration', async () => { + render(, { + path, + routerProps: { + initialEntries: [ + `/library/${mockContentLibrary.libraryId}?migration_task=${mockGetMigrationStatus.migrationIdOneLibrary}`, + ], + }, + }); + + await waitFor(() => + expect(mockShowToast).toHaveBeenCalledWith('The legacy library with this key has failed: legacy-lib-1') + ); + }); }); diff --git a/src/library-authoring/LibraryAuthoringPage.tsx b/src/library-authoring/LibraryAuthoringPage.tsx index 9625130d61..29f71acfaf 100644 --- a/src/library-authoring/LibraryAuthoringPage.tsx +++ b/src/library-authoring/LibraryAuthoringPage.tsx @@ -1,6 +1,7 @@ import { type ReactNode, useCallback, + useContext, useEffect, useState, } from 'react'; @@ -19,8 +20,10 @@ import { Tabs, } from '@openedx/paragon'; import { Add, InfoOutline } from '@openedx/paragon/icons'; -import { Link, useLocation } from 'react-router-dom'; +import { Link, useLocation, useNavigate } from 'react-router-dom'; +import { useQueryClient } from '@tanstack/react-query'; +import { useModulestoreMigrationStatus } from '@src/data/apiHooks'; import Loading from '@src/generic/Loading'; import SubHeader from '@src/generic/sub-header/SubHeader'; import Header from '@src/header'; @@ -30,6 +33,9 @@ import { SearchContextProvider, TypesFilterData, } from '@src/search-manager'; +import { ToastContext } from '@src/generic/toast-context'; +import migrationMessages from '@src/legacy-libraries-migration/messages'; + import { FiltersProps } from '@src/library-authoring/library-filters'; import { MainFilters } from '@src/library-authoring/library-filters/MainFilters'; import { useMultiLibraryContext } from '@src/library-authoring/common/context/MultiLibraryContext'; @@ -41,6 +47,7 @@ import { useOptionalLibraryContext } from './common/context/LibraryContext'; import { SidebarBodyItemId, useSidebarContext } from './common/context/SidebarContext'; import { allLibraryPageTabs, ContentType, useLibraryRoutes } from './routes'; import messages from './messages'; +import { libraryQueryPredicate } from './data/apiHooks'; const HeaderActions = () => { const intl = useIntl(); @@ -135,6 +142,17 @@ const LibraryAuthoringPage = ({ }: LibraryAuthoringPageProps) => { const intl = useIntl(); const location = useLocation(); + const navigate = useNavigate(); + const params = new URLSearchParams(location.search); + const { showToast } = useContext(ToastContext); + const queryClient = useQueryClient(); + + // Get migration status every second if applicable + const migrationId = params.get('migration_task'); + const { + data: migrationStatusData, + } = useModulestoreMigrationStatus(migrationId); + const { isLoadingPage: isLoadingStudioHome, isFailedLoadingPage: isFailedLoadingStudioHome, @@ -208,6 +226,45 @@ const LibraryAuthoringPage = ({ } }, [navigateTo]); + // Verify the migration task status + if (migrationId && libraryId) { + let deleteMigrationIdParam = false; + if (migrationStatusData?.state === 'Succeeded') { + // Check if any library migrations failed. + // A `Succeeded` state means that the bulk migration ended, but some libraries might have failed. + const failedMigrations = migrationStatusData.parameters.filter(item => item.isFailed); + if (failedMigrations.length > 1) { + showToast(intl.formatMessage(migrationMessages.migrationFailedMultiple)); + } else if (failedMigrations.length === 1) { + showToast(intl.formatMessage( + migrationMessages.migrationFailedOneLibrary, + { + key: failedMigrations[0].source, + }, + )); + } else { + showToast(intl.formatMessage(migrationMessages.migrationSuccess)); + } + queryClient.invalidateQueries({ predicate: (query) => libraryQueryPredicate(query, libraryId) }); + deleteMigrationIdParam = true; + } else if (migrationStatusData?.state === 'Failed') { + // A `Failed` state means that the entire bulk migration has failed. + showToast(intl.formatMessage(migrationMessages.migrationFailed)); + deleteMigrationIdParam = true; + } else if (migrationStatusData?.state === 'Canceled') { + /* istanbul ignore next */ + deleteMigrationIdParam = true; + } + + if (deleteMigrationIdParam) { + params.delete('migration_task'); + navigate({ + pathname: location.pathname, + search: params.toString(), + }, { replace: true }); + } + } + if (isLoadingLibraryData) { return ; } diff --git a/src/studio-home/tabs-section/libraries-tab/MigrateLegacyLibrariesAlert.tsx b/src/studio-home/tabs-section/libraries-tab/MigrateLegacyLibrariesAlert.tsx new file mode 100644 index 0000000000..a4d72dd15b --- /dev/null +++ b/src/studio-home/tabs-section/libraries-tab/MigrateLegacyLibrariesAlert.tsx @@ -0,0 +1,28 @@ +import { useNavigate } from 'react-router-dom'; +import { Alert, Button } from '@openedx/paragon'; +import { Warning } from '@openedx/paragon/icons'; +import { FormattedMessage } from '@edx/frontend-platform/i18n'; + +import messages from '../messages'; + +export const MigrateLegacyLibrariesAlert = () => { + const navigate = useNavigate(); + + return ( + + + + +
+
+ +
+
+ +
+
+
+ ); +}; diff --git a/src/studio-home/tabs-section/libraries-tab/index.tsx b/src/studio-home/tabs-section/libraries-tab/index.tsx new file mode 100644 index 0000000000..35f089b858 --- /dev/null +++ b/src/studio-home/tabs-section/libraries-tab/index.tsx @@ -0,0 +1,354 @@ +import { useCallback, useState } from 'react'; +import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; +import { + ActionRow, + Form, + Icon, + Menu, + MenuItem, + Pagination, + Row, + SearchField, + Stack, +} from '@openedx/paragon'; +import { Error, FilterList, AccessTime } from '@openedx/paragon/icons'; + +import { LoadingSpinner } from '@src/generic/Loading'; +import AlertMessage from '@src/generic/alert-message'; +import { useLibrariesV1Data } from '@src/studio-home/data/apiHooks'; +import { CardItem, MakeLinkOrSpan, PrevToNextName } from '@src/studio-home/card-item'; +import SearchFilterWidget from '@src/search-manager/SearchFilterWidget'; +import type { LibraryV1Data } from '@src/studio-home/data/api'; +import { parseLibraryKey } from '@src/generic/key-utils'; + +import messages from '../messages'; +import { MigrateLegacyLibrariesAlert } from './MigrateLegacyLibrariesAlert'; + +const CardList = ({ + data, + inSelectMode, + selectedIds, +}: { + data: LibraryV1Data[]; + inSelectMode: boolean; + selectedIds?: string[]; +}) => ( + // eslint-disable-next-line react/jsx-no-useless-fragment + <> + {data?.map(({ + displayName, + org, + number, + url, + isMigrated, + migratedToKey, + migratedToTitle, + migratedToCollectionKey, + libraryKey, + }) => { + const collectionLink = () => { + let libUrl = `/library/${migratedToKey}`; + if (migratedToCollectionKey) { + libUrl += `/collection/${migratedToCollectionKey}`; + } + return libUrl; + }; + + const migratedToKeyObj = migratedToKey ? parseLibraryKey(migratedToKey) : undefined; + + const subtitleWrapper = (subtitle) => ( + {migratedToKeyObj?.org} / {migratedToKeyObj?.lib}} + /> + ); + + return ( + + {migratedToTitle} + + ) : + null} + cardStatusWidget={(isMigrated && migratedToKey) ? + ( + + + + + + {migratedToTitle} + + + + ) : + null} + /> + ); + })} + +); + +function findInValues(arr: T[] | undefined, searchValue: string) { + return arr?.filter(o => + Object.values(o).some(value => + String(value).toLowerCase().includes( + String(searchValue).toLowerCase().trim(), + ) + ) + ); +} + +export enum Filter { + migrated = 'migrated', + unmigrated = 'unmigrated', +} + +export const BaseFilterState = Object.values(Filter); + +interface MigrationFilterProps { + filters: Filter[]; + setFilters: React.Dispatch>; +} + +const MigrationFilter = ({ filters, setFilters }: MigrationFilterProps) => { + const intl = useIntl(); + const filterLabels = { + [Filter.migrated]: intl.formatMessage(messages.librariesV1TabMigrationFilterMigratedLabel), + [Filter.unmigrated]: intl.formatMessage(messages.librariesV1TabMigrationFilterUnmigratedLabel), + }; + + let label = intl.formatMessage(messages.librariesV1TabMigrationFilterLabel); + // Set appliedFilters to empty list to indicate clear state + let appliedFilters: { label: string; }[] = []; + if (filters.length === 1) { + // Update label to display selected filter item, i.e., Migrated or Unmigrated + label = filterLabels[filters[0]]; + // Only update appliedFilters if a single option is selected else show clear state. + appliedFilters = filters.map(filter => ({ label: filterLabels[filter] })); + } + + const toggleFilter = useCallback((filter: Filter) => { + setFilters((oldList: Filter[]) => { + if (oldList.includes(filter)) { + const newList = oldList.filter(m => m !== filter); + if (newList.length === 0) { + return BaseFilterState; + } + return newList; + } + // istanbul ignore next + return [...oldList, filter]; + }); + }, [setFilters]); + + const menuItems = useCallback(() => + BaseFilterState.map((item) => ( + { + toggleFilter(item); + }} + > + {filterLabels[item]} + + )), [toggleFilter, BaseFilterState]); + + return ( + setFilters(BaseFilterState)} // On clear select both migrated and unmigrated options. + icon={FilterList} + skipLabelUpdate + > + + + + {menuItems()} + + + + + ); +}; + +interface LibrariesListProps { + selectedIds?: string[]; + handleCheck?: (library: LibraryV1Data, action: 'add' | 'remove') => void; + setSelectedLibraries?: (libraries: LibraryV1Data[]) => void; + hideMigationAlert?: boolean; + // We lift `migrationFilter` and `setMigrationFilter` into props + // so that the filter state is maintained consistently across different + // steps of the legacy libraries migration flow, and to allow + // parent components to control and persist the filter context. + migrationFilter: Filter[]; + setMigrationFilter: React.Dispatch>; +} + +export const LibrariesList = ({ + selectedIds, + handleCheck, + setSelectedLibraries, + hideMigationAlert = false, + migrationFilter, + setMigrationFilter, +}: LibrariesListProps) => { + const intl = useIntl(); + const { isPending, data, isError } = useLibrariesV1Data(); + const [currentPage, setCurrentPage] = useState(1); + const [search, setSearch] = useState(''); + + let filteredData = findInValues(data?.libraries, search || '') || []; + if (migrationFilter.length === 1) { + // filter results by migrated status + filteredData = filteredData.filter((obj) => obj.isMigrated === (migrationFilter[0] === Filter.migrated)); + } + const perPage = 10; + const totalPages = Math.ceil(filteredData.length / perPage); + const currentPageData = filteredData.slice((currentPage - 1) * perPage, currentPage * perPage); + const inSelectMode = handleCheck !== undefined; + + const allChecked = filteredData.every(value => selectedIds?.includes(value.libraryKey)); + const someChecked = filteredData.some(value => selectedIds?.includes(value.libraryKey)); + const checkboxIsIndeterminate = someChecked && !allChecked; + + const handleChangeCheckboxSet = useCallback((event) => { + if (handleCheck) { + const libraryId = event.target.value; + const library = currentPageData.find((item) => item.libraryKey === libraryId); + if (library) { + if (event.target.checked) { + handleCheck(library, 'add'); + } else { + handleCheck(library, 'remove'); + } + } + } + }, [handleCheck, currentPageData]); + + const handleSelectAll = useCallback(() => { + if (checkboxIsIndeterminate || selectedIds?.length === 0) { + setSelectedLibraries?.(filteredData); + } else { + setSelectedLibraries?.([]); + } + }, [checkboxIsIndeterminate, selectedIds, filteredData]); + + if (isPending) { + return ( + + + + ); + } + + if (isError) { + return ( + + + {intl.formatMessage(messages.librariesTabErrorMessage)} + + } + /> + ); + } + + return ( + <> + {!hideMigationAlert && } +
+ + {inSelectMode && ( + + + + )} + {}} + onChange={setSearch} + value={search} + className="mr-4" + placeholder={intl.formatMessage(messages.librariesV2TabLibrarySearchPlaceholder)} + /> + + + {!isPending && !isError + && ( + <> + {intl.formatMessage(messages.coursesPaginationInfo, { + length: currentPageData?.length, + total: data?.libraries.length, + })} + + )} + + {inSelectMode ? + ( + + + + ) : + ( + + )} + {totalPages > 1 + && ( + + )} +
+ + ); +}; diff --git a/src/studio-home/tabs-section/libraries-v2-tab/WelcomeLibrariesV2Alert.tsx b/src/studio-home/tabs-section/libraries-v2-tab/WelcomeLibrariesV2Alert.tsx index eb1270ffeb..8aacb3de41 100644 --- a/src/studio-home/tabs-section/libraries-v2-tab/WelcomeLibrariesV2Alert.tsx +++ b/src/studio-home/tabs-section/libraries-v2-tab/WelcomeLibrariesV2Alert.tsx @@ -1,6 +1,9 @@ -import { Alert, Hyperlink } from '@openedx/paragon'; +import { Alert, Button, Hyperlink } from '@openedx/paragon'; import { FormattedMessage } from '@edx/frontend-platform/i18n'; import { getExternalLinkUrl } from '@edx/frontend-platform'; +import { useNavigate } from 'react-router-dom'; + +import { useLibrariesV1Data } from '@src/studio-home/data/apiHooks'; import messages from '../messages'; @@ -16,8 +19,38 @@ const libraryDocsLink = ( ); -export const WelcomeLibrariesV2Alert = () => ( - - - -); +export const WelcomeLibrariesV2Alert = () => { + const { data, isPending, isError } = useLibrariesV1Data(); + const navigate = useNavigate(); + + // Does not show the alert if we are still loading or if there was an error fetching libraries + if (isPending || isError) { + return null; + } + + const hasPendingV1Migrations = data.libraries.some(library => !library.isMigrated); + return ( + + {hasPendingV1Migrations ? + ( + <> + + + +
+
+ + +
+
+ +
+
+ + ) : + } +
+ ); +};