import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { CircularProgress, IconButton, Menu, MenuItem, Button } from '@mui/material'; import MoreVertIcon from '@mui/icons-material/MoreVert'; import { Utils } from '@pega/react-sdk-components/lib/components/helpers/utils'; import { clearFieldErrorMessages, deleteAttachments, getIconFromFileType, getMappedValue, insertAttachments, useDeepMemo, useFileDownload, validateFileExtension } from './AttachmentUtils'; import { validateMaxSize } from '@pega/react-sdk-components/lib/components/helpers/attachmentShared'; import type { PageInstructionOptions } from './Attachment.types'; import type { PConnFieldProps } from '@pega/react-sdk-components/lib/types/PConnProps'; import './Attachment.css'; interface AttachmentProps extends Omit { // If any, enter additional props that only exist on this component value: any; allowMultiple: boolean | string; extensions: string; editMode: string; isTableFormatter: boolean; } export default function Attachment(props: AttachmentProps) { const { value, getPConnect, label, validatemessage, extensions, displayMode, helperText, editMode, isTableFormatter } = props; /* this is a temporary fix because required is supposed to be passed as a boolean and NOT as a string */ let { required, disabled, allowMultiple } = props; [required, disabled, allowMultiple] = [required, disabled, allowMultiple].map( prop => prop === true || (typeof prop === 'string' && prop === 'true') ); const pConn = getPConnect(); const localizationService = pConn.getLocalizationService(); const actionSequencer = useMemo(() => PCore.getActionsSequencer(), []); const rawValue = pConn.getComponentConfig().value; const isAttachmentAnnotationPresent = typeof rawValue === 'object' ? false : rawValue?.includes('@ATTACHMENT'); const { attachments, isOldAttachment } = isAttachmentAnnotationPresent ? value : PCore.getAttachmentUtils().prepareAttachmentData(value); let valueRef = (pConn.getStateProps() as any).value; valueRef = valueRef.indexOf('.') === 0 ? valueRef.substring(1) : valueRef; pConn.setReferenceList(`.${valueRef}`); const isMultiAttachmentInInlineEditTable = isTableFormatter && allowMultiple && editMode === 'tableRows'; const [files, setFiles] = useState(attachments); const overrideLocalState = useRef(false); const attachmentCount = useRef(attachments.length); const filesWithError = useRef([]); const multiAttachmentsInInlineEdit = useRef([]); const thumbnailURLs = useRef([]); const contextName = pConn.getContextName(); const onFileDownload = useFileDownload(contextName); const localizedVal = PCore.getLocaleUtils().getLocaleValue; const localeCategory = 'CosmosFields'; const uploadMultipleFilesLabel = localizedVal('file_upload_text_multiple', localeCategory); const uploadSingleFileLabel = localizedVal('file_upload_text_one', localeCategory); const deleteIcon = Utils.getImageSrc('trash', Utils.getSDKStaticConentUrl()); const srcImg = Utils.getImageSrc('document-doc', Utils.getSDKStaticConentUrl()); const [menuAnchorEl, setMenuAnchorEl] = useState(null); const [menuOpenIndex, setMenuOpenIndex] = useState(null); const fileInputRef = useRef(null); const [toggleUploadBegin, setToggleUploadBegin] = useState(false); const deleteFile = useCallback( (file, fileIndex) => { setMenuAnchorEl(null); setMenuOpenIndex(null); // reset the file input so that it will allow re-uploading the same file after deletion if (fileInputRef.current) { fileInputRef.current.value = ''; // Reset the input } if (filesWithError.current.length > 0) { filesWithError.current = filesWithError.current.filter(fileWithError => fileWithError.props.id !== file.props.id); if (filesWithError.current.length === 0) { clearFieldErrorMessages(pConn); } } if (file.inProgress) { // @ts-ignore - Expected 1 arguments, but got 2.ts(2554) PCore.getAttachmentUtils().cancelRequest(file.props.id, contextName); actionSequencer.deRegisterBlockingAction(contextName).catch(() => {}); setFiles(localFiles => { return localFiles.filter(localFile => localFile.props.id !== file.props.id); }); } else { deleteAttachments([file], pConn, multiAttachmentsInInlineEdit.current, { allowMultiple, isOldAttachment, isMultiAttachmentInInlineEditTable, attachmentCount: attachmentCount.current, deleteIndex: fileIndex } as any); // Filter out without deleted file and reset the file indexes setFiles(localFiles => { let tempLocalFiles = [...localFiles]; tempLocalFiles = tempLocalFiles.filter(localFile => localFile.props.id !== file.props.id); tempLocalFiles.forEach(localFile => { if (!localFile.props.error && !file.props.error) { const updatedDeleteIndex = localFile.responseProps.deleteIndex > fileIndex ? localFile.responseProps.deleteIndex - 1 : localFile.responseProps.deleteIndex; localFile.props.onDelete = () => deleteFile(localFile, updatedDeleteIndex); localFile.responseProps.deleteIndex = updatedDeleteIndex; } }); return tempLocalFiles; }); if (!file.props.error) { attachmentCount.current -= 1; } } setToggleUploadBegin(false); }, [pConn] ); const onUploadProgress = (id, ev) => { const progress = Math.floor((ev.loaded / ev.total) * 100); setFiles(localFiles => [ ...localFiles.map(localFile => { if (localFile.props?.id === id) { localFile.inProgress = true; localFile.props.progress = progress; } return localFile; }) ]); }; const populateErrorAndUpdateRedux = file => { const fieldName = (pConn.getStateProps() as any).value; // set errors to property to block submit even on errors in file upload PCore.getMessageManager().addMessages({ messages: [ { type: 'error', message: localizationService.getLocalizedText('Error with one or more files') } ], property: fieldName, pageReference: pConn.getPageReference(), context: contextName }); insertAttachments([file], pConn, multiAttachmentsInInlineEdit.current, { allowMultiple, isOldAttachment, isMultiAttachmentInInlineEditTable, attachmentCount: attachmentCount.current } as any); }; const errorHandler = (isFetchCanceled, file) => { return error => { if (!isFetchCanceled(error)) { let uploadFailMsg = localizationService.getLocalizedText('Something went wrong'); if (error.response && error.response.data && error.response.data.errorDetails) { uploadFailMsg = localizationService.getLocalizedText(error.response.data.errorDetails[0].localizedValue); } setFiles(current => { return current.map((localFile, index) => { if (localFile.props.id === file.props.id) { localFile.props.meta = uploadFailMsg; localFile.props.error = true; localFile.props.onDelete = () => deleteFile(localFile, index); localFile.props.icon = getIconFromFileType(localFile.type); localFile.props.name = localizationService.getLocalizedText('Unable to upload file'); localFile.inProgress = false; delete localFile.props.progress; filesWithError.current.push(localFile); populateErrorAndUpdateRedux(localFile); } return localFile; }); }); } throw error; }; }; const onFileAdded = event => { let addedFiles = Array.from(event.target.files); addedFiles = allowMultiple ? addedFiles : [addedFiles[0]]; const maxAttachmentSize = PCore.getEnvironmentInfo().getMaxAttachmentSize() || '5'; const tempFilesToBeUploaded = [ ...addedFiles.map((f: any, index) => { f.ID = `${new Date().getTime()}I${index}`; f.props = { type: f.type, name: f.name, id: f.ID, format: f.name.split('.').pop(), icon: getIconFromFileType(f.type), onDelete: () => deleteFile(f, index), thumbnail: window.URL.createObjectURL(f) }; if (!validateMaxSize(f, maxAttachmentSize)) { f.props.error = true; f.props.meta = localizationService.getLocalizedText(`File is too big. Max allowed size is ${maxAttachmentSize}MB.`); } else if (!validateFileExtension(f, extensions)) { f.props.error = true; f.props.meta = `${localizationService.getLocalizedText('File has invalid extension. Allowed extensions are:')} ${extensions.replaceAll( '.', '' )}`; } if (f.props.error) { const fieldName = (pConn.getStateProps() as any).value; PCore.getMessageManager().addMessages({ messages: [ { type: 'error', message: localizationService.getLocalizedText('Error with one or more files') } ], property: fieldName, pageReference: pConn.getPageReference(), context: contextName }); } return f; }) ]; const tempFilesWithError = tempFilesToBeUploaded.filter(f => f.props.error); if (tempFilesWithError.length > 0) { filesWithError.current = [...filesWithError.current, ...tempFilesWithError]; insertAttachments(tempFilesWithError, pConn, multiAttachmentsInInlineEdit.current, { allowMultiple, isOldAttachment, isMultiAttachmentInInlineEditTable, attachmentCount: attachmentCount.current } as PageInstructionOptions); } setFiles(current => (!allowMultiple ? [...tempFilesToBeUploaded] : [...current, ...tempFilesToBeUploaded])); setToggleUploadBegin(true); }; const uploadFiles = useCallback(() => { const filesToBeUploaded = files .filter(e => { const isFileUploaded = e.props && e.props.progress === 100; const fileHasError = e.props && e.props.error; const isFileUploadedInLastStep = e.responseProps && e.responseProps.ID !== 'temp'; const isFileUploadInProgress = e.inProgress; return !isFileUploadInProgress && !isFileUploaded && !fileHasError && !isFileUploadedInLastStep; }) .map(file => PCore.getAttachmentUtils().uploadAttachment( file, ev => { onUploadProgress(file.props.id, ev); }, isFetchCanceled => { return errorHandler(isFetchCanceled, file); }, contextName ) ); // allow new files to be added when other files upload is still in progress setToggleUploadBegin(false); Promise.allSettled(filesToBeUploaded) .then((fileResponses: any) => { fileResponses = fileResponses.filter(fr => fr.status !== 'rejected'); // in case of deleting an in progress file, promise gets cancelled but still enters then block if (fileResponses.length > 0) { setFiles(localFiles => { const tempFilesUploaded = [...localFiles]; tempFilesUploaded.forEach(localFile => { // if attach field has multiple files & in bw any error files are present // Example : files = [properFile1, errFile, errFile, properFile2] // indexes for delete & preview should be for files [properFile1, properFile2] which is [1,2] const index = fileResponses.findIndex(fileResponse => fileResponse.value.clientFileID === localFile.props.id); if (index >= 0) { fileResponses[index].value.thumbnail = localFile.props.thumbnail; localFile.inProgress = false; localFile.ID = fileResponses[index].value.ID; localFile.props.meta = localizationService.getLocalizedText('Uploaded successfully'); localFile.props.progress = 100; localFile.handle = fileResponses[index].value.ID; localFile.label = valueRef; localFile.responseProps = { pzInsKey: 'temp' }; } }); return tempFilesUploaded; }); insertAttachments(fileResponses, pConn, multiAttachmentsInInlineEdit.current, { allowMultiple, isOldAttachment, isMultiAttachmentInInlineEditTable, attachmentCount: attachmentCount.current, insert: true } as any); attachmentCount.current += fileResponses.length; if (filesWithError.current.length === 0) { clearFieldErrorMessages(pConn); } } actionSequencer.deRegisterBlockingAction(contextName).catch(() => {}); }) .catch(error => { console.log(error); setToggleUploadBegin(false); }); }, [files]); useEffect(() => { if (toggleUploadBegin && files.length > 0) { actionSequencer .registerBlockingAction(contextName) .then(() => { uploadFiles(); }) .catch(e => { console.error(e); }); } }, [toggleUploadBegin]); useEffect(() => { if (filesWithError.current.length === 0) { clearFieldErrorMessages(pConn); } }, [filesWithError]); const memoizedAttachments = useDeepMemo(() => { return attachments; }, [attachments]); // Prepares new structure as per Cosmos component (immutable - does not mutate source) const transformAttachments = () => { let deleteIndex = -1; const transformedFiles = attachments.map(attachment => { const newProps = { ...attachment.props, id: attachment.responseProps.ID, format: attachment.props.name.split('.').pop() }; const newResponseProps = { ...attachment.responseProps }; if (newProps.error) { newResponseProps.deleteIndex = deleteIndex; } else { deleteIndex += 1; newResponseProps.deleteIndex = deleteIndex; } if (newProps.thumbnail) { thumbnailURLs.current.push(newProps.thumbnail); } return { ...attachment, props: newProps, responseProps: newResponseProps }; }); return transformedFiles; }; useEffect(() => { const caseID = PCore.getStoreValue(`.${getMappedValue('pyID')}`, PCore.getResolvedConstantValue('caseInfo.content'), contextName); if (displayMode !== 'DISPLAY_ONLY') { PCore.getPubSubUtils().subscribe( PCore.getConstants().PUB_SUB_EVENTS.CASE_EVENTS.ASSIGNMENT_SUBMISSION, () => { overrideLocalState.current = true; }, caseID ); } // When component mounts, only set local files state from redux. const serverFiles = transformAttachments(); setFiles(serverFiles); filesWithError.current = serverFiles.filter(file => file.props.error); return () => { if (displayMode !== 'DISPLAY_ONLY') { PCore.getPubSubUtils().unsubscribe(PCore.getConstants().PUB_SUB_EVENTS.CASE_EVENTS.ASSIGNMENT_SUBMISSION, caseID); } }; }, []); useEffect(() => { if (overrideLocalState.current) { const serverFiles = transformAttachments(); overrideLocalState.current = false; attachmentCount.current = attachments.length; filesWithError.current = []; setFiles(serverFiles); } else { // Determine whether refresh call has overridden any error files in redux, push error files back to redux from local state to perform client side validation during assignment submit const errorFiles = attachments.filter(attachment => attachment.props.error); if (errorFiles.length === 0 && filesWithError.current.length > 0) { // Check if local file state contains error files and push those to redux const uniqueKey = getMappedValue('pzInsKey'); const transformedErrorFiles = filesWithError.current.map(errorFile => { const filename = errorFile.props.name; return { [uniqueKey]: errorFile.props.id, FileName: filename, Category: '', FileExtension: filename.split('.').pop() ?? filename, error: errorFile.props.error || null }; }); let key = ''; let updatedAttachments: any = []; if (allowMultiple || isOldAttachment) { key = isOldAttachment ? `${valueRef}.pxResults` : valueRef; const existingAttachments = PCore.getStoreValue(`.${key}`, pConn.getPageReference(), pConn.getContextName()) || []; updatedAttachments = [...existingAttachments, ...transformedErrorFiles]; } else { key = valueRef; updatedAttachments = transformedErrorFiles[0]; } PCore.getStateUtils().updateState(pConn.getContextName(), key, updatedAttachments, { pageReference: pConn.getPageReference(), isArrayDeepMerge: false, removePropertyFromChangedList: true }); } else if (filesWithError.current.length === 0) { // Sync local state from server when attachments genuinely changed (e.g., table remount after modal submit) const hasActiveUpload = files.some(f => f.inProgress); if (!hasActiveUpload) { const serverFiles = transformAttachments(); attachmentCount.current = attachments.length; filesWithError.current = []; setFiles(serverFiles); } } } }, [memoizedAttachments]); const handleClick = (event, index) => { setMenuAnchorEl(event.currentTarget); setMenuOpenIndex(index); }; const handleClose = () => { setMenuAnchorEl(null); setMenuOpenIndex(null); }; const content = (
); const fileDisplay = (
{files && files.length > 0 && files.map((item, index) => { return (
{!item.inProgress && } {item.inProgress && (
)}
{item.props.name}
{item.props.meta &&
{item.props.meta}
}
{(item.ID || item.responseProps?.localAttachment) && ( )} {!item.ID && !item.responseProps?.localAttachment && (
handleClick(event, index)} size='large' > { handleClose(); onFileDownload(item.responseProps ? item.responseProps : {}); }} > Download deleteFile(item, index)}> Delete
)}
); })}
); const displayOnlyFileDisplay = (
{files && files.length > 0 && files.map((item, index) => { return (
{item.props.name}
{item.props.meta &&
{item.props.meta}
}
handleClick(event, index)} size='large' > { handleClose(); onFileDownload(item.responseProps ? item.responseProps : {}); }} > Download
); })}
); if (displayMode === 'DISPLAY_ONLY') { return (
{label} {validatemessage !== '' ? {validatemessage} : {helperText}} {files && files.length > 0 ?
{displayOnlyFileDisplay}
: ---}
); } return (
{label} {((files.length === 0 && !allowMultiple) || allowMultiple) &&
{content}
} {validatemessage !== '' ? {validatemessage} : {helperText}} {files && files.length > 0 &&
{fileDisplay}
}
); }