From 0db61f3709f646d61419f7138a360d9a07e8686e Mon Sep 17 00:00:00 2001 From: tenkalden Date: Mon, 18 May 2026 15:28:41 +0530 Subject: [PATCH] feat: add upload progress indicator and toast for upload and delete action --- .../files-page/CourseFilesTable.tsx | 6 ++ src/files-and-videos/files-page/data/api.js | 4 +- src/files-and-videos/files-page/data/slice.js | 5 ++ .../files-page/data/thunks.js | 11 ++- .../generic/ApiStatusToast.jsx | 15 +++- src/files-and-videos/generic/FileTable.jsx | 69 ++++++++++++++++++- src/files-and-videos/generic/messages.js | 10 +++ 7 files changed, 113 insertions(+), 7 deletions(-) diff --git a/src/files-and-videos/files-page/CourseFilesTable.tsx b/src/files-and-videos/files-page/CourseFilesTable.tsx index 156bbe3aa9..c1d65c3964 100644 --- a/src/files-and-videos/files-page/CourseFilesTable.tsx +++ b/src/files-and-videos/files-page/CourseFilesTable.tsx @@ -34,6 +34,9 @@ export const CourseFilesTable = () => { const { assetIds, loadingStatus, + addingStatus, + deletingStatus, + uploadProgress, usageStatus: usagePathStatus, errors: errorMessages, } = useSelector((state: DeprecatedReduxState) => state.assets); @@ -71,6 +74,9 @@ export const CourseFilesTable = () => { const data = { fileIds: assetIds, loadingStatus, + addingStatus, + deletingStatus, + uploadProgress, usagePathStatus, usageErrorMessages: errorMessages.usageMetrics, fileType: 'file', diff --git a/src/files-and-videos/files-page/data/api.js b/src/files-and-videos/files-page/data/api.js index 616338d85b..cf325a7956 100644 --- a/src/files-and-videos/files-page/data/api.js +++ b/src/files-and-videos/files-page/data/api.js @@ -114,11 +114,11 @@ export async function deleteAsset(courseId, assetId) { * @param {blockId} courseId Course ID for the course to operate on */ -export async function addAsset(courseId, file) { +export async function addAsset(courseId, file, onUploadProgress) { const formData = new FormData(); formData.append('file', file); const { data } = await getAuthenticatedHttpClient() - .post(getAssetsUrl(courseId), formData); + .post(getAssetsUrl(courseId), formData, { onUploadProgress }); 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 eba9366c0c..25f5292fb3 100644 --- a/src/files-and-videos/files-page/data/slice.js +++ b/src/files-and-videos/files-page/data/slice.js @@ -12,6 +12,7 @@ const slice = createSlice({ duplicateFiles: [], updatingStatus: '', addingStatus: '', + uploadProgress: 0, deletingStatus: '', usageStatus: '', errors: { @@ -84,6 +85,9 @@ const slice = createSlice({ const { error } = payload; state.errors[error] = []; }, + updateUploadProgress: (state, { payload }) => { + state.uploadProgress = payload.progress; + }, }, }); @@ -98,6 +102,7 @@ export const { updateEditStatus, updateDuplicateFiles, clearAssetIds, + updateUploadProgress, } = slice.actions; export const { diff --git a/src/files-and-videos/files-page/data/thunks.js b/src/files-and-videos/files-page/data/thunks.js index 2bc9c2c136..bad5b1f8f3 100644 --- a/src/files-and-videos/files-page/data/thunks.js +++ b/src/files-and-videos/files-page/data/thunks.js @@ -28,6 +28,7 @@ import { updateEditStatus, updateDuplicateFiles, clearAssetIds, + updateUploadProgress, } from './slice'; import { getUploadConflicts, updateFileValues } from './utils'; @@ -111,9 +112,15 @@ export function deleteAssetFile(courseId, id) { export function addAssetFile(courseId, file, isOverwrite) { return async (dispatch) => { dispatch(updateEditStatus({ editType: 'add', status: RequestStatus.IN_PROGRESS })); + dispatch(updateUploadProgress({ progress: 0 })); + + const onUploadProgress = ({ loaded, total }) => { + const percent = Math.round((loaded / total) * 100); + dispatch(updateUploadProgress({ progress: percent })); + }; try { - const { asset } = await addAsset(courseId, file); + const { asset } = await addAsset(courseId, file, onUploadProgress); const [parsedAssets] = updateFileValues([asset]); dispatch(addModel({ modelType: 'assets', @@ -125,6 +132,7 @@ export function addAssetFile(courseId, file, isOverwrite) { })); } dispatch(updateEditStatus({ editType: 'add', status: RequestStatus.SUCCESSFUL })); + dispatch(updateUploadProgress({ progress: 0 })); } catch (error) { if (error.response && error.response.status === 413) { const message = error.response.data.error; @@ -133,6 +141,7 @@ export function addAssetFile(courseId, file, isOverwrite) { dispatch(updateErrors({ error: 'add', message: `Failed to add ${file.name}.` })); } dispatch(updateEditStatus({ editType: 'add', status: RequestStatus.FAILED })); + dispatch(updateUploadProgress({ progress: 0 })); } }; } diff --git a/src/files-and-videos/generic/ApiStatusToast.jsx b/src/files-and-videos/generic/ApiStatusToast.jsx index 0f302330ad..c0f175dc40 100644 --- a/src/files-and-videos/generic/ApiStatusToast.jsx +++ b/src/files-and-videos/generic/ApiStatusToast.jsx @@ -1,7 +1,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import { useIntl } from '@edx/frontend-platform/i18n'; -import { Toast } from '@openedx/paragon'; +import { Icon, Toast } from '@openedx/paragon'; import messages from './messages'; const ApiStatusToast = ({ @@ -11,6 +11,7 @@ const ApiStatusToast = ({ setClose, setSelectedRows, fileType, + icon, }) => { const intl = useIntl(); const handleClose = () => { @@ -23,7 +24,12 @@ const ApiStatusToast = ({ show={isOpen} onClose={handleClose} > - {intl.formatMessage(messages.apiStatusToastMessage, { actionType, selectedRowCount, fileType })} +
+ {icon && } + + {intl.formatMessage(messages.apiStatusToastMessage, { actionType, selectedRowCount, fileType })} + +
); }; @@ -35,6 +41,11 @@ ApiStatusToast.propTypes = { setClose: PropTypes.func.isRequired, setSelectedRows: PropTypes.func.isRequired, fileType: PropTypes.string.isRequired, + icon: PropTypes.elementType, +}; + +ApiStatusToast.defaultProps = { + icon: null, }; export default ApiStatusToast; diff --git a/src/files-and-videos/generic/FileTable.jsx b/src/files-and-videos/generic/FileTable.jsx index 78e676fa88..c9d7318bbe 100644 --- a/src/files-and-videos/generic/FileTable.jsx +++ b/src/files-and-videos/generic/FileTable.jsx @@ -1,4 +1,6 @@ -import { useCallback, useEffect, useState } from 'react'; +import { + useCallback, useEffect, useRef, useState, +} from 'react'; import { useSelector } from 'react-redux'; import PropTypes from 'prop-types'; import isEmpty from 'lodash/isEmpty'; @@ -7,9 +9,13 @@ import { CardView, DataTable, Dropzone, + Icon, + ProgressBar, TextFilter, + Toast, useToggle, } from '@openedx/paragon'; +import { Check, DeleteOutline, FileUpload } from '@openedx/paragon/icons'; import { RequestStatus } from '../../data/constants'; import { sortFiles } from './utils'; @@ -59,6 +65,8 @@ const FileTable = ({ const [isAddOpen, setAddOpen, setAddClose] = useToggle(false); const [selectedRows, setSelectedRows] = useState([]); const [isDeleteConfirmationOpen, openDeleteConfirmation, closeDeleteConfirmation] = useToggle(false); + const [isUploadSuccessOpen, setUploadSuccessOpen, setUploadSuccessClose] = useToggle(false); + const prevAddingStatusRef = useRef(null); const [initialState, setInitialState] = useState({ filters: [], hiddenColumns: [], @@ -70,12 +78,40 @@ const FileTable = ({ const { loadingStatus, + addingStatus, + deletingStatus, + uploadProgress, usagePathStatus, usageErrorMessages, encodingsDownloadUrl, supportedFileFormats, fileType, } = data; + + useEffect(() => { + if (prevAddingStatusRef.current === RequestStatus.IN_PROGRESS + && addingStatus === RequestStatus.SUCCESSFUL) { + setUploadSuccessOpen(); + } + prevAddingStatusRef.current = addingStatus; + }, [addingStatus]); + + useEffect(() => { + if (isUploadSuccessOpen) { + const timer = setTimeout(() => setUploadSuccessClose(), 3000); + return () => clearTimeout(timer); + } + return undefined; + }, [isUploadSuccessOpen]); + + useEffect(() => { + if (deletingStatus === RequestStatus.SUCCESSFUL && isDeleteOpen) { + const timer = setTimeout(() => setDeleteClose(), 3000); + return () => clearTimeout(timer); + } + return undefined; + }, [deletingStatus, isDeleteOpen]); + const defaultCurrentView = (fileType === 'video' && localStorage.getItem('videosCurrentView')) || (fileType === 'file' && localStorage.getItem('filesCurrentView')) || defaultView; const [currentView, setCurrentView] = useState(defaultCurrentView); @@ -190,6 +226,18 @@ const FileTable = ({ return (
+ {addingStatus === RequestStatus.IN_PROGRESS && uploadProgress > 0 && ( +
+ +
+
+ Uploading... + {uploadProgress}% +
+ +
+
+ )} {fileType === 'file' && ( @@ -261,9 +312,20 @@ const FileTable = ({ setClose={setAddClose} setSelectedRows={setSelectedRows} fileType={fileType} + icon={FileUpload} /> )} + +
+ + {intl.formatMessage(messages.uploadSuccessToastMessage)} +
+
+