parent
3815cab856
commit
48dfc1b76c
11 changed files with 3 additions and 1087 deletions
@ -1,334 +0,0 @@ |
||||
import { Box, Checkbox, checkboxClasses } from '@mui/material'; |
||||
import { AxiosResponse } from 'axios'; |
||||
import { |
||||
FormEventHandler, |
||||
MouseEventHandler, |
||||
useEffect, |
||||
useState, |
||||
} from 'react'; |
||||
|
||||
import API_BASE_URL from '../../lib/consts/API_BASE_URL'; |
||||
import { GREY, RED, TEXT } from '../../lib/consts/DEFAULT_THEME'; |
||||
|
||||
import api from '../../lib/api'; |
||||
import ConfirmDialog from '../ConfirmDialog'; |
||||
import ContainedButton from '../ContainedButton'; |
||||
import FileInfo from './FileInfo'; |
||||
import Spinner from '../Spinner'; |
||||
|
||||
import fetchJSON from '../../lib/fetchers/fetchJSON'; |
||||
|
||||
type ReducedFileLocation = Partial< |
||||
Pick<FileLocation, 'fileLocationUUID' | 'isFileLocationActive'> |
||||
>; |
||||
|
||||
type EditRequestContent = Partial< |
||||
Pick<FileDetailMetadata, 'fileName' | 'fileType' | 'fileUUID'> |
||||
> & { |
||||
fileLocations: ReducedFileLocation[]; |
||||
}; |
||||
|
||||
type FileEditProps = { |
||||
filesOverview: FileOverviewMetadata[]; |
||||
onEditFilesComplete?: () => void; |
||||
onPurgeFilesComplete?: () => void; |
||||
}; |
||||
|
||||
type FileToEdit = FileDetailMetadata & { |
||||
dataIncompleteError?: unknown; |
||||
isSelected?: boolean; |
||||
}; |
||||
|
||||
const FILE_EDIT_FORM_DEFAULT_PROPS = { |
||||
onEditFilesComplete: undefined, |
||||
onPurgeFilesComplete: undefined, |
||||
}; |
||||
|
||||
const FileEditForm = ( |
||||
{ |
||||
filesOverview, |
||||
onEditFilesComplete, |
||||
onPurgeFilesComplete, |
||||
}: FileEditProps = FILE_EDIT_FORM_DEFAULT_PROPS as FileEditProps, |
||||
): JSX.Element => { |
||||
const [editRequestContents, setEditRequestContents] = useState< |
||||
EditRequestContent[] |
||||
>([]); |
||||
const [filesToEdit, setFilesToEdit] = useState<FileToEdit[]>([]); |
||||
const [isLoadingFilesToEdit, setIsLoadingFilesToEdit] = |
||||
useState<boolean>(false); |
||||
const [isOpenPurgeConfirmDialog, setIsOpenConfirmPurgeDialog] = |
||||
useState<boolean>(false); |
||||
const [selectedFilesCount, setSelectedFilesCount] = useState<number>(0); |
||||
|
||||
const purgeButtonStyleOverride = { |
||||
backgroundColor: RED, |
||||
color: TEXT, |
||||
|
||||
'&:hover': { backgroundColor: RED }, |
||||
}; |
||||
|
||||
const generateFileInfoChangeHandler = |
||||
(fileIndex: number): FileInfoChangeHandler => |
||||
(inputValues, { fileLocationIndex } = {}) => { |
||||
if (fileLocationIndex !== undefined) { |
||||
editRequestContents[fileIndex].fileLocations[fileLocationIndex] = { |
||||
...editRequestContents[fileIndex].fileLocations[fileLocationIndex], |
||||
...inputValues, |
||||
}; |
||||
} else { |
||||
editRequestContents[fileIndex] = { |
||||
...editRequestContents[fileIndex], |
||||
...inputValues, |
||||
}; |
||||
} |
||||
}; |
||||
|
||||
const editFiles: FormEventHandler<HTMLFormElement> = (event) => { |
||||
event.preventDefault(); |
||||
|
||||
setIsLoadingFilesToEdit(true); |
||||
|
||||
const editPromises = editRequestContents.reduce<Promise<AxiosResponse>[]>( |
||||
( |
||||
reducedEditPromises, |
||||
{ fileLocations, fileName, fileType, fileUUID }, |
||||
) => { |
||||
const editRequestContent: Partial<EditRequestContent> = {}; |
||||
|
||||
if (fileName !== undefined) { |
||||
editRequestContent.fileName = fileName; |
||||
} |
||||
|
||||
if (fileType !== undefined) { |
||||
editRequestContent.fileType = fileType; |
||||
} |
||||
|
||||
const changedFileLocations = fileLocations.reduce< |
||||
ReducedFileLocation[] |
||||
>( |
||||
( |
||||
reducedFileLocations, |
||||
{ fileLocationUUID, isFileLocationActive }, |
||||
) => { |
||||
if (isFileLocationActive !== undefined) { |
||||
reducedFileLocations.push({ |
||||
fileLocationUUID, |
||||
isFileLocationActive, |
||||
}); |
||||
} |
||||
|
||||
return reducedFileLocations; |
||||
}, |
||||
[], |
||||
); |
||||
|
||||
if (changedFileLocations.length > 0) { |
||||
editRequestContent.fileLocations = changedFileLocations; |
||||
} |
||||
|
||||
if (Object.keys(editRequestContent).length > 0) { |
||||
reducedEditPromises.push( |
||||
api.put(`/file/${fileUUID}`, editRequestContent), |
||||
); |
||||
} |
||||
|
||||
return reducedEditPromises; |
||||
}, |
||||
[], |
||||
); |
||||
|
||||
Promise.all(editPromises) |
||||
.then(() => { |
||||
setIsLoadingFilesToEdit(false); |
||||
}) |
||||
.then(onEditFilesComplete); |
||||
}; |
||||
|
||||
const purgeFiles: MouseEventHandler<HTMLButtonElement> = () => { |
||||
setIsOpenConfirmPurgeDialog(false); |
||||
setIsLoadingFilesToEdit(true); |
||||
|
||||
const purgePromises = filesToEdit |
||||
.filter(({ isSelected }) => isSelected) |
||||
.map(({ fileUUID }) => api.delete(`/file/${fileUUID}`)); |
||||
|
||||
Promise.all(purgePromises) |
||||
.then(() => { |
||||
setIsLoadingFilesToEdit(false); |
||||
}) |
||||
.then(onPurgeFilesComplete); |
||||
}; |
||||
|
||||
const cancelPurge: MouseEventHandler<HTMLButtonElement> = () => { |
||||
setIsOpenConfirmPurgeDialog(false); |
||||
}; |
||||
|
||||
const confirmPurge: MouseEventHandler<HTMLButtonElement> = () => { |
||||
// We need this local variable because setState functions are async; the
|
||||
// changes won't reflect until the next render cycle.
|
||||
// In this case, the user would have to click on the purge button twice to
|
||||
// trigger the confirmation dialog without using this local variable.
|
||||
const localSelectedFilesCount = filesToEdit.filter( |
||||
({ isSelected }) => isSelected, |
||||
).length; |
||||
|
||||
setSelectedFilesCount(localSelectedFilesCount); |
||||
|
||||
if (localSelectedFilesCount > 0) { |
||||
setIsOpenConfirmPurgeDialog(true); |
||||
} |
||||
}; |
||||
|
||||
useEffect(() => { |
||||
setIsLoadingFilesToEdit(true); |
||||
|
||||
Promise.all( |
||||
filesOverview.map(async (fileOverview: FileOverviewMetadata) => { |
||||
const fileToEdit: FileToEdit = { |
||||
...fileOverview, |
||||
fileLocations: [], |
||||
}; |
||||
|
||||
try { |
||||
const data = await fetchJSON<string[][]>( |
||||
`${API_BASE_URL}/file/${fileOverview.fileUUID}`, |
||||
); |
||||
|
||||
fileToEdit.fileLocations = data.map<FileLocation>( |
||||
({ |
||||
5: fileLocationUUID, |
||||
6: fileLocationActive, |
||||
7: anvilUUID, |
||||
8: anvilName, |
||||
9: anvilDescription, |
||||
10: hostUUID, |
||||
11: hostName, |
||||
}) => ({ |
||||
anvilDescription, |
||||
anvilName, |
||||
anvilUUID, |
||||
fileLocationUUID, |
||||
hostName, |
||||
hostUUID, |
||||
isFileLocationActive: parseInt(fileLocationActive, 10) === 1, |
||||
}), |
||||
); |
||||
} catch (fetchError) { |
||||
fileToEdit.dataIncompleteError = fetchError; |
||||
} |
||||
|
||||
return fileToEdit; |
||||
}), |
||||
).then((fetchedFilesDetail) => { |
||||
setFilesToEdit(fetchedFilesDetail); |
||||
|
||||
const initialEditRequestContents: EditRequestContent[] = []; |
||||
|
||||
for ( |
||||
let fileIndex = 0; |
||||
fileIndex < fetchedFilesDetail.length; |
||||
fileIndex += 1 |
||||
) { |
||||
const fetchedFileDetail = fetchedFilesDetail[fileIndex]; |
||||
initialEditRequestContents.push({ |
||||
fileUUID: fetchedFileDetail.fileUUID, |
||||
fileLocations: fetchedFileDetail.fileLocations.map( |
||||
({ fileLocationUUID }) => ({ |
||||
fileLocationUUID, |
||||
}), |
||||
), |
||||
}); |
||||
} |
||||
|
||||
setEditRequestContents(initialEditRequestContents); |
||||
|
||||
setIsLoadingFilesToEdit(false); |
||||
}); |
||||
}, [filesOverview]); |
||||
|
||||
return ( |
||||
<> |
||||
{isLoadingFilesToEdit ? ( |
||||
<Spinner /> |
||||
) : ( |
||||
<form onSubmit={editFiles}> |
||||
<Box |
||||
sx={{ |
||||
display: 'flex', |
||||
flexDirection: 'column', |
||||
'& > :not(:first-child)': { marginTop: '1em' }, |
||||
}} |
||||
> |
||||
{filesToEdit.map( |
||||
({ fileName, fileLocations, fileType, fileUUID }, fileIndex) => ( |
||||
<Box |
||||
key={`file-edit-${fileUUID}`} |
||||
sx={{ |
||||
display: 'flex', |
||||
flexDirection: 'row', |
||||
'& > :last-child': { |
||||
flexGrow: 1, |
||||
}, |
||||
}} |
||||
> |
||||
<Box sx={{ marginTop: '.4em' }}> |
||||
<Checkbox |
||||
onChange={({ target: { checked } }) => { |
||||
filesToEdit[fileIndex].isSelected = checked; |
||||
}} |
||||
sx={{ |
||||
color: GREY, |
||||
|
||||
[`&.${checkboxClasses.checked}`]: { |
||||
color: TEXT, |
||||
}, |
||||
}} |
||||
/> |
||||
</Box> |
||||
<FileInfo |
||||
{...{ fileName, fileType, fileLocations }} |
||||
onChange={generateFileInfoChangeHandler(fileIndex)} |
||||
/> |
||||
</Box> |
||||
), |
||||
)} |
||||
{filesToEdit.length > 0 && ( |
||||
<Box |
||||
sx={{ |
||||
display: 'flex', |
||||
flexDirection: 'row', |
||||
justifyContent: 'flex-end', |
||||
'& > :not(:last-child)': { |
||||
marginRight: '.5em', |
||||
}, |
||||
}} |
||||
> |
||||
<ContainedButton |
||||
onClick={confirmPurge} |
||||
sx={purgeButtonStyleOverride} |
||||
> |
||||
Purge |
||||
</ContainedButton> |
||||
<ContainedButton type="submit">Update</ContainedButton> |
||||
</Box> |
||||
)} |
||||
</Box> |
||||
<ConfirmDialog |
||||
actionProceedText="Purge" |
||||
content={`${selectedFilesCount} files will be removed from the system. You cannot undo this purge.`} |
||||
dialogProps={{ open: isOpenPurgeConfirmDialog }} |
||||
onCancelAppend={cancelPurge} |
||||
onProceedAppend={purgeFiles} |
||||
proceedButtonProps={{ sx: purgeButtonStyleOverride }} |
||||
titleText={`Are you sure you want to purge ${selectedFilesCount} selected files? `} |
||||
/> |
||||
</form> |
||||
)} |
||||
</> |
||||
); |
||||
}; |
||||
|
||||
FileEditForm.defaultProps = FILE_EDIT_FORM_DEFAULT_PROPS; |
||||
|
||||
export default FileEditForm; |
@ -1,203 +0,0 @@ |
||||
import { |
||||
Checkbox, |
||||
checkboxClasses, |
||||
FormControl, |
||||
FormControlLabel, |
||||
FormGroup, |
||||
Grid, |
||||
styled, |
||||
} from '@mui/material'; |
||||
import { |
||||
Sync as SyncIcon, |
||||
SyncDisabled as SyncDisabledIcon, |
||||
} from '@mui/icons-material'; |
||||
import { ReactElement, useMemo } from 'react'; |
||||
import { v4 as uuidv4 } from 'uuid'; |
||||
|
||||
import { BLUE, RED, TEXT } from '../../lib/consts/DEFAULT_THEME'; |
||||
import { UPLOAD_FILE_TYPES_ARRAY } from '../../lib/consts/UPLOAD_FILE_TYPES'; |
||||
|
||||
import List from '../List'; |
||||
import MenuItem from '../MenuItem'; |
||||
import OutlinedInput from '../OutlinedInput'; |
||||
import OutlinedInputLabel from '../OutlinedInputLabel'; |
||||
import { ExpandablePanel, InnerPanelBody } from '../Panels'; |
||||
import Select from '../Select'; |
||||
|
||||
type FileInfoProps = Pick<FileDetailMetadata, 'fileName' | 'fileLocations'> & |
||||
Partial<Pick<FileDetailMetadata, 'fileType'>> & { |
||||
isReadonly?: boolean; |
||||
onChange?: FileInfoChangeHandler; |
||||
}; |
||||
|
||||
const FILE_INFO_DEFAULT_PROPS: Partial<FileInfoProps> = { |
||||
isReadonly: undefined, |
||||
onChange: undefined, |
||||
}; |
||||
|
||||
const FileLocationActiveCheckbox = styled(Checkbox)({ |
||||
color: RED, |
||||
|
||||
[`&.${checkboxClasses.checked}`]: { |
||||
color: BLUE, |
||||
}, |
||||
}); |
||||
|
||||
const FileInfo = ( |
||||
{ |
||||
fileName, |
||||
fileType, |
||||
fileLocations, |
||||
isReadonly, |
||||
onChange, |
||||
}: FileInfoProps = FILE_INFO_DEFAULT_PROPS as FileInfoProps, |
||||
): JSX.Element => { |
||||
const idExtension = uuidv4(); |
||||
|
||||
const fileNameElementId = `file-name-${idExtension}`; |
||||
const fileNameElementLabel = 'File name'; |
||||
|
||||
const fileTypeElementId = `file-type-${idExtension}`; |
||||
const fileTypeElementLabel = 'File type'; |
||||
|
||||
const anFileLocations = useMemo( |
||||
() => |
||||
fileLocations.reduce< |
||||
Record< |
||||
string, |
||||
Pick<FileLocation, 'anvilDescription' | 'anvilName' | 'anvilUUID'> & { |
||||
flocs: FileLocation[]; |
||||
} |
||||
> |
||||
>((previous, fileLocation) => { |
||||
const { anvilDescription, anvilName, anvilUUID } = fileLocation; |
||||
|
||||
if (!previous[anvilUUID]) { |
||||
previous[anvilUUID] = { |
||||
anvilDescription, |
||||
anvilName, |
||||
anvilUUID, |
||||
flocs: [], |
||||
}; |
||||
} |
||||
|
||||
previous[anvilUUID].flocs.push(fileLocation); |
||||
|
||||
return previous; |
||||
}, {}), |
||||
[fileLocations], |
||||
); |
||||
|
||||
return ( |
||||
<FormGroup sx={{ '> :not(:first-child)': { marginTop: '1em' } }}> |
||||
<FormControl> |
||||
<OutlinedInputLabel htmlFor={fileNameElementId}> |
||||
{fileNameElementLabel} |
||||
</OutlinedInputLabel> |
||||
<OutlinedInput |
||||
defaultValue={fileName} |
||||
disabled={isReadonly} |
||||
id={fileNameElementId} |
||||
label={fileNameElementLabel} |
||||
onChange={({ target: { value } }) => { |
||||
onChange?.call(null, { |
||||
fileName: value === fileName ? undefined : value, |
||||
}); |
||||
}} |
||||
/> |
||||
</FormControl> |
||||
{fileType && ( |
||||
<FormControl> |
||||
<OutlinedInputLabel htmlFor={fileTypeElementId}> |
||||
{fileTypeElementLabel} |
||||
</OutlinedInputLabel> |
||||
<Select |
||||
defaultValue={fileType} |
||||
disabled={isReadonly} |
||||
id={fileTypeElementId} |
||||
input={<OutlinedInput label={fileTypeElementLabel} />} |
||||
onChange={({ target: { value } }) => { |
||||
onChange?.call(null, { |
||||
fileType: value === fileType ? undefined : (value as FileType), |
||||
}); |
||||
}} |
||||
> |
||||
{UPLOAD_FILE_TYPES_ARRAY.map( |
||||
([fileTypeKey, [, fileTypeDisplayString]]) => ( |
||||
<MenuItem key={fileTypeKey} value={fileTypeKey}> |
||||
{fileTypeDisplayString} |
||||
</MenuItem> |
||||
), |
||||
)} |
||||
</Select> |
||||
</FormControl> |
||||
)} |
||||
<List |
||||
listItems={anFileLocations} |
||||
listProps={{ dense: true, disablePadding: true }} |
||||
renderListItem={(anvilUUID, { anvilDescription, anvilName, flocs }) => ( |
||||
<ExpandablePanel |
||||
header={`${anvilName}: ${anvilDescription}`} |
||||
panelProps={{ padding: 0, width: '100%' }} |
||||
> |
||||
<InnerPanelBody> |
||||
<Grid |
||||
columns={{ xs: 1, sm: 2, md: 3, lg: 4, xl: 5 }} |
||||
columnSpacing="1em" |
||||
container |
||||
direction="row" |
||||
> |
||||
{flocs.map<ReactElement>( |
||||
({ |
||||
fileLocationUUID: flocUUID, |
||||
hostName, |
||||
hostUUID, |
||||
isFileLocationActive, |
||||
}) => ( |
||||
<Grid item key={`floc-${anvilUUID}-${hostUUID}`} xs={1}> |
||||
<FormControlLabel |
||||
control={ |
||||
<FileLocationActiveCheckbox |
||||
checkedIcon={<SyncIcon />} |
||||
defaultChecked={isFileLocationActive} |
||||
disabled={isReadonly} |
||||
edge="start" |
||||
icon={<SyncDisabledIcon />} |
||||
onChange={({ target: { checked } }) => { |
||||
onChange?.call( |
||||
null, |
||||
{ |
||||
isFileLocationActive: |
||||
checked === isFileLocationActive |
||||
? undefined |
||||
: checked, |
||||
}, |
||||
{ |
||||
fileLocationIndex: fileLocations.findIndex( |
||||
({ fileLocationUUID }) => |
||||
flocUUID === fileLocationUUID, |
||||
), |
||||
}, |
||||
); |
||||
}} |
||||
/> |
||||
} |
||||
label={hostName} |
||||
sx={{ color: TEXT }} |
||||
value={`${hostUUID}-sync`} |
||||
/> |
||||
</Grid> |
||||
), |
||||
)} |
||||
</Grid> |
||||
</InnerPanelBody> |
||||
</ExpandablePanel> |
||||
)} |
||||
/> |
||||
</FormGroup> |
||||
); |
||||
}; |
||||
|
||||
FileInfo.defaultProps = FILE_INFO_DEFAULT_PROPS; |
||||
|
||||
export default FileInfo; |
@ -1,80 +0,0 @@ |
||||
import { Box, Divider, List, ListItem } from '@mui/material'; |
||||
import * as prettyBytes from 'pretty-bytes'; |
||||
|
||||
import { DIVIDER } from '../../lib/consts/DEFAULT_THEME'; |
||||
import { UPLOAD_FILE_TYPES } from '../../lib/consts/UPLOAD_FILE_TYPES'; |
||||
|
||||
import { BodyText } from '../Text'; |
||||
|
||||
type FileListProps = { |
||||
filesOverview: FileOverviewMetadata[]; |
||||
}; |
||||
|
||||
const FileList = ({ filesOverview }: FileListProps): JSX.Element => ( |
||||
<List> |
||||
{filesOverview.map( |
||||
({ fileChecksum, fileName, fileSizeInBytes, fileType, fileUUID }) => { |
||||
const fileSize: string = prettyBytes.default(fileSizeInBytes, { |
||||
binary: true, |
||||
}); |
||||
|
||||
return ( |
||||
<ListItem key={fileUUID} sx={{ padding: '.6em 0' }}> |
||||
<Box |
||||
sx={{ |
||||
display: 'flex', |
||||
flexDirection: { xs: 'column', md: 'row' }, |
||||
width: '100%', |
||||
}} |
||||
> |
||||
<Box sx={{ flexGrow: 1 }}> |
||||
<Box |
||||
sx={{ |
||||
display: 'flex', |
||||
flexDirection: 'row', |
||||
}} |
||||
> |
||||
<BodyText |
||||
sx={{ |
||||
fontFamily: 'Source Code Pro', |
||||
fontWeight: 400, |
||||
}} |
||||
text={fileName} |
||||
/> |
||||
<Divider |
||||
flexItem |
||||
orientation="vertical" |
||||
sx={{ |
||||
backgroundColor: DIVIDER, |
||||
marginLeft: '.5em', |
||||
marginRight: '.5em', |
||||
}} |
||||
/> |
||||
<BodyText text={UPLOAD_FILE_TYPES.get(fileType)?.[1] ?? ''} /> |
||||
</Box> |
||||
<BodyText text={fileSize} /> |
||||
</Box> |
||||
<Box |
||||
sx={{ |
||||
alignItems: 'center', |
||||
display: 'flex', |
||||
flexDirection: 'row', |
||||
}} |
||||
> |
||||
<BodyText |
||||
sx={{ |
||||
fontFamily: 'Source Code Pro', |
||||
fontWeight: 400, |
||||
}} |
||||
text={fileChecksum} |
||||
/> |
||||
</Box> |
||||
</Box> |
||||
</ListItem> |
||||
); |
||||
}, |
||||
)} |
||||
</List> |
||||
); |
||||
|
||||
export default FileList; |
@ -1,296 +0,0 @@ |
||||
import { Box, Input, InputLabel } from '@mui/material'; |
||||
import EventEmitter from 'events'; |
||||
import { |
||||
ChangeEventHandler, |
||||
FormEventHandler, |
||||
ReactElement, |
||||
useEffect, |
||||
useRef, |
||||
useState, |
||||
} from 'react'; |
||||
|
||||
import { UPLOAD_FILE_TYPES } from '../../lib/consts/UPLOAD_FILE_TYPES'; |
||||
|
||||
import api from '../../lib/api'; |
||||
import { ProgressBar } from '../Bars'; |
||||
import ContainedButton from '../ContainedButton'; |
||||
import FileInfo from './FileInfo'; |
||||
import MessageBox from '../MessageBox'; |
||||
import { BodyText } from '../Text'; |
||||
|
||||
type FileUploadFormProps = { |
||||
onFileUploadComplete?: () => void; |
||||
eventEmitter?: EventEmitter; |
||||
}; |
||||
|
||||
type SelectedFile = Pick< |
||||
FileDetailMetadata, |
||||
'fileName' | 'fileLocations' | 'fileType' |
||||
> & { |
||||
file: File; |
||||
}; |
||||
|
||||
type InUploadFile = Pick<FileDetailMetadata, 'fileName'> & { |
||||
progressValue: number; |
||||
}; |
||||
|
||||
const FILE_UPLOAD_FORM_DEFAULT_PROPS: Partial<FileUploadFormProps> = { |
||||
onFileUploadComplete: undefined, |
||||
eventEmitter: undefined, |
||||
}; |
||||
|
||||
const FILE_UPLOAD_PERCENT_MAX = 99; |
||||
|
||||
const FileUploadForm = ( |
||||
{ |
||||
onFileUploadComplete, |
||||
eventEmitter, |
||||
}: FileUploadFormProps = FILE_UPLOAD_FORM_DEFAULT_PROPS as FileUploadFormProps, |
||||
): JSX.Element => { |
||||
const selectFileRef = useRef<HTMLInputElement>(); |
||||
|
||||
const [selectedFiles, setSelectedFiles] = useState<SelectedFile[]>([]); |
||||
const [inUploadFiles, setInUploadFiles] = useState< |
||||
(InUploadFile | undefined)[] |
||||
>([]); |
||||
|
||||
const convertMIMETypeToFileTypeKey = (fileMIMEType: string): FileType => { |
||||
const fileTypesIterator = UPLOAD_FILE_TYPES.entries(); |
||||
|
||||
let fileType: FileType | undefined; |
||||
|
||||
do { |
||||
const fileTypesResult = fileTypesIterator.next(); |
||||
|
||||
if (fileTypesResult.value) { |
||||
const [fileTypeKey, [mimeTypeToUse]] = fileTypesResult.value; |
||||
|
||||
if (fileMIMEType === mimeTypeToUse) { |
||||
fileType = fileTypeKey; |
||||
} |
||||
} else { |
||||
fileType = 'other'; |
||||
} |
||||
} while (!fileType); |
||||
|
||||
return fileType; |
||||
}; |
||||
|
||||
const autocompleteAfterSelectFile: ChangeEventHandler<HTMLInputElement> = ({ |
||||
target: { files }, |
||||
}) => { |
||||
if (files) { |
||||
setSelectedFiles( |
||||
Array.from(files).map( |
||||
(file): SelectedFile => ({ |
||||
file, |
||||
fileName: file.name, |
||||
fileLocations: [], |
||||
fileType: convertMIMETypeToFileTypeKey(file.type), |
||||
}), |
||||
), |
||||
); |
||||
} |
||||
}; |
||||
|
||||
const generateFileInfoOnChangeHandler = |
||||
(fileIndex: number): FileInfoChangeHandler => |
||||
(inputValues) => { |
||||
selectedFiles[fileIndex] = { |
||||
...selectedFiles[fileIndex], |
||||
...inputValues, |
||||
}; |
||||
}; |
||||
|
||||
const uploadFiles: FormEventHandler<HTMLFormElement> = (event) => { |
||||
event.preventDefault(); |
||||
|
||||
while (selectedFiles.length > 0) { |
||||
const selectedFile = selectedFiles.shift(); |
||||
|
||||
if (selectedFile) { |
||||
const { file, fileName } = selectedFile; |
||||
|
||||
const fileFormData = new FormData(); |
||||
|
||||
fileFormData.append('file', new File([file], fileName, { ...file })); |
||||
// Re-add when the back-end tools can support changing file type on file upload.
|
||||
// Note: get file type from destructuring selectedFile.
|
||||
// fileFormData.append('file-type', fileType);
|
||||
|
||||
const inUploadFile: InUploadFile = { fileName, progressValue: 0 }; |
||||
|
||||
inUploadFiles.push(inUploadFile); |
||||
|
||||
api |
||||
.post('/file', fileFormData, { |
||||
headers: { |
||||
'Content-Type': 'multipart/form-data', |
||||
}, |
||||
onUploadProgress: ( |
||||
(fName: string) => |
||||
({ loaded, total }) => { |
||||
setInUploadFiles((previous) => { |
||||
const fInfo = previous.find((f) => f?.fileName === fName); |
||||
|
||||
if (!fInfo) return previous; |
||||
|
||||
/** |
||||
* Use 99 as upper limit because progress event doesn't |
||||
* represent the "bytes out of total" written to the remote |
||||
* disk. The write completes when the request completes. |
||||
*/ |
||||
fInfo.progressValue = Math.round( |
||||
(loaded / total) * FILE_UPLOAD_PERCENT_MAX, |
||||
); |
||||
|
||||
return [...previous]; |
||||
}); |
||||
} |
||||
)(fileName), |
||||
}) |
||||
.then( |
||||
((fName: string) => () => { |
||||
setInUploadFiles((previous) => { |
||||
const fInfo = previous.find((f) => f?.fileName === fName); |
||||
|
||||
if (!fInfo) return previous; |
||||
|
||||
fInfo.progressValue = 100; |
||||
|
||||
return [...previous]; |
||||
}); |
||||
|
||||
setTimeout(() => { |
||||
setInUploadFiles((previous) => { |
||||
const fIndex = previous.findIndex( |
||||
(f) => f?.fileName === fName, |
||||
); |
||||
|
||||
if (fIndex === -1) return previous; |
||||
|
||||
delete previous[fIndex]; |
||||
|
||||
return [...previous]; |
||||
}); |
||||
}, 5000); |
||||
|
||||
onFileUploadComplete?.call(null); |
||||
})(fileName), |
||||
); |
||||
} |
||||
} |
||||
|
||||
// Clears "staging area" (selected files) and populates "in-progress area" (in-upload files).
|
||||
setSelectedFiles([]); |
||||
setInUploadFiles([...inUploadFiles]); |
||||
}; |
||||
|
||||
useEffect(() => { |
||||
eventEmitter?.addListener('openFilePicker', () => { |
||||
selectFileRef.current?.click(); |
||||
}); |
||||
|
||||
eventEmitter?.addListener('clearSelectedFiles', () => { |
||||
setSelectedFiles([]); |
||||
}); |
||||
}, [eventEmitter]); |
||||
|
||||
return ( |
||||
<form onSubmit={uploadFiles}> |
||||
<InputLabel htmlFor="select-file"> |
||||
<Input |
||||
id="select-file" |
||||
inputProps={{ multiple: true }} |
||||
onChange={autocompleteAfterSelectFile} |
||||
ref={selectFileRef} |
||||
sx={{ display: 'none' }} |
||||
type="file" |
||||
/> |
||||
</InputLabel> |
||||
<Box sx={{ display: 'flex', flexDirection: 'column' }}> |
||||
{inUploadFiles.reduce<ReactElement[]>((previous, upFile) => { |
||||
if (!upFile) return previous; |
||||
|
||||
const { fileName, progressValue } = upFile; |
||||
|
||||
previous.push( |
||||
<Box |
||||
key={`in-upload-${fileName}`} |
||||
sx={{ |
||||
alignItems: { md: 'center' }, |
||||
display: 'flex', |
||||
flexDirection: { xs: 'column', md: 'row' }, |
||||
'& > :first-child': { |
||||
minWidth: 100, |
||||
overflow: 'hidden', |
||||
overflowWrap: 'normal', |
||||
textOverflow: 'ellipsis', |
||||
whiteSpace: 'nowrap', |
||||
width: { xs: '100%', md: 200 }, |
||||
wordBreak: 'keep-all', |
||||
}, |
||||
'& > :last-child': { flexGrow: 1 }, |
||||
}} |
||||
> |
||||
<BodyText text={fileName} /> |
||||
<ProgressBar progressPercentage={progressValue} /> |
||||
</Box>, |
||||
); |
||||
|
||||
return previous; |
||||
}, [])} |
||||
</Box> |
||||
<Box |
||||
sx={{ |
||||
display: 'flex', |
||||
flexDirection: 'column', |
||||
'& > :not(:first-child)': { marginTop: '1em' }, |
||||
}} |
||||
> |
||||
{selectedFiles.length > 0 && ( |
||||
<MessageBox type="info"> |
||||
Uploaded files will be listed automatically, but it may take a while |
||||
for larger files to finish uploading and appear on the list. |
||||
</MessageBox> |
||||
)} |
||||
{selectedFiles.map( |
||||
( |
||||
{ |
||||
file: { name: originalFileName }, |
||||
fileName, |
||||
// Re-add when the back-end tools can support changing file type on file upload.
|
||||
// Note: file type must be supplied to FileInfo.
|
||||
// fileType,
|
||||
fileLocations, |
||||
}, |
||||
fileIndex, |
||||
) => ( |
||||
<FileInfo |
||||
{...{ fileName, fileLocations }} |
||||
// Use a non-changing key to prevent recreating the component.
|
||||
// fileName holds the string from the file-name input, thus it changes when users makes a change.
|
||||
key={`selected-${originalFileName}`} |
||||
onChange={generateFileInfoOnChangeHandler(fileIndex)} |
||||
/> |
||||
), |
||||
)} |
||||
{selectedFiles.length > 0 && ( |
||||
<Box |
||||
sx={{ |
||||
display: 'flex', |
||||
flexDirection: 'row', |
||||
justifyContent: 'flex-end', |
||||
}} |
||||
> |
||||
<ContainedButton type="submit">Upload</ContainedButton> |
||||
</Box> |
||||
)} |
||||
</Box> |
||||
</form> |
||||
); |
||||
}; |
||||
|
||||
FileUploadForm.defaultProps = FILE_UPLOAD_FORM_DEFAULT_PROPS; |
||||
|
||||
export default FileUploadForm; |
@ -1,148 +0,0 @@ |
||||
import { useEffect, useState } from 'react'; |
||||
import { Box } from '@mui/material'; |
||||
import { |
||||
Add as AddIcon, |
||||
Check as CheckIcon, |
||||
Edit as EditIcon, |
||||
} from '@mui/icons-material'; |
||||
import EventEmitter from 'events'; |
||||
|
||||
import API_BASE_URL from '../../lib/consts/API_BASE_URL'; |
||||
import { BLUE } from '../../lib/consts/DEFAULT_THEME'; |
||||
|
||||
import FileEditForm from './FileEditForm'; |
||||
import FileList from './FileList'; |
||||
import FileUploadForm from './FileUploadForm'; |
||||
import IconButton from '../IconButton'; |
||||
import { Panel } from '../Panels'; |
||||
import MessageBox from '../MessageBox'; |
||||
import Spinner from '../Spinner'; |
||||
import { HeaderText } from '../Text'; |
||||
|
||||
import fetchJSON from '../../lib/fetchers/fetchJSON'; |
||||
import periodicFetch from '../../lib/fetchers/periodicFetch'; |
||||
|
||||
const FILES_ENDPOINT_URL = `${API_BASE_URL}/file`; |
||||
|
||||
const Files = (): JSX.Element => { |
||||
const [rawFilesOverview, setRawFilesOverview] = useState<string[][]>([]); |
||||
const [fetchRawFilesError, setFetchRawFilesError] = useState<string>(); |
||||
const [isLoadingRawFilesOverview, setIsLoadingRawFilesOverview] = |
||||
useState<boolean>(false); |
||||
const [isEditMode, setIsEditMode] = useState<boolean>(false); |
||||
|
||||
const fileUploadFormEventEmitter: EventEmitter = new EventEmitter(); |
||||
|
||||
const onAddFileButtonClick = () => { |
||||
fileUploadFormEventEmitter.emit('openFilePicker'); |
||||
}; |
||||
|
||||
const onEditFileButtonClick = () => { |
||||
fileUploadFormEventEmitter.emit('clearSelectedFiles'); |
||||
|
||||
setIsEditMode(!isEditMode); |
||||
}; |
||||
|
||||
const fetchRawFilesOverview = async () => { |
||||
setIsLoadingRawFilesOverview(true); |
||||
|
||||
try { |
||||
const data = await fetchJSON<string[][]>(FILES_ENDPOINT_URL); |
||||
setRawFilesOverview(data); |
||||
} catch (fetchError) { |
||||
setFetchRawFilesError('Failed to get files due to a network issue.'); |
||||
} |
||||
|
||||
setIsLoadingRawFilesOverview(false); |
||||
}; |
||||
|
||||
const buildFileList = (): JSX.Element => { |
||||
let elements: JSX.Element; |
||||
if (isLoadingRawFilesOverview) { |
||||
elements = <Spinner />; |
||||
} else { |
||||
const filesOverview: FileOverviewMetadata[] = rawFilesOverview.map( |
||||
([fileUUID, fileName, fileSizeInBytes, fileType, fileChecksum]) => ({ |
||||
fileChecksum, |
||||
fileName, |
||||
fileSizeInBytes: parseInt(fileSizeInBytes, 10), |
||||
fileType: fileType as FileType, |
||||
fileUUID, |
||||
}), |
||||
); |
||||
|
||||
elements = isEditMode ? ( |
||||
<FileEditForm |
||||
{...{ filesOverview }} |
||||
onEditFilesComplete={fetchRawFilesOverview} |
||||
onPurgeFilesComplete={fetchRawFilesOverview} |
||||
/> |
||||
) : ( |
||||
<FileList {...{ filesOverview }} /> |
||||
); |
||||
} |
||||
|
||||
return elements; |
||||
}; |
||||
|
||||
/** |
||||
* Check for new files periodically and update the file list. |
||||
* |
||||
* We need this because adding new files is done async; adding the file may |
||||
* not finish before the request returns. |
||||
* |
||||
* We don't care about edit because database updates are done before the |
||||
* edit request returns. |
||||
*/ |
||||
periodicFetch<string[][]>(FILES_ENDPOINT_URL, { |
||||
onSuccess: (periodicFilesOverview) => { |
||||
if (periodicFilesOverview.length !== rawFilesOverview.length) { |
||||
setRawFilesOverview(periodicFilesOverview); |
||||
} |
||||
}, |
||||
}); |
||||
|
||||
useEffect(() => { |
||||
if (!isEditMode) { |
||||
fetchRawFilesOverview(); |
||||
} |
||||
}, [isEditMode]); |
||||
|
||||
return ( |
||||
<Panel> |
||||
<Box |
||||
sx={{ |
||||
alignItems: 'center', |
||||
display: 'flex', |
||||
flexDirection: 'row', |
||||
marginBottom: '1em', |
||||
width: '100%', |
||||
'& > :first-child': { flexGrow: 1 }, |
||||
'& > :not(:first-child, :last-child)': { |
||||
marginRight: '.3em', |
||||
}, |
||||
}} |
||||
> |
||||
<HeaderText text="Files" /> |
||||
{!isEditMode && ( |
||||
<IconButton onClick={onAddFileButtonClick}> |
||||
<AddIcon /> |
||||
</IconButton> |
||||
)} |
||||
<IconButton onClick={onEditFileButtonClick}> |
||||
{isEditMode ? <CheckIcon sx={{ color: BLUE }} /> : <EditIcon />} |
||||
</IconButton> |
||||
</Box> |
||||
{fetchRawFilesError && ( |
||||
<MessageBox text={fetchRawFilesError} type="error" /> |
||||
)} |
||||
<FileUploadForm |
||||
{...{ eventEmitter: fileUploadFormEventEmitter }} |
||||
onFileUploadComplete={fetchRawFilesOverview} |
||||
/> |
||||
{buildFileList()} |
||||
</Panel> |
||||
); |
||||
}; |
||||
|
||||
export default Files; |
@ -1,3 +1,3 @@ |
||||
import Files from './Files'; |
||||
import ManageFilePanel from './ManageFilePanel'; |
||||
|
||||
export default Files; |
||||
export default ManageFilePanel; |
||||
|
@ -1,3 +0,0 @@ |
||||
type FileDetailMetadata = FileOverviewMetadata & { |
||||
fileLocations: FileLocation[]; |
||||
}; |
@ -1,4 +0,0 @@ |
||||
type FileInfoChangeHandler = ( |
||||
inputValues: Partial<FileDetailMetadata> | Partial<FileLocation>, |
||||
options?: { fileLocationIndex?: number }, |
||||
) => void; |
@ -1,9 +0,0 @@ |
||||
type FileLocation = { |
||||
anvilName: string; |
||||
anvilDescription: string; |
||||
anvilUUID: string; |
||||
fileLocationUUID: string; |
||||
hostName: string; |
||||
hostUUID: string; |
||||
isFileLocationActive: boolean; |
||||
}; |
@ -1,7 +0,0 @@ |
||||
type FileOverviewMetadata = { |
||||
fileChecksum: string; |
||||
fileName: string; |
||||
fileSizeInBytes: number; |
||||
fileType: FileType; |
||||
fileUUID: string; |
||||
}; |
Loading…
Reference in new issue