diff --git a/src/header/Header.tsx b/src/header/Header.tsx index 54796f00e1..b9f7210fcb 100644 --- a/src/header/Header.tsx +++ b/src/header/Header.tsx @@ -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, '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) { diff --git a/src/header/hooks.jsx b/src/header/hooks.jsx index 069eac7dc6..80389e3a72 100644 --- a/src/header/hooks.jsx +++ b/src/header/hooks.jsx @@ -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; }; diff --git a/src/header/messages.js b/src/header/messages.js index 71cd07be15..b755b9dc5d 100644 --- a/src/header/messages.js +++ b/src/header/messages.js @@ -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', diff --git a/src/library-authoring/LibraryLayout.tsx b/src/library-authoring/LibraryLayout.tsx index cc7d27df30..350bd5c39c 100644 --- a/src/library-authoring/LibraryLayout.tsx +++ b/src/library-authoring/LibraryLayout.tsx @@ -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 = ({ children }) => { const { @@ -85,6 +86,10 @@ const LibraryLayout = () => ( path={ROUTES.UNIT} Component={LibraryUnitPage} /> + ); diff --git a/src/library-authoring/backup-restore/LibraryBackupPage.test.tsx b/src/library-authoring/backup-restore/LibraryBackupPage.test.tsx new file mode 100644 index 0000000000..d00805e6e9 --- /dev/null +++ b/src/library-authoring/backup-restore/LibraryBackupPage.test.tsx @@ -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(, { + extraWrapper: ({ children }) => ( + {children} + ), +}); + +// 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('', () => { + 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(); + }); +}); diff --git a/src/library-authoring/backup-restore/LibraryBackupPage.tsx b/src/library-authoring/backup-restore/LibraryBackupPage.tsx new file mode 100644 index 0000000000..61d4a334cd --- /dev/null +++ b/src/library-authoring/backup-restore/LibraryBackupPage.tsx @@ -0,0 +1,211 @@ +import { getConfig } from '@edx/frontend-platform'; +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 SubHeader from '@src/generic/sub-header/SubHeader'; +import { LibraryBackupStatus } from '@src/library-authoring/backup-restore/data/constants'; +import { useCreateLibraryBackup, useGetLibraryBackupStatus } from '@src/library-authoring/backup-restore/data/hooks'; +import NotFoundAlert from '@src/generic/NotFoundAlert'; +import Header from '@src/header'; +import { useLibraryContext } from '@src/library-authoring/common/context/LibraryContext'; +import { useContentLibrary } from '@src/library-authoring/data/apiHooks'; +import messages from '@src/library-authoring/backup-restore/messages'; + +export const LibraryBackupPage = () => { + const intl = useIntl(); + const { libraryId } = useLibraryContext(); + const [taskId, setTaskId] = useState(''); + const [isMutationInProgress, setIsMutationInProgress] = useState(false); + const timeoutRef = useRef(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) { + const fullUrl = `${getConfig().STUDIO_BASE_URL}${backupStatus.data.url}`; + handleDownload(fullUrl); + 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) { + const fullUrl = `${getConfig().STUDIO_BASE_URL}${backupStatus.data.url}`; + handleDownload(fullUrl); + 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 ; + } + + 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 ( +
+
+ + {libraryData.title} | {process.env.SITE_NAME} + +
+ +
+ +
+ + {/* Error Messages */} + {hasBackupFailed && ( +
+ + {intl.formatMessage(messages.backupFailedError)} + +
+ )} + {mutationError && ( +
+ + {intl.formatMessage(messages.mutationError, { error: mutationError.message })} + +
+ )} + + +
+

{intl.formatMessage(messages.backupDescription)}

+
+ +
+
+
+ + {libraryData.title} +
+ {`${libraryData.org} / ${libraryData.slug}`} +
+ +
+
+
+
+
+ ); +}; diff --git a/src/library-authoring/backup-restore/data/api.test.ts b/src/library-authoring/backup-restore/data/api.test.ts new file mode 100644 index 0000000000..18f895f2d8 --- /dev/null +++ b/src/library-authoring/backup-restore/data/api.test.ts @@ -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(); + }); +}); diff --git a/src/library-authoring/backup-restore/data/api.ts b/src/library-authoring/backup-restore/data/api.ts new file mode 100644 index 0000000000..dd31db8173 --- /dev/null +++ b/src/library-authoring/backup-restore/data/api.ts @@ -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 => { + const { data } = await getAuthenticatedHttpClient().post(getLibraryBackupApiUrl(libraryId), {}); + return data; +}; + +export const getLibraryBackupStatus = async (libraryId: string, taskId: string): +Promise => { + const { data } = await getAuthenticatedHttpClient().get(getLibraryBackupStatusApiUrl(libraryId, taskId)); + return data; +}; diff --git a/src/library-authoring/backup-restore/data/constants.ts b/src/library-authoring/backup-restore/data/constants.ts new file mode 100644 index 0000000000..9824d28fc7 --- /dev/null +++ b/src/library-authoring/backup-restore/data/constants.ts @@ -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], +}; diff --git a/src/library-authoring/backup-restore/data/hooks.test.tsx b/src/library-authoring/backup-restore/data/hooks.test.tsx new file mode 100644 index 0000000000..ddde9bf7cf --- /dev/null +++ b/src/library-authoring/backup-restore/data/hooks.test.tsx @@ -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 {children}; + }; + }; + + 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); + }); +}); diff --git a/src/library-authoring/backup-restore/data/hooks.ts b/src/library-authoring/backup-restore/data/hooks.ts new file mode 100644 index 0000000000..9f3b3d2554 --- /dev/null +++ b/src/library-authoring/backup-restore/data/hooks.ts @@ -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({ + 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 +}); diff --git a/src/library-authoring/backup-restore/index.ts b/src/library-authoring/backup-restore/index.ts new file mode 100644 index 0000000000..16529fad50 --- /dev/null +++ b/src/library-authoring/backup-restore/index.ts @@ -0,0 +1 @@ +export { LibraryBackupPage } from './LibraryBackupPage'; diff --git a/src/library-authoring/backup-restore/messages.ts b/src/library-authoring/backup-restore/messages.ts new file mode 100644 index 0000000000..99b24a68d1 --- /dev/null +++ b/src/library-authoring/backup-restore/messages.ts @@ -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; diff --git a/src/library-authoring/data/api.ts b/src/library-authoring/data/api.ts index 4df6cb6716..7b40cecaf2 100644 --- a/src/library-authoring/data/api.ts +++ b/src/library-authoring/data/api.ts @@ -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. */ diff --git a/src/library-authoring/routes.ts b/src/library-authoring/routes.ts index a65781c297..82c1813298 100644 --- a/src/library-authoring/routes.ts +++ b/src/library-authoring/routes.ts @@ -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 {