[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
This commit is contained in:
Javier Ontiveros
2025-10-20 16:51:05 -06:00
committed by GitHub
parent 98009b3e6a
commit 7bfc73073b
15 changed files with 749 additions and 9 deletions

View File

@@ -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'>;
@@ -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`,
@@ -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) {

View File

@@ -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();
@@ -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;
};

View File

@@ -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',

View File

@@ -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 {
@@ -85,6 +86,10 @@ const LibraryLayout = () => (
path={ROUTES.UNIT}
Component={LibraryUnitPage}
/>
<Route
path={ROUTES.BACKUP}
Component={LibraryBackupPage}
/>
</Route>
</Routes>
);

View File

@@ -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
// why it doesn't work here as expected
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: '' };
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();
});
});

View File

@@ -0,0 +1,208 @@
import {
Alert,
Button,
Container,
} from '@openedx/paragon';
import {
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import { Helmet } from 'react-helmet';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Download, Loop, Newsstand } from '@openedx/paragon/icons';
import NotFoundAlert from '@src/generic/NotFoundAlert';
import SubHeader from '@src/generic/sub-header/SubHeader';
import Header from '@src/header';
import { LibraryBackupStatus } from '@src/library-authoring/backup-restore/data/constants';
import { useCreateLibraryBackup, useGetLibraryBackupStatus } from '@src/library-authoring/backup-restore/data/hooks';
import messages from '@src/library-authoring/backup-restore/messages';
import { useLibraryContext } from '@src/library-authoring/common/context/LibraryContext';
import { useContentLibrary } from '@src/library-authoring/data/apiHooks';
export const LibraryBackupPage = () => {
const intl = useIntl();
const { libraryId } = useLibraryContext();
const [taskId, setTaskId] = useState<string>('');
const [isMutationInProgress, setIsMutationInProgress] = useState<boolean>(false);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const { data: libraryData } = useContentLibrary(libraryId);
const mutation = useCreateLibraryBackup(libraryId);
const backupStatus = useGetLibraryBackupStatus(libraryId, taskId);
// Clean up timeout on unmount
useEffect(() => () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
}, []);
const handleDownload = useCallback((url: string) => {
try {
// Create a temporary anchor element for better download handling
const link = document.createElement('a');
link.href = url;
link.download = `${libraryData?.slug || 'library'}-backup.tar.gz`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} catch (error) {
// Fallback to window.location.href if the above fails
window.location.href = url;
}
}, [libraryData?.slug]);
const handleDownloadBackup = useCallback(() => {
// If backup is ready, download it immediately
if (backupStatus.data?.state === LibraryBackupStatus.Succeeded && backupStatus.data.url) {
handleDownload(backupStatus.data.url);
return;
}
// If no backup in progress, create a new one
if (!taskId) {
setIsMutationInProgress(true);
mutation.mutate(undefined, {
onSuccess: (data) => {
setTaskId(data.task_id);
// Clear task id after 1 minutes to allow new backups
timeoutRef.current = setTimeout(() => {
setTaskId('');
setIsMutationInProgress(false);
timeoutRef.current = null;
}, 60 * 1000);
},
onError: () => {
setIsMutationInProgress(false);
},
});
}
}, [taskId, backupStatus.data, mutation, handleDownload]);
// Auto-download when backup becomes ready
useEffect(() => {
if (backupStatus.data?.state === LibraryBackupStatus.Succeeded && backupStatus.data.url) {
handleDownload(backupStatus.data.url);
setIsMutationInProgress(false);
}
}, [backupStatus.data?.state, backupStatus.data?.url, handleDownload]);
// Reset mutation progress when backup fails
useEffect(() => {
if (backupStatus.data?.state === LibraryBackupStatus.Failed) {
setIsMutationInProgress(false);
}
}, [backupStatus.data?.state]);
const backupState = backupStatus.data?.state;
const isBackupInProgress = isMutationInProgress || (taskId && (
backupState === LibraryBackupStatus.Pending
|| backupState === LibraryBackupStatus.Exporting
));
const hasBackupFailed = backupState === LibraryBackupStatus.Failed;
const hasBackupSucceeded = backupState === LibraryBackupStatus.Succeeded;
// Show error message for failed mutation
const mutationError = mutation.error as Error | null;
if (!libraryData) {
return <NotFoundAlert />;
}
const getButtonText = () => {
if (isBackupInProgress) {
if (isMutationInProgress && !backupState) {
return intl.formatMessage(messages.backupPending);
}
return backupState === LibraryBackupStatus.Pending
? intl.formatMessage(messages.backupPending) : intl.formatMessage(messages.backupExporting);
}
if (hasBackupSucceeded && backupStatus.data?.url) {
return intl.formatMessage(messages.downloadReadyButton);
}
return intl.formatMessage(messages.createBackupButton);
};
const getButtonIcon = () => {
if (isBackupInProgress) {
return Loop;
}
return Download;
};
return (
<div className="d-flex">
<div className="flex-grow-1">
<Helmet>
<title>{libraryData.title} | {process.env.SITE_NAME}</title>
</Helmet>
<Header
number={libraryData.slug}
title={libraryData.title}
org={libraryData.org}
contextId={libraryId}
isLibrary
containerProps={{
size: undefined,
}}
/>
<Container className="px-0 mt-4 mb-5 library-authoring-page bg-white">
<div className="px-4 bg-light-200 border-bottom mb-2">
<SubHeader
title={intl.formatMessage(messages.backupPageTitle)}
subtitle={intl.formatMessage(messages.backupPageSubtitle)}
hideBorder
/>
</div>
{/* Error Messages */}
{hasBackupFailed && (
<div className="px-4">
<Alert variant="danger">
{intl.formatMessage(messages.backupFailedError)}
</Alert>
</div>
)}
{mutationError && (
<div className="px-4">
<Alert variant="danger">
{intl.formatMessage(messages.mutationError, { error: mutationError.message })}
</Alert>
</div>
)}
<Container className="px-4 py-4">
<div className="mb-4">
<p>{intl.formatMessage(messages.backupDescription)}</p>
</div>
<div className="bg-info-700 text-white p-4 rounded row justify-content-between align-items-center">
<div className="d-flex flex-column">
<div className="d-inline-flex align-items-center">
<Newsstand className="mr-2" />
<span>{libraryData.title}</span>
</div>
<span className="small">{`${libraryData.org} / ${libraryData.slug}`}</span>
</div>
<Button
variant="info"
iconBefore={getButtonIcon()}
onClick={handleDownloadBackup}
disabled={Boolean(isBackupInProgress)}
aria-label={intl.formatMessage(messages.downloadAriaLabel, {
buttonText: getButtonText(),
libraryTitle: libraryData.title,
})}
>
{getButtonText()}
</Button>
</div>
</Container>
</Container>
</div>
</div>
);
};

View File

@@ -0,0 +1,52 @@
// eslint-disable-next-line import/no-extraneous-dependencies
import { getLibraryBackupApiUrl, getLibraryBackupStatusApiUrl } from '@src/library-authoring/data/api';
import { mockContentLibrary } from '@src/library-authoring/data/api.mocks';
import { initializeMocks } from '@src/testUtils';
import { createLibraryBackup, getLibraryBackupStatus } from './api';
mockContentLibrary.applyMock();
const { axiosMock } = initializeMocks();
afterEach(() => {
axiosMock.reset();
});
describe('backup-restore api', () => {
it('should call createLibraryBackup and return a promise', async () => {
await expect(createLibraryBackup(mockContentLibrary.libraryId)).rejects.toBeDefined();
});
it('should build correct URL and call post for createLibraryBackup', async () => {
const libraryUrl = getLibraryBackupApiUrl(mockContentLibrary.libraryId);
axiosMock.onPost(libraryUrl).reply(200, { success: true, taskId: 'task123' });
const result = await createLibraryBackup(mockContentLibrary.libraryId);
expect(axiosMock.history.post.length).toBe(1);
expect(axiosMock.history.post[0].url).toMatch(libraryUrl);
expect(result).toEqual({ success: true, taskId: 'task123' });
});
it('should call getLibraryBackupStatus and return a promise', async () => {
await expect(getLibraryBackupStatus('test-library-id', 'test-task-id')).rejects.toBeDefined();
});
it('should build correct URL and call get for getLibraryBackupStatus', async () => {
axiosMock.onGet().reply(200, { status: 'ok' });
const result = await getLibraryBackupStatus(mockContentLibrary.libraryId, 'task123');
expect(axiosMock.history.get.length).toBe(1);
expect(axiosMock.history.get[0].url).toMatch(getLibraryBackupStatusApiUrl(mockContentLibrary.libraryId, 'task123'));
expect(result).toEqual({ status: 'ok' });
});
it('should throw if libraryId is missing for createLibraryBackup', async () => {
// @ts-expect-error
await expect(createLibraryBackup()).rejects.toBeDefined();
});
it('should throw if libraryId or taskId is missing for getLibraryBackupStatus', async () => {
// @ts-expect-error
await expect(getLibraryBackupStatus()).rejects.toBeDefined();
// @ts-expect-error
await expect(getLibraryBackupStatus(mockContentLibrary.libraryId)).rejects.toBeDefined();
});
});

View File

@@ -0,0 +1,14 @@
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { CreateLibraryBackupResponse, GetLibraryBackupStatusResponse } from '@src/library-authoring/backup-restore/data/constants';
import { getLibraryBackupApiUrl, getLibraryBackupStatusApiUrl } from '@src/library-authoring/data/api';
export const createLibraryBackup = async (libraryId: string): Promise<CreateLibraryBackupResponse> => {
const { data } = await getAuthenticatedHttpClient().post(getLibraryBackupApiUrl(libraryId), {});
return data;
};
export const getLibraryBackupStatus = async (libraryId: string, taskId: string):
Promise<GetLibraryBackupStatusResponse> => {
const { data } = await getAuthenticatedHttpClient().get(getLibraryBackupStatusApiUrl(libraryId, taskId));
return data;
};

View File

@@ -0,0 +1,22 @@
export interface CreateLibraryBackupResponse {
task_id: string;
}
export interface GetLibraryBackupStatusResponse {
state: LibraryBackupStatus;
url: string;
}
export enum LibraryBackupStatus {
Pending = 'Pending',
Succeeded = 'Succeeded',
Exporting = 'Exporting',
Failed = 'Failed',
}
export const libraryBackupQueryKeys = {
// TODO: add appId to follow new agreements once definitions are ready for queryKeys
all: ['library-v2-backup'],
backupStatus: (libraryId: string, taskId: string) => [...libraryBackupQueryKeys.all, 'status', libraryId, taskId],
backupMutation: (libraryId: string) => [...libraryBackupQueryKeys.all, 'create-backup', libraryId],
};

View File

@@ -0,0 +1,59 @@
import { initializeMocks, renderHook, waitFor } from '@src/testUtils';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import React from 'react';
import * as api from '@src/library-authoring/backup-restore/data/api';
import { LibraryBackupStatus } from './constants';
import { useCreateLibraryBackup, useGetLibraryBackupStatus } from './hooks';
describe('backup-restore hooks', () => {
const libraryId = 'lib:Org:example';
let createLibraryBackupSpy: jest.SpyInstance;
let getLibraryBackupStatusSpy: jest.SpyInstance;
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return function namingToMakeEslintHappy({ children }: { children: React.ReactNode }) {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
};
};
beforeEach(() => {
initializeMocks();
createLibraryBackupSpy = jest.spyOn(api, 'createLibraryBackup').mockImplementation(async () => ({ task_id: 'task-abc' }));
getLibraryBackupStatusSpy = jest.spyOn(api, 'getLibraryBackupStatus').mockImplementation(async () => ({
state: LibraryBackupStatus.Pending,
url: '',
}));
});
afterEach(() => {
createLibraryBackupSpy.mockRestore();
getLibraryBackupStatusSpy.mockRestore();
});
it('useGetLibraryBackupStatus does not fetch when taskId is empty', async () => {
const wrapper = createWrapper();
renderHook(() => useGetLibraryBackupStatus(libraryId, ''), { wrapper });
expect(getLibraryBackupStatusSpy).not.toHaveBeenCalled();
});
it('useGetLibraryBackupStatus fetches when taskId provided and sets data to Pending', async () => {
const wrapper = createWrapper();
const taskId = 'task-123';
const { result } = renderHook(() => useGetLibraryBackupStatus(libraryId, taskId), { wrapper });
await waitFor(() => {
expect(getLibraryBackupStatusSpy).toHaveBeenCalledWith(libraryId, taskId);
expect(result.current.data).toBeDefined();
});
expect(result.current.data?.state).toBe(LibraryBackupStatus.Pending);
});
it('useCreateLibraryBackup mutation returns task id', async () => {
const wrapper = createWrapper();
const { result } = renderHook(() => useCreateLibraryBackup(libraryId), { wrapper });
await result.current.mutateAsync();
expect(createLibraryBackupSpy).toHaveBeenCalledWith(libraryId);
});
});

View File

@@ -0,0 +1,30 @@
import { createLibraryBackup, getLibraryBackupStatus } from '@src/library-authoring/backup-restore/data/api';
import { GetLibraryBackupStatusResponse, libraryBackupQueryKeys, LibraryBackupStatus } from '@src/library-authoring/backup-restore/data/constants';
import { useMutation, useQuery } from '@tanstack/react-query';
/**
* React Query hook to fetch backup status for a specific library and taskId
* the taskID is returned when creating a backup
*
* @param libraryId - The unique identifier of the library
* @param taskId - The unique identifier of the backup task
*
* @example
* ```tsx
* const { data, isLoading, isError } = useGetLibraryBackupStatus('lib:123', 'task:456abc');
* ```
*/
export const useGetLibraryBackupStatus = (libraryId: string, taskId: string) => useQuery<GetLibraryBackupStatusResponse,
Error>({
queryKey: libraryBackupQueryKeys.backupStatus(libraryId, taskId),
queryFn: () => getLibraryBackupStatus(libraryId, taskId),
enabled: !!taskId, // Only run the query if taskId is provided
refetchInterval: (query) => (query.state.data?.state === LibraryBackupStatus.Pending
|| query.state.data?.state === LibraryBackupStatus.Exporting ? 2000 : false),
});
export const useCreateLibraryBackup = (libraryId: string) => useMutation({
mutationKey: libraryBackupQueryKeys.backupMutation(libraryId),
mutationFn: () => createLibraryBackup(libraryId),
gcTime: 60, // Cache for 1 minute to prevent rapid re-creation of backups
});

View File

@@ -0,0 +1 @@
export { LibraryBackupPage } from './LibraryBackupPage';

View File

@@ -0,0 +1,66 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
backupPageTitle: {
id: 'course-authoring.library-authoring.backup-page.title',
defaultMessage: 'Library Backup',
description: 'Title for the library backup page',
},
backupPageSubtitle: {
id: 'course-authoring.library-authoring.backup-page.subtitle',
defaultMessage: 'Tools',
description: 'Subtitle for the library backup page',
},
backupFailedError: {
id: 'course-authoring.library-authoring.backup-page.error.backup-failed',
defaultMessage: 'There was an error creating the backup. Please try again later.',
description: 'Error message when backup creation fails',
},
mutationError: {
id: 'course-authoring.library-authoring.backup-page.error.mutation-failed',
defaultMessage: 'Failed to start backup: {error}',
description: 'Error message when backup mutation fails',
},
backupPending: {
id: 'course-authoring.library-authoring.backup-page.status.pending',
defaultMessage: 'Preparing to download...',
description: 'Message shown when backup is in pending state',
},
backupExporting: {
id: 'course-authoring.library-authoring.backup-page.status.exporting',
defaultMessage: 'Your backup is being exported...',
description: 'Message shown when backup is being exported',
},
backupDescription: {
id: 'course-authoring.library-authoring.backup-page.description',
defaultMessage: 'Local backups are stored on your machine and are not automatically synced. They will not contain any edit history. You can restore a local backup as a new library on this or another learning site. Anyone who can access the local backup file can view all its content.',
description: 'Description of what library backups are and how they work',
},
createBackupButton: {
id: 'course-authoring.library-authoring.backup-page.button.create',
defaultMessage: 'Download Library Backup',
description: 'Button text to create and download a new backup',
},
downloadReadyButton: {
id: 'course-authoring.library-authoring.backup-page.button.download-ready',
defaultMessage: 'Download Library Backup',
description: 'Button text when backup is ready for download',
},
creatingBackupButton: {
id: 'course-authoring.library-authoring.backup-page.button.creating',
defaultMessage: 'Creating Backup...',
description: 'Button text when backup is being created',
},
exportingBackupButton: {
id: 'course-authoring.library-authoring.backup-page.button.exporting',
defaultMessage: 'Exporting...',
description: 'Button text when backup is being exported',
},
downloadAriaLabel: {
id: 'course-authoring.library-authoring.backup-page.button.aria-label',
defaultMessage: '{buttonText} for {libraryTitle}',
description: 'Aria label for the download button',
},
});
export default messages;

View File

@@ -137,6 +137,14 @@ export const getLibraryContainerCollectionsUrl = (containerId: string) => `${get
* Get the URL for the API endpoint to publish a single container (+ children).
*/
export const getLibraryContainerPublishApiUrl = (containerId: string) => `${getLibraryContainerApiUrl(containerId)}publish/`;
/**
* Get the URL for the API endpoint to create a backup of a v2 library.
*/
export const getLibraryBackupApiUrl = (libraryId: string) => `${getApiBaseUrl()}/api/libraries/v2/${libraryId}/backup/`;
/**
* Get the URL for the API endpoint to get the status of a library backup task.
*/
export const getLibraryBackupStatusApiUrl = (libraryId: string, taskId: string) => `${getApiBaseUrl()}/api/libraries/v2/${libraryId}/backup/?task_id=${taskId}`;
/**
* Get the URL for the API endpoint to copy a single container.
*/

View File

@@ -40,6 +40,8 @@ export const ROUTES = {
// LibraryUnitPage route:
// * with a selected containerId and/or an optionally selected componentId.
UNIT: '/unit/:containerId/:selectedItemId?',
// LibraryBackupPage route:
BACKUP: '/backup',
};
export enum ContentType {