feat: add restore from file UI for libraries v2 (#2558)
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
.pgn__dropzone {
|
||||
.video-uploader .pgn__dropzone {
|
||||
height: 96vh;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 />)}
|
||||
</>
|
||||
|
||||
114
src/library-authoring/create-library/data/api.test.ts
Normal file
114
src/library-authoring/create-library/data/api.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
219
src/library-authoring/create-library/data/apiHooks.test.tsx
Normal file
219
src/library-authoring/create-library/data/apiHooks.test.tsx
Normal 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}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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'],
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user