parent
d8e214715a
commit
34fc75b3d7
8 changed files with 1100 additions and 1 deletions
@ -0,0 +1,223 @@ |
|||||||
|
import { useFormik } from 'formik'; |
||||||
|
import { |
||||||
|
ChangeEventHandler, |
||||||
|
FC, |
||||||
|
ReactElement, |
||||||
|
useCallback, |
||||||
|
useMemo, |
||||||
|
useRef, |
||||||
|
} from 'react'; |
||||||
|
import { v4 as uuidv4 } from 'uuid'; |
||||||
|
|
||||||
|
import ActionGroup from '../ActionGroup'; |
||||||
|
import api from '../../lib/api'; |
||||||
|
import ContainedButton from '../ContainedButton'; |
||||||
|
import convertFormikErrorsToMessages from '../../lib/convertFormikErrorsToMessages'; |
||||||
|
import FileInputGroup from './FileInputGroup'; |
||||||
|
import FlexBox from '../FlexBox'; |
||||||
|
import handleAPIError from '../../lib/handleAPIError'; |
||||||
|
import MessageBox from '../MessageBox'; |
||||||
|
import MessageGroup from '../MessageGroup'; |
||||||
|
import fileListSchema from './schema'; |
||||||
|
import UploadFileProgress from './UploadFileProgress'; |
||||||
|
import useProtectedState from '../../hooks/useProtectedState'; |
||||||
|
|
||||||
|
const REQUEST_INCOMPLETE_UPLOAD_LIMIT = 99; |
||||||
|
|
||||||
|
const setUploadProgress: ( |
||||||
|
previous: UploadFiles | undefined, |
||||||
|
uuid: keyof UploadFiles, |
||||||
|
progress: UploadFiles[string]['progress'], |
||||||
|
) => UploadFiles | undefined = (previous, uuid, progress) => { |
||||||
|
if (!previous) return previous; |
||||||
|
|
||||||
|
previous[uuid].progress = progress; |
||||||
|
|
||||||
|
return { ...previous }; |
||||||
|
}; |
||||||
|
|
||||||
|
const AddFileForm: FC<AddFileFormProps> = (props) => { |
||||||
|
const { anvils, drHosts } = props; |
||||||
|
|
||||||
|
const filePickerRef = useRef<HTMLInputElement>(null); |
||||||
|
|
||||||
|
const [uploads, setUploads] = useProtectedState<UploadFiles | undefined>( |
||||||
|
undefined, |
||||||
|
); |
||||||
|
|
||||||
|
const formik = useFormik<FileFormikValues>({ |
||||||
|
initialValues: {}, |
||||||
|
onSubmit: (values) => { |
||||||
|
const files = Object.values(values); |
||||||
|
|
||||||
|
setUploads( |
||||||
|
files.reduce<UploadFiles>((previous, { file, name, uuid }) => { |
||||||
|
if (!file) return previous; |
||||||
|
|
||||||
|
previous[uuid] = { name, progress: 0, uuid }; |
||||||
|
|
||||||
|
return previous; |
||||||
|
}, {}), |
||||||
|
); |
||||||
|
|
||||||
|
files.forEach(({ file, name, uuid }) => { |
||||||
|
if (!file) return; |
||||||
|
|
||||||
|
const data = new FormData(); |
||||||
|
|
||||||
|
data.append('file', new File([file], name, { ...file })); |
||||||
|
|
||||||
|
api |
||||||
|
.post('/file', data, { |
||||||
|
headers: { |
||||||
|
'Content-Type': 'multipart/form-data', |
||||||
|
}, |
||||||
|
onUploadProgress: ( |
||||||
|
(fileUuid: string) => |
||||||
|
({ loaded, total }) => { |
||||||
|
setUploads((previous) => |
||||||
|
setUploadProgress( |
||||||
|
previous, |
||||||
|
fileUuid, |
||||||
|
Math.round( |
||||||
|
(loaded / total) * REQUEST_INCOMPLETE_UPLOAD_LIMIT, |
||||||
|
), |
||||||
|
), |
||||||
|
); |
||||||
|
} |
||||||
|
)(uuid), |
||||||
|
}) |
||||||
|
.then( |
||||||
|
((fileUuid: string) => () => { |
||||||
|
setUploads((previous) => |
||||||
|
setUploadProgress(previous, fileUuid, 100), |
||||||
|
); |
||||||
|
})(uuid), |
||||||
|
) |
||||||
|
.catch((error) => { |
||||||
|
handleAPIError(error); |
||||||
|
}); |
||||||
|
}); |
||||||
|
}, |
||||||
|
validationSchema: fileListSchema, |
||||||
|
}); |
||||||
|
|
||||||
|
const formikErrors = useMemo<Messages>( |
||||||
|
() => convertFormikErrorsToMessages(formik.errors), |
||||||
|
[formik.errors], |
||||||
|
); |
||||||
|
|
||||||
|
const disableProceed = useMemo<boolean>( |
||||||
|
() => |
||||||
|
!formik.dirty || |
||||||
|
!formik.isValid || |
||||||
|
formik.isValidating || |
||||||
|
formik.isSubmitting, |
||||||
|
[formik.dirty, formik.isSubmitting, formik.isValid, formik.isValidating], |
||||||
|
); |
||||||
|
|
||||||
|
const handleSelectFiles = useCallback<ChangeEventHandler<HTMLInputElement>>( |
||||||
|
(event) => { |
||||||
|
const { |
||||||
|
target: { files }, |
||||||
|
} = event; |
||||||
|
|
||||||
|
if (!files) return; |
||||||
|
|
||||||
|
const values = Array.from(files).reduce<FileFormikValues>( |
||||||
|
(previous, file) => { |
||||||
|
const fileUuid = uuidv4(); |
||||||
|
|
||||||
|
previous[fileUuid] = { |
||||||
|
file, |
||||||
|
name: file.name, |
||||||
|
uuid: fileUuid, |
||||||
|
}; |
||||||
|
|
||||||
|
return previous; |
||||||
|
}, |
||||||
|
{}, |
||||||
|
); |
||||||
|
|
||||||
|
formik.setValues(values); |
||||||
|
}, |
||||||
|
[formik], |
||||||
|
); |
||||||
|
|
||||||
|
const fileInputs = useMemo<ReactElement[]>( |
||||||
|
() => |
||||||
|
formik.values && |
||||||
|
Object.values(formik.values).map((file) => { |
||||||
|
const { uuid: fileUuid } = file; |
||||||
|
|
||||||
|
return ( |
||||||
|
<FileInputGroup |
||||||
|
anvils={anvils} |
||||||
|
drHosts={drHosts} |
||||||
|
fileUuid={fileUuid} |
||||||
|
formik={formik} |
||||||
|
key={fileUuid} |
||||||
|
/> |
||||||
|
); |
||||||
|
}), |
||||||
|
[anvils, drHosts, formik], |
||||||
|
); |
||||||
|
|
||||||
|
return ( |
||||||
|
<FlexBox> |
||||||
|
<MessageBox> |
||||||
|
Uploaded files will be listed automatically, but it may take a while for |
||||||
|
larger files to finish uploading and appear on the list. |
||||||
|
</MessageBox> |
||||||
|
{uploads ? ( |
||||||
|
<> |
||||||
|
<MessageBox> |
||||||
|
This dialog can be closed after all uploads complete. Closing before |
||||||
|
completion will stop the upload. |
||||||
|
</MessageBox> |
||||||
|
<UploadFileProgress uploads={uploads} /> |
||||||
|
</> |
||||||
|
) : ( |
||||||
|
<FlexBox |
||||||
|
component="form" |
||||||
|
onSubmit={(event) => { |
||||||
|
event.preventDefault(); |
||||||
|
|
||||||
|
formik.submitForm(); |
||||||
|
}} |
||||||
|
> |
||||||
|
<input |
||||||
|
id="files" |
||||||
|
multiple |
||||||
|
name="files" |
||||||
|
onChange={handleSelectFiles} |
||||||
|
ref={filePickerRef} |
||||||
|
style={{ display: 'none' }} |
||||||
|
type="file" |
||||||
|
/> |
||||||
|
<ContainedButton |
||||||
|
onClick={() => { |
||||||
|
filePickerRef.current?.click(); |
||||||
|
}} |
||||||
|
> |
||||||
|
Browse |
||||||
|
</ContainedButton> |
||||||
|
{fileInputs} |
||||||
|
<MessageGroup count={1} messages={formikErrors} /> |
||||||
|
<ActionGroup |
||||||
|
actions={[ |
||||||
|
{ |
||||||
|
background: 'blue', |
||||||
|
children: 'Add', |
||||||
|
disabled: disableProceed, |
||||||
|
type: 'submit', |
||||||
|
}, |
||||||
|
]} |
||||||
|
/> |
||||||
|
</FlexBox> |
||||||
|
)} |
||||||
|
</FlexBox> |
||||||
|
); |
||||||
|
}; |
||||||
|
|
||||||
|
export default AddFileForm; |
@ -0,0 +1,154 @@ |
|||||||
|
import { useFormik } from 'formik'; |
||||||
|
import { FC, useMemo } from 'react'; |
||||||
|
|
||||||
|
import ActionGroup from '../ActionGroup'; |
||||||
|
import api from '../../lib/api'; |
||||||
|
import convertFormikErrorsToMessages from '../../lib/convertFormikErrorsToMessages'; |
||||||
|
import FileInputGroup from './FileInputGroup'; |
||||||
|
import FlexBox from '../FlexBox'; |
||||||
|
import MessageGroup from '../MessageGroup'; |
||||||
|
import fileListSchema from './schema'; |
||||||
|
|
||||||
|
const toEditFileRequestBody = ( |
||||||
|
file: FileFormikFile, |
||||||
|
pfile: APIFileDetail, |
||||||
|
): APIEditFileRequestBody | undefined => { |
||||||
|
const { locations, name: fileName, type: fileType, uuid: fileUUID } = file; |
||||||
|
|
||||||
|
if (!locations || !fileType) return undefined; |
||||||
|
|
||||||
|
const fileLocations: APIEditFileRequestBody['fileLocations'] = []; |
||||||
|
|
||||||
|
Object.entries(locations.anvils).reduce< |
||||||
|
APIEditFileRequestBody['fileLocations'] |
||||||
|
>((previous, [anvilUuid, { active: isFileLocationActive }]) => { |
||||||
|
const { |
||||||
|
anvils: { |
||||||
|
[anvilUuid]: { locationUuids }, |
||||||
|
}, |
||||||
|
} = pfile; |
||||||
|
|
||||||
|
const current = locationUuids.map< |
||||||
|
APIEditFileRequestBody['fileLocations'][number] |
||||||
|
>((fileLocationUUID) => ({ |
||||||
|
fileLocationUUID, |
||||||
|
isFileLocationActive, |
||||||
|
})); |
||||||
|
|
||||||
|
previous.push(...current); |
||||||
|
|
||||||
|
return previous; |
||||||
|
}, fileLocations); |
||||||
|
|
||||||
|
Object.entries(locations.drHosts).reduce< |
||||||
|
APIEditFileRequestBody['fileLocations'] |
||||||
|
>((previous, [drHostUuid, { active: isFileLocationActive }]) => { |
||||||
|
const { |
||||||
|
hosts: { |
||||||
|
[drHostUuid]: { locationUuids }, |
||||||
|
}, |
||||||
|
} = pfile; |
||||||
|
|
||||||
|
const current = locationUuids.map< |
||||||
|
APIEditFileRequestBody['fileLocations'][number] |
||||||
|
>((fileLocationUUID) => ({ |
||||||
|
fileLocationUUID, |
||||||
|
isFileLocationActive, |
||||||
|
})); |
||||||
|
|
||||||
|
previous.push(...current); |
||||||
|
|
||||||
|
return previous; |
||||||
|
}, fileLocations); |
||||||
|
|
||||||
|
return { fileLocations, fileName, fileType, fileUUID }; |
||||||
|
}; |
||||||
|
|
||||||
|
const EditFileForm: FC<EditFileFormProps> = (props) => { |
||||||
|
const { anvils, drHosts, previous: file } = props; |
||||||
|
|
||||||
|
const formikInitialValues = useMemo<FileFormikValues>(() => { |
||||||
|
const { locations, name, type, uuid } = file; |
||||||
|
|
||||||
|
return { |
||||||
|
[uuid]: { |
||||||
|
locations: Object.values(locations).reduce<FileFormikLocations>( |
||||||
|
(previous, { active, anvilUuid, hostUuid }) => { |
||||||
|
let category: keyof FileFormikLocations = 'anvils'; |
||||||
|
let id = anvilUuid; |
||||||
|
|
||||||
|
if (hostUuid in drHosts) { |
||||||
|
category = 'drHosts'; |
||||||
|
id = hostUuid; |
||||||
|
} |
||||||
|
|
||||||
|
previous[category][id] = { active }; |
||||||
|
|
||||||
|
return previous; |
||||||
|
}, |
||||||
|
{ anvils: {}, drHosts: {} }, |
||||||
|
), |
||||||
|
name, |
||||||
|
type, |
||||||
|
uuid, |
||||||
|
}, |
||||||
|
}; |
||||||
|
}, [drHosts, file]); |
||||||
|
|
||||||
|
const formik = useFormik<FileFormikValues>({ |
||||||
|
initialValues: formikInitialValues, |
||||||
|
onSubmit: (values) => { |
||||||
|
const body = toEditFileRequestBody(values[file.uuid], file); |
||||||
|
|
||||||
|
api.put(`/file/${file.uuid}`, body); |
||||||
|
}, |
||||||
|
validationSchema: fileListSchema, |
||||||
|
}); |
||||||
|
|
||||||
|
const formikErrors = useMemo<Messages>( |
||||||
|
() => convertFormikErrorsToMessages(formik.errors), |
||||||
|
[formik.errors], |
||||||
|
); |
||||||
|
|
||||||
|
const disableProceed = useMemo<boolean>( |
||||||
|
() => |
||||||
|
!formik.dirty || |
||||||
|
!formik.isValid || |
||||||
|
formik.isValidating || |
||||||
|
formik.isSubmitting, |
||||||
|
[formik.dirty, formik.isSubmitting, formik.isValid, formik.isValidating], |
||||||
|
); |
||||||
|
|
||||||
|
return ( |
||||||
|
<FlexBox |
||||||
|
component="form" |
||||||
|
onSubmit={(event) => { |
||||||
|
event.preventDefault(); |
||||||
|
|
||||||
|
formik.submitForm(); |
||||||
|
}} |
||||||
|
> |
||||||
|
<FileInputGroup |
||||||
|
anvils={anvils} |
||||||
|
drHosts={drHosts} |
||||||
|
fileUuid={file.uuid} |
||||||
|
formik={formik} |
||||||
|
showSyncInputGroup |
||||||
|
showTypeInput |
||||||
|
/> |
||||||
|
<MessageGroup count={1} messages={formikErrors} /> |
||||||
|
<ActionGroup |
||||||
|
actions={[ |
||||||
|
{ |
||||||
|
background: 'blue', |
||||||
|
children: 'Edit', |
||||||
|
disabled: disableProceed, |
||||||
|
type: 'submit', |
||||||
|
}, |
||||||
|
]} |
||||||
|
/> |
||||||
|
</FlexBox> |
||||||
|
); |
||||||
|
}; |
||||||
|
|
||||||
|
export default EditFileForm; |
@ -0,0 +1,217 @@ |
|||||||
|
import { FormGroup } from '@mui/material'; |
||||||
|
import { cloneDeep, debounce } from 'lodash'; |
||||||
|
import { FC, useCallback, useMemo } from 'react'; |
||||||
|
|
||||||
|
import { UPLOAD_FILE_TYPES_ARRAY } from '../../lib/consts/UPLOAD_FILE_TYPES'; |
||||||
|
|
||||||
|
import FlexBox from '../FlexBox'; |
||||||
|
import List from '../List'; |
||||||
|
import OutlinedInputWithLabel from '../OutlinedInputWithLabel'; |
||||||
|
import { ExpandablePanel } from '../Panels'; |
||||||
|
import SelectWithLabel from '../SelectWithLabel'; |
||||||
|
import { BodyText } from '../Text'; |
||||||
|
import UncontrolledInput from '../UncontrolledInput'; |
||||||
|
|
||||||
|
const FileInputGroup: FC<FileInputGroupProps> = (props) => { |
||||||
|
const { |
||||||
|
anvils, |
||||||
|
drHosts, |
||||||
|
fileUuid: fuuid, |
||||||
|
formik, |
||||||
|
showSyncInputGroup, |
||||||
|
showTypeInput, |
||||||
|
} = props; |
||||||
|
|
||||||
|
const { handleBlur, handleChange } = formik; |
||||||
|
|
||||||
|
const debounceChangeEventHandler = useMemo( |
||||||
|
() => debounce(handleChange, 500), |
||||||
|
[handleChange], |
||||||
|
); |
||||||
|
|
||||||
|
const { nameChain, locationsChain, typeChain } = useMemo( |
||||||
|
() => ({ |
||||||
|
nameChain: `${fuuid}.name`, |
||||||
|
locationsChain: `${fuuid}.locations`, |
||||||
|
typeChain: `${fuuid}.type`, |
||||||
|
}), |
||||||
|
[fuuid], |
||||||
|
); |
||||||
|
|
||||||
|
const handleCheckAllLocations = useCallback( |
||||||
|
(type: keyof FileFormikLocations, checked: boolean) => { |
||||||
|
formik.setValues((previous) => { |
||||||
|
const current = cloneDeep(previous); |
||||||
|
const locations = current[fuuid].locations?.[type]; |
||||||
|
|
||||||
|
if (!locations) return previous; |
||||||
|
|
||||||
|
Object.keys(locations).forEach((key) => { |
||||||
|
locations[key].active = checked; |
||||||
|
}); |
||||||
|
|
||||||
|
return current; |
||||||
|
}); |
||||||
|
}, |
||||||
|
[formik, fuuid], |
||||||
|
); |
||||||
|
|
||||||
|
const getAllLocationsCheckboxProps = useCallback( |
||||||
|
(type: keyof FileFormikLocations): CheckboxProps => { |
||||||
|
const locations = formik.values[fuuid].locations?.[type]; |
||||||
|
|
||||||
|
if (!locations) return {}; |
||||||
|
|
||||||
|
return { |
||||||
|
checked: Object.values(locations).every(({ active }) => active), |
||||||
|
onChange: (event, checked) => { |
||||||
|
handleCheckAllLocations(type, checked); |
||||||
|
}, |
||||||
|
}; |
||||||
|
}, |
||||||
|
[formik.values, fuuid, handleCheckAllLocations], |
||||||
|
); |
||||||
|
|
||||||
|
const getLocationCheckboxProps = useCallback( |
||||||
|
(type: keyof FileFormikLocations, uuid: string): CheckboxProps => { |
||||||
|
const gridChain = `${locationsChain}.${type}.${uuid}`; |
||||||
|
const activeChain = `${gridChain}.active`; |
||||||
|
|
||||||
|
return { |
||||||
|
id: activeChain, |
||||||
|
name: activeChain, |
||||||
|
checked: formik.values[fuuid].locations?.[type][uuid].active, |
||||||
|
onBlur: handleBlur, |
||||||
|
onChange: handleChange, |
||||||
|
}; |
||||||
|
}, |
||||||
|
[formik.values, fuuid, handleBlur, handleChange, locationsChain], |
||||||
|
); |
||||||
|
|
||||||
|
const enableCheckAllLocations = useCallback( |
||||||
|
(type: keyof FileFormikLocations) => { |
||||||
|
const locations = formik.values[fuuid].locations?.[type]; |
||||||
|
|
||||||
|
return locations && Object.keys(locations).length > 1; |
||||||
|
}, |
||||||
|
[formik.values, fuuid], |
||||||
|
); |
||||||
|
|
||||||
|
const nameInput = useMemo( |
||||||
|
() => ( |
||||||
|
<UncontrolledInput |
||||||
|
input={ |
||||||
|
<OutlinedInputWithLabel |
||||||
|
id={nameChain} |
||||||
|
label="File name" |
||||||
|
name={nameChain} |
||||||
|
onBlur={handleBlur} |
||||||
|
onChange={debounceChangeEventHandler} |
||||||
|
value={formik.values[fuuid].name} |
||||||
|
/> |
||||||
|
} |
||||||
|
/> |
||||||
|
), |
||||||
|
[debounceChangeEventHandler, formik.values, fuuid, handleBlur, nameChain], |
||||||
|
); |
||||||
|
|
||||||
|
const syncNodeInputGroup = useMemo( |
||||||
|
() => |
||||||
|
showSyncInputGroup && ( |
||||||
|
<ExpandablePanel |
||||||
|
header="Sync with node(s)" |
||||||
|
panelProps={{ mb: 0, mt: 0, width: '100%' }} |
||||||
|
> |
||||||
|
<List |
||||||
|
allowCheckAll={enableCheckAllLocations('anvils')} |
||||||
|
allowCheckItem |
||||||
|
edit |
||||||
|
header |
||||||
|
listItems={anvils} |
||||||
|
getListCheckboxProps={() => getAllLocationsCheckboxProps('anvils')} |
||||||
|
getListItemCheckboxProps={(uuid) => |
||||||
|
getLocationCheckboxProps('anvils', uuid) |
||||||
|
} |
||||||
|
renderListItem={(anvilUuid, { description, name }) => ( |
||||||
|
<BodyText> |
||||||
|
{name}: {description} |
||||||
|
</BodyText> |
||||||
|
)} |
||||||
|
/> |
||||||
|
</ExpandablePanel> |
||||||
|
), |
||||||
|
[ |
||||||
|
anvils, |
||||||
|
enableCheckAllLocations, |
||||||
|
getAllLocationsCheckboxProps, |
||||||
|
getLocationCheckboxProps, |
||||||
|
showSyncInputGroup, |
||||||
|
], |
||||||
|
); |
||||||
|
|
||||||
|
const syncDrHostInputGroup = useMemo( |
||||||
|
() => |
||||||
|
showSyncInputGroup && ( |
||||||
|
<ExpandablePanel |
||||||
|
header="Sync with DR host(s)" |
||||||
|
panelProps={{ mb: 0, mt: 0, width: '100%' }} |
||||||
|
> |
||||||
|
<List |
||||||
|
allowCheckAll={enableCheckAllLocations('drHosts')} |
||||||
|
allowCheckItem |
||||||
|
edit |
||||||
|
header |
||||||
|
listItems={drHosts} |
||||||
|
getListCheckboxProps={() => getAllLocationsCheckboxProps('drHosts')} |
||||||
|
getListItemCheckboxProps={(uuid) => |
||||||
|
getLocationCheckboxProps('drHosts', uuid) |
||||||
|
} |
||||||
|
renderListItem={(anvilUuid, { hostName }) => ( |
||||||
|
<BodyText>{hostName}</BodyText> |
||||||
|
)} |
||||||
|
/> |
||||||
|
</ExpandablePanel> |
||||||
|
), |
||||||
|
[ |
||||||
|
drHosts, |
||||||
|
enableCheckAllLocations, |
||||||
|
getAllLocationsCheckboxProps, |
||||||
|
getLocationCheckboxProps, |
||||||
|
showSyncInputGroup, |
||||||
|
], |
||||||
|
); |
||||||
|
|
||||||
|
const typeInput = useMemo( |
||||||
|
() => |
||||||
|
showTypeInput && ( |
||||||
|
<SelectWithLabel |
||||||
|
id={typeChain} |
||||||
|
label="File type" |
||||||
|
name={typeChain} |
||||||
|
onBlur={handleBlur} |
||||||
|
onChange={handleChange} |
||||||
|
selectItems={UPLOAD_FILE_TYPES_ARRAY.map( |
||||||
|
([value, [, displayValue]]) => ({ |
||||||
|
displayValue, |
||||||
|
value, |
||||||
|
}), |
||||||
|
)} |
||||||
|
value={formik.values[fuuid].type} |
||||||
|
/> |
||||||
|
), |
||||||
|
[formik.values, fuuid, handleBlur, handleChange, showTypeInput, typeChain], |
||||||
|
); |
||||||
|
|
||||||
|
return ( |
||||||
|
<FormGroup sx={{ '& > :not(:first-child)': { marginTop: '1em' } }}> |
||||||
|
<FlexBox sm="row" xs="column"> |
||||||
|
{nameInput} |
||||||
|
{typeInput} |
||||||
|
</FlexBox> |
||||||
|
{syncNodeInputGroup} |
||||||
|
{syncDrHostInputGroup} |
||||||
|
</FormGroup> |
||||||
|
); |
||||||
|
}; |
||||||
|
|
||||||
|
export default FileInputGroup; |
@ -0,0 +1,383 @@ |
|||||||
|
import { dSizeStr } from 'format-data-size'; |
||||||
|
import { FC, useCallback, useMemo, useRef, useState } from 'react'; |
||||||
|
|
||||||
|
import API_BASE_URL from '../../lib/consts/API_BASE_URL'; |
||||||
|
import { UPLOAD_FILE_TYPES } from '../../lib/consts/UPLOAD_FILE_TYPES'; |
||||||
|
|
||||||
|
import AddFileForm from './AddFileForm'; |
||||||
|
import api from '../../lib/api'; |
||||||
|
import ConfirmDialog from '../ConfirmDialog'; |
||||||
|
import { DialogWithHeader } from '../Dialog'; |
||||||
|
import Divider from '../Divider'; |
||||||
|
import EditFileForm from './EditFileForm'; |
||||||
|
import FlexBox from '../FlexBox'; |
||||||
|
import handleAPIError from '../../lib/handleAPIError'; |
||||||
|
import List from '../List'; |
||||||
|
import MessageGroup, { MessageGroupForwardedRefContent } from '../MessageGroup'; |
||||||
|
import { Panel, PanelHeader } from '../Panels'; |
||||||
|
import periodicFetch from '../../lib/fetchers/periodicFetch'; |
||||||
|
import Spinner from '../Spinner'; |
||||||
|
import { BodyText, HeaderText, MonoText } from '../Text'; |
||||||
|
import useChecklist from '../../hooks/useChecklist'; |
||||||
|
import useConfirmDialogProps from '../../hooks/useConfirmDialogProps'; |
||||||
|
import useFetch from '../../hooks/useFetch'; |
||||||
|
import useProtectedState from '../../hooks/useProtectedState'; |
||||||
|
|
||||||
|
const toAnvilOverviewHostList = ( |
||||||
|
data: APIAnvilOverviewArray[number]['hosts'], |
||||||
|
) => |
||||||
|
data.reduce<APIAnvilOverview['hosts']>( |
||||||
|
(previous, { hostName: name, hostType: type, hostUUID: uuid }) => { |
||||||
|
previous[uuid] = { name, type, uuid }; |
||||||
|
|
||||||
|
return previous; |
||||||
|
}, |
||||||
|
{}, |
||||||
|
); |
||||||
|
|
||||||
|
const toAnvilOverviewList = (data: APIAnvilOverviewArray) => |
||||||
|
data.reduce<APIAnvilOverviewList>( |
||||||
|
( |
||||||
|
previous, |
||||||
|
{ |
||||||
|
anvilDescription: description, |
||||||
|
anvilName: name, |
||||||
|
anvilUUID: uuid, |
||||||
|
hosts, |
||||||
|
}, |
||||||
|
) => { |
||||||
|
previous[uuid] = { |
||||||
|
description, |
||||||
|
hosts: toAnvilOverviewHostList(hosts), |
||||||
|
name, |
||||||
|
uuid, |
||||||
|
}; |
||||||
|
|
||||||
|
return previous; |
||||||
|
}, |
||||||
|
{}, |
||||||
|
); |
||||||
|
|
||||||
|
const toFileOverviewList = (rows: string[][]) => |
||||||
|
rows.reduce<APIFileOverviewList>((previous, row) => { |
||||||
|
const [uuid, name, size, type, checksum] = row; |
||||||
|
|
||||||
|
previous[uuid] = { |
||||||
|
checksum, |
||||||
|
name, |
||||||
|
size, |
||||||
|
type: type as FileType, |
||||||
|
uuid, |
||||||
|
}; |
||||||
|
|
||||||
|
return previous; |
||||||
|
}, {}); |
||||||
|
|
||||||
|
const toFileDetail = (rows: string[][]) => { |
||||||
|
const { 0: first } = rows; |
||||||
|
|
||||||
|
if (!first) return undefined; |
||||||
|
|
||||||
|
const [uuid, name, size, type, checksum] = first; |
||||||
|
|
||||||
|
return rows.reduce<APIFileDetail>( |
||||||
|
(previous, row) => { |
||||||
|
const { |
||||||
|
5: locationUuid, |
||||||
|
6: locationActive, |
||||||
|
7: anvilUuid, |
||||||
|
8: anvilName, |
||||||
|
9: anvilDescription, |
||||||
|
10: hostUuid, |
||||||
|
11: hostName, |
||||||
|
12: hostType, |
||||||
|
} = row; |
||||||
|
|
||||||
|
if (!previous.anvils[anvilUuid]) { |
||||||
|
previous.anvils[anvilUuid] = { |
||||||
|
description: anvilDescription, |
||||||
|
locationUuids: [], |
||||||
|
name: anvilName, |
||||||
|
uuid: anvilUuid, |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
if (!previous.hosts[hostUuid]) { |
||||||
|
previous.hosts[hostUuid] = { |
||||||
|
locationUuids: [], |
||||||
|
name: hostName, |
||||||
|
type: hostType, |
||||||
|
uuid: hostUuid, |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
if (hostType === 'dr') { |
||||||
|
previous.hosts[hostUuid].locationUuids.push(locationUuid); |
||||||
|
} else { |
||||||
|
previous.anvils[anvilUuid].locationUuids.push(locationUuid); |
||||||
|
} |
||||||
|
|
||||||
|
const active = Number(locationActive) === 1; |
||||||
|
|
||||||
|
previous.locations[locationUuid] = { |
||||||
|
anvilUuid, |
||||||
|
active, |
||||||
|
hostUuid, |
||||||
|
uuid: locationUuid, |
||||||
|
}; |
||||||
|
|
||||||
|
return previous; |
||||||
|
}, |
||||||
|
{ |
||||||
|
anvils: {}, |
||||||
|
checksum, |
||||||
|
hosts: {}, |
||||||
|
locations: {}, |
||||||
|
name, |
||||||
|
size, |
||||||
|
type: type as FileType, |
||||||
|
uuid, |
||||||
|
}, |
||||||
|
); |
||||||
|
}; |
||||||
|
|
||||||
|
const ManageFilePanel: FC = () => { |
||||||
|
const addFormDialogRef = useRef<DialogForwardedRefContent>(null); |
||||||
|
const confirmDialogRef = useRef<ConfirmDialogForwardedRefContent>({}); |
||||||
|
const editFormDialogRef = useRef<DialogForwardedRefContent>(null); |
||||||
|
const messageGroupRef = useRef<MessageGroupForwardedRefContent>({}); |
||||||
|
|
||||||
|
const [confirmDialogProps, setConfirmDialogProps] = useConfirmDialogProps(); |
||||||
|
const [edit, setEdit] = useState<boolean>(false); |
||||||
|
const [file, setFile] = useProtectedState<APIFileDetail | undefined>( |
||||||
|
undefined, |
||||||
|
); |
||||||
|
const [loadingFile, setLoadingFile] = useProtectedState<boolean>(false); |
||||||
|
|
||||||
|
const { data: rows, isLoading: loadingFiles } = periodicFetch<string[][]>( |
||||||
|
`${API_BASE_URL}/file`, |
||||||
|
); |
||||||
|
|
||||||
|
const files = useMemo( |
||||||
|
() => (rows ? toFileOverviewList(rows) : undefined), |
||||||
|
[rows], |
||||||
|
); |
||||||
|
|
||||||
|
const { |
||||||
|
buildDeleteDialogProps, |
||||||
|
checks, |
||||||
|
getCheck, |
||||||
|
hasAllChecks, |
||||||
|
hasChecks, |
||||||
|
multipleItems, |
||||||
|
setAllChecks, |
||||||
|
setCheck, |
||||||
|
} = useChecklist({ |
||||||
|
list: files, |
||||||
|
}); |
||||||
|
|
||||||
|
const setApiMessage = useCallback( |
||||||
|
(message: Message) => |
||||||
|
messageGroupRef.current.setMessage?.call(null, 'api', message), |
||||||
|
[], |
||||||
|
); |
||||||
|
|
||||||
|
const getFileDetail = useCallback( |
||||||
|
(fileUuid: string) => { |
||||||
|
setLoadingFile(true); |
||||||
|
|
||||||
|
api |
||||||
|
.get<string[][]>(`file/${fileUuid}`) |
||||||
|
.then(({ data }) => { |
||||||
|
setFile(toFileDetail(data)); |
||||||
|
}) |
||||||
|
.catch((error) => { |
||||||
|
const emsg = handleAPIError(error); |
||||||
|
|
||||||
|
emsg.children = <>Failed to get file detail. {emsg.children}</>; |
||||||
|
|
||||||
|
setApiMessage(emsg); |
||||||
|
}) |
||||||
|
.finally(() => { |
||||||
|
setLoadingFile(false); |
||||||
|
}); |
||||||
|
}, |
||||||
|
[setApiMessage, setFile, setLoadingFile], |
||||||
|
); |
||||||
|
|
||||||
|
const { data: rawAnvils, loading: loadingAnvils } = |
||||||
|
useFetch<APIAnvilOverviewArray>('/anvil', { |
||||||
|
onError: (error) => { |
||||||
|
setApiMessage({ |
||||||
|
children: <>Failed to get node list. {error}</>, |
||||||
|
type: 'warning', |
||||||
|
}); |
||||||
|
}, |
||||||
|
}); |
||||||
|
|
||||||
|
const anvils = useMemo( |
||||||
|
() => rawAnvils && toAnvilOverviewList(rawAnvils), |
||||||
|
[rawAnvils], |
||||||
|
); |
||||||
|
|
||||||
|
const { data: drHosts, loading: loadingDrHosts } = |
||||||
|
useFetch<APIHostOverviewList>('/host?types=dr', { |
||||||
|
onError: (error) => { |
||||||
|
setApiMessage({ |
||||||
|
children: <>Failed to get DR host list. {error}</>, |
||||||
|
type: 'warning', |
||||||
|
}); |
||||||
|
}, |
||||||
|
}); |
||||||
|
|
||||||
|
const list = useMemo( |
||||||
|
() => ( |
||||||
|
<List |
||||||
|
allowCheckAll={multipleItems} |
||||||
|
allowEdit |
||||||
|
allowItemButton={edit} |
||||||
|
disableDelete={!hasChecks} |
||||||
|
edit={edit} |
||||||
|
getListCheckboxProps={() => ({ |
||||||
|
checked: hasAllChecks, |
||||||
|
onChange: (event, checked) => { |
||||||
|
setAllChecks(checked); |
||||||
|
}, |
||||||
|
})} |
||||||
|
getListItemCheckboxProps={(uuid) => ({ |
||||||
|
checked: getCheck(uuid), |
||||||
|
onChange: (event, checked) => { |
||||||
|
setCheck(uuid, checked); |
||||||
|
}, |
||||||
|
})} |
||||||
|
header |
||||||
|
listEmpty="No file(s) found." |
||||||
|
listItems={files} |
||||||
|
onAdd={() => { |
||||||
|
addFormDialogRef.current?.setOpen(true); |
||||||
|
}} |
||||||
|
onDelete={() => { |
||||||
|
setConfirmDialogProps( |
||||||
|
buildDeleteDialogProps({ |
||||||
|
onProceedAppend: () => { |
||||||
|
checks.forEach((fileUuid) => api.delete(`/file/${fileUuid}`)); |
||||||
|
}, |
||||||
|
getConfirmDialogTitle: (count) => |
||||||
|
`Delete the following ${count} file(s)?`, |
||||||
|
renderEntry: ({ key }) => ( |
||||||
|
<BodyText>{files?.[key].name}</BodyText> |
||||||
|
), |
||||||
|
}), |
||||||
|
); |
||||||
|
|
||||||
|
confirmDialogRef.current.setOpen?.call(null, true); |
||||||
|
}} |
||||||
|
onEdit={() => { |
||||||
|
setEdit((previous) => !previous); |
||||||
|
}} |
||||||
|
onItemClick={(value, uuid) => { |
||||||
|
editFormDialogRef.current?.setOpen(true); |
||||||
|
getFileDetail(uuid); |
||||||
|
}} |
||||||
|
renderListItem={(uuid, { checksum, name, size, type }) => ( |
||||||
|
<FlexBox columnSpacing={0} fullWidth md="row" xs="column"> |
||||||
|
<FlexBox spacing={0} flexGrow={1}> |
||||||
|
<FlexBox row spacing=".5em"> |
||||||
|
<MonoText>{name}</MonoText> |
||||||
|
<Divider flexItem orientation="vertical" /> |
||||||
|
<BodyText>{UPLOAD_FILE_TYPES.get(type)?.[1]}</BodyText> |
||||||
|
</FlexBox> |
||||||
|
<BodyText>{dSizeStr(size, { toUnit: 'ibyte' })}</BodyText> |
||||||
|
</FlexBox> |
||||||
|
<MonoText>{checksum}</MonoText> |
||||||
|
</FlexBox> |
||||||
|
)} |
||||||
|
/> |
||||||
|
), |
||||||
|
[ |
||||||
|
buildDeleteDialogProps, |
||||||
|
checks, |
||||||
|
edit, |
||||||
|
files, |
||||||
|
getCheck, |
||||||
|
getFileDetail, |
||||||
|
hasAllChecks, |
||||||
|
hasChecks, |
||||||
|
multipleItems, |
||||||
|
setAllChecks, |
||||||
|
setCheck, |
||||||
|
setConfirmDialogProps, |
||||||
|
], |
||||||
|
); |
||||||
|
|
||||||
|
const panelContent = useMemo( |
||||||
|
() => (loadingFiles ? <Spinner /> : list), |
||||||
|
[loadingFiles, list], |
||||||
|
); |
||||||
|
|
||||||
|
const messageArea = useMemo( |
||||||
|
() => ( |
||||||
|
<MessageGroup count={1} ref={messageGroupRef} usePlaceholder={false} /> |
||||||
|
), |
||||||
|
[], |
||||||
|
); |
||||||
|
|
||||||
|
const loadingAddForm = useMemo<boolean>( |
||||||
|
() => loadingFiles || loadingAnvils || loadingDrHosts, |
||||||
|
[loadingAnvils, loadingDrHosts, loadingFiles], |
||||||
|
); |
||||||
|
|
||||||
|
const loadingEditForm = useMemo<boolean>( |
||||||
|
() => loadingFiles || loadingAnvils || loadingDrHosts || loadingFile, |
||||||
|
[loadingAnvils, loadingDrHosts, loadingFile, loadingFiles], |
||||||
|
); |
||||||
|
|
||||||
|
const addForm = useMemo( |
||||||
|
() => |
||||||
|
anvils && drHosts && <AddFileForm anvils={anvils} drHosts={drHosts} />, |
||||||
|
[anvils, drHosts], |
||||||
|
); |
||||||
|
|
||||||
|
const editForm = useMemo( |
||||||
|
() => |
||||||
|
anvils && |
||||||
|
drHosts && |
||||||
|
file && ( |
||||||
|
<EditFileForm anvils={anvils} drHosts={drHosts} previous={file} /> |
||||||
|
), |
||||||
|
[anvils, drHosts, file], |
||||||
|
); |
||||||
|
|
||||||
|
return ( |
||||||
|
<> |
||||||
|
<Panel> |
||||||
|
<PanelHeader> |
||||||
|
<HeaderText>Files</HeaderText> |
||||||
|
</PanelHeader> |
||||||
|
{messageArea} |
||||||
|
{panelContent} |
||||||
|
</Panel> |
||||||
|
<DialogWithHeader |
||||||
|
header="Add file(s)" |
||||||
|
loading={loadingAddForm} |
||||||
|
ref={addFormDialogRef} |
||||||
|
showClose |
||||||
|
wide |
||||||
|
> |
||||||
|
{addForm} |
||||||
|
</DialogWithHeader> |
||||||
|
<DialogWithHeader |
||||||
|
header={`Update file ${file?.name}`} |
||||||
|
loading={loadingEditForm} |
||||||
|
ref={editFormDialogRef} |
||||||
|
showClose |
||||||
|
wide |
||||||
|
> |
||||||
|
{editForm} |
||||||
|
</DialogWithHeader> |
||||||
|
<ConfirmDialog wide {...confirmDialogProps} ref={confirmDialogRef} /> |
||||||
|
</> |
||||||
|
); |
||||||
|
}; |
||||||
|
|
||||||
|
export default ManageFilePanel; |
@ -0,0 +1,42 @@ |
|||||||
|
import { Box as MuiBox } from '@mui/material'; |
||||||
|
import { FC } from 'react'; |
||||||
|
|
||||||
|
import { ProgressBar } from '../Bars'; |
||||||
|
import FlexBox from '../FlexBox'; |
||||||
|
import { BodyText } from '../Text'; |
||||||
|
|
||||||
|
const UploadFileProgress: FC<UploadFileProgressProps> = (props) => { |
||||||
|
const { uploads } = props; |
||||||
|
|
||||||
|
return ( |
||||||
|
<FlexBox columnSpacing=".2em"> |
||||||
|
{Object.values(uploads).map(({ name, progress, uuid }) => ( |
||||||
|
<MuiBox |
||||||
|
key={`upload-${uuid}`} |
||||||
|
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>{name}</BodyText> |
||||||
|
<ProgressBar progressPercentage={progress} /> |
||||||
|
</MuiBox> |
||||||
|
))} |
||||||
|
</FlexBox> |
||||||
|
); |
||||||
|
}; |
||||||
|
|
||||||
|
export default UploadFileProgress; |
@ -0,0 +1,62 @@ |
|||||||
|
type FileFormikLocations = { |
||||||
|
anvils: { |
||||||
|
[anvilUuid: string]: { |
||||||
|
active: boolean; |
||||||
|
}; |
||||||
|
}; |
||||||
|
drHosts: { |
||||||
|
[hostUuid: string]: { |
||||||
|
active: boolean; |
||||||
|
}; |
||||||
|
}; |
||||||
|
}; |
||||||
|
|
||||||
|
type FileFormikFile = { |
||||||
|
file?: File; |
||||||
|
locations?: FileFormikLocations; |
||||||
|
name: string; |
||||||
|
type?: FileType; |
||||||
|
uuid: string; |
||||||
|
}; |
||||||
|
|
||||||
|
type FileFormikValues = { |
||||||
|
[fileUuid: string]: FileFormikFile; |
||||||
|
}; |
||||||
|
|
||||||
|
/** ---------- Component types ---------- */ |
||||||
|
|
||||||
|
/** FileInputGroup */ |
||||||
|
|
||||||
|
type FileInputGroupOptionalProps = { |
||||||
|
showSyncInputGroup?: boolean; |
||||||
|
showTypeInput?: boolean; |
||||||
|
}; |
||||||
|
|
||||||
|
type FileInputGroupProps = FileInputGroupOptionalProps & { |
||||||
|
anvils: APIAnvilOverviewList; |
||||||
|
drHosts: APIHostOverviewList; |
||||||
|
fileUuid: string; |
||||||
|
formik: ReturnType<typeof import('formik').useFormik<FileFormikValues>>; |
||||||
|
}; |
||||||
|
|
||||||
|
/** AddFileForm */ |
||||||
|
|
||||||
|
type UploadFiles = { |
||||||
|
[fileUuid: string]: Pick<FileFormikFile, 'name' | 'uuid'> & { |
||||||
|
progress: number; |
||||||
|
}; |
||||||
|
}; |
||||||
|
|
||||||
|
type AddFileFormProps = Pick<FileInputGroupProps, 'anvils' | 'drHosts'>; |
||||||
|
|
||||||
|
/** EditFileForm */ |
||||||
|
|
||||||
|
type EditFileFormProps = Pick<FileInputGroupProps, 'anvils' | 'drHosts'> & { |
||||||
|
previous: APIFileDetail; |
||||||
|
}; |
||||||
|
|
||||||
|
/** UploadFileProgress */ |
||||||
|
|
||||||
|
type UploadFileProgressProps = { |
||||||
|
uploads: UploadFiles; |
||||||
|
}; |
Loading…
Reference in new issue