[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:
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
247
src/library-authoring/backup-restore/LibraryBackupPage.test.tsx
Normal file
247
src/library-authoring/backup-restore/LibraryBackupPage.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
208
src/library-authoring/backup-restore/LibraryBackupPage.tsx
Normal file
208
src/library-authoring/backup-restore/LibraryBackupPage.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
52
src/library-authoring/backup-restore/data/api.test.ts
Normal file
52
src/library-authoring/backup-restore/data/api.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
14
src/library-authoring/backup-restore/data/api.ts
Normal file
14
src/library-authoring/backup-restore/data/api.ts
Normal 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;
|
||||
};
|
||||
22
src/library-authoring/backup-restore/data/constants.ts
Normal file
22
src/library-authoring/backup-restore/data/constants.ts
Normal 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],
|
||||
};
|
||||
59
src/library-authoring/backup-restore/data/hooks.test.tsx
Normal file
59
src/library-authoring/backup-restore/data/hooks.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
30
src/library-authoring/backup-restore/data/hooks.ts
Normal file
30
src/library-authoring/backup-restore/data/hooks.ts
Normal 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
|
||||
});
|
||||
1
src/library-authoring/backup-restore/index.ts
Normal file
1
src/library-authoring/backup-restore/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { LibraryBackupPage } from './LibraryBackupPage';
|
||||
66
src/library-authoring/backup-restore/messages.ts
Normal file
66
src/library-authoring/backup-restore/messages.ts
Normal 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;
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user