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