From e6ce05571faf7d75aa02f86a59d20c87e1e92a50 Mon Sep 17 00:00:00 2001 From: Jesper Hodge <19345795+jesperhodge@users.noreply.github.com> Date: Tue, 12 Dec 2023 18:23:26 -0500 Subject: [PATCH] Fix tinymce editor problems (#743) Internal issue: https://2u-internal.atlassian.net/servicedesk/customer/portal/9/CR-6328?created=true Reverted 6 merged PRs due to problems. scroll was not working on editors potential problems with editor content loading ------------------------------------------------------ * Revert "fix(deps): update dependency @edx/frontend-lib-content-components to v1.177.4 (#742)" This reverts commit cc40e9d6cbfe46b2b5d09b8001ba4f4858e2f48e. * Revert "feat: add escalation email field for LTI-based proctoring providers (#736)" This reverts commit 0f483dc4e148c8a6a3e2dac4bf97c6f1a0eb9b69. * Revert "fix: video downloads (#728)" This reverts commit c5abd21569ca9ace6c3154547b9e6dd3b1f9ac64. * Revert "fix: import api to chunk file (#734)" This reverts commit 6f7a9928479936b9367ce6788f5bc531956fb5d1. * Revert "feat: Taxonomy delete dialog (#684)" This reverts commit 1eff48915873f8867af96909bc9e9aea3c5dec20. * Revert "fix(deps): update dependency @edx/frontend-lib-content-components to v1.177.1 (#727)" This reverts commit dcabb77218ace1b39e143e498a82070c89776155. --- package-lock.json | 8 +- package.json | 2 +- src/content-tags-drawer/data/api.js | 6 +- src/content-tags-drawer/data/apiHooks.jsx | 3 + src/content-tags-drawer/data/types.mjs | 5 + src/data/services/ExamsApiService.js | 4 +- src/files-and-videos/generic/FileTable.jsx | 11 - src/files-and-videos/generic/messages.js | 4 - .../table-custom-columns/MoreInfoColumn.jsx | 3 +- .../videos-page/VideosPage.jsx | 2 +- .../videos-page/VideosPage.test.jsx | 32 +- src/files-and-videos/videos-page/data/api.js | 70 ++-- .../videos-page/data/api.test.js | 36 +- .../videos-page/data/thunks.js | 18 +- src/import-page/data/api.js | 40 +-- src/import-page/data/api.test.jsx | 4 +- .../import-sidebar/ImportSidebar.jsx | 1 + src/index.scss | 2 +- .../proctoring/Settings.jsx | 128 +++---- .../proctoring/Settings.test.jsx | 329 ++++++++---------- .../proctoring/messages.js | 6 +- src/taxonomy/TaxonomyLayout.jsx | 35 +- src/taxonomy/TaxonomyLayout.test.jsx | 21 +- src/taxonomy/TaxonomyListPage.jsx | 26 +- src/taxonomy/TaxonomyListPage.test.jsx | 63 +--- src/taxonomy/common/context.js | 7 - src/taxonomy/data/api.js | 12 +- src/taxonomy/data/api.test.js | 9 - src/taxonomy/data/apiHooks.jsx | 23 +- src/taxonomy/data/apiHooks.test.jsx | 34 +- src/taxonomy/data/types.mjs | 11 +- src/taxonomy/delete-dialog/DeleteDialog.scss | 6 - src/taxonomy/delete-dialog/index.jsx | 99 ------ src/taxonomy/delete-dialog/messages.js | 30 -- src/taxonomy/export-modal/index.jsx | 4 +- src/taxonomy/index.scss | 2 - src/taxonomy/messages.js | 4 - .../taxonomy-card/TaxonomyCard.test.jsx | 56 +-- .../taxonomy-card/TaxonomyCardMenu.jsx | 29 +- src/taxonomy/taxonomy-card/index.jsx | 46 +-- src/taxonomy/taxonomy-card/messages.js | 4 - .../taxonomy-detail/TaxonomyDetailMenu.jsx | 18 +- .../taxonomy-detail/TaxonomyDetailPage.jsx | 80 ++--- .../TaxonomyDetailPage.test.jsx | 114 +----- src/taxonomy/taxonomy-detail/messages.js | 4 - 45 files changed, 400 insertions(+), 1051 deletions(-) delete mode 100644 src/taxonomy/common/context.js delete mode 100644 src/taxonomy/delete-dialog/DeleteDialog.scss delete mode 100644 src/taxonomy/delete-dialog/index.jsx delete mode 100644 src/taxonomy/delete-dialog/messages.js delete mode 100644 src/taxonomy/index.scss diff --git a/package-lock.json b/package-lock.json index 563b815be..107c6f581 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.177.4", + "@edx/frontend-lib-content-components": "1.176.4", "@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.177.4", - "resolved": "https://registry.npmjs.org/@edx/frontend-lib-content-components/-/frontend-lib-content-components-1.177.4.tgz", - "integrity": "sha512-+co5jsYFUC62vYoXfoPR8+GPa8zh+cuh/vno2PHqkI4t+I1Tw8kDYlBz6jmGiRrrWe4NfNYoO3dy6KCFZ1ZcpA==", + "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==", "dependencies": { "@codemirror/lang-html": "^6.0.0", "@codemirror/lang-xml": "^6.0.0", diff --git a/package.json b/package.json index e303fe2ef..f6df2ec00 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.177.4", + "@edx/frontend-lib-content-components": "1.176.4", "@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 a6082b33d..562bfaa1e 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 97ffde2fe..df5f49beb 100644 --- a/src/content-tags-drawer/data/apiHooks.jsx +++ b/src/content-tags-drawer/data/apiHooks.jsx @@ -12,6 +12,7 @@ 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({ @@ -23,6 +24,7 @@ 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({ @@ -34,6 +36,7 @@ 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 c16cb45ea..00b3fefd4 100644 --- a/src/content-tags-drawer/data/types.mjs +++ b/src/content-tags-drawer/data/types.mjs @@ -100,3 +100,8 @@ * @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 aa438444d..10a6a7526 100644 --- a/src/data/services/ExamsApiService.js +++ b/src/data/services/ExamsApiService.js @@ -1,6 +1,5 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { getConfig } from '@edx/frontend-platform'; -import { convertObjectToSnakeCase } from '../../utils'; class ExamsApiService { static isAvailable() { @@ -27,9 +26,8 @@ class ExamsApiService { } static saveCourseExamConfiguration(courseId, dataToSave) { - const snakecaseDataToSave = convertObjectToSnakeCase(dataToSave, true); const apiClient = getAuthenticatedHttpClient(); - return apiClient.patch(this.getExamConfigurationUrl(courseId), snakecaseDataToSave); + return apiClient.patch(this.getExamConfigurationUrl(courseId), dataToSave); } } diff --git a/src/files-and-videos/generic/FileTable.jsx b/src/files-and-videos/generic/FileTable.jsx index f50eae6b5..d35e30842 100644 --- a/src/files-and-videos/generic/FileTable.jsx +++ b/src/files-and-videos/generic/FileTable.jsx @@ -57,7 +57,6 @@ 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([]); @@ -115,8 +114,6 @@ const FileTable = ({ const handleBulkDownload = useCallback(async (selectedFlatRows) => { handleErrorReset({ errorType: 'download' }); - setSelectedRows(selectedFlatRows); - setDownloadOpen(); handleDownloadFile(selectedFlatRows); }, []); @@ -243,14 +240,6 @@ 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 6b6054dae..ba62f2b2d 100644 --- a/src/files-and-videos/generic/messages.js +++ b/src/files-and-videos/generic/messages.js @@ -17,10 +17,6 @@ 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 7e359ce30..b4e740aa4 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,7 +33,6 @@ const MoreInfoColumn = ({ id, wrapperType, displayName, - downloadLink, } = row.original; return ( <> @@ -100,7 +99,7 @@ const MoreInfoColumn = ({ as={Button} variant="tertiary" onClick={() => handleBulkDownload( - [{ original: { id, displayName, downloadLink } }], + [{ original: { id, displayName } }], )} > {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 79c9ff1b4..d2c3b6241 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, courseId })); + const handleDownloadFile = (selectedRows) => dispatch(fetchVideoDownload({ selectedRows })); 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 05e7f32fa..d431aa7fc 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, getCourseVideosApiUrl, getApiBaseUrl } from './data/api'; +import { getVideosUrl, getCoursVideosApiUrl, 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(getCourseVideosApiUrl(courseId)).reply(204, generateNewVideoApiResponse()); - axiosMock.onGet(getCourseVideosApiUrl(courseId)).reply(200, generateAddVideoApiResponse()); + axiosMock.onPost(getCoursVideosApiUrl(courseId)).reply(204, generateNewVideoApiResponse()); + axiosMock.onGet(getCoursVideosApiUrl(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(getCourseVideosApiUrl(courseId)).reply(204, generateNewVideoApiResponse()); - axiosMock.onGet(getCourseVideosApiUrl(courseId)).reply(200, generateAddVideoApiResponse()); + axiosMock.onPost(getCoursVideosApiUrl(courseId)).reply(204, generateNewVideoApiResponse()); + axiosMock.onGet(getCoursVideosApiUrl(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(`${getCourseVideosApiUrl(courseId)}/mOckID1`).reply(204); + axiosMock.onDelete(`${getCoursVideosApiUrl(courseId)}/mOckID1`).reply(204); fireEvent.click(deleteButton); expect(screen.getByText('Delete video(s) confirmation')).toBeVisible(); @@ -322,7 +322,8 @@ describe('FilesAndUploads', () => { await waitFor(() => { fireEvent.click(actionsButton); }); - axiosMock.onPut(`${getVideosUrl(courseId)}/download`).reply(200, null); + axiosMock.onGet(`${getVideosUrl(courseId)}/mOckID1`).reply(200, { download_link: 'http://download.org' }); + axiosMock.onGet(`${getVideosUrl(courseId)}/mOckID5`).reply(200, { download_link: 'http://download.org' }); const downloadButton = screen.getByText(messages.downloadTitle.defaultMessage).closest('a'); expect(downloadButton).not.toHaveClass('disabled'); @@ -532,6 +533,7 @@ 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')); @@ -551,7 +553,7 @@ describe('FilesAndUploads', () => { expect(fileMenuButton).toBeVisible(); await waitFor(() => { - axiosMock.onDelete(`${getCourseVideosApiUrl(courseId)}/mOckID1`).reply(204); + axiosMock.onDelete(`${getCoursVideosApiUrl(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(); @@ -573,7 +575,7 @@ describe('FilesAndUploads', () => { const errorMessage = 'File download.png exceeds maximum size of 5 GB.'; renderComponent(); await mockStore(RequestStatus.SUCCESSFUL); - axiosMock.onPost(getCourseVideosApiUrl(courseId)).reply(413, { error: errorMessage }); + axiosMock.onPost(getCoursVideosApiUrl(courseId)).reply(413, { error: errorMessage }); const addFilesButton = screen.getAllByLabelText('file-input')[3]; await act(async () => { userEvent.upload(addFilesButton, file); @@ -588,7 +590,7 @@ describe('FilesAndUploads', () => { it('404 add file should show error', async () => { renderComponent(); await mockStore(RequestStatus.SUCCESSFUL); - axiosMock.onPost(getCourseVideosApiUrl(courseId)).reply(404); + axiosMock.onPost(getCoursVideosApiUrl(courseId)).reply(404); const addFilesButton = screen.getAllByLabelText('file-input')[3]; await act(async () => { userEvent.upload(addFilesButton, file); @@ -623,8 +625,8 @@ describe('FilesAndUploads', () => { const mockFetchResponse = Promise.reject(mockResponseData); global.fetch = jest.fn().mockImplementation(() => mockFetchResponse); - axiosMock.onPost(getCourseVideosApiUrl(courseId)).reply(204, generateNewVideoApiResponse()); - axiosMock.onGet(getCourseVideosApiUrl(courseId)).reply(200, generateAddVideoApiResponse()); + axiosMock.onPost(getCoursVideosApiUrl(courseId)).reply(204, generateNewVideoApiResponse()); + axiosMock.onGet(getCoursVideosApiUrl(courseId)).reply(200, generateAddVideoApiResponse()); const addFilesButton = screen.getAllByLabelText('file-input')[3]; await act(async () => { userEvent.upload(addFilesButton, file); @@ -645,7 +647,7 @@ describe('FilesAndUploads', () => { expect(videoMenuButton).toBeVisible(); await waitFor(() => { - axiosMock.onDelete(`${getCourseVideosApiUrl(courseId)}/mOckID1`).reply(404); + axiosMock.onDelete(`${getCoursVideosApiUrl(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(); @@ -699,11 +701,9 @@ 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', downloadLink: 'test' } }]), store.dispatch); + executeThunk(fetchVideoDownload([{ original: { displayName: 'mOckID1', id: '2' } }]), 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 a757dcf9a..3cdd3e433 100644 --- a/src/files-and-videos/videos-page/data/api.js +++ b/src/files-and-videos/videos-page/data/api.js @@ -1,7 +1,8 @@ -import saveAs from 'file-saver'; +/* eslint-disable import/prefer-default-export */ 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([ @@ -10,7 +11,7 @@ ensureConfig([ export const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL; export const getVideosUrl = (courseId) => `${getApiBaseUrl()}/api/contentstore/v1/videos/${courseId}`; -export const getCourseVideosApiUrl = (courseId) => `${getApiBaseUrl()}/videos/${courseId}`; +export const getCoursVideosApiUrl = (courseId) => `${getApiBaseUrl()}/videos/${courseId}`; /** * Fetches the course custom pages for provided course @@ -38,7 +39,7 @@ export async function getVideos(courseId) { */ export async function fetchVideoList(courseId) { const { data } = await getAuthenticatedHttpClient() - .get(getCourseVideosApiUrl(courseId)); + .get(getCoursVideosApiUrl(courseId)); return camelCaseObject(data); } @@ -74,48 +75,27 @@ export async function uploadTranscript({ await getAuthenticatedHttpClient().post(`${getApiBaseUrl()}${apiUrl}`, formData); } -export async function getDownload(selectedRows, courseId) { +export async function getDownload(selectedRows) { const downloadErrors = []; - 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.'); - } + 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.'); + } + }), + ); } else { downloadErrors.push('No files were selected to download.'); } - return downloadErrors; } @@ -137,7 +117,7 @@ export async function getVideoUsagePaths({ courseId, videoId }) { */ export async function deleteVideo(courseId, videoId) { await getAuthenticatedHttpClient() - .delete(`${getCourseVideosApiUrl(courseId)}/${videoId}`); + .delete(`${getCoursVideosApiUrl(courseId)}/${videoId}`); } /** @@ -164,7 +144,7 @@ export async function addVideo(courseId, file) { }; const { data } = await getAuthenticatedHttpClient() - .post(getCourseVideosApiUrl(courseId), postJson); + .post(getCoursVideosApiUrl(courseId), postJson); return camelCaseObject(data); } @@ -186,7 +166,7 @@ export async function uploadVideo( }) .then(async () => { await getAuthenticatedHttpClient() - .post(getCourseVideosApiUrl(courseId), [{ + .post(getCoursVideosApiUrl(courseId), [{ edxVideoId, message: 'Upload completed', status: 'upload_completed', @@ -195,7 +175,7 @@ export async function uploadVideo( .catch(async () => { uploadErrors.push(`Failed to upload ${uploadFile.name} to server.`); await getAuthenticatedHttpClient() - .post(getCourseVideosApiUrl(courseId), [{ + .post(getCoursVideosApiUrl(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 134ec5b25..7604a7a08 100644 --- a/src/files-and-videos/videos-page/data/api.test.js +++ b/src/files-and-videos/videos-page/data/api.test.js @@ -1,26 +1,9 @@ +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 () => { @@ -35,39 +18,36 @@ 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 = ['Cannot find download file for video.']; + const expected = ['Failed to download video.']; const actual = await getDownload([ { asset: { displayName: 'test1', id: '1' } }, { original: { displayName: 'test2', id: '2', downloadLink: 'test1.com' } }, - ], '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); }); + }); + describe('selectedRows length equals one', () => { 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 df0bd214f..72719bc1e 100644 --- a/src/files-and-videos/videos-page/data/thunks.js +++ b/src/files-and-videos/videos-page/data/thunks.js @@ -296,20 +296,16 @@ export function getUsagePaths({ video, courseId }) { }; } -export function fetchVideoDownload({ selectedRows, courseId }) { +export function fetchVideoDownload({ selectedRows }) { return async (dispatch) => { dispatch(updateEditStatus({ editType: 'download', status: RequestStatus.IN_PROGRESS })); - try { - const errors = await getDownload(selectedRows, courseId); + const errors = await getDownload(selectedRows); + if (isEmpty(errors)) { dispatch(updateEditStatus({ editType: 'download', status: RequestStatus.SUCCESSFUL })); - 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.' })); + } else { + errors.forEach(error => { + dispatch(updateErrors({ error: 'download', message: error })); + }); dispatch(updateEditStatus({ editType: 'download', status: RequestStatus.FAILED })); } }; diff --git a/src/import-page/data/api.js b/src/import-page/data/api.js index dda69970e..17c68ab63 100644 --- a/src/import-page/data/api.js +++ b/src/import-page/data/api.js @@ -1,3 +1,4 @@ +/* eslint-disable import/prefer-default-export */ import { camelCaseObject, getConfig } from '@edx/frontend-platform'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; @@ -13,42 +14,9 @@ export const getImportStatusApiUrl = (courseId, fileName) => `${getApiBaseUrl()} * @returns {Promise} */ export async function startCourseImporting(courseId, fileData, requestConfig) { - 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; + const { data } = await getAuthenticatedHttpClient() + .post(postImportCourseApiUrl(courseId), { 'course-data': fileData }, { headers: { 'content-type': 'multipart/form-data' }, ...requestConfig }); + return camelCaseObject(data); } /** diff --git a/src/import-page/data/api.test.jsx b/src/import-page/data/api.test.jsx index 9aa50dac5..d0fd63d06 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, file); + const result = await startCourseImporting(courseId); + 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 262d9e805..3aba183d7 100644 --- a/src/import-page/import-sidebar/ImportSidebar.jsx +++ b/src/import-page/import-sidebar/ImportSidebar.jsx @@ -42,6 +42,7 @@ 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 2a3dc0679..6b7a1f42a 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"; +@import "taxonomy/taxonomy-card/TaxonomyCard"; @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 d224b066d..ecd38769d 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, - escalationEmail: '', + proctortrackEscalationEmail: '', 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 [showEscalationEmail, setShowEscalationEmail] = useState(false); + const [showProctortrackEscalationEmail, setShowProctortrackEscalationEmail] = useState(false); const isEdxStaff = getAuthenticatedUser().administrator; const [formStatus, setFormStatus] = useState({ isValid: true, @@ -53,15 +53,6 @@ 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(); @@ -82,36 +73,38 @@ const ProctoringSettings = ({ intl, onClose }) => { if (value === 'proctortrack') { setFormValues({ ...newFormValues, createZendeskTickets: false }); - setShowEscalationEmail(true); - } else if (value === 'software_secure') { - setFormValues({ ...newFormValues, createZendeskTickets: true }); - setShowEscalationEmail(false); - } else if (isLtiProvider(value)) { - setFormValues(newFormValues); - setShowEscalationEmail(true); + setShowProctortrackEscalationEmail(true); } else { - setFormValues(newFormValues); - setShowEscalationEmail(false); + if (value === 'software_secure') { + setFormValues({ ...newFormValues, createZendeskTickets: true }); + } else { + setFormValues(newFormValues); + } + + setShowProctortrackEscalationEmail(false); } } else { setFormValues({ ...formValues, [name]: value }); } }; - const setFocusToEscalationEmailInput = () => { + function isLtiProvider(provider) { + return ltiProctoringProviders.some(p => p.name === provider); + } + + const setFocusToProctortrackEscalationEmailInput = () => { if (proctoringEscalationEmailInputRef && proctoringEscalationEmailInputRef.current) { proctoringEscalationEmailInputRef.current.focus(); } }; function postSettingsBackToServer() { - const selectedProvider = formValues.proctoringProvider; - const isLtiProviderSelected = isLtiProvider(selectedProvider); + const providerIsLti = isLtiProvider(formValues.proctoringProvider); const studioDataToPostBack = { proctored_exam_settings: { enable_proctored_exams: formValues.enableProctoredExams, // lti providers are managed outside edx-platform, lti_external indicates this - proctoring_provider: isLtiProviderSelected ? 'lti_external' : selectedProvider, + proctoring_provider: providerIsLti ? 'lti_external' : formValues.proctoringProvider, create_zendesk_tickets: formValues.createZendeskTickets, }, }; @@ -120,23 +113,17 @@ const ProctoringSettings = ({ intl, onClose }) => { } if (formValues.proctoringProvider === 'proctortrack') { - studioDataToPostBack.proctored_exam_settings.proctoring_escalation_email = formValues.escalationEmail === '' ? null : formValues.escalationEmail; + studioDataToPostBack.proctored_exam_settings.proctoring_escalation_email = formValues.proctortrackEscalationEmail === '' ? null : formValues.proctortrackEscalationEmail; } // 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: isLtiProviderSelected ? formValues.proctoringProvider : null, - escalationEmail: (isLtiProviderSelected && selectedEscalationEmail !== '') ? selectedEscalationEmail : null, - }, + { provider: providerIsLti ? formValues.proctoringProvider : null }, ), ); } @@ -154,21 +141,20 @@ const ProctoringSettings = ({ intl, onClose }) => { const handleSubmit = (event) => { event.preventDefault(); - const isLtiProviderSelected = isLtiProvider(formValues.proctoringProvider); if ( - (formValues.proctoringProvider === 'proctortrack' || isLtiProviderSelected) - && !EmailValidator.validate(formValues.escalationEmail) - && !(formValues.escalationEmail === '' && !formValues.enableProctoredExams) + formValues.proctoringProvider === 'proctortrack' + && !EmailValidator.validate(formValues.proctortrackEscalationEmail) + && !(formValues.proctortrackEscalationEmail === '' && !formValues.enableProctoredExams) ) { - if (formValues.escalationEmail === '') { - const errorMessage = intl.formatMessage(messages['authoring.proctoring.escalationemail.error.blank'], { proctoringProviderName: getProviderDisplayLabel(formValues.proctoringProvider) }); + if (formValues.proctortrackEscalationEmail === '') { + const errorMessage = intl.formatMessage(messages['authoring.proctoring.escalationemail.error.blank']); setFormStatus({ isValid: false, errors: { - formEscalationEmail: { + formProctortrackEscalationEmail: { dialogErrorMessage: ( - + {errorMessage} ), @@ -182,8 +168,8 @@ const ProctoringSettings = ({ intl, onClose }) => { setFormStatus({ isValid: false, errors: { - formEscalationEmail: { - dialogErrorMessage: ({errorMessage}), + formProctortrackEscalationEmail: { + dialogErrorMessage: ({errorMessage}), inputErrorMessage: errorMessage, }, }, @@ -192,7 +178,7 @@ const ProctoringSettings = ({ intl, onClose }) => { } else { postSettingsBackToServer(); const errors = { ...formStatus.errors }; - delete errors.formEscalationEmail; + delete errors.formProctortrackEscalationEmail; setFormStatus({ isValid: true, errors, @@ -216,6 +202,11 @@ 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 => (