Big folder upload (#780)

This commit is contained in:
Montassar Ghanmy
2025-02-04 15:56:07 +01:00
committed by GitHub
parent 51910fea58
commit 6002974306
39 changed files with 1190 additions and 543 deletions
@@ -1,221 +0,0 @@
import React from 'react';
import { PauseCircle, PlayCircle, Trash2 } from 'react-feather';
import { Row, Col, Typography, Divider, Progress, Button, Tooltip } from 'antd';
import { capitalize } from 'lodash';
import {
isPendingFileStatusCancel,
isPendingFileStatusError,
isPendingFileStatusPause,
isPendingFileStatusPending,
isPendingFileStatusSuccess,
} from '../../../features/files/utils/pending-files';
import Languages from '@features/global/services/languages-service';
import { useUpload } from '@features/files/hooks/use-upload';
import { PendingFileRecoilType, PendingFileType } from '@features/files/types/file';
type PropsType = {
pendingFileState: PendingFileRecoilType;
pendingFile: PendingFileType;
};
const { Text } = Typography;
export default ({ pendingFileState, pendingFile }: PropsType) => {
const { pauseOrResumeUpload, cancelUpload } = useUpload();
const getProgressStrokeColor = (status: PendingFileRecoilType['status']) => {
if (isPendingFileStatusCancel(status)) return 'var(--error)';
if (isPendingFileStatusError(status)) return 'var(--error)';
if (isPendingFileStatusPause(status)) return 'var(--warning)';
if (isPendingFileStatusPending(status)) return 'var(--progress-bar-color)';
return 'var(--success)';
};
const setStatus = () => {
switch (pendingFileState.status) {
case 'error':
case 'pause':
case 'cancel':
return 'exception';
case 'pending':
return 'active';
case 'success':
return 'success';
default:
return 'normal';
}
};
return (
<div
style={{
backgroundColor:
isPendingFileStatusCancel(pendingFileState.status) ||
isPendingFileStatusError(pendingFileState.status)
? 'var(--error-background)'
: undefined,
}}
>
<Row
className="testid:pending-files-row"
justify="space-between"
align="middle"
wrap={false}
style={{
height: 39,
width: '100%',
}}
>
<Col className="small-left-margin" flex="auto" style={{ lineHeight: '16px' }}>
{pendingFile?.originalFile?.name ? (
<Row justify="start" align="middle" wrap={false}>
<Text
ellipsis
style={{
maxWidth: isPendingFileStatusPause(pendingFile.status) ? 130 : 160,
verticalAlign: 'middle',
}}
className="testid:file-name"
>
{capitalize(pendingFile?.originalFile.name)}
</Text>
{isPendingFileStatusPause(pendingFile.status) && (
<Text type="secondary" className='ant-typography-single-line' style={{ verticalAlign: 'middle', marginLeft: 4 }}>
({Languages.t('general.paused')})
</Text>
)}
</Row>
) : (
<div
style={{
marginTop: 8,
height: 8,
maxWidth: 160,
borderRadius: 8,
backgroundColor: 'var(--grey-background)',
}}
/>
)}
{pendingFile?.label ? (
<Row justify="start" align="middle" wrap={false}>
<Text
className="testid:file-label"
ellipsis
style={{
maxWidth: isPendingFileStatusPause(pendingFile.status) ? 130 : 160,
verticalAlign: 'middle',
}}
>
{pendingFile?.label}
</Text>
</Row>
) : (
<div
style={{
marginTop: 8,
height: 8,
maxWidth: 160,
borderRadius: 8,
backgroundColor: 'var(--grey-background)',
}}
/>
)}
</Col>
<Col
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
lineHeight: '16px',
}}
>
{pendingFileState.id ? (
!isPendingFileStatusSuccess(pendingFileState.status) &&
!isPendingFileStatusError(pendingFileState.status) ? (
<Tooltip
placement="top"
title={
isPendingFileStatusPause(pendingFileState.status)
? Languages.t('general.resume')
: Languages.t('general.pause')
}
className="pending-file-row-tooltip-file-status"
>
<Button
type="link"
shape="circle"
disabled={isPendingFileStatusError(pendingFileState.status)}
icon={
isPendingFileStatusPause(pendingFileState.status) ? (
<PlayCircle size={16} color="var(--black)" />
) : (
<PauseCircle size={16} color="var(--black)" />
)
}
onClick={() => pauseOrResumeUpload(pendingFileState.id)}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
className="testid:button-toggle-tooltip-status"
/>
</Tooltip>
) : (
<div style={{ width: 32 }} />
)
) : (
<div
style={{
marginTop: 8,
height: 8,
maxWidth: 32,
borderRadius: 8,
backgroundColor: 'var(--grey-background)',
}}
/>
)}
</Col>
<Col
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
lineHeight: '16px',
}}
>
{!isPendingFileStatusSuccess(pendingFileState.status) &&
!isPendingFileStatusError(pendingFileState.status) ? (
<Tooltip title={Languages.t('general.cancel')} placement="top" className="testid:pending-file-row-tooltip-cancel">
<Button
type="link"
shape="circle"
icon={<Trash2 size={16} color={'var(--black)'} />}
onClick={() => cancelUpload(pendingFileState.id)}
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}
className="testid:button-toggle-tooltip-cancel"
/>
</Tooltip>
) : (
<div style={{ width: 32 }} />
)}
</Col>
</Row>
<div className="file-progress-bar-container testid:progress-bar">
<Progress
type="line"
className="file-progress-bar"
percent={pendingFileState.progress * 100}
showInfo={false}
trailColor="var(--white)"
status={setStatus()}
strokeColor={getProgressStrokeColor(pendingFileState.status)}
/>
</div>
<Divider style={{ margin: 0 }} />
</div>
);
};
@@ -1,99 +0,0 @@
import React, { useCallback, useState } from 'react';
import classNames from 'classnames';
import { Minus, Plus } from 'react-feather';
import { Layout, Row, Col, Typography } from 'antd';
import PerfectScrollbar from 'react-perfect-scrollbar';
import moment from 'moment';
import PendingFileRow from './pending-file-row';
import Languages from '@features/global/services/languages-service';
import { PendingFileRecoilType } from '@features/files/types/file';
import { useUpload } from '@features/files/hooks/use-upload';
import './styles.scss';
type PropsType = {
pendingFilesState: PendingFileRecoilType[];
visible: boolean;
};
const { Text } = Typography;
const { Header, Content } = Layout;
export default ({ pendingFilesState, visible }: PropsType) => {
const { getOnePendingFile, currentTask } = useUpload();
const [hiddenPendingFiles, setHiddenPendingFiles] = useState<boolean>(false);
const handleTimeChange = useCallback(() => {
const pendingFiles = pendingFilesState.map(state => getOnePendingFile(state.id));
const uploadingFiles = pendingFiles.filter(f => f?.resumable && f.resumable.isUploading());
const remainingSizeTotal = uploadingFiles
.map(f => (1 - f.progress) * (f?.originalFile?.size || 0))
.reduce((accumulator: number, nextValue: number) => accumulator + nextValue, 0);
const speed =
uploadingFiles
.map(f => f.speed)
.reduce((accumulator: number, nextValue: number) => accumulator + nextValue, 0) /
uploadingFiles.map(f => f.speed).length;
const timeRemainingInMs = remainingSizeTotal / speed;
const momentTimeRemaining = moment(new Date().getTime() + timeRemainingInMs).fromNow();
if (momentTimeRemaining !== 'Invalid date') {
return Languages.t('components.pending_file_list.estimation.end') + ` ${momentTimeRemaining}...`;
} else {
return Languages.t('components.pending_file_list.estimation.approximations');
}
}, [getOnePendingFile, pendingFilesState]);
return pendingFilesState.length > 0 ? (
<Layout className={'pending-files-list-layout ' + (visible ? 'visible' : '') + ' testid:pending-file-list'}>
<Header
className={classNames('pending-files-list-header')}
onClick={() => setHiddenPendingFiles(!hiddenPendingFiles)}
>
<Row justify="space-between" align="middle">
<Col>
<Text style={{ color: 'var(--white)' }}>
{currentTask.total > 0 && `${currentTask.uploaded}/${currentTask.total} `}
{Languages.t('components.drive_dropzone.uploading')}
</Text>
</Col>
<Col style={{ display: 'flex', alignItems: 'center' }}>
{hiddenPendingFiles ? <Plus size={18} /> : <Minus size={18} />}
</Col>
</Row>
</Header>
{!hiddenPendingFiles && (
<Content className="pending-files-list-content">
<PerfectScrollbar
options={{ suppressScrollX: true, suppressScrollY: false }}
component="div"
style={{ width: '100%', height: 114 }}
>
<Row justify="start" align="middle" style={{ background: '#DFE7FE' }}>
<Col className="small-left-margin">
<Text style={{ color: '#6C6C6D' }}>{handleTimeChange()}</Text>
</Col>
</Row>
<>
{pendingFilesState.length > 0 &&
pendingFilesState.map((pendingFileState, index) => (
<PendingFileRow
key={`${pendingFileState.file?.id}-${index}`}
pendingFileState={pendingFileState}
pendingFile={getOnePendingFile(pendingFileState.id)}
/>
))}
</>
</PerfectScrollbar>
</Content>
)}
</Layout>
) : (
<></>
);
};
@@ -1,41 +0,0 @@
.pending-files-list-layout {
border: 1px solid var(--black-alpha-70);
box-shadow: var(--box-shadow-base);
width: 256px;
border-radius: var(--border-radius-base);
position: absolute;
z-index: 100;
bottom: 8px;
right: 24px;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s;
transition-delay: 1s;
&.visible {
opacity: 1;
transition-delay: 0s;
pointer-events: all;
}
.pending-files-list-header {
color: var(--white);
padding: 0 8px;
background-color: var(--secondary);
height: 32px;
line-height: 32px;
border-radius: 8px 8px 0 0;
cursor: pointer;
&.hidden {
border-radius: 8px;
}
}
.pending-files-list-content {
min-height: 32px;
max-height: 176px;
overflow-y: none;
}
}
@@ -0,0 +1,35 @@
import {
FileTypeArchiveIcon,
FileTypeDocumentIcon,
FileTypeSpreadsheetIcon,
FileTypeMediaIcon,
FileTypeSlidesIcon,
FileTypePdfIcon,
} from 'app/atoms/icons-colored';
// Map mime types to their respective JSX icon elements
export const fileTypeIconsMap = {
'application/pdf': <FileTypePdfIcon />,
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': (
<FileTypeDocumentIcon />
),
'application/msword': <FileTypeDocumentIcon />,
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': <FileTypeSpreadsheetIcon />,
'application/vnd.ms-excel': <FileTypeSpreadsheetIcon />,
'application/vnd.ms-powerpoint': <FileTypeSlidesIcon />,
'application/vnd.openxmlformats-officedocument.presentationml.presentation': (
<FileTypeSlidesIcon />
),
'application/zip': <FileTypeArchiveIcon />,
'application/x-rar-compressed': <FileTypeArchiveIcon />,
'application/x-tar': <FileTypeArchiveIcon />,
'application/x-7z-compressed': <FileTypeArchiveIcon />,
'application/x-bzip': <FileTypeArchiveIcon />,
'application/x-bzip2': <FileTypeArchiveIcon />,
'application/x-gzip': <FileTypeArchiveIcon />,
'video/mp4': <FileTypeMediaIcon />,
'video/mpeg': <FileTypeMediaIcon />,
'video/ogg': <FileTypeMediaIcon />,
'video/webm': <FileTypeMediaIcon />,
'video/quicktime': <FileTypeMediaIcon />,
};
@@ -0,0 +1,152 @@
import { useState, useMemo, useCallback } from 'react';
import { useUpload } from '@features/files/hooks/use-upload';
import PerfectScrollbar from 'react-perfect-scrollbar';
import { ArrowDownIcon, ArrowUpIcon } from 'app/atoms/icons-colored';
import { UploadRootListType } from 'app/features/files/types/file';
import Languages from '@features/global/services/languages-service';
import PendingRootRow from './pending-root-row';
import { UploadStateEnum } from 'app/features/files/services/file-upload-service';
const getFilteredRoots = (keys: string[], roots: UploadRootListType) => {
const inProgress = keys.filter(key => roots[key].status === 'uploading');
const completed = keys.filter(key => roots[key].status === 'completed');
const paused = keys.filter(key => roots[key].status === 'paused');
return { inProgress, completed, paused };
};
interface ModalHeaderProps {
uploadingCount: number;
completedCount: number;
totalRoots: number;
uploadingPercentage: number;
toggleModal: () => void;
modalExpanded: boolean;
}
const ModalHeader: React.FC<ModalHeaderProps> = ({
uploadingCount,
completedCount,
totalRoots,
uploadingPercentage,
toggleModal,
modalExpanded,
}) => (
<div className="w-full flex bg-[#45454A] text-white p-4 items-center justify-between">
<p className="testid:upload-modal-head-status">
{uploadingCount > 0
? `${Languages.t('general.uploading')} ${uploadingCount}`
: `${Languages.t('general.uploaded')} ${completedCount}`}{' '}
{Languages.t('general.files')}
</p>
<button
className="ml-auto flex items-center testid:upload-modal-toggle-arrow"
onClick={toggleModal}
>
{modalExpanded ? <ArrowDownIcon /> : <ArrowUpIcon />}
</button>
</div>
);
interface ModalFooterProps {
pauseOrResumeUpload: () => void;
cancelUpload: () => void;
isPaused: () => boolean;
uploadingCount: number;
}
const ModalFooter: React.FC<ModalFooterProps> = ({
pauseOrResumeUpload,
cancelUpload,
isPaused,
uploadingCount,
}) => (
<div className="w-full flex bg-[#F0F2F3] text-black p-4 items-center justify-between">
<div className="flex space-x-4 ml-auto">
{uploadingCount > 0 && (
<button
className="text-blue-500 px-4 py-2 rounded hover:bg-blue-600 hover:text-white testid:upload-modal-pause-resume"
onClick={pauseOrResumeUpload}
>
{isPaused() ? Languages.t('general.resume') : Languages.t('general.pause')}
</button>
)}
<button
className="text-blue-500 px-4 py-2 rounded hover:bg-blue-600 hover:text-white testid:upload-modal-cancel-close"
onClick={cancelUpload}
>
{uploadingCount ? Languages.t('general.cancel') : Languages.t('general.close')}
</button>
</div>
</div>
);
const PendingRootList = ({
roots,
status,
parentId,
}: {
roots: UploadRootListType;
status: UploadStateEnum;
parentId: string;
}): JSX.Element => {
const [modalExpanded, setModalExpanded] = useState(true);
const { pauseOrResumeUpload, cancelUpload } = useUpload();
const keys = useMemo(() => Object.keys(roots || {}), [roots]);
const {
inProgress: rootsInProgress,
completed: rootsCompleted,
paused: rootsPaused,
} = useMemo(() => getFilteredRoots(keys, roots), [keys, roots]);
const isPaused = useCallback(() => status === UploadStateEnum.Paused, [status]);
const totalRoots = keys.length;
const uploadingCount = rootsInProgress.length;
const completedCount = rootsCompleted.length;
const pausedCount = rootsPaused.length;
const uploadingPercentage = Math.floor((uploadingCount / totalRoots) * 100) || 100;
const toggleModal = useCallback(() => setModalExpanded(prev => !prev), []);
return (
<>
{totalRoots > 0 && (
<div className="fixed bottom-4 right-4 w-1/3 shadow-lg rounded-sm overflow-hidden testid:upload-modal">
<ModalHeader
uploadingCount={uploadingCount + pausedCount}
completedCount={completedCount}
totalRoots={totalRoots}
uploadingPercentage={uploadingPercentage}
toggleModal={toggleModal}
modalExpanded={modalExpanded}
/>
{modalExpanded && (
<div className="modal-body">
<div className="bg-white px-4 py-2">
<PerfectScrollbar
options={{ suppressScrollX: true, suppressScrollY: false }}
component="div"
style={{ width: '100%', maxHeight: 300 }}
>
{keys.map(key => (
<PendingRootRow key={key} rootKey={key} root={roots[key]} parentId={parentId} />
))}
</PerfectScrollbar>
</div>
<ModalFooter
pauseOrResumeUpload={pauseOrResumeUpload}
cancelUpload={cancelUpload}
isPaused={isPaused}
uploadingCount={uploadingCount + pausedCount}
/>
</div>
)}
</div>
)}
</>
);
};
export default PendingRootList;
@@ -0,0 +1,208 @@
import { useEffect, useState, useCallback } from 'react';
import { useUpload } from '@features/files/hooks/use-upload';
import RouterService from '@features/router/services/router-service';
import { UploadRootType } from 'app/features/files/types/file';
import {
FileTypeUnknownIcon,
FolderIcon,
CheckGreenIcon,
PauseIcon,
CancelIcon,
ResumeIcon,
ShowFolderIcon,
} from 'app/atoms/icons-colored';
import { fileTypeIconsMap } from './file-type-icon-map';
import { useDriveActions } from 'app/features/drive/hooks/use-drive-actions';
import { useDriveItem } from 'app/features/drive/hooks/use-drive-item';
import Languages from 'app/features/global/services/languages-service';
const PendingRootRow = ({
rootKey,
root,
parentId,
}: {
rootKey: string;
root: UploadRootType;
parentId: string;
}): JSX.Element => {
const { pauseOrResumeRootUpload, cancelRootUpload, clearRoots } = useUpload();
const [showFolder, setShowFolder] = useState(false);
const [restoredFolder, setRestoredFolder] = useState(false);
const { restore } = useDriveActions();
const { refresh } = useDriveItem(parentId || '');
const firstPendingFile = root.items[0];
const uploadedFilesSize = root.uploadedSize;
const uploadProgress = Math.floor((uploadedFilesSize / root.size) * 100);
const isUploadCompleted = root.status === 'completed';
const isFileRoot = rootKey.includes('.');
// Callback function to open the folder after the upload is completed
const handleShowFolder = useCallback(() => {
if (!showFolder || isFileRoot) {
const redirectionURL = RouterService.generateRouteFromState({
itemId: root.id,
});
window.open(redirectionURL, '_blank');
} else {
RouterService.push(RouterService.generateRouteFromState({ dirId: root.id || '' }));
}
}, [showFolder, root, isFileRoot, clearRoots]);
// Function to determine the icon for the root
// If the root is a file, it will show the file icon based on the content type
// If the root is a folder, it will show the folder icon
const itemTypeIcon = useCallback(
(type: string) =>
isFileRoot ? (
fileTypeIconsMap[type as keyof typeof fileTypeIconsMap] || <FileTypeUnknownIcon />
) : (
<FolderIcon />
),
[isFileRoot],
);
// A timeout to show the folder icon after the upload is completed
// This is to give a visual feedback to the user and will be shown shortly
// after the green check icon appears
useEffect(() => {
if (isUploadCompleted) {
const timeout = setTimeout(async () => {
setShowFolder(true);
}, 1500);
return () => clearTimeout(timeout);
}
}, [isUploadCompleted]);
useEffect(() => {
const postProcess = async () => {
if (isUploadCompleted && !restoredFolder) {
await new Promise(resolve => setTimeout(resolve, 1000));
await restore(root.id, parentId);
await new Promise(resolve => setTimeout(resolve, 1000));
await refresh(parentId);
}
};
if (isUploadCompleted && !restoredFolder) {
setRestoredFolder(true);
postProcess();
}
}, [isUploadCompleted]);
// Helper to convert size to the closest unit
const formatFileSize = (sizeInBytes: number): string => {
if (sizeInBytes) {
if (sizeInBytes < 1024) return `${sizeInBytes} Bytes`;
if (sizeInBytes < 1024 ** 2) return `${(sizeInBytes / 1024).toFixed(2)} KB`;
if (sizeInBytes < 1024 ** 3) return `${(sizeInBytes / 1024 ** 2).toFixed(2)} MB`;
return `${(sizeInBytes / 1024 ** 3).toFixed(2)} GB`;
} else {
return '0 Bytes';
}
};
// Helper to truncate the root name / key if it is too long
const truncateRootName = (rootName: string): string => {
if (rootName.length > 30) {
return `${rootName.substring(0, 20)}...`;
}
return rootName;
};
return (
<div className="root-row">
<div className="root-details mt-2">
<div className="flex items-center">
<div className="w-10 h-10 flex items-center justify-center bg-[#f3f3f7] rounded-md">
<div className="w-full h-full flex items-center justify-center testid:upload-modal-row-type">
{itemTypeIcon(firstPendingFile?.type)}
</div>
</div>
<p className="ml-4">
<span className="font-bold">{truncateRootName(rootKey)} </span>
{root.status !== 'failed' && root.uploadedSize > 0 && (
<span className="ml-4 text-sm">
({formatFileSize(root.uploadedSize)} / {formatFileSize(root.size)})
</span>
)}
{root.status === 'failed' && (
<span className="ml-4 text-red-500">{Languages.t('general.upload_failed')}</span>
)}
</p>
<div className="progress-check flex items-center justify-center ml-auto">
{isUploadCompleted ? (
<button
onClick={handleShowFolder}
className="hover:bg-gray-100 p-2 rounded-md transition-all duration-200 testid:upload-modal-row-show-folder"
>
{!isFileRoot && (
<>
<CheckGreenIcon
className={`transition-opacity ${
showFolder ? 'opacity-0 w-0 h-0' : 'opacity-1 hover:scale-110'
}`}
/>
<ShowFolderIcon
className={`transition-opacity duration-300 ${
showFolder ? 'opacity-1 hover:scale-110' : 'opacity-0 w-0 h-0'
}`}
/>
</>
)}
{isFileRoot && (
<CheckGreenIcon className="opacity-1 hover:scale-110 transition-transform duration-200" />
)}
</button>
) : (
!['cancelled', 'failed'].includes(root.status) &&
firstPendingFile?.status !== 'error' && (
<>
<button
onClick={() => pauseOrResumeRootUpload(rootKey)}
className="hover:bg-blue-100 p-2 rounded-md transition-all duration-200 testid:upload-modal-row-pause-resume"
>
{root.status === 'paused' ? (
<ResumeIcon className="hover:scale-110 transition-transform duration-200" />
) : (
<PauseIcon className="hover:scale-110 transition-transform duration-200" />
)}
</button>
<button
className="ml-2 hover:bg-red-100 p-2 rounded-md transition-all duration-200 testid:upload-modal-row-cancel"
onClick={() => cancelRootUpload(rootKey)}
>
<CancelIcon className="hover:scale-110 transition-transform duration-200" />
</button>
</>
)
)}
</div>
</div>
</div>
<div className="root-progress h-[3px] mt-4">
{!showFolder && (
<div className="w-full h-[3px] bg-[#F0F2F3]">
<div
className={`testid:upload-modal-row-progress h-full ${
root.status === 'failed'
? 'bg-[#FF0000]' // Red color for failed uploads
: root.status === 'cancelled'
? 'bg-[#FFA500]' // Orange for cancelled uploads
: 'bg-[#00A029]' // Green for successful uploads
}`}
style={{
width: `${
root.status === 'failed' || root.status === 'cancelled' ? 100 : uploadProgress
}%`,
}}
></div>
</div>
)}
</div>
</div>
);
};
export default PendingRootRow;
@@ -1,16 +1,19 @@
import React from 'react';
import { useUpload } from '@features/files/hooks/use-upload';
import PendingFilesList from './pending-file-components/pending-files-list';
import PendingRootList from './pending-root-components/pending-root-list';
const ChatUploadsViewer = (): JSX.Element => {
const UploadsViewer = (): JSX.Element => {
const { currentTask } = useUpload();
return (
<PendingFilesList
visible={!!currentTask && currentTask.files.length > 0 && !currentTask.completed}
pendingFilesState={currentTask.files}
/>
);
// Destructure and provide default values for safety
const { roots = {}, status, parentId } = currentTask || {};
const rootKeys = Object.keys(roots);
// Early return for clarity
if (rootKeys.length === 0) {
return <></>;
}
return <PendingRootList roots={roots} status={status} parentId={parentId} />;
};
export default ChatUploadsViewer;
export default UploadsViewer;
@@ -1,4 +1,4 @@
import { FolderIcon } from '@heroicons/react/solid';
import { FolderIcon } from 'app/atoms/icons-colored';
import Highlighter from 'react-highlight-words';
import { useRecoilValue } from 'recoil';
import { onDriveItemDownloadClick } from '../common';
@@ -20,7 +20,7 @@ import RouterServices from '@features/router/services/router-service';
import useRouterCompany from 'app/features/router/hooks/use-router-company';
import { DocumentIcon } from '@views/client/body/drive/documents/document-icon';
export default (props: { driveItem: DriveItem & { user?: UserType }}) => {
export default (props: { driveItem: DriveItem & { user?: UserType } }) => {
const history = useHistory();
const input = useRecoilValue(SearchInputState);
const file = props.driveItem;
@@ -31,7 +31,7 @@ export default (props: { driveItem: DriveItem & { user?: UserType }}) => {
const { open } = useDrivePreview();
const company = useRouterCompany();
function openDoc(file: DriveItem){
function openDoc(file: DriveItem) {
open(file);
if (file.is_directory) setOpen(false);
}
@@ -39,7 +39,12 @@ export default (props: { driveItem: DriveItem & { user?: UserType }}) => {
return (
<div
className="flex items-center p-2 hover:bg-zinc-50 dark:hover:bg-zinc-800 rounded-md cursor-pointer testid:drive-item-result"
onClick={() => {history.push(RouterServices.generateRouteFromState({companyId: company, itemId: file.id})); openDoc(file)}}
onClick={() => {
history.push(
RouterServices.generateRouteFromState({ companyId: company, itemId: file.id }),
);
openDoc(file);
}}
>
<FileResultMedia file={file} className="w-16 h-16 mr-3" />
<div className="grow mr-3 overflow-hidden">
@@ -96,14 +101,26 @@ export const FileResultMedia = (props: {
if (file.is_directory) {
return (
<div className={'relative flex bg-blue-100 rounded-md ' + (props.className || '') + ' testid:folder-result-media'}>
<div
className={
'relative flex bg-blue-100 rounded-md ' +
(props.className || '') +
' testid:folder-result-media'
}
>
<FolderIcon className="w-10 h-10 m-auto text-blue-500 testid:folder-icon" />
</div>
);
}
return (
<div className={'relative flex bg-zinc-200 rounded-md ' + (props.className || '') + ' testid:file-result-media'}>
<div
className={
'relative flex bg-zinc-200 rounded-md ' +
(props.className || '') +
' testid:file-result-media'
}
>
<Media
size={props.size || 'md'}
url={url}
@@ -1,10 +1,13 @@
import { number } from 'prop-types';
/* eslint-disable @typescript-eslint/no-explicit-any */
type TreeItem = { [key: string]: File | TreeItem };
type TreeItem = { [key: string]: { root: string; file: File } | TreeItem };
export type FileTreeObject = {
tree: TreeItem;
documentsCount: number;
totalSize: number;
sizePerRoot: { [key: string]: number };
};
export const getFilesTree = (
@@ -119,6 +122,7 @@ export const getFilesTree = (
});
}
let timeBegin = Date.now();
[].slice.call(items).forEach(function (entry: any) {
entry = entry.webkitGetAsEntry();
if (entry) {
@@ -134,6 +138,7 @@ export const getFilesTree = (
resolve(true);
}, resolve.bind(null, true));
} else if (entry.isDirectory) {
const timeToRead = Date.now();
readDirectory(entry, null, resolve);
}
}),
@@ -145,6 +150,7 @@ export const getFilesTree = (
return false;
}
timeBegin = Date.now();
Promise.all(rootPromises).then(cb.bind(null, fd, files));
}
@@ -152,6 +158,7 @@ export const getFilesTree = (
const documents_number = paths ? paths.length : 0;
let total_size = 0;
const tree: any = {};
const size_per_root: { [key: string]: number } = {};
(paths || []).forEach(function (path, file_index) {
let dirs = tree;
const real_file = files[file_index];
@@ -163,7 +170,17 @@ export const getFilesTree = (
return;
}
if (dir_index === path.split('/').length - 1) {
dirs[dir] = real_file;
const root = path.split('/')[0];
dirs[dir] = {
file: real_file,
root,
};
// Calculate the total size of each root
if (!size_per_root[root]) {
size_per_root[root] = real_file.size;
} else {
size_per_root[root] += real_file.size;
}
} else {
if (!dirs[dir]) {
dirs[dir] = {};
@@ -172,35 +189,76 @@ export const getFilesTree = (
}
});
});
fcb && fcb(tree, documents_number, total_size);
resolve({ tree, documentsCount: documents_number, totalSize: total_size });
resolve({
tree,
documentsCount: documents_number,
totalSize: total_size,
sizePerRoot: size_per_root,
});
};
// Handle file input based on the event type, starting with `dataTransfer` for drag-and-drop events
if (event.dataTransfer) {
const dt = event.dataTransfer;
// When dragging files into the browser, `dataTransfer.items` contains a list of the dragged items.
// `webkitGetAsEntry` allows access to a directory-like API, letting us explore folders and subfolders.
// This means we can recursively scan for files in folders without relying on manual user input.
if (dt.items && dt.items.length && 'webkitGetAsEntry' in dt.items[0]) {
// Use `entriesApi` to iterate through items, handling directories and files.
// This is ideal for cases where users drag entire folder structures into the app.
entriesApi(dt.items, (files, paths) => cb(event, files || [], paths));
} else if ('getFilesAndDirectories' in dt) {
}
// If `getFilesAndDirectories` is available on `dataTransfer`, it indicates a newer API is supported.
// This API directly provides both files and directories, making it easier to process structured uploads.
else if ('getFilesAndDirectories' in dt) {
// Use `newDirectoryApi` to process files and directories in a standardized way.
newDirectoryApi(dt, (files, paths) => cb(event, files || [], paths));
} else if (dt.files) {
}
// If neither of the advanced APIs (`webkitGetAsEntry` or `getFilesAndDirectories`) is available,
// fall back to using the basic `dataTransfer.files` property.
// This works only for files, meaning directories wont be detected or handled.
else if (dt.files) {
// Use `arrayApi` to process the flat list of files.
arrayApi(dt, (files, paths) => cb(event, files || [], paths));
} else cb(event, [], []);
} else if (event.target) {
}
// If no files or directories can be detected (e.g., if the user drops something invalid),
// return an empty response to ensure the application doesnt break.
else cb(event, [], []);
}
// If the event comes from a file input field rather than drag-and-drop (`event.target` exists):
else if (event.target) {
const t = event.target as any;
// When a file input element (`<input type="file">`) is used, it stores the selected files in `target.files`.
// This is the standard way for users to upload files through a file picker dialog.
if (t.files && t.files.length) {
// Process the selected files as a flat array using `arrayApi`.
arrayApi(t, (files, paths) => cb(event, files || [], paths));
} else if ('getFilesAndDirectories' in t) {
}
// If the input element supports `getFilesAndDirectories`, handle structured uploads.
// This could occur in custom or enhanced file inputs that allow folder selection.
else if ('getFilesAndDirectories' in t) {
newDirectoryApi(t, (files, paths) => cb(event, files || [], paths));
} else {
}
// If no valid files or directories can be detected, return an empty response.
else {
cb(event, [], []);
}
} else {
}
// Fallback for cases where neither `dataTransfer` nor `target` is available:
// This typically occurs in unusual scenarios, such as handling a manually triggered upload.
else {
// If a callback (`fcb`) is provided, call it with the first file found (if any).
// This is a last-resort assumption that `event.target.files` has at least one valid file.
fcb && fcb([(event.target as any).files[0]], 1, (event.target as any).files[0].size);
// Resolve the promise with a default response, treating the single file as the entire tree.
resolve({
tree: (event.target as any).files[0],
documentsCount: 1,
totalSize: (event.target as any).files[0].size,
sizePerRoot: { [(event.target as any).files[0].name]: (event.target as any).files[0].size },
});
}
});