feat: add restore from file UI for libraries v2 (#2558)

This commit is contained in:
Javier Ontiveros
2025-10-29 15:37:35 -06:00
committed by GitHub
parent 871d98828c
commit 9b77a40284
12 changed files with 1413 additions and 26 deletions

View File

@@ -72,7 +72,7 @@ export const VideoUploader = ({ setLoading, onUpload, onClose }) => {
};
return (
<div className="d-flex flex-column">
<div className="video-uploader d-flex flex-column">
<div className="d-flex justify-content-end flex-row">
<IconButton
className="position-absolute mr-2 mt-2"

View File

@@ -6,7 +6,7 @@
}
}
.pgn__dropzone {
.video-uploader .pgn__dropzone {
height: 96vh;
width: 100%;
}

View File

@@ -14,6 +14,8 @@ import { getStudioHomeApiUrl } from '@src/studio-home/data/api';
import { getApiWaffleFlagsUrl } from '@src/data/api';
import { CreateLibrary } from '.';
import { getContentLibraryV2CreateApiUrl } from './data/api';
import { LibraryRestoreStatus } from './data/restoreConstants';
import messages from './messages';
const mockNavigate = jest.fn();
let axiosMock: MockAdapter;
@@ -31,17 +33,34 @@ jest.mock('@src/generic/data/apiHooks', () => ({
}),
}));
const mockRestoreMutate = jest.fn();
let mockRestoreStatusData: any = {};
let mockRestoreMutationError: any = null;
let mockRestoreMutationPending = false;
jest.mock('./data/apiHooks', () => ({
...jest.requireActual('./data/apiHooks'),
useCreateLibraryRestore: () => ({
mutate: mockRestoreMutate,
error: mockRestoreMutationError,
isPending: mockRestoreMutationPending,
isError: !!mockRestoreMutationError,
}),
useGetLibraryRestoreStatus: () => ({
data: mockRestoreStatusData,
}),
}));
describe('<CreateLibrary />', () => {
beforeEach(() => {
axiosMock = initializeMocks().axiosMock;
axiosMock
.onGet(getApiWaffleFlagsUrl(undefined))
.reply(200, {});
});
afterEach(() => {
jest.clearAllMocks();
axiosMock.restore();
// Reset restore mocks
mockRestoreMutate.mockReset();
mockRestoreStatusData = {};
mockRestoreMutationError = null;
mockRestoreMutationPending = false;
});
test('call api data with correct data', async () => {
@@ -66,7 +85,7 @@ describe('<CreateLibrary />', () => {
await user.click(slugInput);
await user.type(slugInput, 'test_library_slug');
fireEvent.click(await screen.findByRole('button', { name: /create/i }));
fireEvent.click(await screen.findByRole('button', { name: 'Create' }));
await waitFor(() => {
expect(axiosMock.history.post.length).toBe(1);
expect(axiosMock.history.post[0].data).toBe(
@@ -104,7 +123,7 @@ describe('<CreateLibrary />', () => {
await user.click(slugInput);
await user.type(slugInput, 'test_library_slug');
fireEvent.click(await screen.findByRole('button', { name: /create/i }));
fireEvent.click(await screen.findByRole('button', { name: 'Create' }));
await waitFor(() => {
expect(axiosMock.history.post.length).toBe(0);
});
@@ -141,7 +160,7 @@ describe('<CreateLibrary />', () => {
await user.click(slugInput);
await user.type(slugInput, 'test_library_slug');
fireEvent.click(await screen.findByRole('button', { name: /create/i }));
fireEvent.click(await screen.findByRole('button', { name: 'Create' }));
await waitFor(() => {
expect(axiosMock.history.post.length).toBe(1);
expect(axiosMock.history.post[0].data).toBe(
@@ -172,7 +191,7 @@ describe('<CreateLibrary />', () => {
await user.click(slugInput);
await user.type(slugInput, 'test_library_slug');
fireEvent.click(await screen.findByRole('button', { name: /create/i }));
fireEvent.click(await screen.findByRole('button', { name: 'Create' }));
await waitFor(async () => {
expect(axiosMock.history.post.length).toBe(1);
expect(axiosMock.history.post[0].data).toBe(
@@ -192,4 +211,693 @@ describe('<CreateLibrary />', () => {
expect(mockNavigate).toHaveBeenCalledWith('/libraries');
});
});
test('calls handleCancel when used in modal', async () => {
const mockHandleCancel = jest.fn();
const mockHandlePostCreate = jest.fn();
render(
<CreateLibrary
showInModal
handleCancel={mockHandleCancel}
handlePostCreate={mockHandlePostCreate}
/>,
);
fireEvent.click(await screen.findByRole('button', { name: /cancel/i }));
await waitFor(() => {
expect(mockHandleCancel).toHaveBeenCalled();
expect(mockNavigate).not.toHaveBeenCalled();
});
});
test('calls handlePostCreate when used in modal and library is created', async () => {
const mockHandleCancel = jest.fn();
const mockHandlePostCreate = jest.fn();
const user = userEvent.setup();
axiosMock.onGet(getStudioHomeApiUrl()).reply(200, studioHomeMock);
axiosMock.onPost(getContentLibraryV2CreateApiUrl()).reply(200, {
id: 'library-id',
title: 'Test Library',
});
render(
<CreateLibrary
showInModal
handleCancel={mockHandleCancel}
handlePostCreate={mockHandlePostCreate}
/>,
);
const titleInput = await screen.findByRole('textbox', { name: /library name/i });
await user.click(titleInput);
await user.type(titleInput, 'Test Library Name');
const orgInput = await screen.findByRole('combobox', { name: /organization/i });
await user.click(orgInput);
await user.type(orgInput, 'org1');
await user.tab();
const slugInput = await screen.findByRole('textbox', { name: /library id/i });
await user.click(slugInput);
await user.type(slugInput, 'test_library_slug');
fireEvent.click(await screen.findByRole('button', { name: 'Create' }));
await waitFor(() => {
expect(mockHandlePostCreate).toHaveBeenCalledWith({
id: 'library-id',
title: 'Test Library',
});
expect(mockNavigate).not.toHaveBeenCalled();
});
});
describe('Archive Upload Functionality', () => {
test('shows create from archive button and switches to archive mode', async () => {
const user = userEvent.setup();
axiosMock.onGet(getStudioHomeApiUrl()).reply(200, studioHomeMock);
render(<CreateLibrary />);
const createFromArchiveBtn = await screen.findByRole('button', { name: messages.createFromArchiveButton.defaultMessage });
expect(createFromArchiveBtn).toBeInTheDocument();
await user.click(createFromArchiveBtn);
// Should show dropzone after switching to archive mode
expect(screen.getByTestId('library-archive-dropzone')).toBeInTheDocument();
});
test('handles file upload and starts restore process', async () => {
const user = userEvent.setup();
axiosMock.onGet(getStudioHomeApiUrl()).reply(200, studioHomeMock);
mockRestoreMutate.mockImplementation((_file: File, { onSuccess }: any) => {
onSuccess({ taskId: 'task-123' });
});
render(<CreateLibrary />);
// Switch to archive mode
const createFromArchiveBtn = await screen.findByRole('button', { name: messages.createFromArchiveButton.defaultMessage });
await user.click(createFromArchiveBtn);
// Create a mock file
const file = new File(['test content'], 'test-archive.zip', { type: 'application/zip' });
const dropzone = screen.getByTestId('library-archive-dropzone');
const input = dropzone.querySelector('input[type="file"]') as HTMLInputElement;
// Mock file selection
Object.defineProperty(input, 'files', {
value: [file],
writable: false,
});
// Trigger file change
fireEvent.change(input);
await waitFor(() => {
expect(mockRestoreMutate).toHaveBeenCalledWith(
file,
expect.objectContaining({
onSuccess: expect.any(Function),
onError: expect.any(Function),
}),
);
});
});
test('triggers onError callback when restore mutation fails during file upload', async () => {
const user = userEvent.setup();
axiosMock.onGet(getStudioHomeApiUrl()).reply(200, studioHomeMock);
// Mock console.error to capture the call
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => { });
// Mock the restore mutation to trigger onError callback immediately
mockRestoreMutate.mockImplementation((_file: File, { onError }: any) => {
const restoreError = new Error('Restore mutation failed');
// Call onError immediately to trigger the handleError(restoreError) line
onError(restoreError);
});
render(<CreateLibrary />);
// Switch to archive mode
const createFromArchiveBtn = await screen.findByRole('button', { name: messages.createFromArchiveButton.defaultMessage });
await user.click(createFromArchiveBtn);
// Upload a valid file that will trigger the restore process and its onError callback
const file = new File(['test content'], 'test-archive.zip', { type: 'application/zip' });
const dropzone = screen.getByTestId('library-archive-dropzone');
const input = dropzone.querySelector('input[type="file"]') as HTMLInputElement;
Object.defineProperty(input, 'files', {
value: [file],
writable: false,
});
fireEvent.change(input);
await waitFor(() => {
expect(mockRestoreMutate).toHaveBeenCalledWith(
file,
expect.objectContaining({
onSuccess: expect.any(Function),
onError: expect.any(Function),
}),
);
});
consoleSpy.mockRestore();
});
test('shows restore in progress alert when status is pending', async () => {
const user = userEvent.setup();
axiosMock.onGet(getStudioHomeApiUrl()).reply(200, studioHomeMock);
// Set task ID so the restore status hook is enabled
const mockTaskId = 'test-task-123';
// Pre-set the restore status to pending
mockRestoreStatusData = {
state: LibraryRestoreStatus.Pending,
result: null,
error: null,
errorLog: null,
};
// Mock the mutation to return a task ID
mockRestoreMutate.mockImplementation((_file: File, { onSuccess }: any) => {
onSuccess({ taskId: mockTaskId });
});
render(<CreateLibrary />);
// Switch to archive mode
const createFromArchiveBtn = await screen.findByRole('button', { name: messages.createFromArchiveButton.defaultMessage });
await user.click(createFromArchiveBtn);
// Upload a file to trigger the restore process
const file = new File(['test content'], 'test-archive.zip', { type: 'application/zip' });
const dropzone = screen.getByTestId('library-archive-dropzone');
const input = dropzone.querySelector('input[type="file"]') as HTMLInputElement;
Object.defineProperty(input, 'files', {
value: [file],
writable: false,
});
fireEvent.change(input);
// Should show the restore in progress alert
await waitFor(() => {
expect(screen.getByText(messages.restoreInProgress.defaultMessage)).toBeInTheDocument();
});
});
test('shows success state with archive details after upload', async () => {
const user = userEvent.setup();
axiosMock.onGet(getStudioHomeApiUrl()).reply(200, studioHomeMock);
const mockResult = {
learningPackageId: 123,
title: 'Test Archive Library',
org: 'TestOrg',
slug: 'test-archive',
key: 'TestOrg/test-archive',
archiveKey: 'archive-key',
containers: 5,
components: 15,
collections: 3,
sections: 8,
subsections: 12,
units: 20,
createdOnServer: '2025-01-01T10:00:00Z',
createdAt: '2025-01-01T10:00:00Z',
createdBy: {
username: 'testuser',
email: 'test@example.com',
},
};
// Pre-set the restore status to succeeded
mockRestoreStatusData = {
state: LibraryRestoreStatus.Succeeded,
result: mockResult,
error: null,
errorLog: null,
};
// Mock the restore mutation to return a task ID
mockRestoreMutate.mockImplementation((_file: File, { onSuccess }: any) => {
onSuccess({ taskId: 'task-123' });
});
render(<CreateLibrary />);
// Switch to archive mode
const createFromArchiveBtn = await screen.findByRole('button', { name: messages.createFromArchiveButton.defaultMessage });
await user.click(createFromArchiveBtn);
// Upload a file to trigger the restore process
const file = new File(['test content'], 'test-archive.zip', { type: 'application/zip' });
const dropzone = screen.getByTestId('library-archive-dropzone');
const input = dropzone.querySelector('input[type="file"]') as HTMLInputElement;
Object.defineProperty(input, 'files', {
value: [file],
writable: false,
});
fireEvent.change(input);
// Wait for the restore to complete and archive details to be shown
await waitFor(() => {
expect(screen.getByText('Test Archive Library')).toBeInTheDocument();
expect(screen.getByText('TestOrg / test-archive')).toBeInTheDocument();
expect(screen.getByText(/Contains 15 Components/i)).toBeInTheDocument();
});
});
test('shows error state with error message and link after failed upload', async () => {
const user = userEvent.setup();
axiosMock.onGet(getStudioHomeApiUrl()).reply(200, studioHomeMock);
// Pre-set the restore status to failed
mockRestoreStatusData = {
state: LibraryRestoreStatus.Failed,
result: null,
error: 'Library restore failed. See error log for details.',
errorLog: 'http://example.com/error.log',
};
// Mock the restore mutation to return a task ID
mockRestoreMutate.mockImplementation((_file: File, { onSuccess }: any) => {
onSuccess({ taskId: 'task-456' });
});
render(<CreateLibrary />);
// Switch to archive mode
const createFromArchiveBtn = await screen.findByRole('button', { name: messages.createFromArchiveButton.defaultMessage });
await user.click(createFromArchiveBtn);
// Upload a file to trigger the restore process
const file = new File(['test content'], 'test-archive.zip', { type: 'application/zip' });
const dropzone = screen.getByTestId('library-archive-dropzone');
const input = dropzone.querySelector('input[type="file"]') as HTMLInputElement;
Object.defineProperty(input, 'files', {
value: [file],
writable: false,
});
fireEvent.change(input);
// Wait for the error to be shown
await waitFor(() => {
expect(screen.getByText(messages.restoreError.defaultMessage)).toBeInTheDocument();
});
// Should show error log link
const errorLink = screen.getByText(messages.viewErrorLogText.defaultMessage);
expect(errorLink).toBeInTheDocument();
expect(errorLink.closest('a')).toHaveAttribute('href', 'http://example.com/error.log');
});
test('validates file types and shows error for invalid files', async () => {
const user = userEvent.setup();
axiosMock.onGet(getStudioHomeApiUrl()).reply(200, studioHomeMock);
render(<CreateLibrary />);
// Switch to archive mode
const createFromArchiveBtn = await screen.findByRole('button', { name: messages.createFromArchiveButton.defaultMessage });
await user.click(createFromArchiveBtn);
// Try to upload a file with correct MIME type but wrong extension to trigger our custom validation
const file = new File(['test content'], 'test-file.doc', { type: 'application/zip' });
const dropzone = screen.getByTestId('library-archive-dropzone');
const input = dropzone.querySelector('input[type="file"]') as HTMLInputElement;
Object.defineProperty(input, 'files', {
value: [file],
writable: false,
});
fireEvent.change(input);
// Should not call restore mutation for invalid file
expect(mockRestoreMutate).not.toHaveBeenCalled();
// Should show error message for invalid file type (Dropzone shows generic error)
await waitFor(() => {
expect(screen.getByText(/A problem occured while uploading your file/i)).toBeInTheDocument();
});
});
test('shows archive preview only when all conditions are met', async () => {
const user = userEvent.setup();
axiosMock.onGet(getStudioHomeApiUrl()).reply(200, studioHomeMock);
const mockResult = {
learningPackageId: 123,
title: 'Test Archive Library',
org: 'TestOrg',
slug: 'test-archive',
key: 'TestOrg/test-archive',
archiveKey: 'archive-key',
containers: 5,
components: 15,
collections: 3,
sections: 8,
subsections: 12,
units: 20,
createdOnServer: '2025-01-01T10:00:00Z',
createdAt: '2025-01-01T10:00:00Z',
createdBy: {
username: 'testuser',
email: 'test@example.com',
},
};
render(<CreateLibrary />);
// Switch to archive mode
const createFromArchiveBtn = await screen.findByRole('button', { name: messages.createFromArchiveButton.defaultMessage });
await user.click(createFromArchiveBtn);
// Initially no archive preview should be shown (no uploaded file)
expect(screen.queryByText('Test Archive Library')).not.toBeInTheDocument();
// Pre-set the final restore status to succeeded
mockRestoreStatusData = {
state: LibraryRestoreStatus.Succeeded,
result: mockResult,
};
// Mock successful file upload
mockRestoreMutate.mockImplementation((_file: File, { onSuccess }: any) => {
onSuccess({ taskId: 'task-123' });
});
// Upload file
const file = new File(['test content'], 'test-archive.zip', { type: 'application/zip' });
const dropzone = screen.getByTestId('library-archive-dropzone');
const input = dropzone.querySelector('input[type="file"]') as HTMLInputElement;
Object.defineProperty(input, 'files', {
value: [file],
writable: false,
});
fireEvent.change(input);
// Now archive preview should be shown because:
// uploadedFile && restoreStatus?.state === LibraryRestoreStatus.Succeeded && restoreStatus.result
await waitFor(() => {
expect(screen.getByText('Test Archive Library')).toBeInTheDocument();
expect(screen.getByText('TestOrg / test-archive')).toBeInTheDocument();
});
});
test('creates library from archive with learning package ID', async () => {
const user = userEvent.setup();
axiosMock.onGet(getStudioHomeApiUrl()).reply(200, studioHomeMock);
axiosMock.onPost(getContentLibraryV2CreateApiUrl()).reply(200, {
id: 'library-from-archive-id',
});
const mockResult = {
learningPackageId: 456, // Fixed: use camelCase to match actual API response
title: 'Restored Library',
org: 'RestoredOrg',
slug: 'restored-lib',
key: 'RestoredOrg/restored-lib',
archiveKey: 'archive-key', // Fixed: use camelCase
containers: 3,
components: 10,
collections: 2,
sections: 5,
subsections: 8,
units: 15,
createdOnServer: '2025-01-01T12:00:00Z', // Fixed: use camelCase
createdAt: '2025-01-01T12:00:00Z',
createdBy: { // Fixed: use camelCase
username: 'restoreuser',
email: 'restore@example.com',
},
};
mockRestoreStatusData = {
state: LibraryRestoreStatus.Succeeded,
result: mockResult,
error: null,
errorLog: null,
};
render(<CreateLibrary />);
// Switch to archive mode
const createFromArchiveBtn = await screen.findByRole('button', { name: messages.createFromArchiveButton.defaultMessage });
await user.click(createFromArchiveBtn);
// Fill in form fields
const titleInput = await screen.findByRole('textbox', { name: /library name/i });
await user.click(titleInput);
await user.type(titleInput, 'New Library from Archive');
const orgInput = await screen.findByRole('combobox', { name: /organization/i });
await user.click(orgInput);
await user.type(orgInput, 'org1');
await user.tab();
const slugInput = await screen.findByRole('textbox', { name: /library id/i });
await user.click(slugInput);
await user.type(slugInput, 'new_library_slug');
// Submit form
fireEvent.click(await screen.findByRole('button', { name: /create/i }));
await waitFor(() => {
expect(axiosMock.history.post.length).toBe(1);
const postData = JSON.parse(axiosMock.history.post[0].data);
expect(postData).toEqual({
description: '',
title: 'New Library from Archive',
org: 'org1',
slug: 'new_library_slug',
learning_package: 456, // Should include the learning_package_id from restore
});
expect(mockNavigate).toHaveBeenCalledWith('/library/library-from-archive-id');
});
});
test('handles restore mutation error', async () => {
const user = userEvent.setup();
axiosMock.onGet(getStudioHomeApiUrl()).reply(200, studioHomeMock);
mockRestoreMutationError = new Error('Upload failed');
render(<CreateLibrary />);
// Switch to archive mode
const createFromArchiveBtn = await screen.findByRole('button', { name: messages.createFromArchiveButton.defaultMessage });
await user.click(createFromArchiveBtn);
// Should show error alert with the specific error message
expect(screen.getByText('Upload failed')).toBeInTheDocument();
});
test('shows generic error when no specific error message available', async () => {
const user = userEvent.setup();
axiosMock.onGet(getStudioHomeApiUrl()).reply(200, studioHomeMock);
mockRestoreMutationError = {}; // Error without message
render(<CreateLibrary />);
// Switch to archive mode
const createFromArchiveBtn = await screen.findByRole('button', { name: messages.createFromArchiveButton.defaultMessage });
await user.click(createFromArchiveBtn);
// Should show generic error message
expect(screen.getByText(messages.genericErrorMessage.defaultMessage)).toBeInTheDocument();
});
test('includes learning_package field when creating from successful archive restore', async () => {
const user = userEvent.setup();
axiosMock.onGet(getStudioHomeApiUrl()).reply(200, studioHomeMock);
axiosMock.onPost(getContentLibraryV2CreateApiUrl()).reply(200, {
id: 'library-from-archive-id',
});
// Set up successful restore state with learningPackageId
mockRestoreStatusData = {
state: LibraryRestoreStatus.Succeeded,
result: {
learningPackageId: 789,
title: 'Archive Library',
org: 'ArchiveOrg',
slug: 'archive-slug',
key: 'ArchiveOrg/archive-slug',
archiveKey: 'test-archive-key',
containers: 2,
components: 8,
collections: 1,
sections: 4,
subsections: 6,
units: 10,
createdOnServer: '2025-01-01T15:00:00Z',
createdAt: '2025-01-01T15:00:00Z',
createdBy: {
username: 'archiveuser',
email: 'archive@example.com',
},
},
error: null,
errorLog: null,
};
render(<CreateLibrary />);
// Switch to archive mode
const createFromArchiveBtn = await screen.findByRole('button', { name: messages.createFromArchiveButton.defaultMessage });
await user.click(createFromArchiveBtn);
// Fill in form fields
const titleInput = await screen.findByRole('textbox', { name: /library name/i });
await user.type(titleInput, 'My New Library');
const orgInput = await screen.findByRole('combobox', { name: /organization/i });
await user.click(orgInput);
await user.type(orgInput, 'org1');
await user.tab();
const slugInput = await screen.findByRole('textbox', { name: /library id/i });
await user.type(slugInput, 'my_new_library');
// Submit the form - this should trigger the code path that includes learning_package
fireEvent.click(await screen.findByRole('button', { name: /create/i }));
await waitFor(() => {
expect(axiosMock.history.post.length).toBe(1);
const postData = JSON.parse(axiosMock.history.post[0].data);
// Verify that the learning_package field is included with the correct value
expect(postData).toEqual({
description: '',
title: 'My New Library',
org: 'org1',
slug: 'my_new_library',
learning_package: 789, // Tests: submitData.learning_package = restoreStatus.result.learningPackageId
});
});
});
test('does not include learning_package when creating from archive but restore failed', async () => {
const user = userEvent.setup();
axiosMock.onGet(getStudioHomeApiUrl()).reply(200, studioHomeMock);
axiosMock.onPost(getContentLibraryV2CreateApiUrl()).reply(200, {
id: 'library-from-failed-restore-id',
});
// Set up failed restore state
mockRestoreStatusData = {
state: LibraryRestoreStatus.Failed,
result: null,
error: 'Restore failed',
errorLog: null,
};
render(<CreateLibrary />);
// Switch to archive mode
const createFromArchiveBtn = await screen.findByRole('button', { name: messages.createFromArchiveButton.defaultMessage });
await user.click(createFromArchiveBtn);
// Fill in form fields
const titleInput = await screen.findByRole('textbox', { name: /library name/i });
await user.type(titleInput, 'Library from Failed Restore');
const orgInput = await screen.findByRole('combobox', { name: /organization/i });
await user.click(orgInput);
await user.type(orgInput, 'org1');
await user.tab();
const slugInput = await screen.findByRole('textbox', { name: /library id/i });
await user.type(slugInput, 'failed_restore_lib');
// Submit the form - this should NOT include learning_package since restore failed
fireEvent.click(await screen.findByRole('button', { name: /create/i }));
await waitFor(() => {
expect(axiosMock.history.post.length).toBe(1);
const postData = JSON.parse(axiosMock.history.post[0].data);
// Verify that learning_package field is NOT included since restore failed
expect(postData).toEqual({
description: '',
title: 'Library from Failed Restore',
org: 'org1',
slug: 'failed_restore_lib',
});
expect(postData).not.toHaveProperty('learning_package');
});
});
test('does not include learning_package when creating from archive but result is null', async () => {
const user = userEvent.setup();
axiosMock.onGet(getStudioHomeApiUrl()).reply(200, studioHomeMock);
axiosMock.onPost(getContentLibraryV2CreateApiUrl()).reply(200, {
id: 'library-from-null-result-id',
});
// Set up successful restore state but with null result
mockRestoreStatusData = {
state: LibraryRestoreStatus.Succeeded,
result: null,
error: null,
errorLog: null,
};
render(<CreateLibrary />);
// Switch to archive mode
const createFromArchiveBtn = await screen.findByRole('button', { name: messages.createFromArchiveButton.defaultMessage });
await user.click(createFromArchiveBtn);
// Fill in form fields
const titleInput = await screen.findByRole('textbox', { name: /library name/i });
await user.type(titleInput, 'Library with Null Result');
const orgInput = await screen.findByRole('combobox', { name: /organization/i });
await user.click(orgInput);
await user.type(orgInput, 'org1');
await user.tab();
const slugInput = await screen.findByRole('textbox', { name: /library id/i });
await user.type(slugInput, 'null_result_lib');
// Submit the form - this should NOT include learning_package since result is null
fireEvent.click(await screen.findByRole('button', { name: /create/i }));
await waitFor(() => {
expect(axiosMock.history.post.length).toBe(1);
const postData = JSON.parse(axiosMock.history.post[0].data);
// Verify that learning_package field is NOT included since result is null
expect(postData).toEqual({
description: '',
title: 'Library with Null Result',
org: 'org1',
slug: 'null_result_lib',
});
expect(postData).not.toHaveProperty('learning_package');
});
});
});
});

View File

@@ -1,29 +1,41 @@
import { StudioFooterSlot } from '@edx/frontend-component-footer';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
Container,
Form,
Button,
StatefulButton,
ActionRow,
Alert,
Button,
Card,
Container,
Dropzone,
Form,
Icon,
Spinner,
StatefulButton,
} from '@openedx/paragon';
import {
AccessTime,
Widgets,
} from '@openedx/paragon/icons';
import AlertError from '@src/generic/alert-error';
import classNames from 'classnames';
import { Formik } from 'formik';
import { useCallback, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import * as Yup from 'yup';
import classNames from 'classnames';
import { REGEX_RULES } from '@src/constants';
import { useOrganizationListData } from '@src/generic/data/apiHooks';
import { useStudioHome } from '@src/studio-home/hooks';
import Header from '@src/header';
import SubHeader from '@src/generic/sub-header/SubHeader';
import FormikControl from '@src/generic/FormikControl';
import FormikErrorFeedback from '@src/generic/FormikErrorFeedback';
import AlertError from '@src/generic/alert-error';
import SubHeader from '@src/generic/sub-header/SubHeader';
import Header from '@src/header';
import { useStudioHome } from '@src/studio-home/hooks';
import { useCreateLibraryV2 } from './data/apiHooks';
import messages from './messages';
import type { ContentLibrary } from '../data/api';
import { CreateContentLibraryArgs } from './data/api';
import { useCreateLibraryV2, useCreateLibraryRestore, useGetLibraryRestoreStatus } from './data/apiHooks';
import { DROPZONE_ACCEPT_TYPES, LibraryRestoreStatus, VALID_ARCHIVE_EXTENSIONS } from './data/restoreConstants';
import messages from './messages';
/**
* Renders the form and logic to create a new library.
@@ -47,6 +59,11 @@ export const CreateLibrary = ({
const { noSpaceRule, specialCharsRule } = REGEX_RULES;
const validSlugIdRegex = /^[a-zA-Z\d]+(?:[\w-]*[a-zA-Z\d]+)*$/;
// State for archive creation
const [isFromArchive, setIsFromArchive] = useState(false);
const [uploadedFile, setUploadedFile] = useState<File | null>(null);
const [restoreTaskId, setRestoreTaskId] = useState<string>('');
const {
mutate,
data,
@@ -55,6 +72,11 @@ export const CreateLibrary = ({
error,
} = useCreateLibraryV2();
const restoreMutation = useCreateLibraryRestore();
const {
data: restoreStatus,
} = useGetLibraryRestoreStatus(restoreTaskId);
const {
data: allOrganizations,
isLoading: isOrganizationListLoading,
@@ -81,6 +103,44 @@ export const CreateLibrary = ({
}
};
// Handle toggling create from archive mode
const handleCreateFromArchive = useCallback(() => {
setIsFromArchive(true);
}, []);
// Handle file upload
const handleFileUpload = useCallback(({
fileData,
handleError,
}: {
fileData: FormData;
requestConfig: any;
handleError: any;
}) => {
const file = fileData.get('file') as File;
if (file) {
// Validate file type using the same extensions as the dropzone
const fileName = file.name.toLowerCase();
const isValidFile = VALID_ARCHIVE_EXTENSIONS.some(ext => fileName.endsWith(ext));
if (isValidFile) {
setUploadedFile(file);
// Immediately start the restore process
restoreMutation.mutate(file, {
onSuccess: (response) => {
setRestoreTaskId(response.taskId);
},
onError: (restoreError) => {
handleError(restoreError);
},
});
} else {
// Call handleError for invalid file types
handleError(new Error(intl.formatMessage(messages.invalidFileTypeError)));
}
}
}, [restoreMutation, intl]);
if (data) {
if (handlePostCreate) {
handlePostCreate(data);
@@ -92,12 +152,120 @@ export const CreateLibrary = ({
return (
<>
{!showInModal && (<Header isHiddenMainMenu />)}
<Container size="xl" className="p-4 mt-3">
<Container size="md" className="p-4 mt-3">
{!showInModal && (
<SubHeader
title={intl.formatMessage(messages.createLibrary)}
headerActions={!isFromArchive ? (
<Button
variant="outline-primary"
onClick={handleCreateFromArchive}
>
{intl.formatMessage(messages.createFromArchiveButton)}
</Button>
) : null}
/>
)}
{/* Archive upload section - shown above form when in archive mode */}
{isFromArchive && (
<div className="mb-4">
{!uploadedFile && !restoreMutation.isPending && (
<Dropzone
data-testid="library-archive-dropzone"
accept={DROPZONE_ACCEPT_TYPES}
onProcessUpload={handleFileUpload}
maxSize={5 * 1024 * 1024 * 1024} // 5GB
style={{ height: '300px' }}
/>
)}
{/* Loading state - show spinner in DropZone-like container */}
{restoreMutation.isPending && (
<div
className="border border-2 border-dashed border-light-400 d-flex align-items-center justify-content-center"
style={{
height: '300px',
borderRadius: '8px',
backgroundColor: '#f8f9fa',
}}
>
<Spinner
animation="border"
screenReaderText={intl.formatMessage(messages.uploadingStatus)}
/>
</div>
)}
{uploadedFile && restoreStatus?.state === LibraryRestoreStatus.Succeeded && restoreStatus.result && (
// Show restore result data when succeeded
<Card className="mb-4">
<Card.Body>
<div className="d-flex flex-column flex-md-row justify-content-between align-items-start p-4 text-primary-700">
<div className="flex-grow-1 mb-4 mb-md-0">
<span className="mb-2">{restoreStatus.result.title}</span>
<p className="small mb-0">
{restoreStatus.result.org} / {restoreStatus.result.slug}
</p>
</div>
<div className="d-flex flex-column gap-2 align-items-md-end">
<div className="d-flex align-items-md-center gap-2">
<Icon src={Widgets} style={{ width: '20px', height: '20px', marginRight: '8px' }} />
<span className="x-small">
{intl.formatMessage(messages.archiveComponentsCount, {
count: restoreStatus.result.components,
})}
</span>
</div>
<div className="d-flex align-items-md-center gap-2">
<Icon src={AccessTime} style={{ width: '20px', height: '20px', marginRight: '8px' }} />
<span className="x-small">
{intl.formatMessage(messages.archiveBackupDate, {
date: new Date(restoreStatus.result.createdAt).toLocaleDateString(),
time: new Date(restoreStatus.result.createdAt).toLocaleTimeString(),
})}
</span>
</div>
</div>
</div>
</Card.Body>
</Card>
)}
</div>
)}
{(restoreTaskId || isError || restoreMutation.isError) && (
<div className="mb-4">
{restoreStatus?.state === LibraryRestoreStatus.Pending && (
<Alert variant="info">
{intl.formatMessage(messages.restoreInProgress)}
</Alert>
)}
{(restoreStatus?.state === LibraryRestoreStatus.Failed || restoreMutation.isError) && (
<Alert variant="danger">
{restoreStatus?.state === LibraryRestoreStatus.Failed && (
<div>
{intl.formatMessage(messages.restoreError)}
{restoreStatus.errorLog && (
<div>
<a href={restoreStatus.errorLog} target="_blank" rel="noopener noreferrer">
{intl.formatMessage(messages.viewErrorLogText)}
</a>
</div>
)}
</div>
)}
{restoreMutation.isError && (
<div>
{restoreMutation.error?.message
|| intl.formatMessage(messages.genericErrorMessage)}
</div>
)}
</Alert>
)}
</div>
)}
<Formik
initialValues={{
title: '',
@@ -123,7 +291,16 @@ export const CreateLibrary = ({
),
})
}
onSubmit={(values) => mutate(values)}
onSubmit={(values) => {
const submitData = { ...values } as CreateContentLibraryArgs;
// If we're creating from archive and have a successful restore, include the learningPackageId
if (isFromArchive && restoreStatus?.state === LibraryRestoreStatus.Succeeded && restoreStatus.result) {
submitData.learning_package = restoreStatus.result.learningPackageId;
}
mutate(submitData);
}}
>
{(formikProps) => (
<Form onSubmit={formikProps.handleSubmit}>
@@ -197,6 +374,7 @@ export const CreateLibrary = ({
)}
</Formik>
{isError && (<AlertError error={error} />)}
</Container>
{!showInModal && (<StudioFooterSlot />)}
</>

View File

@@ -0,0 +1,114 @@
import { initializeMocks } from '@src/testUtils';
import type MockAdapter from 'axios-mock-adapter';
import { createLibraryRestore, createLibraryV2, getLibraryRestoreStatus } from './api';
let axiosMock: MockAdapter;
describe('create library api', () => {
beforeEach(() => {
const mocks = initializeMocks();
axiosMock = mocks.axiosMock;
});
afterEach(() => {
jest.clearAllMocks();
});
it('should create library', async () => {
const libraryData = {
title: 'Test Library',
org: 'test-org',
slug: 'test-library',
learning_package: 1,
};
const expectedResult = {
id: 'lib:test-org:test-library',
title: 'Test Library',
org: 'test-org',
slug: 'test-library',
};
axiosMock.onPost().reply(200, expectedResult);
const result = await createLibraryV2(libraryData);
expect(axiosMock.history.post[0].url).toEqual('http://localhost:18010/api/libraries/v2/');
expect(JSON.parse(axiosMock.history.post[0].data)).toEqual({
description: '',
...libraryData,
});
expect(result).toEqual(expectedResult);
});
it('should restore library from file', async () => {
const file = new File(['test content'], 'test.tar.gz', { type: 'application/gzip' });
const response = { task_id: 'test-task-id' };
const expectedResult = { taskId: 'test-task-id' };
axiosMock.onPost().reply(200, response);
const result = await createLibraryRestore(file);
expect(axiosMock.history.post[0].url).toEqual('http://localhost:18010/api/libraries/v2/restore/');
expect(axiosMock.history.post[0].data).toBeInstanceOf(FormData);
expect(result).toEqual(expectedResult);
});
it('should get library restore status', async () => {
const taskId = 'test-task-id';
const response = {
state: 'success',
result: { learning_package_id: 123 },
};
const expectedResult = {
state: 'success',
result: { learningPackageId: 123 },
};
axiosMock.onGet().reply(200, response);
const result = await getLibraryRestoreStatus(taskId);
expect(axiosMock.history.get[0].url).toEqual(`http://localhost:18010/api/libraries/v2/restore/?task_id=${taskId}`);
expect(result).toEqual(expectedResult);
});
it('should throw error when createLibraryV2 fails', async () => {
const libraryData = {
title: 'Test Library',
org: 'test-org',
slug: 'test-library',
};
axiosMock.onPost().reply(400, 'Bad Request');
await expect(createLibraryV2(libraryData)).rejects.toThrow();
});
it('should throw error when createLibraryRestore fails', async () => {
const file = new File(['test content'], 'test.tar.gz', { type: 'application/gzip' });
axiosMock.onPost().reply(400, 'Bad Request');
await expect(createLibraryRestore(file)).rejects.toThrow();
});
it('should throw error when getLibraryRestoreStatus fails', async () => {
const taskId = 'test-task-id';
axiosMock.onGet().reply(404, 'Not Found');
await expect(getLibraryRestoreStatus(taskId)).rejects.toThrow();
});
it('should handle invalid parameters', async () => {
// @ts-expect-error - testing invalid input
await expect(createLibraryV2(null)).rejects.toThrow();
// @ts-expect-error - testing invalid input
await expect(createLibraryRestore(null)).rejects.toThrow();
// @ts-expect-error - testing invalid input
await expect(getLibraryRestoreStatus(null)).rejects.toThrow();
});
});

View File

@@ -1,7 +1,9 @@
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import type { ContentLibrary } from '../../data/api';
import { getLibraryRestoreApiUrl, getLibraryRestoreStatusApiUrl } from '@src/library-authoring/data/api';
import type { ContentLibrary } from '@src/library-authoring/data/api';
import { CreateLibraryRestoreResponse, GetLibraryRestoreStatusResponse } from './restoreConstants';
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
@@ -14,6 +16,7 @@ export interface CreateContentLibraryArgs {
title: string,
org: string,
slug: string,
learning_package?: number,
}
/**
@@ -28,3 +31,20 @@ export async function createLibraryV2(data: CreateContentLibraryArgs): Promise<C
return camelCaseObject(newLibrary);
}
export const createLibraryRestore = async (archiveFile: File): Promise<CreateLibraryRestoreResponse> => {
const formData = new FormData();
formData.append('file', archiveFile);
const { data } = await getAuthenticatedHttpClient().post(getLibraryRestoreApiUrl(), formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return camelCaseObject(data);
};
export const getLibraryRestoreStatus = async (taskId: string): Promise<GetLibraryRestoreStatusResponse> => {
const { data } = await getAuthenticatedHttpClient().get(getLibraryRestoreStatusApiUrl(taskId));
return camelCaseObject(data);
};

View File

@@ -0,0 +1,219 @@
import React from 'react';
import { QueryClientProvider } from '@tanstack/react-query';
import { renderHook, waitFor } from '@testing-library/react';
import { mockContentLibrary } from '@src/library-authoring/data/api.mocks';
import { initializeMocks } from '@src/testUtils';
import { libraryAuthoringQueryKeys } from '../../data/apiHooks';
import {
useCreateLibraryRestore,
useCreateLibraryV2,
useGetLibraryRestoreStatus,
} from './apiHooks';
import { LibraryRestoreStatus } from './restoreConstants';
mockContentLibrary.applyMock();
const { axiosMock, queryClient } = initializeMocks();
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
describe('create library apiHooks', () => {
beforeEach(() => {
queryClient.clear();
axiosMock.reset();
});
describe('useCreateLibraryV2', () => {
it('should create library and invalidate queries', async () => {
const libraryData = {
title: 'Test Library',
org: 'test-org',
slug: 'test-library',
learning_package: 1,
};
const expectedResult = {
id: 'lib:test-org:test-library',
title: 'Test Library',
org: 'test-org',
slug: 'test-library',
};
// Mock the API call
axiosMock.onPost('http://localhost:18010/api/libraries/v2/').reply(200, expectedResult);
// Spy on query invalidation
const invalidateQueriesSpy = jest.spyOn(queryClient, 'invalidateQueries');
const { result } = renderHook(() => useCreateLibraryV2(), { wrapper });
await result.current.mutateAsync(libraryData);
expect(axiosMock.history.post[0].url).toEqual('http://localhost:18010/api/libraries/v2/');
expect(JSON.parse(axiosMock.history.post[0].data)).toEqual({
description: '',
...libraryData,
});
// Check that queries are invalidated on success
expect(invalidateQueriesSpy).toHaveBeenCalledWith({
queryKey: libraryAuthoringQueryKeys.contentLibraryList(),
});
});
});
describe('useCreateLibraryRestore', () => {
it('should restore library from file', async () => {
const file = new File(['test content'], 'test.tar.gz', { type: 'application/gzip' });
const expectedResult = { taskId: 'test-task-id' };
axiosMock.onPost('http://localhost:18010/api/libraries/v2/restore/').reply(200, expectedResult);
const { result } = renderHook(() => useCreateLibraryRestore(), { wrapper });
const response = await result.current.mutateAsync(file);
expect(axiosMock.history.post[0].url).toEqual('http://localhost:18010/api/libraries/v2/restore/');
expect(axiosMock.history.post[0].data).toBeInstanceOf(FormData);
expect(response).toEqual(expectedResult);
});
it('should handle restore error', async () => {
const file = new File(['test content'], 'test.tar.gz', { type: 'application/gzip' });
axiosMock.onPost('http://localhost:18010/api/libraries/v2/restore/').reply(400, 'Bad Request');
const { result } = renderHook(() => useCreateLibraryRestore(), { wrapper });
await expect(result.current.mutateAsync(file)).rejects.toThrow();
});
});
describe('useGetLibraryRestoreStatus', () => {
it('should get restore status when taskId is provided', async () => {
const taskId = 'test-task-id';
const expectedResult = {
state: LibraryRestoreStatus.Succeeded,
result: {
learningPackageId: 123,
title: 'Test Library',
org: 'test-org',
slug: 'test-library',
key: 'lib:test-org:test-library',
archiveKey: 'archive-key',
containers: 1,
components: 5,
collections: 2,
sections: 1,
subsections: 1,
units: 1,
createdOnServer: '2024-01-01T00:00:00Z',
createdAt: '2024-01-01T00:00:00Z',
createdBy: {
username: 'testuser',
email: 'test@example.com',
},
},
error: null,
errorLog: null,
};
axiosMock.onGet(`http://localhost:18010/api/libraries/v2/restore/?task_id=${taskId}`).reply(200, expectedResult);
const { result } = renderHook(() => useGetLibraryRestoreStatus(taskId), { wrapper });
await waitFor(() => {
expect(result.current.isLoading).toBeFalsy();
});
expect(result.current.data).toEqual(expectedResult);
expect(axiosMock.history.get[0].url).toEqual(`http://localhost:18010/api/libraries/v2/restore/?task_id=${taskId}`);
});
it('should not make request when taskId is empty', async () => {
const { result } = renderHook(() => useGetLibraryRestoreStatus(''), { wrapper });
await waitFor(() => {
expect(result.current.isLoading).toBeFalsy();
});
expect(result.current.data).toBeUndefined();
expect(axiosMock.history.get).toHaveLength(0);
});
it('should handle pending status with refetch interval', async () => {
const taskId = 'pending-task-id';
const pendingResult = {
state: LibraryRestoreStatus.Pending,
result: null,
error: null,
error_log: null,
};
const expectedResult = {
state: LibraryRestoreStatus.Pending,
result: null,
error: null,
errorLog: null,
};
axiosMock.onGet(`http://localhost:18010/api/libraries/v2/restore/?task_id=${taskId}`).reply(200, pendingResult);
const { result } = renderHook(() => useGetLibraryRestoreStatus(taskId), { wrapper });
await waitFor(() => {
expect(result.current.isLoading).toBeFalsy();
});
expect(result.current.data).toEqual(expectedResult);
expect(axiosMock.history.get[0].url).toEqual(`http://localhost:18010/api/libraries/v2/restore/?task_id=${taskId}`);
});
it('should handle failed status', async () => {
const taskId = 'failed-task-id';
const failedResult = {
state: LibraryRestoreStatus.Failed,
result: null,
error: 'Restore failed',
error_log: 'Error details here',
};
const expectedResult = {
state: LibraryRestoreStatus.Failed,
result: null,
error: 'Restore failed',
errorLog: 'Error details here',
};
axiosMock.onGet(`http://localhost:18010/api/libraries/v2/restore/?task_id=${taskId}`).reply(200, failedResult);
const { result } = renderHook(() => useGetLibraryRestoreStatus(taskId), { wrapper });
await waitFor(() => {
expect(result.current.isLoading).toBeFalsy();
});
expect(result.current.data).toEqual(expectedResult);
expect(axiosMock.history.get[0].url).toEqual(`http://localhost:18010/api/libraries/v2/restore/?task_id=${taskId}`);
});
it('should handle API error', async () => {
const taskId = 'error-task-id';
axiosMock.onGet(`http://localhost:18010/api/libraries/v2/restore/?task_id=${taskId}`).reply(404, 'Not Found');
const { result } = renderHook(() => useGetLibraryRestoreStatus(taskId), { wrapper });
await waitFor(() => {
expect(result.current.isError).toBeTruthy();
});
expect(result.current.data).toBeUndefined();
expect(axiosMock.history.get[0].url).toEqual(`http://localhost:18010/api/libraries/v2/restore/?task_id=${taskId}`);
});
});
});

View File

@@ -1,10 +1,17 @@
import {
useMutation,
useQuery,
useQueryClient,
} from '@tanstack/react-query';
import { createLibraryV2 } from './api';
import { createLibraryV2, createLibraryRestore, getLibraryRestoreStatus } from './api';
import { libraryAuthoringQueryKeys } from '../../data/apiHooks';
import {
CreateLibraryRestoreResponse,
GetLibraryRestoreStatusResponse,
libraryRestoreQueryKeys,
LibraryRestoreStatus,
} from './restoreConstants';
/**
* Hook that provides a "mutation" that can be used to create a new content library.
@@ -19,3 +26,25 @@ export const useCreateLibraryV2 = () => {
},
});
};
/**
* React Query hook to fetch restore status for a specific task
*
* @param taskId - The unique identifier of the restore task
*
* @example
* ```tsx
* const { data, isLoading, isError } = useGetLibraryRestoreStatus('task:456abc');
* ```
*/
export const useGetLibraryRestoreStatus = (taskId: string) => useQuery<GetLibraryRestoreStatusResponse, Error>({
queryKey: libraryRestoreQueryKeys.restoreStatus(taskId),
queryFn: () => getLibraryRestoreStatus(taskId),
enabled: !!taskId, // Only run the query if taskId is provided
refetchInterval: (query) => (query.state.data?.state === LibraryRestoreStatus.Pending ? 2000 : false),
});
export const useCreateLibraryRestore = () => useMutation<CreateLibraryRestoreResponse, Error, File>({
mutationKey: libraryRestoreQueryKeys.restoreMutation(),
mutationFn: createLibraryRestore,
});

View File

@@ -0,0 +1,48 @@
export interface CreateLibraryRestoreResponse {
taskId: string;
}
export interface LibraryRestoreResult {
learningPackageId: number;
title: string;
org: string;
slug: string;
key: string;
archiveKey: string;
containers: number;
components: number;
collections: number;
sections: number;
subsections: number;
units: number;
createdOnServer: string;
createdAt: string;
createdBy: {
username: string;
email: string;
};
}
export interface GetLibraryRestoreStatusResponse {
state: LibraryRestoreStatus;
result: LibraryRestoreResult | null;
error: string | null;
errorLog: string | null;
}
export enum LibraryRestoreStatus {
Pending = 'Pending',
Succeeded = 'Succeeded',
Failed = 'Failed',
}
export const libraryRestoreQueryKeys = {
all: ['library-v2-restore'],
restoreStatus: (taskId: string) => [...libraryRestoreQueryKeys.all, 'status', taskId],
restoreMutation: () => [...libraryRestoreQueryKeys.all, 'create-restore'],
};
export const VALID_ARCHIVE_EXTENSIONS = ['.zip'];
export const DROPZONE_ACCEPT_TYPES = {
'application/zip': ['.zip'],
};

View File

@@ -1,2 +1,5 @@
export { CreateLibrary } from './CreateLibrary';
export { CreateLibraryModal } from './CreateLibraryModal';
export { useCreateLibraryRestore, useGetLibraryRestoreStatus } from './data/apiHooks';
export { LibraryRestoreStatus } from './data/restoreConstants';
export type { LibraryRestoreResult, GetLibraryRestoreStatusResponse } from './data/restoreConstants';

View File

@@ -88,6 +88,66 @@ const messages = defineMessages({
defaultMessage: 'Cancel',
description: 'Button text to cancel creating a new library.',
},
createFromArchiveButton: {
id: 'course-authoring.library-authoring.create-library.form.create-from-archive.button',
defaultMessage: 'Create from archive',
description: 'Button text to create library from archive.',
},
uploadSuccess: {
id: 'course-authoring.library-authoring.create-library.form.upload.success',
defaultMessage: 'File uploaded successfully',
description: 'Success message when file is uploaded.',
},
restoreInProgress: {
id: 'course-authoring.library-authoring.create-library.form.restore.in-progress',
defaultMessage: 'Restoring library...',
description: 'Message shown while library is being restored.',
},
restoreError: {
id: 'course-authoring.library-authoring.create-library.form.restore.error',
defaultMessage: 'Library restore failed. See error log for details.',
description: 'Error message when library restore fails.',
},
createLibraryFromArchiveButton: {
id: 'course-authoring.library-authoring.create-library.form.create-from-archive-final.button',
defaultMessage: 'Create Library from Archive',
description: 'Button text to finalize library creation from archive.',
},
createLibraryFromArchiveButtonPending: {
id: 'course-authoring.library-authoring.create-library.form.create-from-archive-final.button.pending',
defaultMessage: 'Creating from Archive...',
description: 'Button text while the library is being created from archive.',
},
archiveComponentsCount: {
id: 'course-authoring.library-authoring.create-library.form.archive.components-count',
defaultMessage: 'Contains {count} Components',
description: 'Text showing the number of components in the restored archive.',
},
archiveBackupDate: {
id: 'course-authoring.library-authoring.create-library.form.archive.backup-date',
defaultMessage: 'Backed up {date} at {time}',
description: 'Text showing when the archive was backed up.',
},
uploadingStatus: {
id: 'course-authoring.library-authoring.create-library.form.uploading.status',
defaultMessage: 'Uploading...',
description: 'Status message shown while file is uploading.',
},
invalidFileTypeError: {
id: 'course-authoring.library-authoring.create-library.form.invalid-file-type.error',
defaultMessage: 'Invalid file type. Please upload a .zip, .tar.gz, or .tar file.',
description: 'Error message when user uploads an unsupported file type.',
},
viewErrorLogText: {
id: 'course-authoring.library-authoring.create-library.form.view-error-log.text',
defaultMessage: 'View error log',
description: 'Link text to view the error log when restore fails.',
},
genericErrorMessage: {
id: 'course-authoring.library-authoring.create-library.form.generic-error.message',
defaultMessage: 'An error occurred',
description: 'Generic error message when a specific error is not available.',
},
});
export default messages;

View File

@@ -145,6 +145,14 @@ export const getLibraryBackupApiUrl = (libraryId: string) => `${getApiBaseUrl()}
* 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 restore a library from an archive.
*/
export const getLibraryRestoreApiUrl = () => `${getApiBaseUrl()}/api/libraries/v2/restore/`;
/**
* Get the URL for the API endpoint to get the status of a library restore task.
*/
export const getLibraryRestoreStatusApiUrl = (taskId: string) => `${getApiBaseUrl()}/api/libraries/v2/restore/?task_id=${taskId}`;
/**
* Get the URL for the API endpoint to copy a single container.
*/