fix: files page timeout (#749)

This commit is contained in:
Kristin Aoki
2023-12-15 16:55:24 -05:00
committed by GitHub
parent 75eb0c307e
commit 476f779e76
13 changed files with 308 additions and 174 deletions

View File

@@ -12,6 +12,7 @@ export const RequestStatus = {
DENIED: 'denied',
PENDING: 'pending',
CLEAR: 'clear',
PARTIAL: 'partial',
};
/**

View File

@@ -167,6 +167,7 @@ const FilesPage = ({
</div>
);
}
return (
<FilesPageProvider courseId={courseId}>
<Container size="xl" className="p-4 pt-4.5">
@@ -176,28 +177,31 @@ const FilesPage = ({
addFileStatus={addAssetStatus}
deleteFileStatus={deleteAssetStatus}
updateFileStatus={updateAssetStatus}
loadingStatus={loadingStatus}
/>
<div className="h2">
<FormattedMessage {...messages.heading} />
</div>
<FileTable
{...{
courseId,
data,
handleAddFile,
handleDeleteFile,
handleDownloadFile,
handleLockFile,
handleUsagePaths,
handleErrorReset,
handleFileOrder,
tableColumns,
maxFileSize,
thumbnailPreview,
infoModalSidebar,
files: assets,
}}
/>
{loadingStatus !== RequestStatus.FAILED && (
<FileTable
{...{
courseId,
data,
handleAddFile,
handleDeleteFile,
handleDownloadFile,
handleLockFile,
handleUsagePaths,
handleErrorReset,
handleFileOrder,
tableColumns,
maxFileSize,
thumbnailPreview,
infoModalSidebar,
files: assets,
}}
/>
)}
</Container>
</FilesPageProvider>
);

View File

@@ -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]);

View File

@@ -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);
}

View File

@@ -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,

View File

@@ -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 }));
};
}

View File

@@ -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;
}

View File

@@ -11,10 +11,18 @@ const EditFileErrors = ({
addFileStatus,
deleteFileStatus,
updateFileStatus,
loadingStatus,
// injected
intl,
}) => (
<>
<ErrorAlert
hideHeading={false}
dismissError={() => resetErrors({ errorType: 'loading' })}
isError={loadingStatus === RequestStatus.FAILED || loadingStatus === RequestStatus.PARTIAL}
>
{intl.formatMessage(messages.errorAlertMessage, { message: errorMessages.loading })}
</ErrorAlert>
<ErrorAlert
hideHeading={false}
dismissError={() => 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,
};

View File

@@ -181,6 +181,7 @@ const VideosPage = ({
</div>
);
}
return (
<VideosPageProvider courseId={courseId}>
<Container size="xl" className="p-4 pt-4.5">
@@ -190,6 +191,7 @@ const VideosPage = ({
addFileStatus={addVideoStatus}
deleteFileStatus={deleteVideoStatus}
updateFileStatus={updateVideoStatus}
loadingStatus={loadingStatus}
/>
<ActionRow>
<div className="h2">
@@ -209,35 +211,39 @@ const VideosPage = ({
</Button>
) : null}
</ActionRow>
{isVideoTranscriptEnabled ? (
<TranscriptSettings
{...{
isTranscriptSettingsOpen,
closeTranscriptSettings,
handleErrorReset,
errorMessages,
transcriptStatus,
courseId,
}}
/>
) : null}
<FileTable
{...{
courseId,
data,
handleAddFile,
handleDeleteFile,
handleDownloadFile,
handleUsagePaths,
handleErrorReset,
handleFileOrder,
tableColumns,
maxFileSize,
thumbnailPreview,
infoModalSidebar,
files: videos,
}}
/>
{loadingStatus !== RequestStatus.FAILED && (
<>
{isVideoTranscriptEnabled && (
<TranscriptSettings
{...{
isTranscriptSettingsOpen,
closeTranscriptSettings,
handleErrorReset,
errorMessages,
transcriptStatus,
courseId,
}}
/>
)}
<FileTable
{...{
courseId,
data,
handleAddFile,
handleDeleteFile,
handleDownloadFile,
handleUsagePaths,
handleErrorReset,
handleFileOrder,
tableColumns,
maxFileSize,
thumbnailPreview,
infoModalSidebar,
files: videos,
}}
/>
</>
)}
</Container>
</VideosPageProvider>
);

View File

@@ -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]);

View File

@@ -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;

View File

@@ -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 }));
}
}

View File

@@ -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;
}