Skip to content

Commit 0c93fc4

Browse files
authored
Merge pull request #2123 from gchq/feature/BAI-1672-add-new-file-button-to-files-tab
Added the ability to upload files directly in the files tab for a model.
2 parents 1f12e3e + c39a98a commit 0c93fc4

File tree

7 files changed

+194
-64
lines changed

7 files changed

+194
-64
lines changed

frontend/pages/model/[modelId]/release/new.tsx

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { CreateReleaseParams, postRelease } from 'actions/release'
77
import { AxiosProgressEvent } from 'axios'
88
import { useRouter } from 'next/router'
99
import { FormEvent, useCallback, useMemo, useState } from 'react'
10+
import { FailedFileUpload, FileUploadProgress } from 'src/common/FileUploadProgressDisplay'
1011
import Loading from 'src/common/Loading'
1112
import Title from 'src/common/Title'
1213
import ReleaseForm from 'src/entry/model/releases/ReleaseForm'
@@ -15,9 +16,7 @@ import Link from 'src/Link'
1516
import MessageAlert from 'src/MessageAlert'
1617
import {
1718
EntryKind,
18-
FailedFileUpload,
1919
FileInterface,
20-
FileUploadProgress,
2120
FileWithMetadata,
2221
FlattenedModelImage,
2322
isFileInterface,
@@ -74,6 +73,7 @@ export default function NewRelease() {
7473
event.preventDefault()
7574

7675
setFailedFileUploads([])
76+
const failedFiles: FailedFileUpload[] = []
7777

7878
if (!model) {
7979
return setErrorMessage('Please wait for the model to finish loading before trying to make a release.')
@@ -90,7 +90,6 @@ export default function NewRelease() {
9090
setErrorMessage('')
9191
setLoading(true)
9292

93-
const failedFiles: FailedFileUpload[] = []
9493
const successfulFiles: SuccessfulFileUpload[] = []
9594
for (const file of files) {
9695
if (isFileInterface(file)) {
@@ -125,6 +124,7 @@ export default function NewRelease() {
125124
}
126125
}
127126
}
127+
setFailedFileUploads(failedFiles)
128128

129129
const updatedSuccessfulFiles = successfulFiles.reduce(
130130
(updatedFiles, file) => {
@@ -137,11 +137,6 @@ export default function NewRelease() {
137137
)
138138
setSuccessfulFileUploads(updatedSuccessfulFiles)
139139

140-
if (failedFiles.length > 0) {
141-
setFailedFileUploads(failedFiles)
142-
return
143-
}
144-
145140
const release: CreateReleaseParams = {
146141
modelId: model.id,
147142
semver,
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { Stack, Typography } from '@mui/material'
2+
3+
interface FileUploadProgressDisplayProps {
4+
currentFileUploadProgress: FileUploadProgress
5+
totalFilesToUpload: number
6+
uploadedFiles: number
7+
}
8+
9+
export interface FileUploadProgress {
10+
fileName: string
11+
uploadProgress: number
12+
}
13+
14+
export interface FailedFileUpload {
15+
fileName: string
16+
error: string
17+
}
18+
19+
export default function FileUploadProgressDisplay({
20+
currentFileUploadProgress,
21+
totalFilesToUpload,
22+
uploadedFiles,
23+
}: FileUploadProgressDisplayProps) {
24+
if (!currentFileUploadProgress) {
25+
return <Typography>Could not determine file progress</Typography>
26+
}
27+
if (uploadedFiles && uploadedFiles === totalFilesToUpload) {
28+
return <Typography>All files uploaded successfully.</Typography>
29+
}
30+
return currentFileUploadProgress.uploadProgress < 100 ? (
31+
<Stack direction='row' spacing={1}>
32+
<Typography fontWeight='bold'>
33+
[File {uploadedFiles ? uploadedFiles + 1 : '1'} / {totalFilesToUpload}] -
34+
</Typography>
35+
<Typography fontWeight='bold'>{currentFileUploadProgress.fileName}</Typography>
36+
<Typography>uploading {currentFileUploadProgress.uploadProgress}%</Typography>
37+
</Stack>
38+
) : (
39+
<Stack direction='row' spacing={1}>
40+
<Typography fontWeight='bold'>
41+
File {uploadedFiles ? uploadedFiles + 1 : '1'} / {totalFilesToUpload} -{currentFileUploadProgress.fileName}
42+
</Typography>
43+
<Typography>received - waiting for response from server...</Typography>
44+
</Stack>
45+
)
46+
}

frontend/src/entry/model/Files.tsx

Lines changed: 106 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
1-
import { Card, Container, Stack } from '@mui/material'
1+
import { LoadingButton } from '@mui/lab'
2+
import { Box, Button, Card, Container, LinearProgress, Stack } from '@mui/material'
3+
import { styled } from '@mui/material/styles'
4+
import { postFileForModelId } from 'actions/file'
25
import { useGetModelFiles } from 'actions/model'
3-
import { useMemo } from 'react'
6+
import { AxiosProgressEvent } from 'axios'
7+
import { ChangeEvent, useCallback, useMemo, useState } from 'react'
48
import EmptyBlob from 'src/common/EmptyBlob'
9+
import FileUploadProgressDisplay, { FailedFileUpload, FileUploadProgress } from 'src/common/FileUploadProgressDisplay'
510
import Loading from 'src/common/Loading'
11+
import Restricted from 'src/common/Restricted'
612
import FileDownload from 'src/entry/model/releases/FileDownload'
713
import MessageAlert from 'src/MessageAlert'
814
import { EntryInterface } from 'types/types'
@@ -12,9 +18,19 @@ type FilesProps = {
1218
model: EntryInterface
1319
}
1420

21+
const Input = styled('input')({
22+
display: 'none',
23+
})
24+
1525
export default function Files({ model }: FilesProps) {
1626
const { entryFiles, isEntryFilesLoading, isEntryFilesError, mutateEntryFiles } = useGetModelFiles(model.id)
1727

28+
const [currentFileUploadProgress, setCurrentFileUploadProgress] = useState<FileUploadProgress | undefined>(undefined)
29+
const [uploadedFiles, setUploadedFiles] = useState<string[]>([])
30+
const [totalFilesToUpload, setTotalFilesToUpload] = useState(0)
31+
const [isFilesUploading, setIsFilesUploading] = useState(false)
32+
const [failedFileUploads, setFailedFileUploads] = useState<FailedFileUpload[]>([])
33+
1834
const sortedEntryFiles = useMemo(() => [...entryFiles].sort(sortByCreatedAtDescending), [entryFiles])
1935

2036
const entryFilesList = useMemo(
@@ -38,6 +54,59 @@ export default function Files({ model }: FilesProps) {
3854
[entryFiles.length, model.id, model.name, sortedEntryFiles, mutateEntryFiles],
3955
)
4056

57+
const handleAddNewFiles = useCallback(
58+
async (event: ChangeEvent<HTMLInputElement>) => {
59+
setIsFilesUploading(true)
60+
setFailedFileUploads([])
61+
const failedFiles: FailedFileUpload[] = []
62+
const files = event.target.files ? Array.from(event.target.files) : []
63+
setTotalFilesToUpload(files.length)
64+
for (const file of files) {
65+
const handleUploadProgress = (progressEvent: AxiosProgressEvent) => {
66+
if (progressEvent.total) {
67+
const percentCompleted = Math.round((progressEvent.loaded * 100) / progressEvent.total)
68+
setCurrentFileUploadProgress({ fileName: file.name, uploadProgress: percentCompleted })
69+
}
70+
}
71+
72+
try {
73+
const fileUploadResponse = await postFileForModelId(model.id, file, handleUploadProgress, '')
74+
setCurrentFileUploadProgress(undefined)
75+
if (fileUploadResponse) {
76+
setUploadedFiles((uploadedFiles) => [...uploadedFiles, file.name])
77+
mutateEntryFiles()
78+
} else {
79+
setCurrentFileUploadProgress(undefined)
80+
}
81+
} catch (e) {
82+
if (e instanceof Error) {
83+
failedFiles.push({ fileName: file.name, error: e.message })
84+
setFailedFileUploads([...failedFileUploads, { fileName: file.name, error: e.message }])
85+
setCurrentFileUploadProgress(undefined)
86+
}
87+
}
88+
}
89+
setUploadedFiles([])
90+
setFailedFileUploads(failedFiles)
91+
setTotalFilesToUpload(0)
92+
setIsFilesUploading(false)
93+
},
94+
[model.id, mutateEntryFiles, failedFileUploads],
95+
)
96+
97+
const failedFileList = useMemo(
98+
() =>
99+
failedFileUploads.map((file) => (
100+
<div key={file.fileName}>
101+
<Box component='span' fontWeight='bold'>
102+
{file.fileName}
103+
</Box>
104+
{` - ${file.error}`}
105+
</div>
106+
)),
107+
[failedFileUploads],
108+
)
109+
41110
if (isEntryFilesError) {
42111
return <MessageAlert message={isEntryFilesError.info.message} severity='error' />
43112
}
@@ -49,7 +118,41 @@ export default function Files({ model }: FilesProps) {
49118
return (
50119
<>
51120
<Container sx={{ my: 2 }}>
52-
<Stack direction={{ xs: 'column' }} spacing={2} justifyContent='center' alignItems='center'>
121+
<Stack direction={{ xs: 'column' }} spacing={4}>
122+
<Box display='flex'>
123+
<Box ml='auto'>
124+
<Restricted action='createRelease' fallback={<Button disabled>Add new files</Button>}>
125+
<>
126+
<label htmlFor='add-files-button'>
127+
<LoadingButton loading={isFilesUploading} fullWidth component='span' variant='outlined'>
128+
Add new files
129+
</LoadingButton>
130+
</label>
131+
<Input
132+
multiple
133+
id={'add-files-button'}
134+
type='file'
135+
onInput={handleAddNewFiles}
136+
data-test='uploadFileButton'
137+
/>
138+
</>
139+
</Restricted>
140+
</Box>
141+
</Box>
142+
{currentFileUploadProgress && (
143+
<>
144+
<LinearProgress
145+
variant={currentFileUploadProgress.uploadProgress < 100 ? 'determinate' : 'indeterminate'}
146+
value={currentFileUploadProgress.uploadProgress}
147+
/>
148+
<FileUploadProgressDisplay
149+
currentFileUploadProgress={currentFileUploadProgress}
150+
uploadedFiles={uploadedFiles.length}
151+
totalFilesToUpload={totalFilesToUpload}
152+
/>
153+
</>
154+
)}
155+
{failedFileList}
53156
{entryFilesList}
54157
</Stack>
55158
</Container>

frontend/src/entry/model/releases/EditableRelease.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,16 +12,15 @@ import { AxiosProgressEvent } from 'axios'
1212
import { useRouter } from 'next/router'
1313
import { useCallback, useContext, useEffect, useMemo, useState } from 'react'
1414
import ConfirmationDialogue from 'src/common/ConfirmationDialogue'
15+
import { FailedFileUpload, FileUploadProgress } from 'src/common/FileUploadProgressDisplay'
1516
import Loading from 'src/common/Loading'
1617
import UnsavedChangesContext from 'src/contexts/unsavedChangesContext'
1718
import ReleaseForm from 'src/entry/model/releases/ReleaseForm'
1819
import EditableFormHeading from 'src/Form/EditableFormHeading'
1920
import MessageAlert from 'src/MessageAlert'
2021
import {
2122
EntryKind,
22-
FailedFileUpload,
2323
FileInterface,
24-
FileUploadProgress,
2524
FileWithMetadata,
2625
FlattenedModelImage,
2726
isFileInterface,

frontend/src/entry/model/releases/ExistingFileSelector.tsx

Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,10 @@ import {
1010
ListItemButton,
1111
ListItemIcon,
1212
ListItemText,
13+
Stack,
1314
Typography,
1415
} from '@mui/material'
16+
import { useTheme } from '@mui/material/styles'
1517
import { useGetFilesForModel } from 'actions/file'
1618
import prettyBytes from 'pretty-bytes'
1719
import { useCallback, useMemo, useState } from 'react'
@@ -31,6 +33,7 @@ export default function ExistingFileSelector({ model, existingReleaseFiles, onCh
3133
const [isDialogOpen, setIsDialogOpen] = useState(false)
3234
const { files, isFilesLoading, isFilesError } = useGetFilesForModel(model.id)
3335
const [checkedFiles, setCheckedFiles] = useState<FileInterface[]>([])
36+
const theme = useTheme()
3437

3538
const handleAddFilesOnClick = () => {
3639
if (checkedFiles.length === 0) {
@@ -66,6 +69,23 @@ export default function ExistingFileSelector({ model, existingReleaseFiles, onCh
6669
[checkedFiles],
6770
)
6871

72+
const isFileDisabled = useCallback(
73+
(file: FileInterface) => {
74+
return (
75+
existingReleaseFiles.find(
76+
(existingFile) => isFileInterface(existingFile) && existingFile.name === file.name,
77+
) !== undefined ||
78+
(checkedFiles.find(
79+
(existingCheckedFile) => isFileInterface(existingCheckedFile) && existingCheckedFile.name === file.name,
80+
) !== undefined &&
81+
checkedFiles.find(
82+
(existingCheckedFile) => isFileInterface(existingCheckedFile) && existingCheckedFile._id === file._id,
83+
) === undefined)
84+
)
85+
},
86+
[existingReleaseFiles, checkedFiles],
87+
)
88+
6989
const fileList = useMemo(() => {
7090
if (!files || files.length === 0) {
7191
return <EmptyBlob text='No existing files available' />
@@ -75,15 +95,7 @@ export default function ExistingFileSelector({ model, existingReleaseFiles, onCh
7595
<List>
7696
{files.map((file) => (
7797
<ListItem key={file._id} disablePadding>
78-
<ListItemButton
79-
dense
80-
onClick={handleToggle(file)}
81-
disabled={
82-
existingReleaseFiles.find(
83-
(existingFile) => isFileInterface(existingFile) && existingFile._id === file._id,
84-
) !== undefined
85-
}
86-
>
98+
<ListItemButton dense onClick={handleToggle(file)} disabled={isFileDisabled(file)}>
8799
<ListItemIcon>
88100
<Checkbox
89101
edge='start'
@@ -99,11 +111,16 @@ export default function ExistingFileSelector({ model, existingReleaseFiles, onCh
99111
</ListItemIcon>
100112
<ListItemText
101113
primary={
102-
<>
114+
<Stack>
103115
<Typography color='primary' component='span'>
104116
{file.name}
105117
</Typography>
106-
</>
118+
{isFileDisabled(file) && (
119+
<Typography variant='caption' color={theme.palette.error.main}>
120+
A file with this name has either been selected, or is already on this release
121+
</Typography>
122+
)}
123+
</Stack>
107124
}
108125
secondary={`Added on ${formatDateString(file.createdAt.toString())} - ${prettyBytes(file.size)}`}
109126
/>
@@ -113,7 +130,7 @@ export default function ExistingFileSelector({ model, existingReleaseFiles, onCh
113130
</List>
114131
)
115132
}
116-
}, [checkedFiles, existingReleaseFiles, files, handleToggle])
133+
}, [checkedFiles, existingReleaseFiles, files, handleToggle, isFileDisabled, theme.palette.error.main])
117134

118135
if (isFilesError) {
119136
return <MessageAlert message={isFilesError.info.message} severity='error' />

0 commit comments

Comments
 (0)