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 = ({
- navigate('/libraries-v1')}>
+ navigate('/libraries')}>
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 (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- navigate('/libraries')}>
-
-
-
-
-
- );
-};
-
-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 === 'select-libraries'
- ? intl.formatMessage(messages.cancel)
- : intl.formatMessage(messages.back)}
-
- {currentStep !== 'confirmation-view' ?
- (
-
- {intl.formatMessage(messages.next)}
-
- ) :
- (
-
- )}
-
-
-
-
-
-
-
-
-
- >
- );
-};
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 (
-
-
-
-
-
-
-
-
-
- navigate('migrate')}>
-
-
-
-
-
- );
-};
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 ?
- (
- <>
-
-
-
-
-
-
-
-
-
- navigate('../libraries-v1/migrate')}>
-
-
-
-
- >
- ) :
- }
-
- );
-};
+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 (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ navigate('/libraries')}>
+
+
+
+
+
+ );
+};
+
+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 === 'select-libraries'
+ ? intl.formatMessage(messages.cancel)
+ : intl.formatMessage(messages.back)}
+
+ {currentStep !== 'confirmation-view' ?
+ (
+
+ {intl.formatMessage(messages.next)}
+
+ ) :
+ (
+
+ )}
+
+
+
+
+
+
+
+
+
+ >
+ );
+};
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 (
+
+
+
+
+
+
+
+
+
+ navigate('migrate')}>
+
+
+
+
+
+ );
+};
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 ?
+ (
+ <>
+
+
+
+
+
+
+
+
+
+ navigate('../libraries-v1/migrate')}>
+
+
+
+
+ >
+ ) :
+ }
+
+ );
+};