Skip to content

Commit fb55f69

Browse files
committed
feat: add backup view for libraries v2
1 parent c4a439d commit fb55f69

11 files changed

Lines changed: 378 additions & 9 deletions

File tree

src/header/Header.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ const Header = ({
3939

4040
const contentMenuItems = useContentMenuItems(contextId);
4141
const settingMenuItems = useSettingMenuItems(contextId);
42-
const toolsMenuItems = useToolsMenuItems(contextId);
42+
const toolsMenuItems = useToolsMenuItems(contextId, isLibrary);
4343
const mainMenuDropdowns = !isLibrary ? [
4444
{
4545
id: `${intl.formatMessage(messages['header.links.content'])}-dropdown-menu`,
@@ -56,7 +56,11 @@ const Header = ({
5656
buttonTitle: intl.formatMessage(messages['header.links.tools']),
5757
items: toolsMenuItems,
5858
},
59-
] : [];
59+
] : [{
60+
id: `${intl.formatMessage(messages['header.links.tools'])}-dropdown-menu`,
61+
buttonTitle: intl.formatMessage(messages['header.links.tools']),
62+
items: toolsMenuItems,
63+
}];
6064

6165
const getOutlineLink = () => {
6266
if (isLibrary) {

src/header/hooks.jsx

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

92-
export const useToolsMenuItems = courseId => {
92+
export const useToolsMenuItems = (courseId, isLibrary = false) => {
9393
const intl = useIntl();
9494
const studioBaseUrl = getConfig().STUDIO_BASE_URL;
9595
const waffleFlags = useWaffleFlags();
@@ -123,5 +123,13 @@ export const useToolsMenuItems = courseId => {
123123
),
124124
}] : []),
125125
];
126-
return items;
126+
127+
const libraryItems = [
128+
{
129+
href: `/library/${courseId}/backup`,
130+
title: intl.formatMessage(messages['header.links.exportLibrary']),
131+
},
132+
];
133+
134+
return isLibrary ? libraryItems : items;
127135
};

src/header/messages.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,11 +96,21 @@ const messages = defineMessages({
9696
defaultMessage: 'Import',
9797
description: 'Link to Studio Import page',
9898
},
99+
'header.links.importLibrary': {
100+
id: 'header.links.importLibrary',
101+
defaultMessage: 'Import',
102+
description: 'Link to Studio Import Library page',
103+
},
99104
'header.links.exportCourse': {
100105
id: 'header.links.exportCourse',
101106
defaultMessage: 'Export Course',
102107
description: 'Link to Studio Export page',
103108
},
109+
'header.links.exportLibrary': {
110+
id: 'header.links.exportLibrary',
111+
defaultMessage: 'Backup to local archive',
112+
description: 'Link to Studio Export Library page',
113+
},
104114
'header.links.optimizer': {
105115
id: 'header.links.optimizer',
106116
defaultMessage: 'Course Optimizer',

src/library-authoring/LibraryLayout.tsx

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

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

2122
const LibraryLayoutWrapper: React.FC<React.PropsWithChildren> = ({ children }) => {
2223
const {
@@ -85,6 +86,10 @@ const LibraryLayout = () => (
8586
path={ROUTES.UNIT}
8687
Component={LibraryUnitPage}
8788
/>
89+
<Route
90+
path={ROUTES.BACKUP}
91+
Component={LibraryBackupPage}
92+
/>
8893
</Route>
8994
</Routes>
9095
);
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
import { getConfig } from '@edx/frontend-platform';
2+
import {
3+
Alert,
4+
Button,
5+
Container,
6+
} from '@openedx/paragon';
7+
import {
8+
useCallback,
9+
useEffect,
10+
useRef,
11+
useState,
12+
} from 'react';
13+
import { Helmet } from 'react-helmet';
14+
15+
import { useIntl } from '@edx/frontend-platform/i18n';
16+
import { Download, Loop, Newsstand } from '@openedx/paragon/icons';
17+
import SubHeader from '@src/generic/sub-header/SubHeader';
18+
import { LibraryBackupStatus } from '@src/library-authoring/backup-restore/data/constants';
19+
import { useCreateLibraryBackup, useGetLibraryBackupStatus } from '@src/library-authoring/backup-restore/data/hooks';
20+
import NotFoundAlert from '../../generic/NotFoundAlert';
21+
import Header from '../../header';
22+
import { useLibraryContext } from '../common/context/LibraryContext';
23+
import { useContentLibrary } from '../data/apiHooks';
24+
import messages from './messages';
25+
26+
export const LibraryBackupPage = () => {
27+
const intl = useIntl();
28+
const { libraryId } = useLibraryContext();
29+
const [taskId, setTaskId] = useState<string>('');
30+
const [isMutationInProgress, setIsMutationInProgress] = useState<boolean>(false);
31+
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
32+
const { data: libraryData } = useContentLibrary(libraryId);
33+
34+
const mutation = useCreateLibraryBackup(libraryId);
35+
const backupStatus = useGetLibraryBackupStatus(libraryId, taskId);
36+
37+
// Clean up timeout on unmount
38+
useEffect(() => () => {
39+
if (timeoutRef.current) {
40+
clearTimeout(timeoutRef.current);
41+
}
42+
}, []);
43+
44+
const handleDownload = useCallback((url: string) => {
45+
try {
46+
// Create a temporary anchor element for better download handling
47+
const link = document.createElement('a');
48+
link.href = url;
49+
link.download = `${libraryData?.slug || 'library'}-backup.tar.gz`;
50+
document.body.appendChild(link);
51+
link.click();
52+
document.body.removeChild(link);
53+
} catch (error) {
54+
// Fallback to window.location.href if the above fails
55+
window.location.href = url;
56+
}
57+
}, [libraryData?.slug]);
58+
59+
const handleDownloadBackup = useCallback(() => {
60+
// If backup is ready, download it immediately
61+
if (backupStatus.data?.state === LibraryBackupStatus.Succeeded && backupStatus.data.url) {
62+
const fullUrl = `${getConfig().STUDIO_BASE_URL}${backupStatus.data.url}`;
63+
handleDownload(fullUrl);
64+
return;
65+
}
66+
67+
// If no backup in progress, create a new one
68+
if (!taskId) {
69+
setIsMutationInProgress(true);
70+
mutation.mutate(undefined, {
71+
onSuccess: (data) => {
72+
setTaskId(data.task_id);
73+
// Clear task id after 5 minutes to allow new backups
74+
timeoutRef.current = setTimeout(() => {
75+
setTaskId('');
76+
setIsMutationInProgress(false);
77+
timeoutRef.current = null;
78+
}, 5 * 60 * 1000);
79+
},
80+
onError: () => {
81+
setIsMutationInProgress(false);
82+
},
83+
});
84+
}
85+
}, [taskId, backupStatus.data, mutation, handleDownload]);
86+
87+
// Auto-download when backup becomes ready
88+
useEffect(() => {
89+
if (backupStatus.data?.state === LibraryBackupStatus.Succeeded && backupStatus.data.url) {
90+
const fullUrl = `${getConfig().STUDIO_BASE_URL}${backupStatus.data.url}`;
91+
handleDownload(fullUrl);
92+
setIsMutationInProgress(false);
93+
}
94+
}, [backupStatus.data?.state, backupStatus.data?.url, handleDownload]);
95+
96+
// Reset mutation progress when backup fails
97+
useEffect(() => {
98+
if (backupStatus.data?.state === LibraryBackupStatus.Failed) {
99+
setIsMutationInProgress(false);
100+
}
101+
}, [backupStatus.data?.state]);
102+
103+
const backupState = backupStatus.data?.state;
104+
const isBackupInProgress = isMutationInProgress || (taskId && (
105+
backupState === LibraryBackupStatus.Pending
106+
|| backupState === LibraryBackupStatus.Exporting
107+
));
108+
const hasBackupFailed = backupState === LibraryBackupStatus.Failed;
109+
const hasBackupSucceeded = backupState === LibraryBackupStatus.Succeeded;
110+
111+
// Show error message for failed mutation
112+
const mutationError = mutation.error as Error | null;
113+
114+
if (!libraryData) {
115+
return <NotFoundAlert />;
116+
}
117+
118+
const getButtonText = () => {
119+
if (isBackupInProgress) {
120+
if (isMutationInProgress && !backupState) {
121+
return intl.formatMessage(messages.backupPending);
122+
}
123+
return backupState === LibraryBackupStatus.Pending
124+
? intl.formatMessage(messages.backupPending) : intl.formatMessage(messages.backupExporting);
125+
}
126+
if (hasBackupSucceeded && backupStatus.data?.url) {
127+
return intl.formatMessage(messages.downloadReadyButton);
128+
}
129+
return intl.formatMessage(messages.createBackupButton);
130+
};
131+
132+
const getButtonIcon = () => {
133+
if (isBackupInProgress) {
134+
return Loop;
135+
}
136+
return Download;
137+
};
138+
139+
return (
140+
<div className="d-flex">
141+
<div className="flex-grow-1">
142+
<Helmet>
143+
<title>{libraryData.title} | {process.env.SITE_NAME}</title>
144+
</Helmet>
145+
<Header
146+
number={libraryData.slug}
147+
title={libraryData.title}
148+
org={libraryData.org}
149+
contextId={libraryId}
150+
isLibrary
151+
containerProps={{
152+
size: undefined,
153+
}}
154+
/>
155+
<Container className="px-0 mt-4 mb-5 library-authoring-page bg-white">
156+
<div className="px-4 bg-light-200 border-bottom mb-2">
157+
<SubHeader
158+
title={intl.formatMessage(messages.backupPageTitle)}
159+
subtitle={intl.formatMessage(messages.backupPageSubtitle)}
160+
hideBorder
161+
/>
162+
</div>
163+
164+
{/* Error Messages */}
165+
{hasBackupFailed && (
166+
<div className="px-4">
167+
<Alert variant="danger">
168+
{intl.formatMessage(messages.backupFailedError)}
169+
</Alert>
170+
</div>
171+
)}
172+
{mutationError && (
173+
<div className="px-4">
174+
<Alert variant="danger">
175+
{intl.formatMessage(messages.mutationError, { error: mutationError.message })}
176+
</Alert>
177+
</div>
178+
)}
179+
180+
<Container className="px-4 py-4">
181+
<div className="mb-4">
182+
<p>{intl.formatMessage(messages.backupDescription)}</p>
183+
</div>
184+
185+
<div className="bg-info-700 text-white p-4 rounded row justify-content-between align-items-center">
186+
<div className="d-flex flex-column">
187+
<div className="d-inline-flex align-items-center">
188+
<Newsstand className="mr-2" />
189+
<span>{libraryData.title}</span>
190+
</div>
191+
<span className="small">{`${libraryData.org} / ${libraryData.slug}`}</span>
192+
</div>
193+
<Button
194+
variant="info"
195+
iconBefore={getButtonIcon()}
196+
onClick={handleDownloadBackup}
197+
disabled={Boolean(isBackupInProgress)}
198+
aria-label={intl.formatMessage(messages.downloadAriaLabel, {
199+
buttonText: getButtonText(),
200+
libraryTitle: libraryData.title,
201+
})}
202+
>
203+
{getButtonText()}
204+
</Button>
205+
</div>
206+
</Container>
207+
</Container>
208+
</div>
209+
</div>
210+
);
211+
};
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { getConfig } from '@edx/frontend-platform';
2+
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
3+
import { CreateLibraryBackupResponse, GetLibraryBackupStatusResponse } from '@src/library-authoring/backup-restore/data/constants';
4+
5+
const getApiUrl = (path: string) => `${getConfig().STUDIO_BASE_URL}/${path || ''}`;
6+
7+
export const createLibraryBackup = async (libraryId: string): Promise<CreateLibraryBackupResponse> => {
8+
const { data } = await getAuthenticatedHttpClient().post(getApiUrl(`api/libraries/v2/${libraryId}/backup/`));
9+
return data;
10+
};
11+
12+
export const getLibraryBackupStatus = async (libraryId: string, taskId: string):
13+
Promise<GetLibraryBackupStatusResponse> => {
14+
const { data } = await getAuthenticatedHttpClient().get(getApiUrl(`api/libraries/v2/${libraryId}/backup/?task_id=${taskId}`));
15+
return data;
16+
};
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
export interface CreateLibraryBackupResponse {
2+
task_id: string;
3+
}
4+
5+
export interface GetLibraryBackupStatusResponse {
6+
state: LibraryBackupStatus;
7+
url: string;
8+
}
9+
10+
export enum LibraryBackupStatus {
11+
Pending = 'Pending',
12+
Succeeded = 'Succeeded',
13+
Exporting = 'Exporting',
14+
Failed = 'Failed',
15+
}
16+
17+
export const LIBRARY_BACKUP_MUTATION_KEY = 'create-library-backup';
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { createLibraryBackup, getLibraryBackupStatus } from '@src/library-authoring/backup-restore/data/api';
2+
import { GetLibraryBackupStatusResponse, LIBRARY_BACKUP_MUTATION_KEY } from '@src/library-authoring/backup-restore/data/constants';
3+
import { useMutation, useQuery } from '@tanstack/react-query';
4+
5+
/**
6+
* React Query hook to fetch backup status for a specific library and taskId
7+
* the taskID is returned when creating a backup
8+
*
9+
* @param libraryId - The unique identifier of the library
10+
* @param taskId - The unique identifier of the backup task
11+
*
12+
* @example
13+
* ```tsx
14+
* const { data, isLoading, isError } = useGetLibraryBackupStatus('lib:123', 'task:456abc');
15+
* ```
16+
*/
17+
export const useGetLibraryBackupStatus = (libraryId: string, taskId: string) => useQuery<GetLibraryBackupStatusResponse,
18+
Error>({
19+
queryKey: ['library-backup-status', libraryId, taskId],
20+
queryFn: () => getLibraryBackupStatus(libraryId, taskId),
21+
enabled: !!taskId, // Only run the query if taskId is provided
22+
refetchInterval: (data) => (data?.state === 'Pending' || data?.state === 'Exporting' ? 2000 : false),
23+
});
24+
25+
export const useCreateLibraryBackup = (libraryId: string) => useMutation({
26+
mutationKey: [LIBRARY_BACKUP_MUTATION_KEY, libraryId],
27+
mutationFn: () => createLibraryBackup(libraryId),
28+
cacheTime: 60, // Cache for 1 minute to prevent rapid re-creation of backups
29+
});
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { LibraryBackupPage } from './LibraryBackupPage';

0 commit comments

Comments
 (0)