From 476f779e76707b1d561c7bcbca61cccaad031d95 Mon Sep 17 00:00:00 2001 From: Kristin Aoki <42981026+KristinAoki@users.noreply.github.com> Date: Fri, 15 Dec 2023 16:55:24 -0500 Subject: [PATCH] fix: files page timeout (#749) --- src/data/constants.js | 1 + src/files-and-videos/files-page/FilesPage.jsx | 40 +++--- .../files-page/FilesPage.test.jsx | 136 +++++++++--------- src/files-and-videos/files-page/data/api.js | 6 +- src/files-and-videos/files-page/data/slice.js | 18 ++- .../files-page/data/thunks.js | 35 ++++- .../files-page/factories/mockApiResponses.jsx | 24 +++- .../generic/EditFileErrors.jsx | 10 ++ .../videos-page/VideosPage.jsx | 64 +++++---- .../videos-page/VideosPage.test.jsx | 61 ++------ .../videos-page/data/slice.js | 9 +- .../videos-page/data/thunks.js | 1 + .../factories/mockApiResponses.jsx | 77 +++++++++- 13 files changed, 308 insertions(+), 174 deletions(-) diff --git a/src/data/constants.js b/src/data/constants.js index d91b6bfb5..d379233ac 100644 --- a/src/data/constants.js +++ b/src/data/constants.js @@ -12,6 +12,7 @@ export const RequestStatus = { DENIED: 'denied', PENDING: 'pending', CLEAR: 'clear', + PARTIAL: 'partial', }; /** diff --git a/src/files-and-videos/files-page/FilesPage.jsx b/src/files-and-videos/files-page/FilesPage.jsx index 8bbf0802f..236e594a9 100644 --- a/src/files-and-videos/files-page/FilesPage.jsx +++ b/src/files-and-videos/files-page/FilesPage.jsx @@ -167,6 +167,7 @@ const FilesPage = ({ ); } + return ( @@ -176,28 +177,31 @@ const FilesPage = ({ addFileStatus={addAssetStatus} deleteFileStatus={deleteAssetStatus} updateFileStatus={updateAssetStatus} + loadingStatus={loadingStatus} />
- + {loadingStatus !== RequestStatus.FAILED && ( + + )}
); diff --git a/src/files-and-videos/files-page/FilesPage.test.jsx b/src/files-and-videos/files-page/FilesPage.test.jsx index da15d8969..bae1edb63 100644 --- a/src/files-and-videos/files-page/FilesPage.test.jsx +++ b/src/files-and-videos/files-page/FilesPage.test.jsx @@ -27,6 +27,7 @@ import { getStatusValue, courseId, initialState, + generateNextPageResponse, } from './factories/mockApiResponses'; import { @@ -57,15 +58,22 @@ const renderComponent = () => { const mockStore = async ( status, + skipNextPageFetch, ) => { - const fetchAssetsUrl = `${getAssetsUrl(courseId)}?page_size=50`; + const fetchAssetsUrl = `${getAssetsUrl(courseId)}?page=0`; axiosMock.onGet(fetchAssetsUrl).reply(getStatusValue(status), generateFetchAssetApiResponse()); + if (!skipNextPageFetch) { + const nextPageUrl = `${getAssetsUrl(courseId)}?page=1`; + axiosMock.onGet(nextPageUrl).reply(getStatusValue(status), generateNextPageResponse()); + } + renderComponent(); await executeThunk(fetchAssets(courseId), store.dispatch); }; const emptyMockStore = async (status) => { - const fetchAssetsUrl = getAssetsUrl(courseId); + const fetchAssetsUrl = `${getAssetsUrl(courseId)}?page=0`; axiosMock.onGet(fetchAssetsUrl).reply(getStatusValue(status), generateEmptyApiResponse()); + renderComponent(); await executeThunk(fetchAssets(courseId), store.dispatch); }; @@ -93,27 +101,26 @@ describe('FilesAndUploads', () => { }); it('should return placeholder component', async () => { - renderComponent(); await mockStore(RequestStatus.DENIED); + expect(screen.getByTestId('under-construction-placeholder')).toBeVisible(); }); it('should have Files title', async () => { - renderComponent(); await emptyMockStore(RequestStatus.SUCCESSFUL); + expect(screen.getByText('Files')).toBeVisible(); }); it('should render dropzone', async () => { - renderComponent(); await emptyMockStore(RequestStatus.SUCCESSFUL); + expect(screen.getByTestId('files-dropzone')).toBeVisible(); expect(screen.queryByTestId('files-data-table')).toBeNull(); }); it('should upload a single file', async () => { - renderComponent(); await emptyMockStore(RequestStatus.SUCCESSFUL); const dropzone = screen.getByTestId('files-dropzone'); await act(async () => { @@ -154,19 +161,22 @@ describe('FilesAndUploads', () => { describe('table view', () => { it('should render table with gallery card', async () => { - renderComponent(); await mockStore(RequestStatus.SUCCESSFUL); + expect(screen.getByTestId('files-data-table')).toBeVisible(); - expect(screen.getByTestId('grid-card-mOckID1')).toBeVisible(); + await waitFor(() => { + expect(screen.getAllByTestId('grid-card-mOckID1')[0]).toBeVisible(); + }); }); it('should switch table to list view', async () => { - renderComponent(); await mockStore(RequestStatus.SUCCESSFUL); expect(screen.getByTestId('files-data-table')).toBeVisible(); - expect(screen.getByTestId('grid-card-mOckID1')).toBeVisible(); + await waitFor(() => { + expect(screen.getAllByTestId('grid-card-mOckID1')[0]).toBeVisible(); + }); expect(screen.queryByRole('table')).toBeNull(); @@ -182,10 +192,12 @@ describe('FilesAndUploads', () => { describe('table actions', () => { it('should upload a single file', async () => { - renderComponent(); await mockStore(RequestStatus.SUCCESSFUL); axiosMock.onPost(getAssetsUrl(courseId)).reply(200, generateNewAssetApiResponse()); - const addFilesButton = screen.getByLabelText('file-input'); + let addFilesButton; + await waitFor(() => { + addFilesButton = screen.getByLabelText('file-input'); + }); await act(async () => { userEvent.upload(addFilesButton, file); await executeThunk(addAssetFile(courseId, file, 1), store.dispatch); @@ -195,12 +207,11 @@ describe('FilesAndUploads', () => { }); it('should have disabled action buttons', async () => { - renderComponent(); await mockStore(RequestStatus.SUCCESSFUL); - const actionsButton = screen.getByText(messages.actionsButtonLabel.defaultMessage); - expect(actionsButton).toBeVisible(); + let actionsButton; await waitFor(() => { + actionsButton = screen.getByText(messages.actionsButtonLabel.defaultMessage); fireEvent.click(actionsButton); }); expect(screen.getByText(messages.downloadTitle.defaultMessage).closest('a')).toHaveClass('disabled'); @@ -209,10 +220,14 @@ describe('FilesAndUploads', () => { }); it('delete button should be enabled and delete selected file', async () => { - renderComponent(); await mockStore(RequestStatus.SUCCESSFUL); - const selectCardButton = screen.getAllByTestId('datatable-select-column-checkbox-cell')[0]; - fireEvent.click(selectCardButton); + let selectCardButton; + + await waitFor(() => { + [selectCardButton] = screen.getAllByTestId('datatable-select-column-checkbox-cell'); + fireEvent.click(selectCardButton); + }); + const actionsButton = screen.getByText(messages.actionsButtonLabel.defaultMessage); expect(actionsButton).toBeVisible(); @@ -248,7 +263,6 @@ describe('FilesAndUploads', () => { }); it('download button should be enabled and download single selected file', async () => { - renderComponent(); await mockStore(RequestStatus.SUCCESSFUL); const selectCardButton = screen.getAllByTestId('datatable-select-column-checkbox-cell')[0]; fireEvent.click(selectCardButton); @@ -266,7 +280,6 @@ describe('FilesAndUploads', () => { }); it('download button should be enabled and download multiple selected files', async () => { - renderComponent(); await mockStore(RequestStatus.SUCCESSFUL); const selectCardButtons = screen.getAllByTestId('datatable-select-column-checkbox-cell'); fireEvent.click(selectCardButtons[0]); @@ -288,7 +301,6 @@ describe('FilesAndUploads', () => { }); it('sort button should be enabled and sort files by name', async () => { - renderComponent(); await mockStore(RequestStatus.SUCCESSFUL); const sortsButton = screen.getByText(messages.sortButtonLabel.defaultMessage); expect(sortsButton).toBeVisible(); @@ -309,7 +321,6 @@ describe('FilesAndUploads', () => { }); it('sort button should be enabled and sort files by file size', async () => { - renderComponent(); await mockStore(RequestStatus.SUCCESSFUL); const sortsButton = screen.getByText(messages.sortButtonLabel.defaultMessage); expect(sortsButton).toBeVisible(); @@ -332,12 +343,12 @@ describe('FilesAndUploads', () => { describe('card menu actions', () => { it('should open asset info', async () => { - renderComponent(); await mockStore(RequestStatus.SUCCESSFUL); - expect(screen.getByTestId('grid-card-mOckID1')).toBeVisible(); + let assetMenuButton; - const assetMenuButton = screen.getByTestId('file-menu-dropdown-mOckID1'); - expect(assetMenuButton).toBeVisible(); + await waitFor(() => { + [assetMenuButton] = screen.getAllByTestId('file-menu-dropdown-mOckID1'); + }); axiosMock.onGet(`${getAssetsUrl(courseId)}mOckID1/usage`) .reply(201, { @@ -365,11 +376,8 @@ describe('FilesAndUploads', () => { }); it('should open asset info and handle lock checkbox', async () => { - renderComponent(); await mockStore(RequestStatus.SUCCESSFUL); - expect(screen.getByTestId('grid-card-mOckID1')).toBeVisible(); - const assetMenuButton = screen.getByTestId('file-menu-dropdown-mOckID1'); - expect(assetMenuButton).toBeVisible(); + const assetMenuButton = screen.getAllByTestId('file-menu-dropdown-mOckID1')[0]; axiosMock.onPut(`${getAssetsUrl(courseId)}mOckID1`).reply(201, { locked: false }); axiosMock.onGet(`${getAssetsUrl(courseId)}mOckID1/usage`).reply(201, { usage_locations: { mOckID1: [] } }); @@ -397,12 +405,9 @@ describe('FilesAndUploads', () => { }); it('should unlock asset', async () => { - renderComponent(); await mockStore(RequestStatus.SUCCESSFUL); - expect(screen.getByTestId('grid-card-mOckID1')).toBeVisible(); - const assetMenuButton = screen.getByTestId('file-menu-dropdown-mOckID1'); - expect(assetMenuButton).toBeVisible(); + const assetMenuButton = screen.getAllByTestId('file-menu-dropdown-mOckID1')[0]; await waitFor(() => { axiosMock.onPut(`${getAssetsUrl(courseId)}mOckID1`).reply(201, { locked: false }); @@ -419,12 +424,9 @@ describe('FilesAndUploads', () => { }); it('should lock asset', async () => { - renderComponent(); await mockStore(RequestStatus.SUCCESSFUL); - expect(screen.getByTestId('grid-card-mOckID3')).toBeVisible(); - const assetMenuButton = screen.getByTestId('file-menu-dropdown-mOckID3'); - expect(assetMenuButton).toBeVisible(); + const assetMenuButton = screen.getAllByTestId('file-menu-dropdown-mOckID3')[0]; await waitFor(() => { axiosMock.onPut(`${getAssetsUrl(courseId)}mOckID3`).reply(201, { locked: true }); @@ -441,12 +443,9 @@ describe('FilesAndUploads', () => { }); it('download button should download file', async () => { - renderComponent(); await mockStore(RequestStatus.SUCCESSFUL); - expect(screen.getByTestId('grid-card-mOckID1')).toBeVisible(); - const assetMenuButton = screen.getByTestId('file-menu-dropdown-mOckID1'); - expect(assetMenuButton).toBeVisible(); + const assetMenuButton = screen.getAllByTestId('file-menu-dropdown-mOckID1')[0]; await waitFor(() => { fireEvent.click(within(assetMenuButton).getByLabelText('file-menu-toggle')); @@ -456,12 +455,9 @@ describe('FilesAndUploads', () => { }); it('delete button should delete file', async () => { - renderComponent(); await mockStore(RequestStatus.SUCCESSFUL); - expect(screen.getByTestId('grid-card-mOckID1')).toBeVisible(); - const assetMenuButton = screen.getByTestId('file-menu-dropdown-mOckID1'); - expect(assetMenuButton).toBeVisible(); + const assetMenuButton = screen.getAllByTestId('file-menu-dropdown-mOckID1')[0]; await waitFor(() => { axiosMock.onDelete(`${getAssetsUrl(courseId)}mOckID1`).reply(204); @@ -482,15 +478,36 @@ describe('FilesAndUploads', () => { }); describe('api errors', () => { + it('404 intitial fetch should show error', async () => { + await mockStore(RequestStatus.FAILED); + const { loadingStatus } = store.getState().assets; + await waitFor(() => { + expect(screen.getByText('Error')).toBeVisible(); + }); + + expect(loadingStatus).toEqual(RequestStatus.FAILED); + expect(screen.getByText('Failed to load all files.')).toBeVisible(); + }); + + it('404 intitial fetch should show error', async () => { + await mockStore(RequestStatus.SUCCESSFUL, true); + const { loadingStatus } = store.getState().assets; + await waitFor(() => { + expect(screen.getByText('Error')).toBeVisible(); + }); + + expect(loadingStatus).toEqual(RequestStatus.PARTIAL); + expect(screen.getByText('Failed to load remaining files.')).toBeVisible(); + }); + it('invalid file size should show error', async () => { const errorMessage = 'File download.png exceeds maximum size of 20 MB.'; - renderComponent(); await mockStore(RequestStatus.SUCCESSFUL); + axiosMock.onPost(getAssetsUrl(courseId)).reply(413, { error: errorMessage }); const addFilesButton = screen.getByLabelText('file-input'); await act(async () => { userEvent.upload(addFilesButton, file); - await executeThunk(addAssetFile(courseId, file, 1), store.dispatch); }); const addStatus = store.getState().assets.addingStatus; expect(addStatus).toEqual(RequestStatus.FAILED); @@ -499,7 +516,6 @@ describe('FilesAndUploads', () => { }); it('404 upload should show error', async () => { - renderComponent(); await mockStore(RequestStatus.SUCCESSFUL); axiosMock.onPost(getAssetsUrl(courseId)).reply(404); const addFilesButton = screen.getByLabelText('file-input'); @@ -514,15 +530,12 @@ describe('FilesAndUploads', () => { }); it('404 delete should show error', async () => { - renderComponent(); await mockStore(RequestStatus.SUCCESSFUL); - expect(screen.getByTestId('grid-card-mOckID1')).toBeVisible(); - const assetMenuButton = screen.getByTestId('file-menu-dropdown-mOckID1'); - expect(assetMenuButton).toBeVisible(); + const assetMenuButton = screen.getAllByTestId('file-menu-dropdown-mOckID3')[0]; await waitFor(() => { - axiosMock.onDelete(`${getAssetsUrl(courseId)}mOckID1`).reply(404); + axiosMock.onDelete(`${getAssetsUrl(courseId)}mOckID3`).reply(404); fireEvent.click(within(assetMenuButton).getByLabelText('file-menu-toggle')); fireEvent.click(screen.getByTestId('open-delete-confirmation-button')); expect(screen.getByText('Delete file(s) confirmation')).toBeVisible(); @@ -530,23 +543,20 @@ describe('FilesAndUploads', () => { fireEvent.click(screen.getByText(messages.deleteFileButtonLabel.defaultMessage)); expect(screen.queryByText('Delete file(s) confirmation')).toBeNull(); - executeThunk(deleteAssetFile(courseId, 'mOckID1', 5), store.dispatch); + executeThunk(deleteAssetFile(courseId, 'mOckID3', 5), store.dispatch); }); const deleteStatus = store.getState().assets.deletingStatus; expect(deleteStatus).toEqual(RequestStatus.FAILED); - expect(screen.getByTestId('grid-card-mOckID1')).toBeVisible(); + expect(screen.getAllByTestId('grid-card-mOckID3')[0]).toBeVisible(); expect(screen.getByText('Error')).toBeVisible(); }); it('404 usage path fetch should show error', async () => { - renderComponent(); await mockStore(RequestStatus.SUCCESSFUL); - expect(screen.getByTestId('grid-card-mOckID3')).toBeVisible(); - const assetMenuButton = screen.getByTestId('file-menu-dropdown-mOckID3'); - expect(assetMenuButton).toBeVisible(); + const assetMenuButton = screen.getAllByTestId('file-menu-dropdown-mOckID3')[0]; axiosMock.onGet(`${getAssetsUrl(courseId)}mOckID3/usage`).reply(404); await waitFor(() => { @@ -563,12 +573,9 @@ describe('FilesAndUploads', () => { }); it('404 lock update should show error', async () => { - renderComponent(); await mockStore(RequestStatus.SUCCESSFUL); - expect(screen.getByTestId('grid-card-mOckID3')).toBeVisible(); - const assetMenuButton = screen.getByTestId('file-menu-dropdown-mOckID3'); - expect(assetMenuButton).toBeVisible(); + const assetMenuButton = screen.getAllByTestId('file-menu-dropdown-mOckID3')[0]; await waitFor(() => { axiosMock.onPut(`${getAssetsUrl(courseId)}mOckID3`).reply(404); @@ -587,7 +594,6 @@ describe('FilesAndUploads', () => { }); it('multiple asset file fetch failure should show error', async () => { - renderComponent(); await mockStore(RequestStatus.SUCCESSFUL); const selectCardButtons = screen.getAllByTestId('datatable-select-column-checkbox-cell'); fireEvent.click(selectCardButtons[0]); diff --git a/src/files-and-videos/files-page/data/api.js b/src/files-and-videos/files-page/data/api.js index b86d62340..ded712b83 100644 --- a/src/files-and-videos/files-page/data/api.js +++ b/src/files-and-videos/files-page/data/api.js @@ -17,10 +17,10 @@ export const getAssetsUrl = (courseId) => `${getApiBaseUrl()}/assets/${courseId} * @param {string} courseId * @returns {Promise<[{}]>} */ -export async function getAssets(courseId, totalCount) { - const pageCount = totalCount || 50; +export async function getAssets(courseId, page) { + const nextPage = page || 0; const { data } = await getAuthenticatedHttpClient() - .get(`${getAssetsUrl(courseId)}?page_size=${pageCount}`); + .get(`${getAssetsUrl(courseId)}?page=${nextPage}`); return camelCaseObject(data); } diff --git a/src/files-and-videos/files-page/data/slice.js b/src/files-and-videos/files-page/data/slice.js index 080f0ea02..e9bad9581 100644 --- a/src/files-and-videos/files-page/data/slice.js +++ b/src/files-and-videos/files-page/data/slice.js @@ -1,5 +1,6 @@ /* eslint-disable no-param-reassign */ import { createSlice } from '@reduxjs/toolkit'; +import { isEmpty } from 'lodash'; import { RequestStatus } from '../../../data/constants'; @@ -18,10 +19,18 @@ const slice = createSlice({ lock: [], download: [], usageMetrics: [], + loading: '', }, }, reducers: { setAssetIds: (state, { payload }) => { + if (isEmpty(state.assetIds)) { + state.assetIds = payload.assetIds; + } else { + state.assetIds = [...state.assetIds, ...payload.assetIds]; + } + }, + setSortedAssetIds: (state, { payload }) => { state.assetIds = payload.assetIds; }, updateLoadingStatus: (state, { payload }) => { @@ -57,8 +66,12 @@ const slice = createSlice({ }, updateErrors: (state, { payload }) => { const { error, message } = payload; - const currentErrorState = state.errors[error]; - state.errors[error] = [...currentErrorState, message]; + if (error === 'loading') { + state.errors.loading = message; + } else { + const currentErrorState = state.errors[error]; + state.errors[error] = [...currentErrorState, message]; + } }, clearErrors: (state, { payload }) => { const { error } = payload; @@ -69,6 +82,7 @@ const slice = createSlice({ export const { setAssetIds, + setSortedAssetIds, updateLoadingStatus, deleteAssetSuccess, addAssetSuccess, diff --git a/src/files-and-videos/files-page/data/thunks.js b/src/files-and-videos/files-page/data/thunks.js index 90bdb5ab4..c51b51b0c 100644 --- a/src/files-and-videos/files-page/data/thunks.js +++ b/src/files-and-videos/files-page/data/thunks.js @@ -18,6 +18,7 @@ import { } from './api'; import { setAssetIds, + setSortedAssetIds, updateLoadingStatus, deleteAssetSuccess, addAssetSuccess, @@ -28,23 +29,51 @@ import { import { updateFileValues } from './utils'; +export function fetchAddtionalAsstets(courseId, totalCount) { + return async (dispatch) => { + let remainingAssetCount = totalCount; + let page = 1; + + /* eslint-disable no-await-in-loop */ + while (remainingAssetCount > 0) { + try { + const { assets } = await getAssets(courseId, page); + const parsedAssets = updateFileValues(assets); + dispatch(addModels({ modelType: 'assets', models: parsedAssets })); + dispatch(setAssetIds({ + assetIds: assets.map(asset => asset.id), + })); + remainingAssetCount -= 50; + page += 1; + } catch (error) { + remainingAssetCount = 0; + dispatch(updateErrors({ error: 'loading', message: 'Failed to load remaining files.' })); + dispatch(updateLoadingStatus({ status: RequestStatus.PARTIAL })); + } + } + }; +} + export function fetchAssets(courseId) { return async (dispatch) => { dispatch(updateLoadingStatus({ courseId, status: RequestStatus.IN_PROGRESS })); try { - const { totalCount } = await getAssets(courseId); - const { assets } = await getAssets(courseId, totalCount); + const { assets, totalCount } = await getAssets(courseId); const parsedAssets = updateFileValues(assets); dispatch(addModels({ modelType: 'assets', models: parsedAssets })); dispatch(setAssetIds({ assetIds: assets.map(asset => asset.id), })); + if (totalCount > 50) { + dispatch(fetchAddtionalAsstets(courseId, totalCount - 50)); + } dispatch(updateLoadingStatus({ courseId, status: RequestStatus.SUCCESSFUL })); } catch (error) { if (error.response && error.response.status === 403) { dispatch(updateLoadingStatus({ status: RequestStatus.DENIED })); } else { + dispatch(updateErrors({ error: 'loading', message: 'Failed to load all files.' })); dispatch(updateLoadingStatus({ courseId, status: RequestStatus.FAILED })); } } @@ -54,7 +83,7 @@ export function fetchAssets(courseId) { export function updateAssetOrder(courseId, assetIds) { return async (dispatch) => { dispatch(updateLoadingStatus({ courseId, status: RequestStatus.IN_PROGRESS })); - dispatch(setAssetIds({ assetIds })); + dispatch(setSortedAssetIds({ assetIds })); dispatch(updateLoadingStatus({ courseId, status: RequestStatus.SUCCESSFUL })); }; } diff --git a/src/files-and-videos/files-page/factories/mockApiResponses.jsx b/src/files-and-videos/files-page/factories/mockApiResponses.jsx index e7df3d0e7..0ad2f0640 100644 --- a/src/files-and-videos/files-page/factories/mockApiResponses.jsx +++ b/src/files-and-videos/files-page/factories/mockApiResponses.jsx @@ -20,6 +20,7 @@ export const initialState = { lock: [], download: [], usageMetrics: [], + loading: '', }, }, models: { @@ -106,13 +107,28 @@ export const generateFetchAssetApiResponse = () => ({ thumbnail: null, }, ], - totalCount: 50, + totalCount: 51, }); -export const generateEmptyApiResponse = () => ([{ +export const generateNextPageResponse = () => ({ + assets: [ + { + id: 'mOckID6-3', + displayName: 'mOckID6-3', + locked: false, + externalUrl: 'static_tab_1', + portableUrl: 'May 17, 2023 at 22:08 UTC', + contentType: 'application/octet-stream', + dateAdded: '', + thumbnail: null, + }, + ], +}); + +export const generateEmptyApiResponse = () => ({ assets: [], totalCount: 0, -}]); +}); export const generateNewAssetApiResponse = () => ({ asset: { @@ -133,6 +149,8 @@ export const getStatusValue = (status) => { switch (status) { case RequestStatus.DENIED: return 403; + case RequestStatus.FAILED: + return 404; default: return 200; } diff --git a/src/files-and-videos/generic/EditFileErrors.jsx b/src/files-and-videos/generic/EditFileErrors.jsx index 0b1d04986..3f42defd0 100644 --- a/src/files-and-videos/generic/EditFileErrors.jsx +++ b/src/files-and-videos/generic/EditFileErrors.jsx @@ -11,10 +11,18 @@ const EditFileErrors = ({ addFileStatus, deleteFileStatus, updateFileStatus, + loadingStatus, // injected intl, }) => ( <> + resetErrors({ errorType: 'loading' })} + isError={loadingStatus === RequestStatus.FAILED || loadingStatus === RequestStatus.PARTIAL} + > + {intl.formatMessage(messages.errorAlertMessage, { message: errorMessages.loading })} + resetErrors({ errorType: 'add' })} @@ -75,10 +83,12 @@ EditFileErrors.propTypes = { lock: PropTypes.arrayOf(PropTypes.string), download: PropTypes.arrayOf(PropTypes.string).isRequired, thumbnail: PropTypes.arrayOf(PropTypes.string), + loading: PropTypes.string.isRequired, }).isRequired, addFileStatus: PropTypes.string.isRequired, deleteFileStatus: PropTypes.string.isRequired, updateFileStatus: PropTypes.string.isRequired, + loadingStatus: PropTypes.string.isRequired, // injected intl: intlShape.isRequired, }; diff --git a/src/files-and-videos/videos-page/VideosPage.jsx b/src/files-and-videos/videos-page/VideosPage.jsx index 79c9ff1b4..3b4fe807f 100644 --- a/src/files-and-videos/videos-page/VideosPage.jsx +++ b/src/files-and-videos/videos-page/VideosPage.jsx @@ -181,6 +181,7 @@ const VideosPage = ({ ); } + return ( @@ -190,6 +191,7 @@ const VideosPage = ({ addFileStatus={addVideoStatus} deleteFileStatus={deleteVideoStatus} updateFileStatus={updateVideoStatus} + loadingStatus={loadingStatus} />
@@ -209,35 +211,39 @@ const VideosPage = ({ ) : null} - {isVideoTranscriptEnabled ? ( - - ) : null} - + {loadingStatus !== RequestStatus.FAILED && ( + <> + {isVideoTranscriptEnabled && ( + + )} + + + )} ); diff --git a/src/files-and-videos/videos-page/VideosPage.test.jsx b/src/files-and-videos/videos-page/VideosPage.test.jsx index 05e7f32fa..0168b1a4a 100644 --- a/src/files-and-videos/videos-page/VideosPage.test.jsx +++ b/src/files-and-videos/videos-page/VideosPage.test.jsx @@ -60,12 +60,14 @@ const mockStore = async ( ) => { const fetchVideosUrl = getVideosUrl(courseId); axiosMock.onGet(fetchVideosUrl).reply(getStatusValue(status), generateFetchVideosApiResponse()); + renderComponent(); await executeThunk(fetchVideos(courseId), store.dispatch); }; const emptyMockStore = async (status) => { const fetchVideosUrl = getVideosUrl(courseId); axiosMock.onGet(fetchVideosUrl).reply(getStatusValue(status), generateEmptyApiResponse()); + renderComponent(); await executeThunk(fetchVideos(courseId), store.dispatch); }; @@ -93,25 +95,21 @@ describe('FilesAndUploads', () => { }); it('should return placeholder component', async () => { - renderComponent(); await mockStore(RequestStatus.DENIED); expect(screen.getByTestId('under-construction-placeholder')).toBeVisible(); }); it('should not render transcript settings button', async () => { - renderComponent(); await emptyMockStore(RequestStatus.SUCCESSFUL); expect(screen.queryByText(videoMessages.transcriptSettingsButtonLabel.defaultMessage)); }); it('should have Video uploads title', async () => { - renderComponent(); await emptyMockStore(RequestStatus.SUCCESSFUL); expect(screen.getByText(videoMessages.heading.defaultMessage)).toBeVisible(); }); it('should render dropzone', async () => { - renderComponent(); await emptyMockStore(RequestStatus.SUCCESSFUL); expect(screen.getByTestId('files-dropzone')).toBeVisible(); @@ -119,7 +117,6 @@ describe('FilesAndUploads', () => { }); it('should upload a single file', async () => { - renderComponent(); await emptyMockStore(RequestStatus.SUCCESSFUL); const dropzone = screen.getByTestId('files-dropzone'); await act(async () => { @@ -162,7 +159,6 @@ describe('FilesAndUploads', () => { describe('table view', () => { it('should render transcript settings button', async () => { - renderComponent(); await mockStore(RequestStatus.SUCCESSFUL); const transcriptSettingsButton = screen.getByText(videoMessages.transcriptSettingsButtonLabel.defaultMessage); expect(transcriptSettingsButton).toBeVisible(); @@ -175,7 +171,6 @@ describe('FilesAndUploads', () => { }); it('should render table with gallery card', async () => { - renderComponent(); await mockStore(RequestStatus.SUCCESSFUL); expect(screen.getByTestId('files-data-table')).toBeVisible(); @@ -183,7 +178,6 @@ describe('FilesAndUploads', () => { }); it('should switch table to list view', async () => { - renderComponent(); await mockStore(RequestStatus.SUCCESSFUL); expect(screen.getByTestId('files-data-table')).toBeVisible(); @@ -201,7 +195,6 @@ describe('FilesAndUploads', () => { }); it('should update video thumbnail', async () => { - renderComponent(); await mockStore(RequestStatus.SUCCESSFUL); axiosMock.onPost(`${getApiBaseUrl()}/video_images/${courseId}/mOckID1`).reply(200, { image_url: 'url' }); const addThumbnailButton = screen.getByTestId('video-thumbnail-mOckID1'); @@ -217,7 +210,6 @@ describe('FilesAndUploads', () => { describe('table actions', () => { it('should upload a single file', async () => { - renderComponent(); await mockStore(RequestStatus.SUCCESSFUL); const mockResponseData = { status: '200', ok: true, blob: () => 'Data' }; const mockFetchResponse = Promise.resolve(mockResponseData); @@ -236,10 +228,8 @@ describe('FilesAndUploads', () => { }); it('should have disabled action buttons', async () => { - renderComponent(); await mockStore(RequestStatus.SUCCESSFUL); const actionsButton = screen.getByText(messages.actionsButtonLabel.defaultMessage); - expect(actionsButton).toBeVisible(); await waitFor(() => { fireEvent.click(actionsButton); @@ -250,12 +240,10 @@ describe('FilesAndUploads', () => { }); it('delete button should be enabled and delete selected file', async () => { - renderComponent(); await mockStore(RequestStatus.SUCCESSFUL); const selectCardButton = screen.getAllByTestId('datatable-select-column-checkbox-cell')[0]; fireEvent.click(selectCardButton); const actionsButton = screen.getByText(messages.actionsButtonLabel.defaultMessage); - expect(actionsButton).toBeVisible(); await waitFor(() => { fireEvent.click(actionsButton); @@ -289,12 +277,10 @@ describe('FilesAndUploads', () => { }); it('download button should be enabled and download single selected file', async () => { - renderComponent(); await mockStore(RequestStatus.SUCCESSFUL); const selectCardButton = screen.getAllByTestId('datatable-select-column-checkbox-cell')[0]; fireEvent.click(selectCardButton); const actionsButton = screen.getByText(messages.actionsButtonLabel.defaultMessage); - expect(actionsButton).toBeVisible(); await waitFor(() => { fireEvent.click(actionsButton); @@ -311,13 +297,11 @@ describe('FilesAndUploads', () => { }); it('download button should be enabled and download multiple selected files', async () => { - renderComponent(); await mockStore(RequestStatus.SUCCESSFUL); const selectCardButtons = screen.getAllByTestId('datatable-select-column-checkbox-cell'); fireEvent.click(selectCardButtons[0]); fireEvent.click(selectCardButtons[1]); const actionsButton = screen.getByText(messages.actionsButtonLabel.defaultMessage); - expect(actionsButton).toBeVisible(); await waitFor(() => { fireEvent.click(actionsButton); @@ -337,7 +321,6 @@ describe('FilesAndUploads', () => { describe('Sort and filter button', () => { beforeEach(async () => { - renderComponent(); await mockStore(RequestStatus.SUCCESSFUL); const sortAndFilterButton = screen.getByText(messages.sortButtonLabel.defaultMessage); @@ -431,12 +414,9 @@ describe('FilesAndUploads', () => { describe('card menu actions', () => { describe('Info', () => { it('should open video info', async () => { - renderComponent(); await mockStore(RequestStatus.SUCCESSFUL); - expect(screen.getByTestId('grid-card-mOckID1')).toBeVisible(); const videoMenuButton = screen.getByTestId('file-menu-dropdown-mOckID1'); - expect(videoMenuButton).toBeVisible(); axiosMock.onGet(`${getVideosUrl(courseId)}/mOckID1/usage`) .reply(201, { @@ -460,11 +440,8 @@ describe('FilesAndUploads', () => { }); it('should open video info modal and show info tab', async () => { - renderComponent(); await mockStore(RequestStatus.SUCCESSFUL); - expect(screen.getByTestId('grid-card-mOckID1')).toBeVisible(); const videoMenuButton = screen.getByTestId('file-menu-dropdown-mOckID1'); - expect(videoMenuButton).toBeVisible(); axiosMock.onGet(`${getVideosUrl(courseId)}/mOckID1/usage`).reply(201, { usageLocations: [] }); await waitFor(() => { @@ -481,11 +458,8 @@ describe('FilesAndUploads', () => { }); it('should open video info modal and show transcript tab', async () => { - renderComponent(); await mockStore(RequestStatus.SUCCESSFUL); - expect(screen.getByTestId('grid-card-mOckID1')).toBeVisible(); const videoMenuButton = screen.getByTestId('file-menu-dropdown-mOckID1'); - expect(videoMenuButton).toBeVisible(); axiosMock.onGet(`${getVideosUrl(courseId)}/mOckID1/usage`).reply(201, { usageLocations: [] }); await waitFor(() => { @@ -505,7 +479,6 @@ describe('FilesAndUploads', () => { }); it('should show transcript error', async () => { - renderComponent(); await mockStore(RequestStatus.SUCCESSFUL); const videoMenuButton = screen.getByTestId('file-menu-dropdown-mOckID3'); @@ -525,12 +498,9 @@ describe('FilesAndUploads', () => { }); it('download button should download file', async () => { - renderComponent(); await mockStore(RequestStatus.SUCCESSFUL); - expect(screen.getByTestId('grid-card-mOckID1')).toBeVisible(); const videoMenuButton = screen.getByTestId('file-menu-dropdown-mOckID1'); - expect(videoMenuButton).toBeVisible(); await waitFor(() => { fireEvent.click(within(videoMenuButton).getByLabelText('file-menu-toggle')); @@ -543,12 +513,9 @@ describe('FilesAndUploads', () => { }); it('delete button should delete file', async () => { - renderComponent(); await mockStore(RequestStatus.SUCCESSFUL); - expect(screen.getByTestId('grid-card-mOckID1')).toBeVisible(); const fileMenuButton = screen.getByTestId('file-menu-dropdown-mOckID1'); - expect(fileMenuButton).toBeVisible(); await waitFor(() => { axiosMock.onDelete(`${getCourseVideosApiUrl(courseId)}/mOckID1`).reply(204); @@ -569,11 +536,20 @@ describe('FilesAndUploads', () => { }); describe('api errors', () => { + it('404 intitial fetch should show error', async () => { + await mockStore(RequestStatus.FAILED); + + const { loadingStatus } = store.getState().videos; + expect(loadingStatus).toEqual(RequestStatus.FAILED); + + expect(screen.getByText('Error')).toBeVisible(); + }); + it('invalid file size should show error', async () => { const errorMessage = 'File download.png exceeds maximum size of 5 GB.'; - renderComponent(); await mockStore(RequestStatus.SUCCESSFUL); axiosMock.onPost(getCourseVideosApiUrl(courseId)).reply(413, { error: errorMessage }); + const addFilesButton = screen.getAllByLabelText('file-input')[3]; await act(async () => { userEvent.upload(addFilesButton, file); @@ -586,9 +562,9 @@ describe('FilesAndUploads', () => { }); it('404 add file should show error', async () => { - renderComponent(); await mockStore(RequestStatus.SUCCESSFUL); axiosMock.onPost(getCourseVideosApiUrl(courseId)).reply(404); + const addFilesButton = screen.getAllByLabelText('file-input')[3]; await act(async () => { userEvent.upload(addFilesButton, file); @@ -601,9 +577,9 @@ describe('FilesAndUploads', () => { }); it('404 add thumbnail should show error', async () => { - renderComponent(); await mockStore(RequestStatus.SUCCESSFUL); axiosMock.onPost(`${getApiBaseUrl()}/video_images/${courseId}/mOckID1`).reply(404); + const addThumbnailButton = screen.getByTestId('video-thumbnail-mOckID1'); const thumbnail = new File(['test'], 'sOMEUrl.jpg', { type: 'image/jpg' }); await act(async () => { @@ -617,7 +593,6 @@ describe('FilesAndUploads', () => { }); it('404 upload file to server should show error', async () => { - renderComponent(); await mockStore(RequestStatus.SUCCESSFUL); const mockResponseData = { status: '404', ok: false, blob: () => 'Data' }; const mockFetchResponse = Promise.reject(mockResponseData); @@ -637,12 +612,9 @@ describe('FilesAndUploads', () => { }); it('404 delete should show error', async () => { - renderComponent(); await mockStore(RequestStatus.SUCCESSFUL); - expect(screen.getByTestId('grid-card-mOckID1')).toBeVisible(); const videoMenuButton = screen.getByTestId('file-menu-dropdown-mOckID1'); - expect(videoMenuButton).toBeVisible(); await waitFor(() => { axiosMock.onDelete(`${getCourseVideosApiUrl(courseId)}/mOckID1`).reply(404); @@ -664,12 +636,9 @@ describe('FilesAndUploads', () => { }); it('404 usage path fetch should show error', async () => { - renderComponent(); await mockStore(RequestStatus.SUCCESSFUL); - expect(screen.getByTestId('grid-card-mOckID3')).toBeVisible(); const videoMenuButton = screen.getByTestId('file-menu-dropdown-mOckID3'); - expect(videoMenuButton).toBeVisible(); axiosMock.onGet(`${getVideosUrl(courseId)}/mOckID3/usage`).reply(404); await waitFor(() => { @@ -685,8 +654,8 @@ describe('FilesAndUploads', () => { }); it('multiple video files fetch failure should show error', async () => { - renderComponent(); await mockStore(RequestStatus.SUCCESSFUL); + const selectCardButtons = screen.getAllByTestId('datatable-select-column-checkbox-cell'); fireEvent.click(selectCardButtons[0]); fireEvent.click(selectCardButtons[2]); diff --git a/src/files-and-videos/videos-page/data/slice.js b/src/files-and-videos/videos-page/data/slice.js index 6a6410fcd..74574660f 100644 --- a/src/files-and-videos/videos-page/data/slice.js +++ b/src/files-and-videos/videos-page/data/slice.js @@ -21,6 +21,7 @@ const slice = createSlice({ download: [], usageMetrics: [], transcript: [], + loading: '', }, }, reducers: { @@ -76,8 +77,12 @@ const slice = createSlice({ }, updateErrors: (state, { payload }) => { const { error, message } = payload; - const currentErrorState = state.errors[error]; - state.errors[error] = [...currentErrorState, message]; + if (error === 'loading') { + state.errors.loading = message; + } else { + const currentErrorState = state.errors[error]; + state.errors[error] = [...currentErrorState, message]; + } }, clearErrors: (state, { payload }) => { const { error } = payload; diff --git a/src/files-and-videos/videos-page/data/thunks.js b/src/files-and-videos/videos-page/data/thunks.js index df0bd214f..245ffd0bb 100644 --- a/src/files-and-videos/videos-page/data/thunks.js +++ b/src/files-and-videos/videos-page/data/thunks.js @@ -55,6 +55,7 @@ export function fetchVideos(courseId) { if (error.response && error.response.status === 403) { dispatch(updateLoadingStatus({ status: RequestStatus.DENIED })); } else { + dispatch(updateErrors({ error: 'loading', message: 'Failed to load videos' })); dispatch(updateLoadingStatus({ courseId, status: RequestStatus.FAILED })); } } diff --git a/src/files-and-videos/videos-page/factories/mockApiResponses.jsx b/src/files-and-videos/videos-page/factories/mockApiResponses.jsx index 3aa621e9d..c20aa9013 100644 --- a/src/files-and-videos/videos-page/factories/mockApiResponses.jsx +++ b/src/files-and-videos/videos-page/factories/mockApiResponses.jsx @@ -69,7 +69,7 @@ export const initialState = { }, transcriptCredentials: { cielo24: false, '3PlayMedia': false }, }, - loadingStatus: RequestStatus.SUCCESSFUL, + loadingStatus: RequestStatus.IN_PROGRESS, updatingStatus: '', addingStatus: '', deletingStatus: '', @@ -82,6 +82,7 @@ export const initialState = { download: [], usageMetrics: [], transcript: [], + loading: '', }, }, models: { @@ -225,9 +226,77 @@ export const generateAddVideoApiResponse = () => ({ ], }); -export const generateEmptyApiResponse = () => ([{ +export const generateEmptyApiResponse = () => ({ previousUploads: [], -}]); + image_upload_url: '/video_images/course', + video_handler_url: '/videos/course', + encodings_download_url: '/video_encodings_download/course', + default_video_image_url: '/static/studio/images/video-images/default_video_image.png', + concurrent_upload_limit: 4, + video_supported_file_formats: ['.mp4', '.mov'], + video_upload_max_file_size: '5', + video_image_settings: { + video_image_upload_enabled: true, + max_size: 2097152, + min_size: 2048, + max_width: 1280, + max_height: 720, + supported_file_formats: { + '.bmp': 'image/bmp', + '.bmp2': 'image/x-ms-bmp', + '.gif': 'image/gif', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + }, + }, + is_video_transcript_enabled: false, + active_transcript_preferences: null, + transcript_credentials: {}, + transcript_available_languages: [{ language_code: 'ab', language_text: 'Abkhazian' }], + video_transcript_settings: { + transcript_download_handler_url: '/transcript_download/', + transcript_upload_handler_url: '/transcript_upload/', + transcript_delete_handler_url: '/transcript_delete/course', + trancript_download_file_format: 'srt', + transcript_preferences_handler_url: '/transcript_preferences/course', + transcript_credentials_handler_url: '/transcript_credentials/course', + transcription_plans: { + Cielo24: { + display_name: 'Cielo24', + turnaround: { PRIORITY: 'Priority (24 hours)', STANDARD: 'Standard (48 hours)' }, + fidelity: { + MECHANICAL: { + display_name: 'Mechanical (75% accuracy)', + languages: { nl: 'Dutch', en: 'English', fr: 'French' }, + }, + PREMIUM: { display_name: 'Premium (95% accuracy)', languages: { en: 'English' } }, + PROFESSIONAL: { + display_name: 'Professional (99% accuracy)', + languages: { ar: 'Arabic', 'zh-tw': 'Chinese - Mandarin (Traditional)' }, + }, + }, + }, + '3PlayMedia': { + display_name: '3Play Media', + turnaround: { + two_hour: '2 hours', + same_day: 'Same day', + rush: '24 hours (rush)', + expedited: '2 days (expedited)', + standard: '4 days (standard)', + extended: '10 days (extended)', + }, + languages: { en: 'English', el: 'Greek', zh: 'Chinese' }, + translations: { + es: ['en'], + en: ['el', 'en', 'zh'], + }, + }, + }, + }, + pagination_context: {}, +}); export const generateNewVideoApiResponse = () => ({ files: [{ @@ -240,6 +309,8 @@ export const getStatusValue = (status) => { switch (status) { case RequestStatus.DENIED: return 403; + case RequestStatus.FAILED: + return 404; default: return 200; }