Created
May 19, 2025 10:37
-
-
Save ambrinchaudhary/7f824e1d71660b9816248f40618a8149 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // /Users/ambrinchaudhary/Liferay/portal/modules/apps/item-selector/item-selector-taglib/src/main/resources/META-INF/resources/js/item_selector_uploader/MultipleFileUploader.js | |
| // (This would be a new file) | |
| import {ClayButtonWithIcon, ClayButton} from '@clayui/button'; | |
| import ClayForm from '@clayui/form'; | |
| import ClayLayout from '@clayui/layout'; | |
| import ClayProgressBar from '@clayui/progress-bar'; | |
| import {useIsMounted} from '@liferay/frontend-js-react-web'; | |
| import classNames from 'classnames'; | |
| import {formatStorage, sub} from 'frontend-js-web'; | |
| import PropTypes from 'prop-types'; | |
| import React, {useEffect, useMemo, useState, useCallback} from 'react'; | |
| import {ErrorCode, useDropzone} from 'react-dropzone'; | |
| import ItemSelectorPreview from '../item_selector_preview/ItemSelectorPreview'; | |
| import DragFileBackground from './components/DragFilePlaceholder'; | |
| import getPreviewProps from './utils/getPreviewProps'; | |
| import getUploadErrorMessage from './utils/getUploadErrorMessage'; | |
| import sendFile from './utils/sendFile'; | |
| // Helper to generate a unique ID for file entries | |
| const generateFileId = (file) => `${file.name}-${file.lastModified}-${file.size}-${Math.random().toString(36).substr(2, 9)}`; | |
| function MultipleFileUploader({ | |
| // closeCaption, // May need rethinking for multiple files | |
| editImageURL, | |
| itemSelectedEventName, | |
| maxFileSize: initialMaxFileSizeString = Liferay.PropsValues | |
| .UPLOAD_SERVLET_REQUEST_IMPL_MAX_SIZE, | |
| mimeTypeRestriction, | |
| uploadItemReturnType, | |
| uploadItemURL, | |
| validExtensions, | |
| maxFiles = 0, // 0 for no limit, otherwise positive integer | |
| }) { | |
| const [filesInfo, setFilesInfo] = useState([]); | |
| const [globalErrorMessage, setGlobalErrorMessage] = useState(''); | |
| const isMounted = useIsMounted(); | |
| const maxFileSize = Number(initialMaxFileSizeString); | |
| const CLIENT_ERRORS = useMemo( | |
| () => ({ | |
| [ErrorCode.FileInvalidType]: sub( | |
| Liferay.Language.get( | |
| 'please-enter-a-file-with-a-valid-extension-x' | |
| ), | |
| [validExtensions] | |
| ), | |
| [ErrorCode.FileTooLarge]: sub( | |
| Liferay.Language.get( | |
| 'please-enter-a-file-with-a-valid-file-size-no-larger-than-x' | |
| ), | |
| [formatStorage(maxFileSize)] | |
| ), | |
| // ErrorCode.TooManyFiles is handled by react-dropzone's maxFiles option | |
| }), | |
| [maxFileSize, validExtensions] | |
| ); | |
| const updateFileState = useCallback((fileId, updates) => { | |
| setFilesInfo((prevFilesInfo) => | |
| prevFilesInfo.map((info) => | |
| info.id === fileId ? {...info, ...updates} : info | |
| ) | |
| ); | |
| }, []); | |
| const {getInputProps, getRootProps, isDragActive} = useDropzone({ | |
| accept: validExtensions === '*' ? undefined : validExtensions, | |
| maxSize: maxFileSize, | |
| maxFiles: maxFiles, | |
| multiple: true, | |
| onDropAccepted: (acceptedFiles) => { | |
| setGlobalErrorMessage(''); | |
| const newFilesToUpload = acceptedFiles.map((file) => ({ | |
| id: generateFileId(file), | |
| file, | |
| progress: null, | |
| errorMessage: null, | |
| serverData: null, | |
| uploadClient: null, | |
| status: 'pending', | |
| })); | |
| setFilesInfo((prevFilesInfo) => { | |
| const currentIds = new Set(prevFilesInfo.map(f => f.id)); | |
| const uniqueNewFiles = newFilesToUpload.filter(nf => !currentIds.has(nf.id)); | |
| return [...prevFilesInfo, ...uniqueNewFiles]; | |
| }); | |
| }, | |
| onDropRejected: (fileRejections) => { | |
| let message = Liferay.Language.get('some-files-were-rejected'); | |
| const firstRejection = fileRejections?.[0]; | |
| if (firstRejection) { | |
| const firstErrorCode = firstRejection.errors?.[0]?.code; | |
| if (firstErrorCode === ErrorCode.TooManyFiles) { | |
| message = sub(Liferay.Language.get('please-select-no-more-than-x-files'), [maxFiles]); | |
| } | |
| else if (CLIENT_ERRORS[firstErrorCode]) { | |
| message = CLIENT_ERRORS[firstErrorCode]; | |
| } | |
| } | |
| setGlobalErrorMessage(message); | |
| }, | |
| }); | |
| useEffect(() => { | |
| filesInfo.forEach((info) => { | |
| if (info.status === 'pending' && info.file && !info.uploadClient) { | |
| updateFileState(info.id, { status: 'uploading' }); | |
| const client = sendFile({ | |
| file: info.file, | |
| fileFieldName: 'itemSelectorFileName', // Consider making this a prop | |
| onError: (error) => { | |
| if (!isMounted()) return; | |
| // Check if already aborted | |
| const latestInfo = filesInfo.find(fi => fi.id === info.id); | |
| if (latestInfo && latestInfo.status === 'aborted') return; | |
| updateFileState(info.id, { | |
| errorMessage: getUploadErrorMessage(error), | |
| status: 'error', | |
| progress: null, | |
| uploadClient: null, | |
| }); | |
| }, | |
| onProgress: (progressValue) => { | |
| if (!isMounted()) return; | |
| const latestInfo = filesInfo.find(fi => fi.id === info.id); | |
| if (latestInfo && latestInfo.status === 'aborted') return; | |
| updateFileState(info.id, { progress: progressValue }); | |
| }, | |
| onSuccess: (itemData) => { | |
| if (!isMounted()) return; | |
| const latestInfo = filesInfo.find(fi => fi.id === info.id); | |
| if (latestInfo && latestInfo.status === 'aborted') return; | |
| if (itemData.success) { | |
| updateFileState(info.id, { | |
| serverData: itemData, | |
| status: 'success', | |
| progress: 100, | |
| uploadClient: null, | |
| }); | |
| if (itemSelectedEventName && uploadItemReturnType && Liferay.Events) { | |
| Liferay.Events.emit(itemSelectedEventName, { | |
| // Adjust data structure as needed for multiple items | |
| data: itemData[uploadItemReturnType] || itemData, | |
| fileInfo: {name: info.file.name, size: info.file.size} | |
| }); | |
| } | |
| } else { | |
| updateFileState(info.id, { | |
| errorMessage: getUploadErrorMessage(itemData.error, maxFileSize), | |
| status: 'error', | |
| progress: null, | |
| uploadClient: null, | |
| }); | |
| } | |
| }, | |
| url: uploadItemURL, | |
| }); | |
| // Store client to allow aborting | |
| updateFileState(info.id, { uploadClient: client }); | |
| } | |
| }); | |
| }, [filesInfo, isMounted, maxFileSize, itemSelectedEventName, uploadItemReturnType, uploadItemURL, updateFileState]); | |
| const handleRemoveOrCancelFile = (fileIdToRemove) => { | |
| setFilesInfo((prevFilesInfo) => | |
| prevFilesInfo.filter((info) => { | |
| if (info.id === fileIdToRemove) { | |
| if (info.uploadClient && info.status === 'uploading') { | |
| info.uploadClient.abort(); | |
| } | |
| return false; // Remove from list | |
| } | |
| return true; | |
| }) | |
| ); | |
| }; | |
| const clearAll = () => { | |
| filesInfo.forEach(info => { | |
| if (info.uploadClient && info.status === 'uploading') { | |
| info.uploadClient.abort(); | |
| } | |
| }); | |
| setFilesInfo([]); | |
| setGlobalErrorMessage(''); | |
| }; | |
| const hasUploadingFiles = filesInfo.some(f => f.status === 'uploading'); | |
| return ( | |
| <> | |
| <div | |
| {...getRootProps({ | |
| className: classNames('dropzone', { | |
| 'dropzone-drag-active': isDragActive, | |
| 'dropzone-uploading': hasUploadingFiles, | |
| }), | |
| })} | |
| > | |
| <input {...getInputProps()} disabled={hasUploadingFiles} /> | |
| <DragFileBackground mimeTypeRestriction={mimeTypeRestriction} /> | |
| </div> | |
| {globalErrorMessage && ( | |
| <ClayForm.FeedbackGroup className="has-error mt-2"> | |
| <ClayForm.FeedbackItem> | |
| <ClayForm.FeedbackIndicator symbol="exclamation-full" /> | |
| {globalErrorMessage} | |
| </ClayForm.FeedbackItem> | |
| </ClayForm.FeedbackGroup> | |
| )} | |
| {filesInfo.length > 0 && ( | |
| <div className="multi-file-upload-list mt-3"> | |
| {filesInfo.map((info) => ( | |
| <div key={info.id} className="file-item mb-3 p-2 border rounded"> | |
| <ClayLayout.Row align="center"> | |
| <ClayLayout.Col md={editImageURL && info.status === 'success' ? 6 : 10}> | |
| <p className="text-truncate mb-1" title={info.file.name}> | |
| <strong>{info.file.name}</strong> ({formatStorage(info.file.size)}) | |
| </p> | |
| {info.status === 'uploading' && info.progress !== null && ( | |
| <ClayProgressBar value={info.progress} /> | |
| )} | |
| {info.status === 'error' && info.errorMessage && ( | |
| <ClayForm.FeedbackGroup className="has-error"> | |
| <ClayForm.FeedbackItem> | |
| <ClayForm.FeedbackIndicator symbol="exclamation-full" /> | |
| {info.errorMessage} | |
| </ClayForm.FeedbackItem> | |
| </ClayForm.FeedbackGroup> | |
| )} | |
| {info.status === 'success' && !editImageURL && ( | |
| <div className="text-success">{Liferay.Language.get('upload-successful')}</div> | |
| )} | |
| </ClayLayout.Col> | |
| {editImageURL && info.status === 'success' && info.serverData && ( | |
| <ClayLayout.Col md={4}> | |
| <div className="item-selector-preview-container-small"> {/* Needs styling */} | |
| <ItemSelectorPreview | |
| {...getPreviewProps({ | |
| file: info.file, | |
| itemData: info.serverData, | |
| itemSelectedEventName, // Event is emitted in useEffect | |
| uploadItemReturnType, | |
| })} | |
| editImageURL={editImageURL} // May need dynamic URL if item-specific | |
| // handleClose={() => { /* Optional: remove preview or item */ }} | |
| /> | |
| </div> | |
| </ClayLayout.Col> | |
| )} | |
| <ClayLayout.Col md={2} className="text-right"> | |
| <ClayButtonWithIcon | |
| aria-label={info.status === 'uploading' ? Liferay.Language.get('cancel') : Liferay.Language.get('remove')} | |
| borderless | |
| displayType="secondary" | |
| onClick={() => handleRemoveOrCancelFile(info.id)} | |
| symbol={info.status === 'uploading' ? 'times-circle' : 'times'} | |
| /> | |
| </ClayLayout.Col> | |
| </ClayLayout.Row> | |
| </div> | |
| ))} | |
| <ClayButton displayType="secondary" onClick={clearAll} className="mt-2"> | |
| {Liferay.Language.get('clear-all')} | |
| </ClayButton> | |
| </div> | |
| )} | |
| </> | |
| ); | |
| } | |
| MultipleFileUploader.propTypes = { | |
| // editImageURL: PropTypes.string, // URL or a function (itemData) => url | |
| // itemSelectedEventName: PropTypes.string.isRequired, | |
| // maxFileSize: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), | |
| // maxFiles: PropTypes.number, | |
| // mimeTypeRestriction: PropTypes.string, | |
| // uploadItemReturnType: PropTypes.string.isRequired, | |
| // uploadItemURL: PropTypes.string.isRequired, | |
| // validExtensions: PropTypes.string, | |
| // ... (Copy relevant props from SingleFileUploader and add new ones like maxFiles) | |
| }; | |
| // export default MultipleFileUploader; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment