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