Skip to content

Commit 7bfc730

Browse files
[feature] add backup view for libraries v2 (#2532)
* feat: add backup view for libraries v2 * chore: updated paths and cleanup * chore: cleanup text * chore: added test * chore: fix contracts after rebase * chore: more tests to improve coverage * chore: more test for coverage * chore: more test for coverage * chore: fixed lint issues * chore: update naming for a more semantic one * chore: changed fireEvent to userEvent * chore: improved queryKeys * chore: lint cleanup * chore: changed tests and time to 1min * chore: even more tests * chore: split hook for library menu items * chore: fixed typo on refactor * chore: improved test to use available mocks * chore: change from jest.mocks to spyon * chore: update test based on commets * chore: update test to get URL from a better place * chore: added extra getters for new endpoints * chore: update test to prevent issues with useContentLibrary * chore: added comments for clarity * chore: lint fix * chore: updated url handle to use full URL * chore: linting fixes
1 parent 98009b3 commit 7bfc730

File tree

15 files changed

+749
-9
lines changed

15 files changed

+749
-9
lines changed

src/header/Header.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
1+
import { StudioHeader } from '@edx/frontend-component-header';
12
import { getConfig } from '@edx/frontend-platform';
23
import { useIntl } from '@edx/frontend-platform/i18n';
3-
import { StudioHeader } from '@edx/frontend-component-header';
44
import { type Container, useToggle } from '@openedx/paragon';
55

66
import { useWaffleFlags } from '../data/apiHooks';
77
import { SearchModal } from '../search-modal';
8-
import { useContentMenuItems, useSettingMenuItems, useToolsMenuItems } from './hooks';
8+
import {
9+
useContentMenuItems, useLibraryToolsMenuItems, useSettingMenuItems, useToolsMenuItems,
10+
} from './hooks';
911
import messages from './messages';
1012

1113
type ContainerPropsType = Omit<React.ComponentProps<typeof Container>, 'children'>;
@@ -40,6 +42,7 @@ const Header = ({
4042
const contentMenuItems = useContentMenuItems(contextId);
4143
const settingMenuItems = useSettingMenuItems(contextId);
4244
const toolsMenuItems = useToolsMenuItems(contextId);
45+
const libraryToolsMenuItems = useLibraryToolsMenuItems(contextId);
4346
const mainMenuDropdowns = !isLibrary ? [
4447
{
4548
id: `${intl.formatMessage(messages['header.links.content'])}-dropdown-menu`,
@@ -56,7 +59,11 @@ const Header = ({
5659
buttonTitle: intl.formatMessage(messages['header.links.tools']),
5760
items: toolsMenuItems,
5861
},
59-
] : [];
62+
] : [{
63+
id: `${intl.formatMessage(messages['header.links.tools'])}-dropdown-menu`,
64+
buttonTitle: intl.formatMessage(messages['header.links.tools']),
65+
items: libraryToolsMenuItems,
66+
}];
6067

6168
const getOutlineLink = () => {
6269
if (isLibrary) {

src/header/hooks.jsx

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ export const useSettingMenuItems = courseId => {
8989
return items;
9090
};
9191

92-
export const useToolsMenuItems = courseId => {
92+
export const useToolsMenuItems = (courseId) => {
9393
const intl = useIntl();
9494
const studioBaseUrl = getConfig().STUDIO_BASE_URL;
9595
const waffleFlags = useWaffleFlags();
@@ -123,5 +123,19 @@ export const useToolsMenuItems = courseId => {
123123
),
124124
}] : []),
125125
];
126+
127+
return items;
128+
};
129+
130+
export const useLibraryToolsMenuItems = itemId => {
131+
const intl = useIntl();
132+
133+
const items = [
134+
{
135+
href: `/library/${itemId}/backup`,
136+
title: intl.formatMessage(messages['header.links.exportLibrary']),
137+
},
138+
];
139+
126140
return items;
127141
};

src/header/messages.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,11 @@ const messages = defineMessages({
101101
defaultMessage: 'Export Course',
102102
description: 'Link to Studio Export page',
103103
},
104+
'header.links.exportLibrary': {
105+
id: 'header.links.exportLibrary',
106+
defaultMessage: 'Backup to local archive',
107+
description: 'Link to Studio Backup Library page',
108+
},
104109
'header.links.optimizer': {
105110
id: 'header.links.optimizer',
106111
defaultMessage: 'Course Optimizer',

src/library-authoring/LibraryLayout.tsx

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,18 @@ import {
66
useParams,
77
} from 'react-router-dom';
88

9-
import { ROUTES } from './routes';
9+
import { LibraryBackupPage } from '@src/library-authoring/backup-restore';
1010
import LibraryAuthoringPage from './LibraryAuthoringPage';
11+
import LibraryCollectionPage from './collections/LibraryCollectionPage';
1112
import { LibraryProvider } from './common/context/LibraryContext';
1213
import { SidebarProvider } from './common/context/SidebarContext';
13-
import { CreateCollectionModal } from './create-collection';
14-
import { CreateContainerModal } from './create-container';
15-
import LibraryCollectionPage from './collections/LibraryCollectionPage';
1614
import { ComponentPicker } from './component-picker';
1715
import { ComponentEditorModal } from './components/ComponentEditorModal';
18-
import { LibraryUnitPage } from './units';
16+
import { CreateCollectionModal } from './create-collection';
17+
import { CreateContainerModal } from './create-container';
18+
import { ROUTES } from './routes';
1919
import { LibrarySectionPage, LibrarySubsectionPage } from './section-subsections';
20+
import { LibraryUnitPage } from './units';
2021

2122
const LibraryLayoutWrapper: React.FC<React.PropsWithChildren> = ({ children }) => {
2223
const {
@@ -85,6 +86,10 @@ const LibraryLayout = () => (
8586
path={ROUTES.UNIT}
8687
Component={LibraryUnitPage}
8788
/>
89+
<Route
90+
path={ROUTES.BACKUP}
91+
Component={LibraryBackupPage}
92+
/>
8893
</Route>
8994
</Routes>
9095
);
Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
import { LibraryProvider } from '@src/library-authoring/common/context/LibraryContext';
2+
import { mockContentLibrary } from '@src/library-authoring/data/api.mocks';
3+
import {
4+
act,
5+
render as baseRender,
6+
initializeMocks,
7+
screen,
8+
} from '@src/testUtils';
9+
import userEvent from '@testing-library/user-event';
10+
import { LibraryBackupStatus } from './data/constants';
11+
import { LibraryBackupPage } from './LibraryBackupPage';
12+
import messages from './messages';
13+
14+
const render = (libraryId: string = mockContentLibrary.libraryId) => baseRender(<LibraryBackupPage />, {
15+
extraWrapper: ({ children }) => (
16+
<LibraryProvider libraryId={libraryId}>{children}</LibraryProvider>
17+
),
18+
});
19+
20+
// Mocking i18n to prevent having to generate all dynamic translations for this specific test file
21+
// Other tests can still use the real implementation as needed
22+
jest.mock('@edx/frontend-platform/i18n', () => ({
23+
...jest.requireActual('@edx/frontend-platform/i18n'),
24+
useIntl: () => ({
25+
formatMessage: (message) => message.defaultMessage,
26+
}),
27+
}));
28+
29+
const mockLibraryData:
30+
{ data: typeof mockContentLibrary.libraryData | undefined } = { data: mockContentLibrary.libraryData };
31+
32+
// TODO: consider using the usual mockContentLibrary.applyMocks pattern after figuring out
33+
// why it doesn't work here as expected
34+
jest.mock('@src/library-authoring/data/apiHooks', () => ({
35+
useContentLibrary: () => (mockLibraryData),
36+
}));
37+
38+
// Mutable mocks varied per test
39+
const mockMutate = jest.fn();
40+
let mockStatusData: any = {};
41+
let mockMutationError: any = null; // allows testing mutation error branch
42+
jest.mock('@src/library-authoring/backup-restore/data/hooks', () => ({
43+
useCreateLibraryBackup: () => ({
44+
mutate: mockMutate,
45+
error: mockMutationError,
46+
}),
47+
useGetLibraryBackupStatus: () => ({
48+
data: mockStatusData,
49+
}),
50+
}));
51+
52+
describe('<LibraryBackupPage />', () => {
53+
beforeEach(() => {
54+
initializeMocks();
55+
mockMutate.mockReset();
56+
mockStatusData = {};
57+
mockMutationError = null;
58+
mockLibraryData.data = mockContentLibrary.libraryData;
59+
});
60+
61+
it('returns NotFoundAlert if no libraryData', () => {
62+
mockLibraryData.data = undefined as any;
63+
render(mockContentLibrary.libraryIdThatNeverLoads);
64+
65+
expect(screen.getByText(/Not Found/i)).toBeVisible();
66+
});
67+
68+
it('renders the backup page title and initial download button', () => {
69+
render();
70+
expect(screen.getByText(messages.backupPageTitle.defaultMessage)).toBeVisible();
71+
const button = screen.getByRole('button', { name: messages.downloadAriaLabel.defaultMessage });
72+
expect(button).toBeEnabled();
73+
});
74+
75+
it('shows pending state disables button after starting backup', async () => {
76+
mockMutate.mockImplementation((_arg: any, { onSuccess }: any) => {
77+
onSuccess({ task_id: 'task-123' });
78+
mockStatusData = { state: LibraryBackupStatus.Pending };
79+
});
80+
render();
81+
const initialButton = screen.getByRole('button', { name: messages.downloadAriaLabel.defaultMessage });
82+
expect(initialButton).toBeEnabled();
83+
await userEvent.click(initialButton);
84+
const pendingText = await screen.findByText(messages.backupPending.defaultMessage);
85+
const pendingButton = pendingText.closest('button');
86+
expect(pendingButton).toBeDisabled();
87+
});
88+
89+
it('shows exporting state disables button and changes text', async () => {
90+
mockMutate.mockImplementation((_arg: any, { onSuccess }: any) => {
91+
onSuccess({ task_id: 'task-123' });
92+
mockStatusData = { state: LibraryBackupStatus.Exporting };
93+
});
94+
render();
95+
const initialButton = screen.getByRole('button', { name: messages.downloadAriaLabel.defaultMessage });
96+
await userEvent.click(initialButton);
97+
const exportingText = await screen.findByText(messages.backupExporting.defaultMessage);
98+
const exportingButton = exportingText.closest('button');
99+
expect(exportingButton).toBeDisabled();
100+
});
101+
102+
it('shows succeeded state uses ready text and triggers download', () => {
103+
mockStatusData = { state: 'Succeeded', url: '/fake/path.tar.gz' };
104+
const downloadSpy = jest.spyOn(document, 'createElement');
105+
render();
106+
const button = screen.getByRole('button');
107+
expect(button).toHaveTextContent(messages.downloadReadyButton.defaultMessage);
108+
userEvent.click(button);
109+
expect(downloadSpy).toHaveBeenCalledWith('a');
110+
downloadSpy.mockRestore();
111+
});
112+
113+
it('shows failed state and error alert', () => {
114+
mockStatusData = { state: LibraryBackupStatus.Failed };
115+
render();
116+
expect(screen.getByText(messages.backupFailedError.defaultMessage)).toBeVisible();
117+
const button = screen.getByRole('button');
118+
expect(button).toBeEnabled();
119+
});
120+
121+
it('covers timeout cleanup on unmount', () => {
122+
mockMutate.mockImplementation((_arg: any, { onSuccess }: any) => {
123+
onSuccess({ task_id: 'task-123' });
124+
mockStatusData = { state: LibraryBackupStatus.Pending };
125+
});
126+
const { unmount } = render();
127+
const button = screen.getByRole('button');
128+
userEvent.click(button);
129+
unmount();
130+
// No assertion needed, just coverage for cleanup
131+
});
132+
133+
it('covers fallback download logic', () => {
134+
mockStatusData = { state: LibraryBackupStatus.Succeeded, url: '/fake/path.tar.gz' };
135+
// Spy on createElement to force click failure for anchor
136+
const originalCreate = document.createElement.bind(document);
137+
const createSpy = jest.spyOn(document, 'createElement').mockImplementation((tagName: string) => {
138+
const el = originalCreate(tagName);
139+
if (tagName === 'a') {
140+
// Force failure when click is invoked
141+
(el as any).click = () => { throw new Error('fail'); };
142+
}
143+
return el;
144+
});
145+
// Stub window.location.href writable
146+
const originalLocation = window.location;
147+
// Use a minimal fake location object
148+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
149+
// @ts-ignore
150+
delete window.location;
151+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
152+
// @ts-ignore
153+
window.location = { href: '' };
154+
render();
155+
const button = screen.getByRole('button');
156+
userEvent.click(button);
157+
expect(window.location.href).toContain('/fake/path.tar.gz');
158+
// restore
159+
createSpy.mockRestore();
160+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
161+
// @ts-ignore
162+
window.location = originalLocation;
163+
});
164+
165+
it('executes timeout callback clearing task and re-enabling button after 5 minutes', async () => {
166+
jest.useFakeTimers();
167+
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
168+
mockMutate.mockImplementation((_arg: any, { onSuccess }: any) => {
169+
onSuccess({ task_id: 'task-123' });
170+
mockStatusData = { state: LibraryBackupStatus.Pending };
171+
});
172+
render();
173+
const button = screen.getByRole('button');
174+
expect(button).toBeEnabled();
175+
await user.click(button);
176+
177+
// Now in progress
178+
expect(button).toBeDisabled();
179+
act(() => {
180+
jest.advanceTimersByTime(1 * 60 * 1000); // advance 1 minutes
181+
});
182+
// After timeout callback, should be enabled again
183+
expect(button).toBeEnabled();
184+
jest.useRealTimers();
185+
});
186+
187+
it('shows pending message when mutation is in progress but no backup state yet', async () => {
188+
// Mock mutation to trigger onSuccess but don't immediately set backup state
189+
mockMutate.mockImplementation((_arg: any, { onSuccess }: any) => {
190+
onSuccess({ task_id: 'task-123' });
191+
// Don't set mockStatusData.state immediately to simulate the state
192+
// before the status API has returned any backup state
193+
});
194+
195+
render();
196+
const button = screen.getByRole('button');
197+
198+
await userEvent.click(button);
199+
200+
// This should trigger the specific line: return intl.formatMessage(messages.backupPending);
201+
// when isMutationInProgress is true but !backupState
202+
expect(screen.getByText(messages.backupPending.defaultMessage)).toBeVisible();
203+
expect(button).toBeDisabled();
204+
});
205+
206+
it('downloads backup immediately when clicking button with already succeeded backup', async () => {
207+
// Set up a scenario where backup is already succeeded with a URL
208+
mockStatusData = {
209+
state: LibraryBackupStatus.Succeeded,
210+
url: '/api/libraries/v2/backup/download/test-backup.tar.gz',
211+
};
212+
213+
render();
214+
215+
// Spy on handleDownload function call
216+
const createElementSpy = jest.spyOn(document, 'createElement');
217+
const mockAnchor = {
218+
href: '',
219+
download: '',
220+
click: jest.fn(),
221+
};
222+
createElementSpy.mockReturnValue(mockAnchor as any);
223+
const appendChildSpy = jest.spyOn(document.body, 'appendChild').mockImplementation();
224+
const removeChildSpy = jest.spyOn(document.body, 'removeChild').mockImplementation();
225+
226+
const button = screen.getByRole('button');
227+
228+
// Click the button - this should trigger the early return in handleDownloadBackup
229+
await userEvent.click(button);
230+
231+
// Verify the download was triggered
232+
expect(createElementSpy).toHaveBeenCalledWith('a');
233+
expect(mockAnchor.href).toContain('/api/libraries/v2/backup/download/test-backup.tar.gz');
234+
expect(mockAnchor.download).toContain('backup.tar.gz');
235+
expect(mockAnchor.click).toHaveBeenCalled();
236+
expect(appendChildSpy).toHaveBeenCalledWith(mockAnchor);
237+
expect(removeChildSpy).toHaveBeenCalledWith(mockAnchor);
238+
239+
// Verify mutate was NOT called since backup already exists
240+
expect(mockMutate).not.toHaveBeenCalled();
241+
242+
// Clean up spies
243+
createElementSpy.mockRestore();
244+
appendChildSpy.mockRestore();
245+
removeChildSpy.mockRestore();
246+
});
247+
});

0 commit comments

Comments
 (0)