diff --git a/package-lock.json b/package-lock.json index 107c6f581..728a799bc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,7 @@ "@edx/frontend-component-footer": "^12.3.0", "@edx/frontend-component-header": "^4.7.0", "@edx/frontend-enterprise-hotjar": "^2.0.0", - "@edx/frontend-lib-content-components": "1.176.4", + "@edx/frontend-lib-content-components": "^1.177.6", "@edx/frontend-platform": "5.6.1", "@edx/paragon": "^21.5.6", "@fortawesome/fontawesome-svg-core": "1.2.36", @@ -2762,9 +2762,9 @@ } }, "node_modules/@edx/frontend-lib-content-components": { - "version": "1.176.4", - "resolved": "https://registry.npmjs.org/@edx/frontend-lib-content-components/-/frontend-lib-content-components-1.176.4.tgz", - "integrity": "sha512-scxJWs2nVnsUNno5CFHQWIrROSp3m/8b6dxBza6fKaX78Xm6TRTYpnpVM+uMw6XffJRY1NzUKGyT7AbF+36jvA==", + "version": "1.177.6", + "resolved": "https://registry.npmjs.org/@edx/frontend-lib-content-components/-/frontend-lib-content-components-1.177.6.tgz", + "integrity": "sha512-skR0QgWYtkWAQEsfxR4dDAiHIPI7yfWckwmj9oJEzuy9VNUYwcn2tXRe6DupuwVHULnR/60pFAHaYINvtCP0tg==", "dependencies": { "@codemirror/lang-html": "^6.0.0", "@codemirror/lang-xml": "^6.0.0", diff --git a/package.json b/package.json index f6df2ec00..81d1346c6 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "@edx/frontend-component-footer": "^12.3.0", "@edx/frontend-component-header": "^4.7.0", "@edx/frontend-enterprise-hotjar": "^2.0.0", - "@edx/frontend-lib-content-components": "1.176.4", + "@edx/frontend-lib-content-components": "^1.177.6", "@edx/frontend-platform": "5.6.1", "@edx/paragon": "^21.5.6", "@fortawesome/fontawesome-svg-core": "1.2.36", diff --git a/src/content-tags-drawer/data/api.js b/src/content-tags-drawer/data/api.js index 562bfaa1e..a6082b33d 100644 --- a/src/content-tags-drawer/data/api.js +++ b/src/content-tags-drawer/data/api.js @@ -12,7 +12,7 @@ export const getContentDataApiUrl = (contentId) => new URL(`/xblock/outline/${co * @param {number} taxonomyId The id of the taxonomy to fetch tags for * @param {string} fullPathProvided Optional param that contains the full URL to fetch data * If provided, we use it instead of generating the URL. This is usually for fetching subTags - * @returns {Promise} + * @returns {Promise} */ export async function getTaxonomyTagsData(taxonomyId, fullPathProvided) { const { data } = await getAuthenticatedHttpClient().get( @@ -24,7 +24,7 @@ export async function getTaxonomyTagsData(taxonomyId, fullPathProvided) { /** * Get the tags that are applied to the content object * @param {string} contentId The id of the content object to fetch the applied tags for - * @returns {Promise} + * @returns {Promise} */ export async function getContentTaxonomyTagsData(contentId) { const { data } = await getAuthenticatedHttpClient().get(getContentTaxonomyTagsApiUrl(contentId)); @@ -34,7 +34,7 @@ export async function getContentTaxonomyTagsData(contentId) { /** * Fetch meta data (eg: display_name) about the content object (unit/compoenent) * @param {string} contentId The id of the content object (unit/component) - * @returns {Promise} + * @returns {Promise} */ export async function getContentData(contentId) { const { data } = await getAuthenticatedHttpClient().get(getContentDataApiUrl(contentId)); diff --git a/src/content-tags-drawer/data/apiHooks.jsx b/src/content-tags-drawer/data/apiHooks.jsx index df5f49beb..97ffde2fe 100644 --- a/src/content-tags-drawer/data/apiHooks.jsx +++ b/src/content-tags-drawer/data/apiHooks.jsx @@ -12,7 +12,6 @@ import { * @param {number} taxonomyId The id of the taxonomy to fetch tags for * @param {string} fullPathProvided Optional param that contains the full URL to fetch data * If provided, we use it instead of generating the URL. This is usually for fetching subTags - * @returns {import("@tanstack/react-query").UseQueryResult} */ export const useTaxonomyTagsData = (taxonomyId, fullPathProvided) => ( useQuery({ @@ -24,7 +23,6 @@ export const useTaxonomyTagsData = (taxonomyId, fullPathProvided) => ( /** * Builds the query to get the taxonomy tags applied to the content object * @param {string} contentId The id of the content object to fetch the applied tags for - * @returns {import("@tanstack/react-query").UseQueryResult} */ export const useContentTaxonomyTagsData = (contentId) => ( useQuery({ @@ -36,7 +34,6 @@ export const useContentTaxonomyTagsData = (contentId) => ( /** * Builds the query to get meta data about the content object * @param {string} contentId The id of the content object (unit/component) - * @returns {import("@tanstack/react-query").UseQueryResult} */ export const useContentData = (contentId) => ( useQuery({ diff --git a/src/content-tags-drawer/data/types.mjs b/src/content-tags-drawer/data/types.mjs index 00b3fefd4..c16cb45ea 100644 --- a/src/content-tags-drawer/data/types.mjs +++ b/src/content-tags-drawer/data/types.mjs @@ -100,8 +100,3 @@ * @property {TaxonomyTagData[]} results */ -/** - * @typedef {Object} UseQueryResult - * @property {Object} data - * @property {string} status - */ diff --git a/src/data/services/ExamsApiService.js b/src/data/services/ExamsApiService.js index 10a6a7526..aa438444d 100644 --- a/src/data/services/ExamsApiService.js +++ b/src/data/services/ExamsApiService.js @@ -1,5 +1,6 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { getConfig } from '@edx/frontend-platform'; +import { convertObjectToSnakeCase } from '../../utils'; class ExamsApiService { static isAvailable() { @@ -26,8 +27,9 @@ class ExamsApiService { } static saveCourseExamConfiguration(courseId, dataToSave) { + const snakecaseDataToSave = convertObjectToSnakeCase(dataToSave, true); const apiClient = getAuthenticatedHttpClient(); - return apiClient.patch(this.getExamConfigurationUrl(courseId), dataToSave); + return apiClient.patch(this.getExamConfigurationUrl(courseId), snakecaseDataToSave); } } diff --git a/src/files-and-videos/generic/FileTable.jsx b/src/files-and-videos/generic/FileTable.jsx index d35e30842..f50eae6b5 100644 --- a/src/files-and-videos/generic/FileTable.jsx +++ b/src/files-and-videos/generic/FileTable.jsx @@ -57,6 +57,7 @@ const FileTable = ({ }; const [currentView, setCurrentView] = useState(defaultVal); const [isDeleteOpen, setDeleteOpen, setDeleteClose] = useToggle(false); + const [isDownloadOpen, setDownloadOpen, setDownloadClose] = useToggle(false); const [isAssetInfoOpen, openAssetInfo, closeAssetinfo] = useToggle(false); const [isAddOpen, setAddOpen, setAddClose] = useToggle(false); const [selectedRows, setSelectedRows] = useState([]); @@ -114,6 +115,8 @@ const FileTable = ({ const handleBulkDownload = useCallback(async (selectedFlatRows) => { handleErrorReset({ errorType: 'download' }); + setSelectedRows(selectedFlatRows); + setDownloadOpen(); handleDownloadFile(selectedFlatRows); }, []); @@ -240,6 +243,14 @@ const FileTable = ({ setSelectedRows={setSelectedRows} fileType={fileType} /> + {!isEmpty(selectedRows) && ( diff --git a/src/files-and-videos/generic/messages.js b/src/files-and-videos/generic/messages.js index ba62f2b2d..6b6054dae 100644 --- a/src/files-and-videos/generic/messages.js +++ b/src/files-and-videos/generic/messages.js @@ -17,6 +17,10 @@ const messages = defineMessages({ id: 'course-authoring.files-and-upload.apiStatus.deletingAction.message', defaultMessage: 'Deleting', }, + apiStatusDownloadingAction: { + id: 'course-authoring.files-and-upload.apiStatus.downloadingAction.message', + defaultMessage: 'Downloading', + }, fileSizeError: { id: 'course-authoring.files-and-upload.addFiles.error.fileSize', defaultMessage: 'Uploaded file(s) must be 20 MB or less. Please resize file(s) and try again.', diff --git a/src/files-and-videos/generic/table-components/table-custom-columns/MoreInfoColumn.jsx b/src/files-and-videos/generic/table-components/table-custom-columns/MoreInfoColumn.jsx index b4e740aa4..7e359ce30 100644 --- a/src/files-and-videos/generic/table-components/table-custom-columns/MoreInfoColumn.jsx +++ b/src/files-and-videos/generic/table-components/table-custom-columns/MoreInfoColumn.jsx @@ -33,6 +33,7 @@ const MoreInfoColumn = ({ id, wrapperType, displayName, + downloadLink, } = row.original; return ( <> @@ -99,7 +100,7 @@ const MoreInfoColumn = ({ as={Button} variant="tertiary" onClick={() => handleBulkDownload( - [{ original: { id, displayName } }], + [{ original: { id, displayName, downloadLink } }], )} > {intl.formatMessage(messages.downloadTitle)} diff --git a/src/files-and-videos/videos-page/VideosPage.jsx b/src/files-and-videos/videos-page/VideosPage.jsx index d2c3b6241..79c9ff1b4 100644 --- a/src/files-and-videos/videos-page/VideosPage.jsx +++ b/src/files-and-videos/videos-page/VideosPage.jsx @@ -82,7 +82,7 @@ const VideosPage = ({ const handleAddFile = (file) => dispatch(addVideoFile(courseId, file)); const handleDeleteFile = (id) => dispatch(deleteVideoFile(courseId, id)); - const handleDownloadFile = (selectedRows) => dispatch(fetchVideoDownload({ selectedRows })); + const handleDownloadFile = (selectedRows) => dispatch(fetchVideoDownload({ selectedRows, courseId })); const handleUsagePaths = (video) => dispatch(getUsagePaths({ video, courseId })); const handleErrorReset = (error) => dispatch(resetErrors(error)); const handleFileOrder = ({ newFileIdOrder, sortType }) => { diff --git a/src/files-and-videos/videos-page/VideosPage.test.jsx b/src/files-and-videos/videos-page/VideosPage.test.jsx index d431aa7fc..05e7f32fa 100644 --- a/src/files-and-videos/videos-page/VideosPage.test.jsx +++ b/src/files-and-videos/videos-page/VideosPage.test.jsx @@ -36,7 +36,7 @@ import { addVideoThumbnail, fetchVideoDownload, } from './data/thunks'; -import { getVideosUrl, getCoursVideosApiUrl, getApiBaseUrl } from './data/api'; +import { getVideosUrl, getCourseVideosApiUrl, getApiBaseUrl } from './data/api'; import videoMessages from './messages'; import messages from '../generic/messages'; @@ -127,8 +127,8 @@ describe('FilesAndUploads', () => { const mockFetchResponse = Promise.resolve(mockResponseData); global.fetch = jest.fn().mockImplementation(() => mockFetchResponse); - axiosMock.onPost(getCoursVideosApiUrl(courseId)).reply(204, generateNewVideoApiResponse()); - axiosMock.onGet(getCoursVideosApiUrl(courseId)).reply(200, generateAddVideoApiResponse()); + axiosMock.onPost(getCourseVideosApiUrl(courseId)).reply(204, generateNewVideoApiResponse()); + axiosMock.onGet(getCourseVideosApiUrl(courseId)).reply(200, generateAddVideoApiResponse()); Object.defineProperty(dropzone, 'files', { value: [file], @@ -223,8 +223,8 @@ describe('FilesAndUploads', () => { const mockFetchResponse = Promise.resolve(mockResponseData); global.fetch = jest.fn().mockImplementation(() => mockFetchResponse); - axiosMock.onPost(getCoursVideosApiUrl(courseId)).reply(204, generateNewVideoApiResponse()); - axiosMock.onGet(getCoursVideosApiUrl(courseId)).reply(200, generateAddVideoApiResponse()); + axiosMock.onPost(getCourseVideosApiUrl(courseId)).reply(204, generateNewVideoApiResponse()); + axiosMock.onGet(getCourseVideosApiUrl(courseId)).reply(200, generateAddVideoApiResponse()); const addFilesButton = screen.getAllByLabelText('file-input')[3]; await act(async () => { @@ -263,7 +263,7 @@ describe('FilesAndUploads', () => { const deleteButton = screen.getByText(messages.deleteTitle.defaultMessage).closest('a'); expect(deleteButton).not.toHaveClass('disabled'); - axiosMock.onDelete(`${getCoursVideosApiUrl(courseId)}/mOckID1`).reply(204); + axiosMock.onDelete(`${getCourseVideosApiUrl(courseId)}/mOckID1`).reply(204); fireEvent.click(deleteButton); expect(screen.getByText('Delete video(s) confirmation')).toBeVisible(); @@ -322,8 +322,7 @@ describe('FilesAndUploads', () => { await waitFor(() => { fireEvent.click(actionsButton); }); - axiosMock.onGet(`${getVideosUrl(courseId)}/mOckID1`).reply(200, { download_link: 'http://download.org' }); - axiosMock.onGet(`${getVideosUrl(courseId)}/mOckID5`).reply(200, { download_link: 'http://download.org' }); + axiosMock.onPut(`${getVideosUrl(courseId)}/download`).reply(200, null); const downloadButton = screen.getByText(messages.downloadTitle.defaultMessage).closest('a'); expect(downloadButton).not.toHaveClass('disabled'); @@ -533,7 +532,6 @@ describe('FilesAndUploads', () => { const videoMenuButton = screen.getByTestId('file-menu-dropdown-mOckID1'); expect(videoMenuButton).toBeVisible(); - axiosMock.onGet(`${getVideosUrl(courseId)}/mOckID1`).reply(200, { download_link: 'test' }); await waitFor(() => { fireEvent.click(within(videoMenuButton).getByLabelText('file-menu-toggle')); fireEvent.click(screen.getByText('Download')); @@ -553,7 +551,7 @@ describe('FilesAndUploads', () => { expect(fileMenuButton).toBeVisible(); await waitFor(() => { - axiosMock.onDelete(`${getCoursVideosApiUrl(courseId)}/mOckID1`).reply(204); + axiosMock.onDelete(`${getCourseVideosApiUrl(courseId)}/mOckID1`).reply(204); fireEvent.click(within(fileMenuButton).getByLabelText('file-menu-toggle')); fireEvent.click(screen.getByTestId('open-delete-confirmation-button')); expect(screen.getByText('Delete video(s) confirmation')).toBeVisible(); @@ -575,7 +573,7 @@ describe('FilesAndUploads', () => { const errorMessage = 'File download.png exceeds maximum size of 5 GB.'; renderComponent(); await mockStore(RequestStatus.SUCCESSFUL); - axiosMock.onPost(getCoursVideosApiUrl(courseId)).reply(413, { error: errorMessage }); + axiosMock.onPost(getCourseVideosApiUrl(courseId)).reply(413, { error: errorMessage }); const addFilesButton = screen.getAllByLabelText('file-input')[3]; await act(async () => { userEvent.upload(addFilesButton, file); @@ -590,7 +588,7 @@ describe('FilesAndUploads', () => { it('404 add file should show error', async () => { renderComponent(); await mockStore(RequestStatus.SUCCESSFUL); - axiosMock.onPost(getCoursVideosApiUrl(courseId)).reply(404); + axiosMock.onPost(getCourseVideosApiUrl(courseId)).reply(404); const addFilesButton = screen.getAllByLabelText('file-input')[3]; await act(async () => { userEvent.upload(addFilesButton, file); @@ -625,8 +623,8 @@ describe('FilesAndUploads', () => { const mockFetchResponse = Promise.reject(mockResponseData); global.fetch = jest.fn().mockImplementation(() => mockFetchResponse); - axiosMock.onPost(getCoursVideosApiUrl(courseId)).reply(204, generateNewVideoApiResponse()); - axiosMock.onGet(getCoursVideosApiUrl(courseId)).reply(200, generateAddVideoApiResponse()); + axiosMock.onPost(getCourseVideosApiUrl(courseId)).reply(204, generateNewVideoApiResponse()); + axiosMock.onGet(getCourseVideosApiUrl(courseId)).reply(200, generateAddVideoApiResponse()); const addFilesButton = screen.getAllByLabelText('file-input')[3]; await act(async () => { userEvent.upload(addFilesButton, file); @@ -647,7 +645,7 @@ describe('FilesAndUploads', () => { expect(videoMenuButton).toBeVisible(); await waitFor(() => { - axiosMock.onDelete(`${getCoursVideosApiUrl(courseId)}/mOckID1`).reply(404); + axiosMock.onDelete(`${getCourseVideosApiUrl(courseId)}/mOckID1`).reply(404); fireEvent.click(within(videoMenuButton).getByLabelText('file-menu-toggle')); fireEvent.click(screen.getByTestId('open-delete-confirmation-button')); expect(screen.getByText('Delete video(s) confirmation')).toBeVisible(); @@ -701,9 +699,11 @@ describe('FilesAndUploads', () => { const downloadButton = screen.getByText(messages.downloadTitle.defaultMessage).closest('a'); expect(downloadButton).not.toHaveClass('disabled'); + axiosMock.onPut(`${getVideosUrl(courseId)}/download`).reply(404); + await waitFor(() => { fireEvent.click(downloadButton); - executeThunk(fetchVideoDownload([{ original: { displayName: 'mOckID1', id: '2' } }]), store.dispatch); + executeThunk(fetchVideoDownload([{ original: { displayName: 'mOckID1', id: '2', downloadLink: 'test' } }]), store.dispatch); }); const updateStatus = store.getState().videos.updatingStatus; diff --git a/src/files-and-videos/videos-page/data/api.js b/src/files-and-videos/videos-page/data/api.js index 3cdd3e433..a757dcf9a 100644 --- a/src/files-and-videos/videos-page/data/api.js +++ b/src/files-and-videos/videos-page/data/api.js @@ -1,8 +1,7 @@ -/* eslint-disable import/prefer-default-export */ +import saveAs from 'file-saver'; import { camelCaseObject, ensureConfig, getConfig } from '@edx/frontend-platform'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; -import saveAs from 'file-saver'; import { isEmpty } from 'lodash'; ensureConfig([ @@ -11,7 +10,7 @@ ensureConfig([ export const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL; export const getVideosUrl = (courseId) => `${getApiBaseUrl()}/api/contentstore/v1/videos/${courseId}`; -export const getCoursVideosApiUrl = (courseId) => `${getApiBaseUrl()}/videos/${courseId}`; +export const getCourseVideosApiUrl = (courseId) => `${getApiBaseUrl()}/videos/${courseId}`; /** * Fetches the course custom pages for provided course @@ -39,7 +38,7 @@ export async function getVideos(courseId) { */ export async function fetchVideoList(courseId) { const { data } = await getAuthenticatedHttpClient() - .get(getCoursVideosApiUrl(courseId)); + .get(getCourseVideosApiUrl(courseId)); return camelCaseObject(data); } @@ -75,27 +74,48 @@ export async function uploadTranscript({ await getAuthenticatedHttpClient().post(`${getApiBaseUrl()}${apiUrl}`, formData); } -export async function getDownload(selectedRows) { +export async function getDownload(selectedRows, courseId) { const downloadErrors = []; - if (selectedRows?.length > 0) { - await Promise.allSettled( - selectedRows.map(async row => { - try { - const video = row.original; - const { downloadLink } = video; - if (!isEmpty(downloadLink)) { - saveAs(downloadLink, video.displayName); - } else { - downloadErrors.push(`Cannot find download file for ${video?.displayName}.`); - } - } catch (error) { - downloadErrors.push('Failed to download video.'); - } - }), - ); + let file; + let filename; + if (selectedRows?.length > 1) { + const downloadLinks = selectedRows.map(row => { + const video = row.original; + try { + const url = video.downloadLink; + const name = video.displayName; + return { url, name }; + } catch (error) { + downloadErrors.push(`Cannot find download file for ${video?.displayName || 'video'}.`); + return null; + } + }); + if (!isEmpty(downloadLinks)) { + const json = { files: downloadLinks }; + const { data } = await getAuthenticatedHttpClient() + .put(`${getVideosUrl(courseId)}/download`, json, { responseType: 'arraybuffer' }); + + const date = new Date().toString(); + filename = `${courseId}-videos-${date}`; + file = new Blob([data], { type: 'application/zip' }); + saveAs(file, filename); + } + } else if (selectedRows?.length === 1) { + try { + const video = selectedRows[0].original; + const { downloadLink } = video; + if (!isEmpty(downloadLink)) { + saveAs(downloadLink, video.displayName); + } else { + downloadErrors.push(`Cannot find download file for ${video?.displayName}.`); + } + } catch (error) { + downloadErrors.push('Failed to download video.'); + } } else { downloadErrors.push('No files were selected to download.'); } + return downloadErrors; } @@ -117,7 +137,7 @@ export async function getVideoUsagePaths({ courseId, videoId }) { */ export async function deleteVideo(courseId, videoId) { await getAuthenticatedHttpClient() - .delete(`${getCoursVideosApiUrl(courseId)}/${videoId}`); + .delete(`${getCourseVideosApiUrl(courseId)}/${videoId}`); } /** @@ -144,7 +164,7 @@ export async function addVideo(courseId, file) { }; const { data } = await getAuthenticatedHttpClient() - .post(getCoursVideosApiUrl(courseId), postJson); + .post(getCourseVideosApiUrl(courseId), postJson); return camelCaseObject(data); } @@ -166,7 +186,7 @@ export async function uploadVideo( }) .then(async () => { await getAuthenticatedHttpClient() - .post(getCoursVideosApiUrl(courseId), [{ + .post(getCourseVideosApiUrl(courseId), [{ edxVideoId, message: 'Upload completed', status: 'upload_completed', @@ -175,7 +195,7 @@ export async function uploadVideo( .catch(async () => { uploadErrors.push(`Failed to upload ${uploadFile.name} to server.`); await getAuthenticatedHttpClient() - .post(getCoursVideosApiUrl(courseId), [{ + .post(getCourseVideosApiUrl(courseId), [{ edxVideoId, message: 'Upload failed', status: 'upload_failed', diff --git a/src/files-and-videos/videos-page/data/api.test.js b/src/files-and-videos/videos-page/data/api.test.js index 7604a7a08..134ec5b25 100644 --- a/src/files-and-videos/videos-page/data/api.test.js +++ b/src/files-and-videos/videos-page/data/api.test.js @@ -1,9 +1,26 @@ -import { getDownload } from './api'; import 'file-saver'; +import MockAdapter from 'axios-mock-adapter'; +import { initializeMockApp } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; + +import { getDownload, getVideosUrl } from './api'; jest.mock('file-saver'); +let axiosMock; + describe('api.js', () => { + beforeEach(() => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: true, + roles: [], + }, + }); + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + }); describe('getDownload', () => { describe('selectedRows length is undefined or less than zero', () => { it('should return with no files selected error if selectedRows is empty', async () => { @@ -18,36 +35,39 @@ describe('api.js', () => { }); }); describe('selectedRows length is greater than one', () => { + beforeEach(() => { + axiosMock.onPut(`${getVideosUrl('SoMEiD')}/download`).reply(200, null); + }); it('should not throw error when blob returns null', async () => { const expected = []; const actual = await getDownload([ { original: { displayName: 'test1', downloadLink: 'test1.com' } }, { original: { displayName: 'test2', id: '2', downloadLink: 'test2.com' } }, - ]); + ], 'SoMEiD'); expect(actual).toEqual(expected); }); it('should return error if row does not contain .original attribute', async () => { - const expected = ['Failed to download video.']; + const expected = ['Cannot find download file for video.']; const actual = await getDownload([ { asset: { displayName: 'test1', id: '1' } }, { original: { displayName: 'test2', id: '2', downloadLink: 'test1.com' } }, - ]); - expect(actual).toEqual(expected); - }); - it('should return error if original does not contain .downloadLink attribute', async () => { - const expected = ['Cannot find download file for test2.']; - const actual = await getDownload([ - { original: { displayName: 'test2', id: '2' } }, - ]); + ], 'SoMEiD'); expect(actual).toEqual(expected); }); }); describe('selectedRows length equals one', () => { + it('should return error if original does not contain .downloadLink attribute', async () => { + const expected = ['Cannot find download file for test2.']; + const actual = await getDownload([ + { original: { displayName: 'test2', id: '2' } }, + ], 'SoMEiD'); + expect(actual).toEqual(expected); + }); it('should return error if row does not contain .original ancestor', async () => { const expected = ['Failed to download video.']; const actual = await getDownload([ { asset: { displayName: 'test1', id: '1', download_link: 'test1.com' } }, - ]); + ], 'SoMEiD'); expect(actual).toEqual(expected); }); }); diff --git a/src/files-and-videos/videos-page/data/thunks.js b/src/files-and-videos/videos-page/data/thunks.js index 72719bc1e..df0bd214f 100644 --- a/src/files-and-videos/videos-page/data/thunks.js +++ b/src/files-and-videos/videos-page/data/thunks.js @@ -296,16 +296,20 @@ export function getUsagePaths({ video, courseId }) { }; } -export function fetchVideoDownload({ selectedRows }) { +export function fetchVideoDownload({ selectedRows, courseId }) { return async (dispatch) => { dispatch(updateEditStatus({ editType: 'download', status: RequestStatus.IN_PROGRESS })); - const errors = await getDownload(selectedRows); - if (isEmpty(errors)) { + try { + const errors = await getDownload(selectedRows, courseId); dispatch(updateEditStatus({ editType: 'download', status: RequestStatus.SUCCESSFUL })); - } else { - errors.forEach(error => { - dispatch(updateErrors({ error: 'download', message: error })); - }); + if (!isEmpty(errors)) { + errors.forEach(error => { + dispatch(updateErrors({ error: 'download', message: error })); + }); + dispatch(updateEditStatus({ editType: 'download', status: RequestStatus.FAILED })); + } + } catch (error) { + dispatch(updateErrors({ error: 'download', message: 'Failed to download zip file of videos.' })); dispatch(updateEditStatus({ editType: 'download', status: RequestStatus.FAILED })); } }; diff --git a/src/import-page/data/api.js b/src/import-page/data/api.js index 17c68ab63..dda69970e 100644 --- a/src/import-page/data/api.js +++ b/src/import-page/data/api.js @@ -1,4 +1,3 @@ -/* eslint-disable import/prefer-default-export */ import { camelCaseObject, getConfig } from '@edx/frontend-platform'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; @@ -14,9 +13,42 @@ export const getImportStatusApiUrl = (courseId, fileName) => `${getApiBaseUrl()} * @returns {Promise} */ export async function startCourseImporting(courseId, fileData, requestConfig) { - const { data } = await getAuthenticatedHttpClient() - .post(postImportCourseApiUrl(courseId), { 'course-data': fileData }, { headers: { 'content-type': 'multipart/form-data' }, ...requestConfig }); - return camelCaseObject(data); + const chunkSize = 20 * 1000000; // 20 MB + const fileSize = fileData.size || 0; + const chunkLength = Math.ceil(fileSize / chunkSize); + let resp; + + const upload = async (blob, start, stop) => { + const contentRange = `bytes ${start}-${stop}/${fileSize}`; + const contentDisposition = `attachment; filename="${fileData.name}"`; + const headers = { + 'Content-Range': contentRange, + 'Content-Disposition': contentDisposition, + }; + const formData = new FormData(); + formData.append('course-data', blob, fileData.name); + const { data } = await getAuthenticatedHttpClient() + .post( + postImportCourseApiUrl(courseId), + formData, + { headers, ...requestConfig }, + ); + resp = camelCaseObject(data); + }; + + const chunkUpload = async (file, index) => { + const start = index * chunkSize; + const stop = start + chunkSize < fileSize ? start + chunkSize : fileSize; + const blob = file.slice(start, stop, file.type); + await upload(blob, start, stop - 1); + }; + + /* eslint-disable no-await-in-loop */ + for (let i = 0; i < chunkLength; i++) { + await chunkUpload(fileData, i); + } + + return resp; } /** diff --git a/src/import-page/data/api.test.jsx b/src/import-page/data/api.test.jsx index d0fd63d06..9aa50dac5 100644 --- a/src/import-page/data/api.test.jsx +++ b/src/import-page/data/api.test.jsx @@ -25,11 +25,11 @@ describe('API Functions', () => { }); it('should fetch status on start importing', async () => { + const file = new File(['(⌐□_□)'], 'download.tar.gz', { size: 20 }); const data = { importStatus: 1 }; axiosMock.onPost(postImportCourseApiUrl(courseId)).reply(200, data); - const result = await startCourseImporting(courseId); - + const result = await startCourseImporting(courseId, file); expect(axiosMock.history.post[0].url).toEqual(postImportCourseApiUrl(courseId)); expect(result).toEqual(data); }); diff --git a/src/import-page/import-sidebar/ImportSidebar.jsx b/src/import-page/import-sidebar/ImportSidebar.jsx index 3aba183d7..262d9e805 100644 --- a/src/import-page/import-sidebar/ImportSidebar.jsx +++ b/src/import-page/import-sidebar/ImportSidebar.jsx @@ -42,7 +42,6 @@ const ImportSidebar = ({ intl, courseId }) => { className="small" href={importLearnMoreUrl} target="_blank" - variant="outline-primary" > {intl.formatMessage(messages.learnMoreButtonTitle)} diff --git a/src/index.scss b/src/index.scss index 6b7a1f42a..2a3dc0679 100755 --- a/src/index.scss +++ b/src/index.scss @@ -18,7 +18,7 @@ @import "course-updates/CourseUpdates"; @import "export-page/CourseExportPage"; @import "import-page/CourseImportPage"; -@import "taxonomy/taxonomy-card/TaxonomyCard"; +@import "taxonomy"; @import "files-and-videos"; @import "content-tags-drawer/TagBubble"; @import "course-outline/CourseOutline"; diff --git a/src/pages-and-resources/proctoring/Settings.jsx b/src/pages-and-resources/proctoring/Settings.jsx index ecd38769d..d224b066d 100644 --- a/src/pages-and-resources/proctoring/Settings.jsx +++ b/src/pages-and-resources/proctoring/Settings.jsx @@ -28,7 +28,7 @@ const ProctoringSettings = ({ intl, onClose }) => { const initialFormValues = { enableProctoredExams: false, proctoringProvider: false, - proctortrackEscalationEmail: '', + escalationEmail: '', allowOptingOut: false, createZendeskTickets: false, }; @@ -44,7 +44,7 @@ const ProctoringSettings = ({ intl, onClose }) => { const [saveSuccess, setSaveSuccess] = useState(false); const [saveError, setSaveError] = useState(false); const [submissionInProgress, setSubmissionInProgress] = useState(false); - const [showProctortrackEscalationEmail, setShowProctortrackEscalationEmail] = useState(false); + const [showEscalationEmail, setShowEscalationEmail] = useState(false); const isEdxStaff = getAuthenticatedUser().administrator; const [formStatus, setFormStatus] = useState({ isValid: true, @@ -53,6 +53,15 @@ const ProctoringSettings = ({ intl, onClose }) => { const isMobile = useIsMobile(); const modalVariant = isMobile ? 'dark' : 'default'; + const isLtiProvider = (provider) => ( + ltiProctoringProviders.some(p => p.name === provider) + ); + + function getProviderDisplayLabel(provider) { + // if a display label exists for this provider return it + return ltiProctoringProviders.find(p => p.name === provider)?.verbose_name || provider; + } + const { courseId } = useContext(PagesAndResourcesContext); const appInfo = useModel('courseApps', 'proctoring'); const alertRef = React.createRef(); @@ -73,38 +82,36 @@ const ProctoringSettings = ({ intl, onClose }) => { if (value === 'proctortrack') { setFormValues({ ...newFormValues, createZendeskTickets: false }); - setShowProctortrackEscalationEmail(true); + setShowEscalationEmail(true); + } else if (value === 'software_secure') { + setFormValues({ ...newFormValues, createZendeskTickets: true }); + setShowEscalationEmail(false); + } else if (isLtiProvider(value)) { + setFormValues(newFormValues); + setShowEscalationEmail(true); } else { - if (value === 'software_secure') { - setFormValues({ ...newFormValues, createZendeskTickets: true }); - } else { - setFormValues(newFormValues); - } - - setShowProctortrackEscalationEmail(false); + setFormValues(newFormValues); + setShowEscalationEmail(false); } } else { setFormValues({ ...formValues, [name]: value }); } }; - function isLtiProvider(provider) { - return ltiProctoringProviders.some(p => p.name === provider); - } - - const setFocusToProctortrackEscalationEmailInput = () => { + const setFocusToEscalationEmailInput = () => { if (proctoringEscalationEmailInputRef && proctoringEscalationEmailInputRef.current) { proctoringEscalationEmailInputRef.current.focus(); } }; function postSettingsBackToServer() { - const providerIsLti = isLtiProvider(formValues.proctoringProvider); + const selectedProvider = formValues.proctoringProvider; + const isLtiProviderSelected = isLtiProvider(selectedProvider); const studioDataToPostBack = { proctored_exam_settings: { enable_proctored_exams: formValues.enableProctoredExams, // lti providers are managed outside edx-platform, lti_external indicates this - proctoring_provider: providerIsLti ? 'lti_external' : formValues.proctoringProvider, + proctoring_provider: isLtiProviderSelected ? 'lti_external' : selectedProvider, create_zendesk_tickets: formValues.createZendeskTickets, }, }; @@ -113,17 +120,23 @@ const ProctoringSettings = ({ intl, onClose }) => { } if (formValues.proctoringProvider === 'proctortrack') { - studioDataToPostBack.proctored_exam_settings.proctoring_escalation_email = formValues.proctortrackEscalationEmail === '' ? null : formValues.proctortrackEscalationEmail; + studioDataToPostBack.proctored_exam_settings.proctoring_escalation_email = formValues.escalationEmail === '' ? null : formValues.escalationEmail; } // only save back to exam service if necessary setSubmissionInProgress(true); + const saveOperations = [StudioApiService.saveProctoredExamSettingsData(courseId, studioDataToPostBack)]; if (allowLtiProviders && ExamsApiService.isAvailable()) { + const selectedEscalationEmail = formValues.escalationEmail; + saveOperations.push( ExamsApiService.saveCourseExamConfiguration( courseId, - { provider: providerIsLti ? formValues.proctoringProvider : null }, + { + provider: isLtiProviderSelected ? formValues.proctoringProvider : null, + escalationEmail: (isLtiProviderSelected && selectedEscalationEmail !== '') ? selectedEscalationEmail : null, + }, ), ); } @@ -141,20 +154,21 @@ const ProctoringSettings = ({ intl, onClose }) => { const handleSubmit = (event) => { event.preventDefault(); + const isLtiProviderSelected = isLtiProvider(formValues.proctoringProvider); if ( - formValues.proctoringProvider === 'proctortrack' - && !EmailValidator.validate(formValues.proctortrackEscalationEmail) - && !(formValues.proctortrackEscalationEmail === '' && !formValues.enableProctoredExams) + (formValues.proctoringProvider === 'proctortrack' || isLtiProviderSelected) + && !EmailValidator.validate(formValues.escalationEmail) + && !(formValues.escalationEmail === '' && !formValues.enableProctoredExams) ) { - if (formValues.proctortrackEscalationEmail === '') { - const errorMessage = intl.formatMessage(messages['authoring.proctoring.escalationemail.error.blank']); + if (formValues.escalationEmail === '') { + const errorMessage = intl.formatMessage(messages['authoring.proctoring.escalationemail.error.blank'], { proctoringProviderName: getProviderDisplayLabel(formValues.proctoringProvider) }); setFormStatus({ isValid: false, errors: { - formProctortrackEscalationEmail: { + formEscalationEmail: { dialogErrorMessage: ( - + {errorMessage} ), @@ -168,8 +182,8 @@ const ProctoringSettings = ({ intl, onClose }) => { setFormStatus({ isValid: false, errors: { - formProctortrackEscalationEmail: { - dialogErrorMessage: ({errorMessage}), + formEscalationEmail: { + dialogErrorMessage: ({errorMessage}), inputErrorMessage: errorMessage, }, }, @@ -178,7 +192,7 @@ const ProctoringSettings = ({ intl, onClose }) => { } else { postSettingsBackToServer(); const errors = { ...formStatus.errors }; - delete errors.formProctortrackEscalationEmail; + delete errors.formEscalationEmail; setFormStatus({ isValid: true, errors, @@ -202,11 +216,6 @@ const ProctoringSettings = ({ intl, onClose }) => { return markDisabled; } - function getProviderDisplayLabel(provider) { - // if a display label exists for this provider return it - return ltiProctoringProviders.find(p => p.name === provider)?.verbose_name || provider; - } - function getProctoringProviderOptions(providers) { return providers.map(provider => (