Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 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
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
13 changes: 10 additions & 3 deletions src/header/Header.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { StudioHeader } from '@edx/frontend-component-header';
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import { StudioHeader } from '@edx/frontend-component-header';
import { type Container, useToggle } from '@openedx/paragon';

import { useWaffleFlags } from '../data/apiHooks';
import { SearchModal } from '../search-modal';
import { useContentMenuItems, useSettingMenuItems, useToolsMenuItems } from './hooks';
import {
useContentMenuItems, useLibraryToolsMenuItems, useSettingMenuItems, useToolsMenuItems,
} from './hooks';
import messages from './messages';

type ContainerPropsType = Omit<React.ComponentProps<typeof Container>, 'children'>;
Expand Down Expand Up @@ -40,6 +42,7 @@ const Header = ({
const contentMenuItems = useContentMenuItems(contextId);
const settingMenuItems = useSettingMenuItems(contextId);
const toolsMenuItems = useToolsMenuItems(contextId);
const libraryToolsMenuItems = useLibraryToolsMenuItems(contextId);
const mainMenuDropdowns = !isLibrary ? [
{
id: `${intl.formatMessage(messages['header.links.content'])}-dropdown-menu`,
Expand All @@ -56,7 +59,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: libraryToolsMenuItems,
}];

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

export const useToolsMenuItems = courseId => {
export const useToolsMenuItems = (courseId) => {
const intl = useIntl();
const studioBaseUrl = getConfig().STUDIO_BASE_URL;
const waffleFlags = useWaffleFlags();
Expand Down Expand Up @@ -123,5 +123,19 @@ export const useToolsMenuItems = courseId => {
),
}] : []),
];

return items;
};

export const useLibraryToolsMenuItems = itemId => {
const intl = useIntl();

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

return 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
247 changes: 247 additions & 0 deletions src/library-authoring/backup-restore/LibraryBackupPage.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
import { LibraryProvider } from '@src/library-authoring/common/context/LibraryContext';
import { mockContentLibrary } from '@src/library-authoring/data/api.mocks';
import {
act,
render as baseRender,
initializeMocks,
screen,
} from '@src/testUtils';
import userEvent from '@testing-library/user-event';
import { LibraryBackupStatus } from './data/constants';
import { LibraryBackupPage } from './LibraryBackupPage';
import messages from './messages';

const render = (libraryId: string = mockContentLibrary.libraryId) => baseRender(<LibraryBackupPage />, {
extraWrapper: ({ children }) => (
<LibraryProvider libraryId={libraryId}>{children}</LibraryProvider>
),
});

// Mocking i18n to prevent having to generate all dynamic translations for this specific test file
// Other tests can still use the real implementation as needed
jest.mock('@edx/frontend-platform/i18n', () => ({
...jest.requireActual('@edx/frontend-platform/i18n'),
useIntl: () => ({
formatMessage: (message) => message.defaultMessage,
}),
}));

const mockLibraryData:
{ data: typeof mockContentLibrary.libraryData | undefined } = { data: mockContentLibrary.libraryData };

// TODO: consider using the usual mockContentLibrary.applyMocks pattern after figuring out

Check failure on line 32 in src/library-authoring/backup-restore/LibraryBackupPage.test.tsx

View workflow job for this annotation

GitHub Actions / tests

Trailing spaces not allowed
// why it doesn't work here as expected

Check failure on line 33 in src/library-authoring/backup-restore/LibraryBackupPage.test.tsx

View workflow job for this annotation

GitHub Actions / tests

Trailing spaces not allowed
jest.mock('@src/library-authoring/data/apiHooks', () => ({
useContentLibrary: () => (mockLibraryData),
}));

// 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 = {};
mockMutationError = null;
mockLibraryData.data = mockContentLibrary.libraryData;
});

it('returns NotFoundAlert if no libraryData', () => {
mockLibraryData.data = undefined as any;
render(mockContentLibrary.libraryIdThatNeverLoads);

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

it('renders the backup page title and initial download button', () => {
render();
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();
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();
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();
const button = screen.getByRole('button');
expect(button).toHaveTextContent(messages.downloadReadyButton.defaultMessage);
userEvent.click(button);
expect(downloadSpy).toHaveBeenCalledWith('a');
downloadSpy.mockRestore();
});

it('shows failed state and error alert', () => {
mockStatusData = { state: LibraryBackupStatus.Failed };
render();
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();
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();
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', async () => {
jest.useFakeTimers();
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
mockMutate.mockImplementation((_arg: any, { onSuccess }: any) => {
onSuccess({ task_id: 'task-123' });
mockStatusData = { state: LibraryBackupStatus.Pending };
});
render();
const button = screen.getByRole('button');
expect(button).toBeEnabled();
await user.click(button);

// Now in progress
expect(button).toBeDisabled();
act(() => {
jest.advanceTimersByTime(1 * 60 * 1000); // advance 1 minutes
});
// After timeout callback, should be enabled again
expect(button).toBeEnabled();
jest.useRealTimers();
});

it('shows pending message when mutation is in progress but no backup state yet', async () => {
// Mock mutation to trigger onSuccess but don't immediately set backup state
mockMutate.mockImplementation((_arg: any, { onSuccess }: any) => {
onSuccess({ task_id: 'task-123' });
// Don't set mockStatusData.state immediately to simulate the state
// before the status API has returned any backup state
});

render();
const button = screen.getByRole('button');

await userEvent.click(button);

// This should trigger the specific line: return intl.formatMessage(messages.backupPending);
// when isMutationInProgress is true but !backupState
expect(screen.getByText(messages.backupPending.defaultMessage)).toBeVisible();
expect(button).toBeDisabled();
});

it('downloads backup immediately when clicking button with already succeeded backup', async () => {
// Set up a scenario where backup is already succeeded with a URL
mockStatusData = {
state: LibraryBackupStatus.Succeeded,
url: '/api/libraries/v2/backup/download/test-backup.tar.gz',
};

render();

// Spy on handleDownload function call
const createElementSpy = jest.spyOn(document, 'createElement');
const mockAnchor = {
href: '',
download: '',
click: jest.fn(),
};
createElementSpy.mockReturnValue(mockAnchor as any);
const appendChildSpy = jest.spyOn(document.body, 'appendChild').mockImplementation();
const removeChildSpy = jest.spyOn(document.body, 'removeChild').mockImplementation();

const button = screen.getByRole('button');

// Click the button - this should trigger the early return in handleDownloadBackup
await userEvent.click(button);

// Verify the download was triggered
expect(createElementSpy).toHaveBeenCalledWith('a');
expect(mockAnchor.href).toContain('/api/libraries/v2/backup/download/test-backup.tar.gz');
expect(mockAnchor.download).toContain('backup.tar.gz');
expect(mockAnchor.click).toHaveBeenCalled();
expect(appendChildSpy).toHaveBeenCalledWith(mockAnchor);
expect(removeChildSpy).toHaveBeenCalledWith(mockAnchor);

// Verify mutate was NOT called since backup already exists
expect(mockMutate).not.toHaveBeenCalled();

// Clean up spies
createElementSpy.mockRestore();
appendChildSpy.mockRestore();
removeChildSpy.mockRestore();
});
});
Loading
Loading