Skip to content
Open
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
fb55f69
feat: add backup view for libraries v2
holaontiveros Oct 9, 2025
16c65b4
chore: updated paths and cleanup
holaontiveros Oct 9, 2025
f051ec9
chore: cleanup text
holaontiveros Oct 13, 2025
70aefaa
chore: added test
holaontiveros Oct 13, 2025
8347538
chore: fix contracts after rebase
holaontiveros Oct 14, 2025
f69e875
chore: more tests to improve coverage
holaontiveros Oct 14, 2025
2c3f5c7
chore: more test for coverage
holaontiveros Oct 16, 2025
33a7d75
chore: more test for coverage
holaontiveros Oct 16, 2025
bfd3a5d
chore: fixed lint issues
holaontiveros Oct 16, 2025
541f2b1
chore: update naming for a more semantic one
holaontiveros Oct 16, 2025
ec963f0
chore: changed fireEvent to userEvent
holaontiveros Oct 16, 2025
1f51ebf
chore: improved queryKeys
holaontiveros Oct 16, 2025
4b4b93a
chore: lint cleanup
holaontiveros Oct 16, 2025
26ea500
chore: changed tests and time to 1min
holaontiveros Oct 16, 2025
7c4b6f4
chore: even more tests
holaontiveros Oct 17, 2025
15c9c15
chore: split hook for library menu items
holaontiveros Oct 17, 2025
ded03af
chore: fixed typo on refactor
holaontiveros Oct 17, 2025
dd2674c
chore: improved test to use available mocks
holaontiveros Oct 17, 2025
269d0f7
chore: change from jest.mocks to spyon
holaontiveros Oct 17, 2025
76ae11b
chore: update test based on commets
holaontiveros Oct 17, 2025
821af33
chore: update test to get URL from a better place
holaontiveros Oct 17, 2025
d7c627c
chore: added extra getters for new endpoints
holaontiveros Oct 17, 2025
b31ad58
Merge branch 'master' into feat/librariesv2-backup-page
holaontiveros Oct 17, 2025
43016d7
chore: update test to prevent issues with useContentLibrary
holaontiveros Oct 17, 2025
e02acc4
chore: added comments for clarity
holaontiveros Oct 17, 2025
9d2d103
chore: lint fix
holaontiveros Oct 20, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions src/header/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ const Header = ({

const contentMenuItems = useContentMenuItems(contextId);
const settingMenuItems = useSettingMenuItems(contextId);
const toolsMenuItems = useToolsMenuItems(contextId);
const toolsMenuItems = useToolsMenuItems(contextId, isLibrary);
const mainMenuDropdowns = !isLibrary ? [
{
id: `${intl.formatMessage(messages['header.links.content'])}-dropdown-menu`,
Expand All @@ -56,7 +56,11 @@ const Header = ({
buttonTitle: intl.formatMessage(messages['header.links.tools']),
items: toolsMenuItems,
},
] : [];
] : [{
id: `${intl.formatMessage(messages['header.links.tools'])}-dropdown-menu`,
buttonTitle: intl.formatMessage(messages['header.links.tools']),
items: toolsMenuItems,
}];

const getOutlineLink = () => {
if (isLibrary) {
Expand Down
22 changes: 15 additions & 7 deletions src/header/hooks.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,32 +89,32 @@ export const useSettingMenuItems = courseId => {
return items;
};

export const useToolsMenuItems = courseId => {
export const useToolsMenuItems = (itemId, isLibrary = false) => {
const intl = useIntl();
const studioBaseUrl = getConfig().STUDIO_BASE_URL;
const waffleFlags = useWaffleFlags();

const items = [
{
href: waffleFlags.useNewImportPage ? `/course/${courseId}/import` : `${studioBaseUrl}/import/${courseId}`,
href: waffleFlags.useNewImportPage ? `/course/${itemId}/import` : `${studioBaseUrl}/import/${itemId}`,
title: intl.formatMessage(messages['header.links.import']),
},
{
href: waffleFlags.useNewExportPage ? `/course/${courseId}/export` : `${studioBaseUrl}/export/${courseId}`,
href: waffleFlags.useNewExportPage ? `/course/${itemId}/export` : `${studioBaseUrl}/export/${itemId}`,
title: intl.formatMessage(messages['header.links.exportCourse']),
},
...(getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true'
? [{
href: `${studioBaseUrl}/course/${courseId}#export-tags`,
href: `${studioBaseUrl}/course/${itemId}#export-tags`,
title: intl.formatMessage(messages['header.links.exportTags']),
}] : []
),
{
href: `/course/${courseId}/checklists`,
href: `/course/${itemId}/checklists`,
title: intl.formatMessage(messages['header.links.checklists']),
},
...(waffleFlags.enableCourseOptimizer ? [{
href: `/course/${courseId}/optimizer`,
href: `/course/${itemId}/optimizer`,
title: (
<>
{intl.formatMessage(messages['header.links.optimizer'])}
Expand All @@ -123,5 +123,13 @@ export const useToolsMenuItems = courseId => {
),
}] : []),
];
return items;

const libraryItems = [
{
href: `/library/${itemId}/backup`,
title: intl.formatMessage(messages['header.links.exportLibrary']),
},
];

return isLibrary ? libraryItems : items;
};
5 changes: 5 additions & 0 deletions src/header/messages.js
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,11 @@ const messages = defineMessages({
defaultMessage: 'Export Course',
description: 'Link to Studio Export page',
},
'header.links.exportLibrary': {
id: 'header.links.exportLibrary',
defaultMessage: 'Backup to local archive',
description: 'Link to Studio Backup Library page',
},
'header.links.optimizer': {
id: 'header.links.optimizer',
defaultMessage: 'Course Optimizer',
Expand Down
15 changes: 10 additions & 5 deletions src/library-authoring/LibraryLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,18 @@ import {
useParams,
} from 'react-router-dom';

import { ROUTES } from './routes';
import { LibraryBackupPage } from '@src/library-authoring/backup-restore';
import LibraryAuthoringPage from './LibraryAuthoringPage';
import LibraryCollectionPage from './collections/LibraryCollectionPage';
import { LibraryProvider } from './common/context/LibraryContext';
import { SidebarProvider } from './common/context/SidebarContext';
import { CreateCollectionModal } from './create-collection';
import { CreateContainerModal } from './create-container';
import LibraryCollectionPage from './collections/LibraryCollectionPage';
import { ComponentPicker } from './component-picker';
import { ComponentEditorModal } from './components/ComponentEditorModal';
import { LibraryUnitPage } from './units';
import { CreateCollectionModal } from './create-collection';
import { CreateContainerModal } from './create-container';
import { ROUTES } from './routes';
import { LibrarySectionPage, LibrarySubsectionPage } from './section-subsections';
import { LibraryUnitPage } from './units';

const LibraryLayoutWrapper: React.FC<React.PropsWithChildren> = ({ children }) => {
const {
Expand Down Expand Up @@ -85,6 +86,10 @@ const LibraryLayout = () => (
path={ROUTES.UNIT}
Component={LibraryUnitPage}
/>
<Route
path={ROUTES.BACKUP}
Component={LibraryBackupPage}
/>
</Route>
</Routes>
);
Expand Down
182 changes: 182 additions & 0 deletions src/library-authoring/backup-restore/LibraryBackupPage.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import {
initializeMocks,
render,
screen,
} from '@src/testUtils';
import userEvent from '@testing-library/user-event';
import { act } from '@testing-library/react';
import { LibraryBackupStatus } from './data/constants';
import { LibraryBackupPage } from './LibraryBackupPage';
import messages from './messages';

// Mock the hooks/context used by the page so we can render it in isolation.
jest.mock('@src/library-authoring/common/context/LibraryContext', () => ({
useLibraryContext: () => ({ libraryId: 'lib:TestOrg:test-lib' }),
}));

jest.mock('@edx/frontend-platform/i18n', () => ({
...jest.requireActual('@edx/frontend-platform/i18n'),
useIntl: () => ({
formatMessage: (message) => message.defaultMessage,
}),
}));

const mockLibraryData: { data: any } = { data: {} };

jest.mock('@src/library-authoring/data/apiHooks', () => ({
useContentLibrary: () => (mockLibraryData),
}));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This may be fine but I'd usually recommend the more realistic mock data helpers that we have:

import { mockContentLibrary } from '../data/api.mocks';
mockContentLibrary.applyMock();

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did try this one, but somehow the mocks don't apply properly, so the libraryData it's always undefined... not sure why exactly becasue it works in other places that have the exact same scenario... so I'll leave it as is for now

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Weird.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right?, I can show you if you want, maybe it's just me after seing the tests for too long 🤷‍♂️

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's fine for now :p


// Mutable mocks varied per test
const mockMutate = jest.fn();
let mockStatusData: any = {};
let mockMutationError: any = null; // allows testing mutation error branch
jest.mock('@src/library-authoring/backup-restore/data/hooks', () => ({
useCreateLibraryBackup: () => ({
mutate: mockMutate,
error: mockMutationError,
}),
useGetLibraryBackupStatus: () => ({
data: mockStatusData,
}),
}));

describe('<LibraryBackupPage />', () => {
beforeEach(() => {
initializeMocks();
mockMutate.mockReset();
mockStatusData = {};
mockLibraryData.data = {
title: 'My Test Library',
slug: 'test-lib',
org: 'TestOrg',
};
mockMutationError = null;
});

it('returns NotFoundAlert if no libraryData', () => {
mockLibraryData.data = undefined;

render(<LibraryBackupPage />);

expect(screen.getByText(/Not Found/i)).toBeVisible();
});

it('renders the backup page title and initial download button', () => {
mockStatusData = {};
render(<LibraryBackupPage />);
expect(screen.getByText(messages.backupPageTitle.defaultMessage)).toBeVisible();
const button = screen.getByRole('button', { name: messages.downloadAriaLabel.defaultMessage });
expect(button).toBeEnabled();
});

it('shows pending state disables button after starting backup', async () => {
mockMutate.mockImplementation((_arg: any, { onSuccess }: any) => {
onSuccess({ task_id: 'task-123' });
mockStatusData = { state: LibraryBackupStatus.Pending };
});
render(<LibraryBackupPage />);
const initialButton = screen.getByRole('button', { name: messages.downloadAriaLabel.defaultMessage });
expect(initialButton).toBeEnabled();
await userEvent.click(initialButton);
const pendingText = await screen.findByText(messages.backupPending.defaultMessage);
const pendingButton = pendingText.closest('button');
expect(pendingButton).toBeDisabled();
});

it('shows exporting state disables button and changes text', async () => {
mockMutate.mockImplementation((_arg: any, { onSuccess }: any) => {
onSuccess({ task_id: 'task-123' });
mockStatusData = { state: LibraryBackupStatus.Exporting };
});
render(<LibraryBackupPage />);
const initialButton = screen.getByRole('button', { name: messages.downloadAriaLabel.defaultMessage });
await userEvent.click(initialButton);
const exportingText = await screen.findByText(messages.backupExporting.defaultMessage);
const exportingButton = exportingText.closest('button');
expect(exportingButton).toBeDisabled();
});

it('shows succeeded state uses ready text and triggers download', () => {
mockStatusData = { state: 'Succeeded', url: '/fake/path.tar.gz' };
const downloadSpy = jest.spyOn(document, 'createElement');
render(<LibraryBackupPage />);
const button = screen.getByRole('button');
expect(button).toHaveTextContent(/Download Library Backup/);
userEvent.click(button);
expect(downloadSpy).toHaveBeenCalledWith('a');
downloadSpy.mockRestore();
});

it('shows failed state and error alert', () => {
mockStatusData = { state: LibraryBackupStatus.Failed };
render(<LibraryBackupPage />);
expect(screen.getByText(messages.backupFailedError.defaultMessage)).toBeVisible();
const button = screen.getByRole('button');
expect(button).toBeEnabled();
});

it('covers timeout cleanup on unmount', () => {
mockMutate.mockImplementation((_arg: any, { onSuccess }: any) => {
onSuccess({ task_id: 'task-123' });
mockStatusData = { state: LibraryBackupStatus.Pending };
});
const { unmount } = render(<LibraryBackupPage />);
const button = screen.getByRole('button');
userEvent.click(button);
unmount();
// No assertion needed, just coverage for cleanup
});

it('covers fallback download logic', () => {
mockStatusData = { state: LibraryBackupStatus.Succeeded, url: '/fake/path.tar.gz' };
// Spy on createElement to force click failure for anchor
const originalCreate = document.createElement.bind(document);
const createSpy = jest.spyOn(document, 'createElement').mockImplementation((tagName: string) => {
const el = originalCreate(tagName);
if (tagName === 'a') {
// Force failure when click is invoked
(el as any).click = () => { throw new Error('fail'); };
}
return el;
});
// Stub window.location.href writable
const originalLocation = window.location;
// Use a minimal fake location object
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
delete window.location;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
window.location = { href: '' };
Comment on lines +147 to +153
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I usually prefer mocking react-router instead of mocking window.location; it should not required so many lint overrides if you do it that way. But this is fine too.

render(<LibraryBackupPage />);
const button = screen.getByRole('button');
userEvent.click(button);
expect(window.location.href).toContain('/fake/path.tar.gz');
// restore
createSpy.mockRestore();
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
window.location = originalLocation;
});

it('executes timeout callback clearing task and re-enabling button after 5 minutes', () => {
jest.useFakeTimers();
mockMutate.mockImplementation((_arg: any, { onSuccess }: any) => {
onSuccess({ task_id: 'task-123' });
mockStatusData = { state: LibraryBackupStatus.Pending };
});
render(<LibraryBackupPage />);
const button = screen.getByRole('button');
expect(button).toBeEnabled();
userEvent.click(button);
// Now in progress
expect(button).toBeDisabled();
act(() => {
jest.advanceTimersByTime(5 * 60 * 1000); // advance 5 minutes
});
// After timeout callback, should be enabled again
expect(button).toBeEnabled();
jest.useRealTimers();
});
});
Loading
Loading