From b9eeb115a6baed67f97a0464b08f6e6de45bbb0d Mon Sep 17 00:00:00 2001 From: Javier Ontiveros Date: Wed, 22 Oct 2025 14:12:49 -0500 Subject: [PATCH 01/18] chore: added context to class so it doesn't affect everything --- src/editors/containers/VideoUploadEditor/VideoUploader.jsx | 2 +- src/editors/containers/VideoUploadEditor/index.scss | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 ( -
+
Date: Wed, 22 Oct 2025 14:13:07 -0500 Subject: [PATCH 02/18] chore: base modifications for restore action --- .../create-library/CreateLibrary.tsx | 182 +++++++++++++++++- .../create-library/data/api.ts | 1 + .../create-library/data/restoreApi.ts | 20 ++ .../create-library/data/restoreConstants.ts | 43 +++++ .../create-library/data/restoreHooks.ts | 30 +++ src/library-authoring/create-library/index.ts | 3 + .../create-library/messages.ts | 40 ++++ src/library-authoring/data/api.ts | 8 + 8 files changed, 325 insertions(+), 2 deletions(-) create mode 100644 src/library-authoring/create-library/data/restoreApi.ts create mode 100644 src/library-authoring/create-library/data/restoreConstants.ts create mode 100644 src/library-authoring/create-library/data/restoreHooks.ts diff --git a/src/library-authoring/create-library/CreateLibrary.tsx b/src/library-authoring/create-library/CreateLibrary.tsx index 676d02d9a4..8142b9007a 100644 --- a/src/library-authoring/create-library/CreateLibrary.tsx +++ b/src/library-authoring/create-library/CreateLibrary.tsx @@ -6,11 +6,18 @@ import { Button, StatefulButton, ActionRow, + Dropzone, + Card, + Stack, + Icon, + Alert, } from '@openedx/paragon'; +import { Upload, InsertDriveFile, CheckCircle } from '@openedx/paragon/icons'; import { Formik } from 'formik'; import { useNavigate } from 'react-router-dom'; import * as Yup from 'yup'; import classNames from 'classnames'; +import { useState, useCallback } from 'react'; import { REGEX_RULES } from '@src/constants'; import { useOrganizationListData } from '@src/generic/data/apiHooks'; @@ -22,6 +29,9 @@ import FormikErrorFeedback from '@src/generic/FormikErrorFeedback'; import AlertError from '@src/generic/alert-error'; import { useCreateLibraryV2 } from './data/apiHooks'; +import { CreateContentLibraryArgs } from './data/api'; +import { useCreateLibraryRestore, useGetLibraryRestoreStatus } from './data/restoreHooks'; +import { LibraryRestoreStatus } from './data/restoreConstants'; import messages from './messages'; import type { ContentLibrary } from '../data/api'; @@ -47,6 +57,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 +70,11 @@ export const CreateLibrary = ({ error, } = useCreateLibraryV2(); + const restoreMutation = useCreateLibraryRestore(); + const { + data: restoreStatus, + } = useGetLibraryRestoreStatus(restoreTaskId); + const { data: allOrganizations, isLoading: isOrganizationListLoading, @@ -81,6 +101,45 @@ 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 + const validExtensions = ['.zip', '.tar.gz', '.tar']; + const fileName = file.name.toLowerCase(); + const isValidFile = validExtensions.some(ext => fileName.endsWith(ext)); + + if (isValidFile) { + setUploadedFile(file); + // Immediately start the restore process + restoreMutation.mutate(file, { + onSuccess: (response) => { + setRestoreTaskId(response.task_id); + }, + onError: (restoreError) => { + handleError(restoreError); + }, + }); + } else { + // Call handleError for invalid file types + handleError(new Error('Invalid file type. Please upload a .zip, .tar.gz, or .tar file.')); + } + } + }, [restoreMutation]); + if (data) { if (handlePostCreate) { handlePostCreate(data); @@ -96,8 +155,116 @@ export const CreateLibrary = ({ {!showInModal && ( + {intl.formatMessage(messages.createFromArchiveButton)} + + ) : null} /> )} + + {/* Archive upload section - shown above form when in archive mode */} + {isFromArchive && ( +
+ {!uploadedFile && ( + + + +
+

{intl.formatMessage(messages.dropzoneTitle)}

+

{intl.formatMessage(messages.dropzoneSubtitle)}

+
+
+
+ )} + + {uploadedFile && restoreStatus?.state === LibraryRestoreStatus.Succeeded && restoreStatus.result && ( + // Show restore result data when succeeded + + + + +
+
{restoreStatus.result.title}
+

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

+

+ Contains {restoreStatus.result.components} Components • + Backed up {new Date(restoreStatus.result.created_at).toLocaleDateString()} at{' '} + {new Date(restoreStatus.result.created_at).toLocaleTimeString()} +

+
+
+
+
+ )} + + {uploadedFile && restoreStatus?.state !== LibraryRestoreStatus.Succeeded && ( + // Show uploaded file info during processing + + + + +
+
{uploadedFile.name}
+

+ {(uploadedFile.size / (1024 * 1024)).toFixed(2)} MB +

+
+ {restoreMutation.isPending && ( +
+ Processing... +
+ )} +
+
+
+ )} + + {/* Archive restore status */} + {restoreTaskId && ( +
+ {restoreStatus?.state === LibraryRestoreStatus.Pending && ( + + {intl.formatMessage(messages.restoreInProgress)} + + )} + {restoreStatus?.state === LibraryRestoreStatus.Failed && ( + + {intl.formatMessage(messages.restoreError)} + {restoreStatus.error_log && ( + + )} + + )} +
+ )} +
+ )} + + {/* Regular form - always shown */} mutate(values)} + onSubmit={(values) => { + const submitData = { ...values } as CreateContentLibraryArgs; + + // If we're creating from archive and have a successful restore, include the learning_package_id + if (isFromArchive && restoreStatus?.state === LibraryRestoreStatus.Succeeded && restoreStatus.result) { + submitData.learning_package = restoreStatus.result.learning_package_id; + } + + mutate(submitData); + }} > {(formikProps) => (
@@ -196,7 +372,9 @@ export const CreateLibrary = ({
)}
- {isError && ()} + {(isError || restoreMutation.isError) && ( + + )} {!showInModal && ()} diff --git a/src/library-authoring/create-library/data/api.ts b/src/library-authoring/create-library/data/api.ts index b529de5e9d..53544578de 100644 --- a/src/library-authoring/create-library/data/api.ts +++ b/src/library-authoring/create-library/data/api.ts @@ -14,6 +14,7 @@ export interface CreateContentLibraryArgs { title: string, org: string, slug: string, + learning_package?: number, } /** diff --git a/src/library-authoring/create-library/data/restoreApi.ts b/src/library-authoring/create-library/data/restoreApi.ts new file mode 100644 index 0000000000..c11462eb87 --- /dev/null +++ b/src/library-authoring/create-library/data/restoreApi.ts @@ -0,0 +1,20 @@ +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { CreateLibraryRestoreResponse, GetLibraryRestoreStatusResponse } from './restoreConstants'; +import { getLibraryRestoreApiUrl, getLibraryRestoreStatusApiUrl } from '../../data/api'; + +export const createLibraryRestore = async (archiveFile: File): Promise => { + const formData = new FormData(); + formData.append('file', archiveFile); + + const { data } = await getAuthenticatedHttpClient().post(getLibraryRestoreApiUrl(), formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + return data; +}; + +export const getLibraryRestoreStatus = async (taskId: string): Promise => { + const { data } = await getAuthenticatedHttpClient().get(getLibraryRestoreStatusApiUrl(taskId)); + return data; +}; \ No newline at end of file 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..4a6cce7c3c --- /dev/null +++ b/src/library-authoring/create-library/data/restoreConstants.ts @@ -0,0 +1,43 @@ +export interface CreateLibraryRestoreResponse { + task_id: string; +} + +export interface LibraryRestoreResult { + learning_package_id: number; + title: string; + org: string; + slug: string; + key: string; + archive_key: string; + containers: number; + components: number; + collections: number; + sections: number; + subsections: number; + units: number; + created_on_server: string; + created_at: string; + created_by: { + username: string; + email: string; + }; +} + +export interface GetLibraryRestoreStatusResponse { + state: LibraryRestoreStatus; + result: LibraryRestoreResult | null; + error: string | null; + error_log: 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'], +}; \ No newline at end of file diff --git a/src/library-authoring/create-library/data/restoreHooks.ts b/src/library-authoring/create-library/data/restoreHooks.ts new file mode 100644 index 0000000000..b9dd1bf9a8 --- /dev/null +++ b/src/library-authoring/create-library/data/restoreHooks.ts @@ -0,0 +1,30 @@ +import { useMutation, useQuery } from '@tanstack/react-query'; +import { createLibraryRestore, getLibraryRestoreStatus } from './restoreApi'; +import { + CreateLibraryRestoreResponse, + GetLibraryRestoreStatusResponse, + libraryRestoreQueryKeys, + LibraryRestoreStatus, +} from './restoreConstants'; + +/** + * 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, +}); \ No newline at end of file diff --git a/src/library-authoring/create-library/index.ts b/src/library-authoring/create-library/index.ts index 689f853c3f..c0a7e1c3c8 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/restoreHooks'; +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..6e3bd8d367 100644 --- a/src/library-authoring/create-library/messages.ts +++ b/src/library-authoring/create-library/messages.ts @@ -88,6 +88,46 @@ 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.', + }, + dropzoneTitle: { + id: 'course-authoring.library-authoring.create-library.form.dropzone.title', + defaultMessage: 'Drag and drop your local backup archive file here to upload', + description: 'Title for the dropzone area.', + }, + dropzoneSubtitle: { + id: 'course-authoring.library-authoring.create-library.form.dropzone.subtitle', + defaultMessage: 'Upload .zip or .tar.gz files (5 GB max)', + description: 'Subtitle for the dropzone area.', + }, + 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.', + }, }); 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. */ From 40ca5ad68baa59be14b49835c5d311d4f4f39c13 Mon Sep 17 00:00:00 2001 From: Javier Ontiveros Date: Wed, 22 Oct 2025 14:20:23 -0500 Subject: [PATCH 03/18] chore: lint fixes --- src/library-authoring/create-library/CreateLibrary.tsx | 8 ++++---- src/library-authoring/create-library/data/restoreApi.ts | 2 +- .../create-library/data/restoreConstants.ts | 2 +- src/library-authoring/create-library/data/restoreHooks.ts | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/library-authoring/create-library/CreateLibrary.tsx b/src/library-authoring/create-library/CreateLibrary.tsx index 8142b9007a..640bf9e3bd 100644 --- a/src/library-authoring/create-library/CreateLibrary.tsx +++ b/src/library-authoring/create-library/CreateLibrary.tsx @@ -121,7 +121,7 @@ export const CreateLibrary = ({ const validExtensions = ['.zip', '.tar.gz', '.tar']; const fileName = file.name.toLowerCase(); const isValidFile = validExtensions.some(ext => fileName.endsWith(ext)); - + if (isValidFile) { setUploadedFile(file); // Immediately start the restore process @@ -165,7 +165,7 @@ export const CreateLibrary = ({ ) : null} /> )} - + {/* Archive upload section - shown above form when in archive mode */} {isFromArchive && (
@@ -292,12 +292,12 @@ export const CreateLibrary = ({ } onSubmit={(values) => { const submitData = { ...values } as CreateContentLibraryArgs; - + // If we're creating from archive and have a successful restore, include the learning_package_id if (isFromArchive && restoreStatus?.state === LibraryRestoreStatus.Succeeded && restoreStatus.result) { submitData.learning_package = restoreStatus.result.learning_package_id; } - + mutate(submitData); }} > diff --git a/src/library-authoring/create-library/data/restoreApi.ts b/src/library-authoring/create-library/data/restoreApi.ts index c11462eb87..5d89d76d0b 100644 --- a/src/library-authoring/create-library/data/restoreApi.ts +++ b/src/library-authoring/create-library/data/restoreApi.ts @@ -17,4 +17,4 @@ export const createLibraryRestore = async (archiveFile: File): Promise => { const { data } = await getAuthenticatedHttpClient().get(getLibraryRestoreStatusApiUrl(taskId)); return data; -}; \ No newline at end of file +}; diff --git a/src/library-authoring/create-library/data/restoreConstants.ts b/src/library-authoring/create-library/data/restoreConstants.ts index 4a6cce7c3c..b5cc6724d6 100644 --- a/src/library-authoring/create-library/data/restoreConstants.ts +++ b/src/library-authoring/create-library/data/restoreConstants.ts @@ -40,4 +40,4 @@ export const libraryRestoreQueryKeys = { all: ['library-v2-restore'], restoreStatus: (taskId: string) => [...libraryRestoreQueryKeys.all, 'status', taskId], restoreMutation: () => [...libraryRestoreQueryKeys.all, 'create-restore'], -}; \ No newline at end of file +}; diff --git a/src/library-authoring/create-library/data/restoreHooks.ts b/src/library-authoring/create-library/data/restoreHooks.ts index b9dd1bf9a8..617b60fa1c 100644 --- a/src/library-authoring/create-library/data/restoreHooks.ts +++ b/src/library-authoring/create-library/data/restoreHooks.ts @@ -27,4 +27,4 @@ export const useGetLibraryRestoreStatus = (taskId: string) => useQuery useMutation({ mutationKey: libraryRestoreQueryKeys.restoreMutation(), mutationFn: createLibraryRestore, -}); \ No newline at end of file +}); From 61d1c32a68b0bbd4282be24ab782d6aa28116262 Mon Sep 17 00:00:00 2001 From: Javier Ontiveros Date: Wed, 22 Oct 2025 16:02:21 -0500 Subject: [PATCH 04/18] chore: updated copys for intl and slight style changes --- .../create-library/CreateLibrary.tsx | 81 +++++++++++-------- .../create-library/messages.ts | 25 ++++++ 2 files changed, 71 insertions(+), 35 deletions(-) diff --git a/src/library-authoring/create-library/CreateLibrary.tsx b/src/library-authoring/create-library/CreateLibrary.tsx index 640bf9e3bd..941bcdd919 100644 --- a/src/library-authoring/create-library/CreateLibrary.tsx +++ b/src/library-authoring/create-library/CreateLibrary.tsx @@ -12,7 +12,11 @@ import { Icon, Alert, } from '@openedx/paragon'; -import { Upload, InsertDriveFile, CheckCircle } from '@openedx/paragon/icons'; +import { + Upload, + LibraryBooks, + AccessTime, +} from '@openedx/paragon/icons'; import { Formik } from 'formik'; import { useNavigate } from 'react-router-dom'; import * as Yup from 'yup'; @@ -135,7 +139,7 @@ export const CreateLibrary = ({ }); } else { // Call handleError for invalid file types - handleError(new Error('Invalid file type. Please upload a .zip, .tar.gz, or .tar file.')); + handleError(new Error(intl.formatMessage(messages.invalidFileTypeError))); } } }, [restoreMutation]); @@ -169,7 +173,7 @@ export const CreateLibrary = ({ {/* Archive upload section - shown above form when in archive mode */} {isFromArchive && (
- {!uploadedFile && ( + {!uploadedFile && !restoreMutation.isPending && ( @@ -195,46 +199,53 @@ export const CreateLibrary = ({ )} - {uploadedFile && restoreStatus?.state === LibraryRestoreStatus.Succeeded && restoreStatus.result && ( - // Show restore result data when succeeded - - - - -
-
{restoreStatus.result.title}
-

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

-

- Contains {restoreStatus.result.components} Components • - Backed up {new Date(restoreStatus.result.created_at).toLocaleDateString()} at{' '} - {new Date(restoreStatus.result.created_at).toLocaleTimeString()} -

-
-
-
-
+ {/* Loading state - show spinner in DropZone-like container */} + {restoreMutation.isPending && ( +
+
+ {intl.formatMessage(messages.uploadingStatus)} +
+
)} - {uploadedFile && restoreStatus?.state !== LibraryRestoreStatus.Succeeded && ( - // Show uploaded file info during processing + {uploadedFile && restoreStatus?.state === LibraryRestoreStatus.Succeeded && restoreStatus.result && ( + // Show restore result data when succeeded - - +
-
{uploadedFile.name}
+

{restoreStatus.result.title}

- {(uploadedFile.size / (1024 * 1024)).toFixed(2)} MB + {restoreStatus.result.org} / {restoreStatus.result.slug}

- {restoreMutation.isPending && ( -
- Processing... +
+
+ + + {intl.formatMessage(messages.archiveComponentsCount, { + count: restoreStatus.result.components, + })} +
- )} - +
+ + + {intl.formatMessage(messages.archiveBackupDate, { + date: new Date(restoreStatus.result.created_at).toLocaleDateString(), + time: new Date(restoreStatus.result.created_at).toLocaleTimeString(), + })} + +
+
+
)} diff --git a/src/library-authoring/create-library/messages.ts b/src/library-authoring/create-library/messages.ts index 6e3bd8d367..841106549a 100644 --- a/src/library-authoring/create-library/messages.ts +++ b/src/library-authoring/create-library/messages.ts @@ -128,6 +128,31 @@ const messages = defineMessages({ 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.', + }, + dropzoneMultipleDraggedError: { + id: 'course-authoring.library-authoring.create-library.form.dropzone.multiple-dragged-error', + defaultMessage: 'Please upload only one archive file.', + description: 'Error message when multiple files are dragged to the dropzone.', + }, + 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.', + }, }); export default messages; From 8e72ad1e2b1fcdb5160f3c46979e139ecbc2db53 Mon Sep 17 00:00:00 2001 From: javier ontiveros Date: Mon, 27 Oct 2025 09:16:39 -0600 Subject: [PATCH 05/18] chore: update tests for the create library --- .../create-library/CreateLibrary.test.tsx | 356 +++++++++++++++++- .../create-library/CreateLibrary.tsx | 132 ++++--- .../create-library/messages.ts | 27 +- 3 files changed, 428 insertions(+), 87 deletions(-) diff --git a/src/library-authoring/create-library/CreateLibrary.test.tsx b/src/library-authoring/create-library/CreateLibrary.test.tsx index cf73083982..86927bee6b 100644 --- a/src/library-authoring/create-library/CreateLibrary.test.tsx +++ b/src/library-authoring/create-library/CreateLibrary.test.tsx @@ -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,12 +33,32 @@ jest.mock('@src/generic/data/apiHooks', () => ({ }), })); +// Mock restore hooks +const mockRestoreMutate = jest.fn(); +let mockRestoreStatusData: any = {}; +let mockRestoreMutationError: any = null; +jest.mock('./data/restoreHooks', () => ({ + useCreateLibraryRestore: () => ({ + mutate: mockRestoreMutate, + error: mockRestoreMutationError, + isPending: false, + isError: !!mockRestoreMutationError, + }), + useGetLibraryRestoreStatus: () => ({ + data: mockRestoreStatusData, + }), +})); + describe('', () => { beforeEach(() => { axiosMock = initializeMocks().axiosMock; axiosMock .onGet(getApiWaffleFlagsUrl(undefined)) .reply(200, {}); + // Reset restore mocks + mockRestoreMutate.mockReset(); + mockRestoreStatusData = {}; + mockRestoreMutationError = null; }); afterEach(() => { @@ -66,7 +88,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 +126,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 +163,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 +194,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 +214,330 @@ describe('', () => { expect(mockNavigate).toHaveBeenCalledWith('/libraries'); }); }); + + 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({ task_id: '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('shows loading state during restore', async () => { + const user = userEvent.setup(); + axiosMock.onGet(getStudioHomeApiUrl()).reply(200, studioHomeMock); + + mockRestoreMutate.mockImplementation((_file: File, { onSuccess }: any) => { + onSuccess({ task_id: 'task-123' }); + mockRestoreStatusData = { state: LibraryRestoreStatus.Pending }; + }); + + render(); + + // Switch to archive mode + const createFromArchiveBtn = await screen.findByRole('button', { name: messages.createFromArchiveButton.defaultMessage }); + await user.click(createFromArchiveBtn); + + // 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); + + 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 = { + learning_package_id: 123, + title: 'Test Archive Library', + org: 'TestOrg', + slug: 'test-archive', + key: 'TestOrg/test-archive', + archive_key: 'archive-key', + containers: 5, + components: 15, + collections: 3, + sections: 8, + subsections: 12, + units: 20, + created_on_server: '2025-01-01T10:00:00Z', + created_at: '2025-01-01T10:00:00Z', + created_by: { + username: 'testuser', + email: 'test@example.com', + }, + }; + + // Mock the restore mutation to simulate successful upload and restore + mockRestoreMutate.mockImplementation((_file: File, { onSuccess }: any) => { + onSuccess({ task_id: 'task-123' }); + // Simulate successful restore completion + mockRestoreStatusData = { + state: LibraryRestoreStatus.Succeeded, + result: mockResult, + error: null, + error_log: null, + }; + }); + + 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); + + // Mock the restore mutation to simulate upload starting then failing + mockRestoreMutate.mockImplementation((_file: File, { onSuccess }: any) => { + onSuccess({ task_id: 'task-456' }); + // Simulate restore failure + mockRestoreStatusData = { + state: LibraryRestoreStatus.Failed, + result: null, + error: 'Library restore failed. See error log for details.', + error_log: 'http://example.com/error.log', + }; + }); + + 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 invalid file type + const file = new File(['test content'], 'test-file.txt', { type: 'text/plain' }); + 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(); + }); + + 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 = { + learning_package_id: 456, + title: 'Restored Library', + org: 'RestoredOrg', + slug: 'restored-lib', + key: 'RestoredOrg/restored-lib', + archive_key: 'archive-key', + containers: 3, + components: 10, + collections: 2, + sections: 5, + subsections: 8, + units: 15, + created_on_server: '2025-01-01T12:00:00Z', + created_at: '2025-01-01T12:00:00Z', + created_by: { + username: 'restoreuser', + email: 'restore@example.com', + }, + }; + + mockRestoreStatusData = { + state: LibraryRestoreStatus.Succeeded, + result: mockResult, + error: null, + error_log: 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(); + }); + }); }); diff --git a/src/library-authoring/create-library/CreateLibrary.tsx b/src/library-authoring/create-library/CreateLibrary.tsx index 941bcdd919..d87ce7b6c1 100644 --- a/src/library-authoring/create-library/CreateLibrary.tsx +++ b/src/library-authoring/create-library/CreateLibrary.tsx @@ -1,43 +1,49 @@ import { StudioFooterSlot } from '@edx/frontend-component-footer'; import { useIntl } from '@edx/frontend-platform/i18n'; import { - Container, - Form, - Button, - StatefulButton, ActionRow, - Dropzone, + Alert, + Button, Card, - Stack, + Container, + Dropzone, + Form, Icon, - Alert, + StatefulButton } from '@openedx/paragon'; import { - Upload, - LibraryBooks, 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 { useState, useCallback } from 'react'; 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 type { ContentLibrary } from '../data/api'; import { CreateContentLibraryArgs } from './data/api'; -import { useCreateLibraryRestore, useGetLibraryRestoreStatus } from './data/restoreHooks'; +import { useCreateLibraryV2 } from './data/apiHooks'; import { LibraryRestoreStatus } from './data/restoreConstants'; +import { useCreateLibraryRestore, useGetLibraryRestoreStatus } from './data/restoreHooks'; import messages from './messages'; -import type { ContentLibrary } from '../data/api'; + +// Valid file extensions for library archive uploads +const VALID_ARCHIVE_EXTENSIONS = ['.zip', '.tar.gz', '.tar']; +const DROPZONE_ACCEPT_TYPES = { + 'application/zip': ['.zip'], + 'application/gzip': ['.tar.gz'], + 'application/x-tar': ['.tar'], +}; /** * Renders the form and logic to create a new library. @@ -121,10 +127,9 @@ export const CreateLibrary = ({ }) => { const file = fileData.get('file') as File; if (file) { - // Validate file type - const validExtensions = ['.zip', '.tar.gz', '.tar']; + // Validate file type using the same extensions as the dropzone const fileName = file.name.toLowerCase(); - const isValidFile = validExtensions.some(ext => fileName.endsWith(ext)); + const isValidFile = VALID_ARCHIVE_EXTENSIONS.some(ext => fileName.endsWith(ext)); if (isValidFile) { setUploadedFile(file); @@ -142,7 +147,7 @@ export const CreateLibrary = ({ handleError(new Error(intl.formatMessage(messages.invalidFileTypeError))); } } - }, [restoreMutation]); + }, [restoreMutation, intl]); if (data) { if (handlePostCreate) { @@ -155,7 +160,7 @@ export const CreateLibrary = ({ return ( <> {!showInModal && (
)} - + {!showInModal && ( - - -
-

{intl.formatMessage(messages.dropzoneTitle)}

-

{intl.formatMessage(messages.dropzoneSubtitle)}

-
-
- + /> )} {/* Loading state - show spinner in DropZone-like container */} @@ -219,25 +208,25 @@ export const CreateLibrary = ({ // Show restore result data when succeeded -
-
-

{restoreStatus.result.title}

-

+

+
+ {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.created_at).toLocaleDateString(), time: new Date(restoreStatus.result.created_at).toLocaleTimeString(), @@ -249,28 +238,38 @@ export const CreateLibrary = ({ )} +
+ )} - {/* Archive restore status */} - {restoreTaskId && ( -
- {restoreStatus?.state === LibraryRestoreStatus.Pending && ( - - {intl.formatMessage(messages.restoreInProgress)} - - )} + {/* Error alerts - shown for both archive and regular creation */} + {(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.error_log && ( )} - +
)} -
+ {restoreMutation.isError && ( +
+ {restoreMutation.error?.message + || intl.formatMessage(messages.genericErrorMessage)} +
+ )} + )}
)} @@ -383,9 +382,8 @@ export const CreateLibrary = ({ )} - {(isError || restoreMutation.isError) && ( - - )} + {isError && ()} + {!showInModal && ()} diff --git a/src/library-authoring/create-library/messages.ts b/src/library-authoring/create-library/messages.ts index 841106549a..190c9a5831 100644 --- a/src/library-authoring/create-library/messages.ts +++ b/src/library-authoring/create-library/messages.ts @@ -90,19 +90,9 @@ const messages = defineMessages({ }, createFromArchiveButton: { id: 'course-authoring.library-authoring.create-library.form.create-from-archive.button', - defaultMessage: 'Create from Archive', + defaultMessage: 'Create from archive', description: 'Button text to create library from archive.', }, - dropzoneTitle: { - id: 'course-authoring.library-authoring.create-library.form.dropzone.title', - defaultMessage: 'Drag and drop your local backup archive file here to upload', - description: 'Title for the dropzone area.', - }, - dropzoneSubtitle: { - id: 'course-authoring.library-authoring.create-library.form.dropzone.subtitle', - defaultMessage: 'Upload .zip or .tar.gz files (5 GB max)', - description: 'Subtitle for the dropzone area.', - }, uploadSuccess: { id: 'course-authoring.library-authoring.create-library.form.upload.success', defaultMessage: 'File uploaded successfully', @@ -138,11 +128,6 @@ const messages = defineMessages({ defaultMessage: 'Backed up {date} at {time}', description: 'Text showing when the archive was backed up.', }, - dropzoneMultipleDraggedError: { - id: 'course-authoring.library-authoring.create-library.form.dropzone.multiple-dragged-error', - defaultMessage: 'Please upload only one archive file.', - description: 'Error message when multiple files are dragged to the dropzone.', - }, uploadingStatus: { id: 'course-authoring.library-authoring.create-library.form.uploading.status', defaultMessage: 'Uploading...', @@ -153,6 +138,16 @@ const messages = defineMessages({ 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; From 33b538675776da41a88fde0e741a143c2353cd0a Mon Sep 17 00:00:00 2001 From: javier ontiveros Date: Mon, 27 Oct 2025 09:50:19 -0600 Subject: [PATCH 06/18] chore: extra coverage and lint fixes --- .../create-library/CreateLibrary.test.tsx | 9 +++++++-- src/library-authoring/create-library/CreateLibrary.tsx | 4 ++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/library-authoring/create-library/CreateLibrary.test.tsx b/src/library-authoring/create-library/CreateLibrary.test.tsx index 86927bee6b..cabc992d78 100644 --- a/src/library-authoring/create-library/CreateLibrary.test.tsx +++ b/src/library-authoring/create-library/CreateLibrary.test.tsx @@ -420,8 +420,8 @@ describe('', () => { const createFromArchiveBtn = await screen.findByRole('button', { name: messages.createFromArchiveButton.defaultMessage }); await user.click(createFromArchiveBtn); - // Try to upload invalid file type - const file = new File(['test content'], 'test-file.txt', { type: 'text/plain' }); + // 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; @@ -434,6 +434,11 @@ describe('', () => { // 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('creates library from archive with learning package ID', async () => { diff --git a/src/library-authoring/create-library/CreateLibrary.tsx b/src/library-authoring/create-library/CreateLibrary.tsx index d87ce7b6c1..1e4d085f6c 100644 --- a/src/library-authoring/create-library/CreateLibrary.tsx +++ b/src/library-authoring/create-library/CreateLibrary.tsx @@ -9,11 +9,11 @@ import { Dropzone, Form, Icon, - StatefulButton + StatefulButton, } from '@openedx/paragon'; import { AccessTime, - Widgets + Widgets, } from '@openedx/paragon/icons'; import AlertError from '@src/generic/alert-error'; import classNames from 'classnames'; From 5a53057a08f12f3bc31a5f56bc2e3b24d6bb770a Mon Sep 17 00:00:00 2001 From: javier ontiveros Date: Mon, 27 Oct 2025 10:20:11 -0600 Subject: [PATCH 07/18] chore: unified extra files --- .../create-library/CreateLibrary.test.tsx | 3 +- .../create-library/CreateLibrary.tsx | 3 +- .../create-library/data/api.ts | 19 ++++++++++++ .../create-library/data/apiHooks.ts | 31 ++++++++++++++++++- .../create-library/data/restoreApi.ts | 20 ------------ .../create-library/data/restoreHooks.ts | 30 ------------------ src/library-authoring/create-library/index.ts | 2 +- 7 files changed, 53 insertions(+), 55 deletions(-) delete mode 100644 src/library-authoring/create-library/data/restoreApi.ts delete mode 100644 src/library-authoring/create-library/data/restoreHooks.ts diff --git a/src/library-authoring/create-library/CreateLibrary.test.tsx b/src/library-authoring/create-library/CreateLibrary.test.tsx index cabc992d78..f79a977422 100644 --- a/src/library-authoring/create-library/CreateLibrary.test.tsx +++ b/src/library-authoring/create-library/CreateLibrary.test.tsx @@ -37,7 +37,8 @@ jest.mock('@src/generic/data/apiHooks', () => ({ const mockRestoreMutate = jest.fn(); let mockRestoreStatusData: any = {}; let mockRestoreMutationError: any = null; -jest.mock('./data/restoreHooks', () => ({ +jest.mock('./data/apiHooks', () => ({ + ...jest.requireActual('./data/apiHooks'), useCreateLibraryRestore: () => ({ mutate: mockRestoreMutate, error: mockRestoreMutationError, diff --git a/src/library-authoring/create-library/CreateLibrary.tsx b/src/library-authoring/create-library/CreateLibrary.tsx index 1e4d085f6c..d817bc4207 100644 --- a/src/library-authoring/create-library/CreateLibrary.tsx +++ b/src/library-authoring/create-library/CreateLibrary.tsx @@ -32,9 +32,8 @@ import { useStudioHome } from '@src/studio-home/hooks'; import type { ContentLibrary } from '../data/api'; import { CreateContentLibraryArgs } from './data/api'; -import { useCreateLibraryV2 } from './data/apiHooks'; +import { useCreateLibraryV2, useCreateLibraryRestore, useGetLibraryRestoreStatus } from './data/apiHooks'; import { LibraryRestoreStatus } from './data/restoreConstants'; -import { useCreateLibraryRestore, useGetLibraryRestoreStatus } from './data/restoreHooks'; import messages from './messages'; // Valid file extensions for library archive uploads diff --git a/src/library-authoring/create-library/data/api.ts b/src/library-authoring/create-library/data/api.ts index 53544578de..f17dd9ab7c 100644 --- a/src/library-authoring/create-library/data/api.ts +++ b/src/library-authoring/create-library/data/api.ts @@ -2,6 +2,8 @@ import { camelCaseObject, getConfig } from '@edx/frontend-platform'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import type { ContentLibrary } from '../../data/api'; +import { CreateLibraryRestoreResponse, GetLibraryRestoreStatusResponse } from './restoreConstants'; +import { getLibraryRestoreApiUrl, getLibraryRestoreStatusApiUrl } from '../../data/api'; const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL; @@ -29,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 data; +}; + +export const getLibraryRestoreStatus = async (taskId: string): Promise => { + const { data } = await getAuthenticatedHttpClient().get(getLibraryRestoreStatusApiUrl(taskId)); + return data; +}; 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/restoreApi.ts b/src/library-authoring/create-library/data/restoreApi.ts deleted file mode 100644 index 5d89d76d0b..0000000000 --- a/src/library-authoring/create-library/data/restoreApi.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; -import { CreateLibraryRestoreResponse, GetLibraryRestoreStatusResponse } from './restoreConstants'; -import { getLibraryRestoreApiUrl, getLibraryRestoreStatusApiUrl } from '../../data/api'; - -export const createLibraryRestore = async (archiveFile: File): Promise => { - const formData = new FormData(); - formData.append('file', archiveFile); - - const { data } = await getAuthenticatedHttpClient().post(getLibraryRestoreApiUrl(), formData, { - headers: { - 'Content-Type': 'multipart/form-data', - }, - }); - return data; -}; - -export const getLibraryRestoreStatus = async (taskId: string): Promise => { - const { data } = await getAuthenticatedHttpClient().get(getLibraryRestoreStatusApiUrl(taskId)); - return data; -}; diff --git a/src/library-authoring/create-library/data/restoreHooks.ts b/src/library-authoring/create-library/data/restoreHooks.ts deleted file mode 100644 index 617b60fa1c..0000000000 --- a/src/library-authoring/create-library/data/restoreHooks.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { useMutation, useQuery } from '@tanstack/react-query'; -import { createLibraryRestore, getLibraryRestoreStatus } from './restoreApi'; -import { - CreateLibraryRestoreResponse, - GetLibraryRestoreStatusResponse, - libraryRestoreQueryKeys, - LibraryRestoreStatus, -} from './restoreConstants'; - -/** - * 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/index.ts b/src/library-authoring/create-library/index.ts index c0a7e1c3c8..8c7c20f7fa 100644 --- a/src/library-authoring/create-library/index.ts +++ b/src/library-authoring/create-library/index.ts @@ -1,5 +1,5 @@ export { CreateLibrary } from './CreateLibrary'; export { CreateLibraryModal } from './CreateLibraryModal'; -export { useCreateLibraryRestore, useGetLibraryRestoreStatus } from './data/restoreHooks'; +export { useCreateLibraryRestore, useGetLibraryRestoreStatus } from './data/apiHooks'; export { LibraryRestoreStatus } from './data/restoreConstants'; export type { LibraryRestoreResult, GetLibraryRestoreStatusResponse } from './data/restoreConstants'; From e7957b2b9c4fe499d17d796ef57ec34626686e37 Mon Sep 17 00:00:00 2001 From: javier ontiveros Date: Mon, 27 Oct 2025 11:16:35 -0600 Subject: [PATCH 08/18] chore: updated tests to match up to date standards --- .../create-library/data/api.test.ts | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 src/library-authoring/create-library/data/api.test.ts 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..e96b7aaeec --- /dev/null +++ b/src/library-authoring/create-library/data/api.test.ts @@ -0,0 +1,118 @@ +import MockAdapter from 'axios-mock-adapter'; +import { initializeMockApp } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; + +import { createLibraryV2, createLibraryRestore, getLibraryRestoreStatus } from './api'; + +let axiosMock: MockAdapter; + +describe('create library api', () => { + beforeEach(() => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: true, + roles: [], + }, + }); + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + }); + + 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 expectedResult = { task_id: 'test-task-id' }; + + axiosMock.onPost().reply(200, expectedResult); + + 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 expectedResult = { + state: 'success', + result: { learning_package_id: 123 }, + }; + + axiosMock.onGet().reply(200, expectedResult); + + 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(); + }); +}); From 953e4edc5ea71f60c5a15ad561bc757c0d9309dd Mon Sep 17 00:00:00 2001 From: javier ontiveros Date: Mon, 27 Oct 2025 12:26:44 -0600 Subject: [PATCH 09/18] chore: add apiHooks test --- .../create-library/data/apiHooks.test.tsx | 216 ++++++++++++++++++ 1 file changed, 216 insertions(+) create mode 100644 src/library-authoring/create-library/data/apiHooks.test.tsx 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..f0112c98f8 --- /dev/null +++ b/src/library-authoring/create-library/data/apiHooks.test.tsx @@ -0,0 +1,216 @@ +import React from 'react'; + +import { QueryClient, 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 } = initializeMocks(); + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, +}); + +const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + +); + +describe('create library apiHooks', () => { + beforeEach(() => { + queryClient.clear(); + }); + + afterEach(() => { + 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 = { task_id: '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: { + learning_package_id: 123, + title: 'Test Library', + org: 'test-org', + slug: 'test-library', + key: 'lib:test-org:test-library', + archive_key: 'archive-key', + containers: 1, + components: 5, + collections: 2, + sections: 1, + subsections: 1, + units: 1, + created_on_server: '2024-01-01T00:00:00Z', + created_at: '2024-01-01T00:00:00Z', + created_by: { + username: 'testuser', + email: 'test@example.com', + }, + }, + error: null, + error_log: 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, + }; + + 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(pendingResult); + 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', + }; + + 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(failedResult); + 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}`); + }); + }); +}); From d4eaefc300fc0e30b09922786c35c625fe6592ac Mon Sep 17 00:00:00 2001 From: javier ontiveros Date: Mon, 27 Oct 2025 12:48:54 -0600 Subject: [PATCH 10/18] chore: extra tests for complete coverage --- .../create-library/CreateLibrary.test.tsx | 174 ++++++++++++++++++ 1 file changed, 174 insertions(+) diff --git a/src/library-authoring/create-library/CreateLibrary.test.tsx b/src/library-authoring/create-library/CreateLibrary.test.tsx index f79a977422..b36e27d1a3 100644 --- a/src/library-authoring/create-library/CreateLibrary.test.tsx +++ b/src/library-authoring/create-library/CreateLibrary.test.tsx @@ -216,6 +216,68 @@ describe('', () => { }); }); + 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(); @@ -271,6 +333,51 @@ describe('', () => { }); }); + 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 loading state during restore', async () => { const user = userEvent.setup(); axiosMock.onGet(getStudioHomeApiUrl()).reply(200, studioHomeMock); @@ -442,6 +549,73 @@ describe('', () => { }); }); + test('shows archive preview only when all conditions are met', async () => { + const user = userEvent.setup(); + axiosMock.onGet(getStudioHomeApiUrl()).reply(200, studioHomeMock); + + const mockResult = { + learning_package_id: 123, + title: 'Test Archive Library', + org: 'TestOrg', + slug: 'test-archive', + key: 'TestOrg/test-archive', + archive_key: 'archive-key', + containers: 5, + components: 15, + collections: 3, + sections: 8, + subsections: 12, + units: 20, + created_on_server: '2025-01-01T10:00:00Z', + created_at: '2025-01-01T10:00:00Z', + created_by: { + 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(); + + // Set state to have uploadedFile but no successful restore status + mockRestoreStatusData = { state: LibraryRestoreStatus.Pending }; + + // Mock successful file upload and restore + mockRestoreMutate.mockImplementation((_file: File, { onSuccess }: any) => { + onSuccess({ task_id: 'task-123' }); + // Update restore status to succeeded with result + mockRestoreStatusData = { + state: LibraryRestoreStatus.Succeeded, + result: mockResult, + }; + }); + + // 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); From 33fd861e10422950aeeca94b94bc5d568e2d5d91 Mon Sep 17 00:00:00 2001 From: javier ontiveros Date: Mon, 27 Oct 2025 12:53:29 -0600 Subject: [PATCH 11/18] chore: move constants to a proper place --- src/library-authoring/create-library/CreateLibrary.tsx | 10 +--------- .../create-library/data/restoreConstants.ts | 8 ++++++++ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/library-authoring/create-library/CreateLibrary.tsx b/src/library-authoring/create-library/CreateLibrary.tsx index d817bc4207..c018339527 100644 --- a/src/library-authoring/create-library/CreateLibrary.tsx +++ b/src/library-authoring/create-library/CreateLibrary.tsx @@ -33,17 +33,9 @@ import { useStudioHome } from '@src/studio-home/hooks'; import type { ContentLibrary } from '../data/api'; import { CreateContentLibraryArgs } from './data/api'; import { useCreateLibraryV2, useCreateLibraryRestore, useGetLibraryRestoreStatus } from './data/apiHooks'; -import { LibraryRestoreStatus } from './data/restoreConstants'; +import { DROPZONE_ACCEPT_TYPES, LibraryRestoreStatus, VALID_ARCHIVE_EXTENSIONS } from './data/restoreConstants'; import messages from './messages'; -// Valid file extensions for library archive uploads -const VALID_ARCHIVE_EXTENSIONS = ['.zip', '.tar.gz', '.tar']; -const DROPZONE_ACCEPT_TYPES = { - 'application/zip': ['.zip'], - 'application/gzip': ['.tar.gz'], - 'application/x-tar': ['.tar'], -}; - /** * Renders the form and logic to create a new library. * diff --git a/src/library-authoring/create-library/data/restoreConstants.ts b/src/library-authoring/create-library/data/restoreConstants.ts index b5cc6724d6..7f06347bbb 100644 --- a/src/library-authoring/create-library/data/restoreConstants.ts +++ b/src/library-authoring/create-library/data/restoreConstants.ts @@ -41,3 +41,11 @@ export const libraryRestoreQueryKeys = { restoreStatus: (taskId: string) => [...libraryRestoreQueryKeys.all, 'status', taskId], restoreMutation: () => [...libraryRestoreQueryKeys.all, 'create-restore'], }; + +// Valid file extensions for library archive uploads +export const VALID_ARCHIVE_EXTENSIONS = ['.zip', '.tar.gz', '.tar']; +export const DROPZONE_ACCEPT_TYPES = { + 'application/zip': ['.zip'], + 'application/gzip': ['.tar.gz'], + 'application/x-tar': ['.tar'], +}; From 96d2e4cec4512fc1af238ff638fedef9bbafd9ae Mon Sep 17 00:00:00 2001 From: javier ontiveros Date: Mon, 27 Oct 2025 14:44:01 -0600 Subject: [PATCH 12/18] chore: cleanup --- src/library-authoring/create-library/CreateLibrary.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/library-authoring/create-library/CreateLibrary.tsx b/src/library-authoring/create-library/CreateLibrary.tsx index c018339527..02ee9fcfeb 100644 --- a/src/library-authoring/create-library/CreateLibrary.tsx +++ b/src/library-authoring/create-library/CreateLibrary.tsx @@ -232,7 +232,6 @@ export const CreateLibrary = ({
)} - {/* Error alerts - shown for both archive and regular creation */} {(restoreTaskId || isError || restoreMutation.isError) && (
{restoreStatus?.state === LibraryRestoreStatus.Pending && ( @@ -265,7 +264,6 @@ export const CreateLibrary = ({
)} - {/* Regular form - always shown */} Date: Mon, 27 Oct 2025 14:59:25 -0600 Subject: [PATCH 13/18] chore: comments cleanup --- src/library-authoring/create-library/data/restoreConstants.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/library-authoring/create-library/data/restoreConstants.ts b/src/library-authoring/create-library/data/restoreConstants.ts index 7f06347bbb..5643953fbd 100644 --- a/src/library-authoring/create-library/data/restoreConstants.ts +++ b/src/library-authoring/create-library/data/restoreConstants.ts @@ -42,7 +42,6 @@ export const libraryRestoreQueryKeys = { restoreMutation: () => [...libraryRestoreQueryKeys.all, 'create-restore'], }; -// Valid file extensions for library archive uploads export const VALID_ARCHIVE_EXTENSIONS = ['.zip', '.tar.gz', '.tar']; export const DROPZONE_ACCEPT_TYPES = { 'application/zip': ['.zip'], From 32211c757b68be39691e60eb06c56ab7400bd73c Mon Sep 17 00:00:00 2001 From: javier ontiveros Date: Tue, 28 Oct 2025 07:38:06 -0600 Subject: [PATCH 14/18] chore: fixed comments --- src/library-authoring/create-library/CreateLibrary.tsx | 1 - src/library-authoring/create-library/data/api.ts | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/library-authoring/create-library/CreateLibrary.tsx b/src/library-authoring/create-library/CreateLibrary.tsx index 02ee9fcfeb..6be7897e3e 100644 --- a/src/library-authoring/create-library/CreateLibrary.tsx +++ b/src/library-authoring/create-library/CreateLibrary.tsx @@ -171,7 +171,6 @@ export const CreateLibrary = ({
{!uploadedFile && !restoreMutation.isPending && ( getConfig().STUDIO_BASE_URL; From 8bb8c90654c37a4a8e2b2398367787b78fc0447e Mon Sep 17 00:00:00 2001 From: javier ontiveros Date: Tue, 28 Oct 2025 07:55:52 -0600 Subject: [PATCH 15/18] chore: return test id --- src/library-authoring/create-library/CreateLibrary.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/library-authoring/create-library/CreateLibrary.tsx b/src/library-authoring/create-library/CreateLibrary.tsx index 6be7897e3e..02ee9fcfeb 100644 --- a/src/library-authoring/create-library/CreateLibrary.tsx +++ b/src/library-authoring/create-library/CreateLibrary.tsx @@ -171,6 +171,7 @@ export const CreateLibrary = ({
{!uploadedFile && !restoreMutation.isPending && ( Date: Wed, 29 Oct 2025 14:26:23 -0600 Subject: [PATCH 16/18] chore: applied comments --- .../create-library/CreateLibrary.test.tsx | 289 ++++++++++++++---- .../create-library/CreateLibrary.tsx | 22 +- .../create-library/data/api.test.ts | 32 +- .../create-library/data/api.ts | 4 +- .../create-library/data/apiHooks.test.tsx | 47 +-- .../create-library/data/restoreConstants.ts | 14 +- 6 files changed, 295 insertions(+), 113 deletions(-) diff --git a/src/library-authoring/create-library/CreateLibrary.test.tsx b/src/library-authoring/create-library/CreateLibrary.test.tsx index b36e27d1a3..509e2ee931 100644 --- a/src/library-authoring/create-library/CreateLibrary.test.tsx +++ b/src/library-authoring/create-library/CreateLibrary.test.tsx @@ -37,12 +37,13 @@ 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: false, + isPending: mockRestoreMutationPending, isError: !!mockRestoreMutationError, }), useGetLibraryRestoreStatus: () => ({ @@ -60,11 +61,7 @@ describe('', () => { mockRestoreMutate.mockReset(); mockRestoreStatusData = {}; mockRestoreMutationError = null; - }); - - afterEach(() => { - jest.clearAllMocks(); - axiosMock.restore(); + mockRestoreMutationPending = false; }); test('call api data with correct data', async () => { @@ -299,7 +296,7 @@ describe('', () => { axiosMock.onGet(getStudioHomeApiUrl()).reply(200, studioHomeMock); mockRestoreMutate.mockImplementation((_file: File, { onSuccess }: any) => { - onSuccess({ task_id: 'task-123' }); + onSuccess({ taskId: 'task-123' }); }); render(); @@ -378,13 +375,24 @@ describe('', () => { consoleSpy.mockRestore(); }); - test('shows loading state during restore', async () => { + 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({ task_id: 'task-123' }); - mockRestoreStatusData = { state: LibraryRestoreStatus.Pending }; + onSuccess({ taskId: mockTaskId }); }); render(); @@ -393,7 +401,7 @@ describe('', () => { const createFromArchiveBtn = await screen.findByRole('button', { name: messages.createFromArchiveButton.defaultMessage }); await user.click(createFromArchiveBtn); - // Upload file + // 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; @@ -405,6 +413,7 @@ describe('', () => { fireEvent.change(input); + // Should show the restore in progress alert await waitFor(() => { expect(screen.getByText(messages.restoreInProgress.defaultMessage)).toBeInTheDocument(); }); @@ -415,36 +424,37 @@ describe('', () => { axiosMock.onGet(getStudioHomeApiUrl()).reply(200, studioHomeMock); const mockResult = { - learning_package_id: 123, + learningPackageId: 123, title: 'Test Archive Library', org: 'TestOrg', slug: 'test-archive', key: 'TestOrg/test-archive', - archive_key: 'archive-key', + archiveKey: 'archive-key', containers: 5, components: 15, collections: 3, sections: 8, subsections: 12, units: 20, - created_on_server: '2025-01-01T10:00:00Z', - created_at: '2025-01-01T10:00:00Z', - created_by: { + createdOnServer: '2025-01-01T10:00:00Z', + createdAt: '2025-01-01T10:00:00Z', + createdBy: { username: 'testuser', email: 'test@example.com', }, }; - // Mock the restore mutation to simulate successful upload and restore + // 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({ task_id: 'task-123' }); - // Simulate successful restore completion - mockRestoreStatusData = { - state: LibraryRestoreStatus.Succeeded, - result: mockResult, - error: null, - error_log: null, - }; + onSuccess({ taskId: 'task-123' }); }); render(); @@ -477,16 +487,17 @@ describe('', () => { const user = userEvent.setup(); axiosMock.onGet(getStudioHomeApiUrl()).reply(200, studioHomeMock); - // Mock the restore mutation to simulate upload starting then failing + // 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({ task_id: 'task-456' }); - // Simulate restore failure - mockRestoreStatusData = { - state: LibraryRestoreStatus.Failed, - result: null, - error: 'Library restore failed. See error log for details.', - error_log: 'http://example.com/error.log', - }; + onSuccess({ taskId: 'task-456' }); }); render(); @@ -554,21 +565,21 @@ describe('', () => { axiosMock.onGet(getStudioHomeApiUrl()).reply(200, studioHomeMock); const mockResult = { - learning_package_id: 123, + learningPackageId: 123, title: 'Test Archive Library', org: 'TestOrg', slug: 'test-archive', key: 'TestOrg/test-archive', - archive_key: 'archive-key', + archiveKey: 'archive-key', containers: 5, components: 15, collections: 3, sections: 8, subsections: 12, units: 20, - created_on_server: '2025-01-01T10:00:00Z', - created_at: '2025-01-01T10:00:00Z', - created_by: { + createdOnServer: '2025-01-01T10:00:00Z', + createdAt: '2025-01-01T10:00:00Z', + createdBy: { username: 'testuser', email: 'test@example.com', }, @@ -583,17 +594,15 @@ describe('', () => { // Initially no archive preview should be shown (no uploaded file) expect(screen.queryByText('Test Archive Library')).not.toBeInTheDocument(); - // Set state to have uploadedFile but no successful restore status - mockRestoreStatusData = { state: LibraryRestoreStatus.Pending }; + // Pre-set the final restore status to succeeded + mockRestoreStatusData = { + state: LibraryRestoreStatus.Succeeded, + result: mockResult, + }; - // Mock successful file upload and restore + // Mock successful file upload mockRestoreMutate.mockImplementation((_file: File, { onSuccess }: any) => { - onSuccess({ task_id: 'task-123' }); - // Update restore status to succeeded with result - mockRestoreStatusData = { - state: LibraryRestoreStatus.Succeeded, - result: mockResult, - }; + onSuccess({ taskId: 'task-123' }); }); // Upload file @@ -624,21 +633,21 @@ describe('', () => { }); const mockResult = { - learning_package_id: 456, + learningPackageId: 456, // Fixed: use camelCase to match actual API response title: 'Restored Library', org: 'RestoredOrg', slug: 'restored-lib', key: 'RestoredOrg/restored-lib', - archive_key: 'archive-key', + archiveKey: 'archive-key', // Fixed: use camelCase containers: 3, components: 10, collections: 2, sections: 5, subsections: 8, units: 15, - created_on_server: '2025-01-01T12:00:00Z', - created_at: '2025-01-01T12:00:00Z', - created_by: { + 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', }, @@ -648,7 +657,7 @@ describe('', () => { state: LibraryRestoreStatus.Succeeded, result: mockResult, error: null, - error_log: null, + errorLog: null, }; render(); @@ -719,5 +728,177 @@ describe('', () => { // 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 02ee9fcfeb..34217222f0 100644 --- a/src/library-authoring/create-library/CreateLibrary.tsx +++ b/src/library-authoring/create-library/CreateLibrary.tsx @@ -9,6 +9,7 @@ import { Dropzone, Form, Icon, + Spinner, StatefulButton, } from '@openedx/paragon'; import { @@ -127,7 +128,7 @@ export const CreateLibrary = ({ // Immediately start the restore process restoreMutation.mutate(file, { onSuccess: (response) => { - setRestoreTaskId(response.task_id); + setRestoreTaskId(response.taskId); }, onError: (restoreError) => { handleError(restoreError); @@ -189,9 +190,10 @@ export const CreateLibrary = ({ backgroundColor: '#f8f9fa', }} > -
- {intl.formatMessage(messages.uploadingStatus)} -
+
)} @@ -219,8 +221,8 @@ export const CreateLibrary = ({ {intl.formatMessage(messages.archiveBackupDate, { - date: new Date(restoreStatus.result.created_at).toLocaleDateString(), - time: new Date(restoreStatus.result.created_at).toLocaleTimeString(), + date: new Date(restoreStatus.result.createdAt).toLocaleDateString(), + time: new Date(restoreStatus.result.createdAt).toLocaleTimeString(), })}
@@ -244,9 +246,9 @@ export const CreateLibrary = ({ {restoreStatus?.state === LibraryRestoreStatus.Failed && (
{intl.formatMessage(messages.restoreError)} - {restoreStatus.error_log && ( + {restoreStatus.errorLog && ( @@ -292,9 +294,9 @@ export const CreateLibrary = ({ onSubmit={(values) => { const submitData = { ...values } as CreateContentLibraryArgs; - // If we're creating from archive and have a successful restore, include the learning_package_id + // 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.learning_package_id; + submitData.learning_package = restoreStatus.result.learningPackageId; } mutate(submitData); diff --git a/src/library-authoring/create-library/data/api.test.ts b/src/library-authoring/create-library/data/api.test.ts index e96b7aaeec..93bbc8d75c 100644 --- a/src/library-authoring/create-library/data/api.test.ts +++ b/src/library-authoring/create-library/data/api.test.ts @@ -1,22 +1,13 @@ -import MockAdapter from 'axios-mock-adapter'; -import { initializeMockApp } from '@edx/frontend-platform'; -import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; - -import { createLibraryV2, createLibraryRestore, getLibraryRestoreStatus } from './api'; +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(() => { - initializeMockApp({ - authenticatedUser: { - userId: 3, - username: 'abc123', - administrator: true, - roles: [], - }, - }); - axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + const mocks = initializeMocks(); + axiosMock = mocks.axiosMock; }); afterEach(() => { @@ -51,9 +42,10 @@ describe('create library api', () => { it('should restore library from file', async () => { const file = new File(['test content'], 'test.tar.gz', { type: 'application/gzip' }); - const expectedResult = { task_id: 'test-task-id' }; + const response = { task_id: 'test-task-id' }; + const expectedResult = { taskId: 'test-task-id' }; - axiosMock.onPost().reply(200, expectedResult); + axiosMock.onPost().reply(200, response); const result = await createLibraryRestore(file); @@ -64,12 +56,16 @@ describe('create library api', () => { it('should get library restore status', async () => { const taskId = 'test-task-id'; - const expectedResult = { + const response = { state: 'success', result: { learning_package_id: 123 }, }; + const expectedResult = { + state: 'success', + result: { learningPackageId: 123 }, + }; - axiosMock.onGet().reply(200, expectedResult); + axiosMock.onGet().reply(200, response); const result = await getLibraryRestoreStatus(taskId); diff --git a/src/library-authoring/create-library/data/api.ts b/src/library-authoring/create-library/data/api.ts index 35143c2358..392899b52d 100644 --- a/src/library-authoring/create-library/data/api.ts +++ b/src/library-authoring/create-library/data/api.ts @@ -41,10 +41,10 @@ export const createLibraryRestore = async (archiveFile: File): Promise => { const { data } = await getAuthenticatedHttpClient().get(getLibraryRestoreStatusApiUrl(taskId)); - return data; + 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 index f0112c98f8..e4befed86d 100644 --- a/src/library-authoring/create-library/data/apiHooks.test.tsx +++ b/src/library-authoring/create-library/data/apiHooks.test.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { QueryClientProvider } from '@tanstack/react-query'; import { renderHook, waitFor } from '@testing-library/react'; import { mockContentLibrary } from '@src/library-authoring/data/api.mocks'; @@ -14,15 +14,7 @@ import { import { LibraryRestoreStatus } from './restoreConstants'; mockContentLibrary.applyMock(); -const { axiosMock } = initializeMocks(); - -const queryClient = new QueryClient({ - defaultOptions: { - queries: { - retry: false, - }, - }, -}); +const { axiosMock, queryClient } = initializeMocks(); const wrapper = ({ children }: { children: React.ReactNode }) => ( @@ -33,9 +25,6 @@ const wrapper = ({ children }: { children: React.ReactNode }) => ( describe('create library apiHooks', () => { beforeEach(() => { queryClient.clear(); - }); - - afterEach(() => { axiosMock.reset(); }); @@ -80,7 +69,7 @@ describe('create library apiHooks', () => { describe('useCreateLibraryRestore', () => { it('should restore library from file', async () => { const file = new File(['test content'], 'test.tar.gz', { type: 'application/gzip' }); - const expectedResult = { task_id: 'test-task-id' }; + const expectedResult = { taskId: 'test-task-id' }; axiosMock.onPost('http://localhost:18010/api/libraries/v2/restore/').reply(200, expectedResult); @@ -110,27 +99,27 @@ describe('create library apiHooks', () => { const expectedResult = { state: LibraryRestoreStatus.Succeeded, result: { - learning_package_id: 123, + learningPackageId: 123, title: 'Test Library', org: 'test-org', slug: 'test-library', key: 'lib:test-org:test-library', - archive_key: 'archive-key', + archiveKey: 'archive-key', containers: 1, components: 5, collections: 2, sections: 1, subsections: 1, units: 1, - created_on_server: '2024-01-01T00:00:00Z', - created_at: '2024-01-01T00:00:00Z', - created_by: { + createdOnServer: '2024-01-01T00:00:00Z', + createdAt: '2024-01-01T00:00:00Z', + createdBy: { username: 'testuser', email: 'test@example.com', }, }, error: null, - error_log: null, + errorLog: null, }; axiosMock.onGet(`http://localhost:18010/api/libraries/v2/restore/?task_id=${taskId}`).reply(200, expectedResult); @@ -165,6 +154,13 @@ describe('create library apiHooks', () => { 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 }); @@ -173,7 +169,7 @@ describe('create library apiHooks', () => { expect(result.current.isLoading).toBeFalsy(); }); - expect(result.current.data).toEqual(pendingResult); + expect(result.current.data).toEqual(expectedResult); expect(axiosMock.history.get[0].url).toEqual(`http://localhost:18010/api/libraries/v2/restore/?task_id=${taskId}`); }); @@ -186,6 +182,13 @@ describe('create library apiHooks', () => { 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 }); @@ -194,7 +197,7 @@ describe('create library apiHooks', () => { expect(result.current.isLoading).toBeFalsy(); }); - expect(result.current.data).toEqual(failedResult); + expect(result.current.data).toEqual(expectedResult); 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/restoreConstants.ts b/src/library-authoring/create-library/data/restoreConstants.ts index 5643953fbd..cf60a382f3 100644 --- a/src/library-authoring/create-library/data/restoreConstants.ts +++ b/src/library-authoring/create-library/data/restoreConstants.ts @@ -1,23 +1,23 @@ export interface CreateLibraryRestoreResponse { - task_id: string; + taskId: string; } export interface LibraryRestoreResult { - learning_package_id: number; + learningPackageId: number; title: string; org: string; slug: string; key: string; - archive_key: string; + archiveKey: string; containers: number; components: number; collections: number; sections: number; subsections: number; units: number; - created_on_server: string; - created_at: string; - created_by: { + createdOnServer: string; + createdAt: string; + createdBy: { username: string; email: string; }; @@ -27,7 +27,7 @@ export interface GetLibraryRestoreStatusResponse { state: LibraryRestoreStatus; result: LibraryRestoreResult | null; error: string | null; - error_log: string | null; + errorLog: string | null; } export enum LibraryRestoreStatus { From bb605a6ea6acbb3e9788e3f1fedfefe8327dd25c Mon Sep 17 00:00:00 2001 From: javier ontiveros Date: Wed, 29 Oct 2025 14:29:38 -0600 Subject: [PATCH 17/18] chore: removed old comment --- src/library-authoring/create-library/CreateLibrary.test.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/library-authoring/create-library/CreateLibrary.test.tsx b/src/library-authoring/create-library/CreateLibrary.test.tsx index 509e2ee931..f9762e5969 100644 --- a/src/library-authoring/create-library/CreateLibrary.test.tsx +++ b/src/library-authoring/create-library/CreateLibrary.test.tsx @@ -33,7 +33,6 @@ jest.mock('@src/generic/data/apiHooks', () => ({ }), })); -// Mock restore hooks const mockRestoreMutate = jest.fn(); let mockRestoreStatusData: any = {}; let mockRestoreMutationError: any = null; From d4b697e4f8ec3371406c8660361fbf1498c30779 Mon Sep 17 00:00:00 2001 From: javier ontiveros Date: Wed, 29 Oct 2025 15:27:01 -0600 Subject: [PATCH 18/18] chore: updated file types to only zip --- src/library-authoring/create-library/data/restoreConstants.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/library-authoring/create-library/data/restoreConstants.ts b/src/library-authoring/create-library/data/restoreConstants.ts index cf60a382f3..323821e631 100644 --- a/src/library-authoring/create-library/data/restoreConstants.ts +++ b/src/library-authoring/create-library/data/restoreConstants.ts @@ -42,9 +42,7 @@ export const libraryRestoreQueryKeys = { restoreMutation: () => [...libraryRestoreQueryKeys.all, 'create-restore'], }; -export const VALID_ARCHIVE_EXTENSIONS = ['.zip', '.tar.gz', '.tar']; +export const VALID_ARCHIVE_EXTENSIONS = ['.zip']; export const DROPZONE_ACCEPT_TYPES = { 'application/zip': ['.zip'], - 'application/gzip': ['.tar.gz'], - 'application/x-tar': ['.tar'], };