diff --git a/src/editors/containers/VideoUploadEditor/VideoUploader.jsx b/src/editors/containers/VideoUploadEditor/VideoUploader.jsx index 09d943db83..e269932fbb 100644 --- a/src/editors/containers/VideoUploadEditor/VideoUploader.jsx +++ b/src/editors/containers/VideoUploadEditor/VideoUploader.jsx @@ -72,7 +72,7 @@ export const VideoUploader = ({ setLoading, onUpload, onClose }) => { }; return ( -
+
({ }), })); +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('', () => { 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('', () => { 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('', () => { 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('', () => { 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('', () => { 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('', () => { expect(mockNavigate).toHaveBeenCalledWith('/libraries'); }); }); + + test('calls handleCancel when used in modal', async () => { + const mockHandleCancel = jest.fn(); + const mockHandlePostCreate = jest.fn(); + + render( + , + ); + + 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( + , + ); + + 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(); + + 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(); + + // 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(); + + // 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(); + + // 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(); + + // 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(); + + // 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(); + + // 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(); + + // 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(); + + // 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(); + + // 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(); + + // 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(); + + // 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(); + + // 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(); + + // 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'); + }); + }); + }); }); diff --git a/src/library-authoring/create-library/CreateLibrary.tsx b/src/library-authoring/create-library/CreateLibrary.tsx index 676d02d9a4..34217222f0 100644 --- a/src/library-authoring/create-library/CreateLibrary.tsx +++ b/src/library-authoring/create-library/CreateLibrary.tsx @@ -1,29 +1,41 @@ import { StudioFooterSlot } from '@edx/frontend-component-footer'; import { useIntl } from '@edx/frontend-platform/i18n'; import { + ActionRow, + Alert, + Button, + Card, Container, + Dropzone, Form, - Button, + Icon, + Spinner, StatefulButton, - ActionRow, } 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(null); + const [restoreTaskId, setRestoreTaskId] = useState(''); + 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 && (
)} - + {!showInModal && ( + {intl.formatMessage(messages.createFromArchiveButton)} + + ) : null} /> )} + + {/* Archive upload section - shown above form when in archive mode */} + {isFromArchive && ( +
+ {!uploadedFile && !restoreMutation.isPending && ( + + )} + + {/* Loading state - show spinner in DropZone-like container */} + {restoreMutation.isPending && ( +
+ +
+ )} + + {uploadedFile && restoreStatus?.state === LibraryRestoreStatus.Succeeded && restoreStatus.result && ( + // Show restore result data when succeeded + + +
+
+ {restoreStatus.result.title} +

+ {restoreStatus.result.org} / {restoreStatus.result.slug} +

+
+
+
+ + + {intl.formatMessage(messages.archiveComponentsCount, { + count: restoreStatus.result.components, + })} + +
+
+ + + {intl.formatMessage(messages.archiveBackupDate, { + date: new Date(restoreStatus.result.createdAt).toLocaleDateString(), + time: new Date(restoreStatus.result.createdAt).toLocaleTimeString(), + })} + +
+
+
+
+
+ )} +
+ )} + + {(restoreTaskId || isError || restoreMutation.isError) && ( +
+ {restoreStatus?.state === LibraryRestoreStatus.Pending && ( + + {intl.formatMessage(messages.restoreInProgress)} + + )} + {(restoreStatus?.state === LibraryRestoreStatus.Failed || restoreMutation.isError) && ( + + {restoreStatus?.state === LibraryRestoreStatus.Failed && ( +
+ {intl.formatMessage(messages.restoreError)} + {restoreStatus.errorLog && ( + + )} +
+ )} + {restoreMutation.isError && ( +
+ {restoreMutation.error?.message + || intl.formatMessage(messages.genericErrorMessage)} +
+ )} +
+ )} +
+ )} + 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) => (
@@ -197,6 +374,7 @@ export const CreateLibrary = ({ )} {isError && ()} + {!showInModal && ()} diff --git a/src/library-authoring/create-library/data/api.test.ts b/src/library-authoring/create-library/data/api.test.ts new file mode 100644 index 0000000000..93bbc8d75c --- /dev/null +++ b/src/library-authoring/create-library/data/api.test.ts @@ -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(); + }); +}); diff --git a/src/library-authoring/create-library/data/api.ts b/src/library-authoring/create-library/data/api.ts index b529de5e9d..392899b52d 100644 --- a/src/library-authoring/create-library/data/api.ts +++ b/src/library-authoring/create-library/data/api.ts @@ -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 => { + 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 => { + const { data } = await getAuthenticatedHttpClient().get(getLibraryRestoreStatusApiUrl(taskId)); + return camelCaseObject(data); +}; diff --git a/src/library-authoring/create-library/data/apiHooks.test.tsx b/src/library-authoring/create-library/data/apiHooks.test.tsx new file mode 100644 index 0000000000..e4befed86d --- /dev/null +++ b/src/library-authoring/create-library/data/apiHooks.test.tsx @@ -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 }) => ( + + {children} + +); + +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}`); + }); + }); +}); diff --git a/src/library-authoring/create-library/data/apiHooks.ts b/src/library-authoring/create-library/data/apiHooks.ts index 053a39bf6c..9fac3edf75 100644 --- a/src/library-authoring/create-library/data/apiHooks.ts +++ b/src/library-authoring/create-library/data/apiHooks.ts @@ -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({ + 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({ + mutationKey: libraryRestoreQueryKeys.restoreMutation(), + mutationFn: createLibraryRestore, +}); diff --git a/src/library-authoring/create-library/data/restoreConstants.ts b/src/library-authoring/create-library/data/restoreConstants.ts new file mode 100644 index 0000000000..323821e631 --- /dev/null +++ b/src/library-authoring/create-library/data/restoreConstants.ts @@ -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'], +}; diff --git a/src/library-authoring/create-library/index.ts b/src/library-authoring/create-library/index.ts index 689f853c3f..8c7c20f7fa 100644 --- a/src/library-authoring/create-library/index.ts +++ b/src/library-authoring/create-library/index.ts @@ -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'; diff --git a/src/library-authoring/create-library/messages.ts b/src/library-authoring/create-library/messages.ts index cf1cff19bc..190c9a5831 100644 --- a/src/library-authoring/create-library/messages.ts +++ b/src/library-authoring/create-library/messages.ts @@ -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; diff --git a/src/library-authoring/data/api.ts b/src/library-authoring/data/api.ts index 7b40cecaf2..f1d29d4c21 100644 --- a/src/library-authoring/data/api.ts +++ b/src/library-authoring/data/api.ts @@ -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. */