Merge pull request #532 from ylei-tsubame/rebuild-webui
Web UI: patches #448, #517, #519, and #527main
commit
9590bf3260
33 changed files with 208 additions and 1158 deletions
File diff suppressed because one or more lines are too long
@ -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 +1 @@ |
||||
self.__BUILD_MANIFEST=function(s,a,c,e,t,n,i,f,b,u,k,h,j,d,r,g,l,_){return{__rewrites:{beforeFiles:[],afterFiles:[],fallback:[]},"/":[s,c,e,f,b,j,"static/chunks/433-a3be905e7a7d3bfc.js",a,t,n,i,d,r,"static/chunks/pages/index-0771f2825962ebc3.js"],"/_error":["static/chunks/pages/_error-2280fa386d040b66.js"],"/anvil":[s,c,e,f,b,j,a,t,n,i,d,"static/chunks/pages/anvil-53b02ffa883f4c5a.js"],"/config":[s,c,e,k,"static/chunks/519-4b7761e884c88eb9.js",a,t,n,i,u,h,g,"static/chunks/pages/config-7be24d332b231569.js"],"/file-manager":["static/chunks/29107295-fbcfe2172188e46f.js",s,c,e,f,"static/chunks/176-7308c25ba374961e.js",a,t,i,u,"static/chunks/pages/file-manager-0697bf1cd793df6d.js"],"/init":[s,c,f,b,k,l,a,t,n,i,_,"static/chunks/pages/init-7cf62951388d0e3b.js"],"/login":[s,c,e,a,t,n,u,h,"static/chunks/pages/login-0b2f91a926538f7c.js"],"/manage-element":[s,c,e,f,b,k,l,"static/chunks/111-2605129c170ed35d.js",a,t,n,i,u,h,_,g,"static/chunks/pages/manage-element-3ed34f8c3a72590a.js"],"/server":[s,e,"static/chunks/528-72edc50189f30fa9.js",a,r,"static/chunks/pages/server-d4d91dcbacc827c4.js"],sortedPages:["/","/_app","/_error","/anvil","/config","/file-manager","/init","/login","/manage-element","/server"]}}("static/chunks/412-ae4bab5809f6a209.js","static/chunks/62-2c80eba24b792af8.js","static/chunks/438-0147a63d98e89439.js","static/chunks/894-e57948de523bcf96.js","static/chunks/195-fa06e61dd4339031.js","static/chunks/987-1ff0d82724b0e58b.js","static/chunks/157-d1418743accab385.js","static/chunks/182-08683bbe95fbb010.js","static/chunks/900-af716a39aed22219.js","static/chunks/248-749f2bec4cb43d28.js","static/chunks/644-c7c6e21c71345aed.js","static/chunks/336-6e600f08d9387d72.js","static/chunks/485-77798bccc4308d0e.js","static/chunks/825-a143aba6cb430f0f.js","static/chunks/94-8322ed453a3c08f0.js","static/chunks/560-0ed707609765e23a.js","static/chunks/676-6159ce853338cc1f.js","static/chunks/86-447b52c8195dea3d.js"),self.__BUILD_MANIFEST_CB&&self.__BUILD_MANIFEST_CB(); |
||||
self.__BUILD_MANIFEST=function(s,a,c,e,t,n,i,f,b,u,k,h,j,d,r,g,l,_){return{__rewrites:{beforeFiles:[],afterFiles:[],fallback:[]},"/":[s,c,e,f,b,j,"static/chunks/433-a3be905e7a7d3bfc.js",a,t,n,i,d,r,"static/chunks/pages/index-0771f2825962ebc3.js"],"/_error":["static/chunks/pages/_error-2280fa386d040b66.js"],"/anvil":[s,c,e,f,b,j,a,t,n,i,d,"static/chunks/pages/anvil-53b02ffa883f4c5a.js"],"/config":[s,c,e,k,"static/chunks/519-4b7761e884c88eb9.js",a,t,n,i,u,h,g,"static/chunks/pages/config-7be24d332b231569.js"],"/file-manager":["static/chunks/29107295-fbcfe2172188e46f.js",s,c,e,f,"static/chunks/176-7308c25ba374961e.js",a,t,i,u,"static/chunks/pages/file-manager-6501dafd856c22ec.js"],"/init":[s,c,f,b,k,l,a,t,n,i,_,"static/chunks/pages/init-7cf62951388d0e3b.js"],"/login":[s,c,e,a,t,n,u,h,"static/chunks/pages/login-0b2f91a926538f7c.js"],"/manage-element":[s,c,e,f,b,k,l,"static/chunks/111-2605129c170ed35d.js",a,t,n,i,u,h,_,g,"static/chunks/pages/manage-element-3ed34f8c3a72590a.js"],"/server":[s,e,"static/chunks/528-72edc50189f30fa9.js",a,r,"static/chunks/pages/server-d4d91dcbacc827c4.js"],sortedPages:["/","/_app","/_error","/anvil","/config","/file-manager","/init","/login","/manage-element","/server"]}}("static/chunks/412-ae4bab5809f6a209.js","static/chunks/62-2c80eba24b792af8.js","static/chunks/438-0147a63d98e89439.js","static/chunks/894-e57948de523bcf96.js","static/chunks/195-fa06e61dd4339031.js","static/chunks/987-1ff0d82724b0e58b.js","static/chunks/157-d1418743accab385.js","static/chunks/182-08683bbe95fbb010.js","static/chunks/900-af716a39aed22219.js","static/chunks/248-749f2bec4cb43d28.js","static/chunks/644-c7c6e21c71345aed.js","static/chunks/336-6e600f08d9387d72.js","static/chunks/485-77798bccc4308d0e.js","static/chunks/825-07aab1f379d63d3c.js","static/chunks/94-8322ed453a3c08f0.js","static/chunks/560-0ed707609765e23a.js","static/chunks/676-6159ce853338cc1f.js","static/chunks/86-447b52c8195dea3d.js"),self.__BUILD_MANIFEST_CB&&self.__BUILD_MANIFEST_CB(); |
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -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