Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feature/edit-release-files #941

Merged
merged 1 commit into from
Dec 6, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions backend/src/routes/v2/release/getRelease.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import { z } from 'zod'

import { AuditInfo } from '../../../connectors/v2/audit/Base.js'
import audit from '../../../connectors/v2/audit/index.js'
import { FileInterface } from '../../../models/v2/File.js'
import { ReleaseInterface } from '../../../models/v2/Release.js'
import { getFilesByIds } from '../../../services/v2/file.js'
import { getReleaseBySemver } from '../../../services/v2/release.js'
import { registerPath, releaseInterfaceSchema } from '../../../services/v2/specification.js'
import { parse } from '../../../utils/v2/validate.js'
Expand Down Expand Up @@ -37,7 +39,7 @@ registerPath({
})

interface getReleaseResponse {
release: ReleaseInterface
release: ReleaseInterface & { files: FileInterface[] }
}

export const getRelease = [
Expand All @@ -50,9 +52,11 @@ export const getRelease = [

const release = await getReleaseBySemver(req.user, modelId, semver)
await audit.onViewRelease(req, release)
const files = await getFilesByIds(req.user, modelId, release.fileIds)
const releaseWithFiles = { ...release.toObject(), files }

return res.json({
release,
release: releaseWithFiles,
})
},
]
3 changes: 2 additions & 1 deletion backend/src/routes/v2/release/getReleases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { z } from 'zod'

import { AuditInfo } from '../../../connectors/v2/audit/Base.js'
import audit from '../../../connectors/v2/audit/index.js'
import { FileInterface } from '../../../models/v2/File.js'
import { ReleaseInterface } from '../../../models/v2/Release.js'
import { getModelReleases } from '../../../services/v2/release.js'
import { registerPath, releaseInterfaceSchema } from '../../../services/v2/specification.js'
Expand Down Expand Up @@ -38,7 +39,7 @@ registerPath({
})

interface getReleasesResponse {
releases: Array<ReleaseInterface>
releases: Array<ReleaseInterface & { files: FileInterface[] }>
}

export const getReleases = [
Expand Down
12 changes: 12 additions & 0 deletions backend/src/services/v2/file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,18 @@ export async function getFilesByModel(user: UserDoc, modelId: string) {
return asyncFilter(files, (file) => authorisation.userFileAction(user, model, file, FileAction.View))
}

export async function getFilesByIds(user: UserDoc, modelId: string, fileIds: string[]) {
const model = await getModelById(user, modelId)
const files = await FileModel.find({ _id: { $in: fileIds } })

if (files.length !== fileIds.length) {
const notFoundFileIds = fileIds.filter((id) => files.some((file) => file._id.toString() === id))
throw NotFound(`The requested files were not found.`, { fileIds: notFoundFileIds })
}

return asyncFilter(files, (file) => authorisation.userFileAction(user, model, file, FileAction.View))
}

export async function removeFile(user: UserDoc, modelId: string, fileId: string) {
const model = await getModelById(user, modelId)
const file = await getFileById(user, fileId)
Expand Down
3 changes: 2 additions & 1 deletion backend/src/services/v2/release.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Optional } from 'utility-types'

import { ModelAction, ReleaseAction } from '../../connectors/v2/authorisation/Base.js'
import authorisation from '../../connectors/v2/authorisation/index.js'
import { FileInterface } from '../../models/v2/File.js'
import { ModelDoc, ModelInterface } from '../../models/v2/Model.js'
import Release, { ImageRef, ReleaseDoc, ReleaseInterface } from '../../models/v2/Release.js'
import { UserDoc } from '../../models/v2/User.js'
Expand Down Expand Up @@ -145,7 +146,7 @@ export async function updateRelease(user: UserDoc, modelId: string, semver: stri
export async function getModelReleases(
user: UserDoc,
modelId: string,
): Promise<Array<ReleaseDoc & { model: ModelInterface }>> {
): Promise<Array<ReleaseDoc & { model: ModelInterface; files: FileInterface[] }>> {
const results = await Release.aggregate()
.match({ modelId })
.sort({ updatedAt: -1 })
Expand Down
10 changes: 2 additions & 8 deletions frontend/actions/release.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,13 +59,7 @@ export function putRelease(release: UpdateReleaseParams) {
})
}

export async function postFile(
artefact: File,
modelId: string,
name: string,
mime: string,
metadata?: string | undefined,
) {
export async function postFile(file: File, modelId: string, name: string, mime: string, metadata?: string | undefined) {
return fetch(
metadata
? `/api/v2/model/${modelId}/files/upload/simple?name=${name}&mine=${mime}?${qs.stringify({
Expand All @@ -75,7 +69,7 @@ export async function postFile(
{
method: 'post',
headers: { 'Content-Type': 'application/json' },
body: artefact,
body: file,
},
)
}
14 changes: 10 additions & 4 deletions frontend/pages/beta/model/[modelId]/release/new.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,15 @@ import MessageAlert from 'src/MessageAlert'
import ReleaseForm from 'src/model/beta/releases/ReleaseForm'
import Wrapper from 'src/Wrapper.beta'
import { FileWithMetadata, FlattenedModelImage } from 'types/interfaces'
import { FileInterface, isFileInterface } from 'types/v2/types'
import { getErrorMessage } from 'utils/fetcher'
import { isValidSemver } from 'utils/stringUtils'

export default function NewRelease() {
const [semver, setSemver] = useState('')
const [releaseNotes, setReleaseNotes] = useState('')
const [isMinorRelease, setIsMinorRelease] = useState(false)
const [files, setFiles] = useState<File[]>([])
const [files, setFiles] = useState<(File | FileInterface)[]>([])
const [filesMetadata, setFilesMetadata] = useState<FileWithMetadata[]>([])
const [imageList, setImageList] = useState<FlattenedModelImage[]>([])
const [errorMessage, setErrorMessage] = useState('')
Expand Down Expand Up @@ -49,8 +50,13 @@ export default function NewRelease() {

const fileIds: string[] = []
for (const file of files) {
const fileMetadata = filesMetadata.find((metadata) => metadata.fileName === file.name)
const postFileResponse = await postFile(file, model.id, file.name, file.type, fileMetadata?.metadata)
if (isFileInterface(file)) {
fileIds.push(file._id)
continue
}

const metadata = filesMetadata.find((fileWithMetadata) => fileWithMetadata.fileName === file.name)?.metadata
const postFileResponse = await postFile(file, model.id, file.name, file.type, metadata)

if (!postFileResponse.ok) {
setErrorMessage(await getErrorMessage(postFileResponse))
Expand All @@ -67,7 +73,7 @@ export default function NewRelease() {
modelCardVersion: model.card.version,
notes: releaseNotes,
minor: isMinorRelease,
fileIds: fileIds,
fileIds,
images: imageList,
}

Expand Down
41 changes: 24 additions & 17 deletions frontend/src/common/MultiFileInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,17 @@ import { styled } from '@mui/system'
import { ChangeEvent, useCallback, useMemo } from 'react'
import MultiFileInputFileDisplay from 'src/common/MultiFileInputFileDisplay'
import { FileWithMetadata } from 'types/interfaces'
import { FileInterface } from 'types/v2/types'

const Input = styled('input')({
display: 'none',
})

type MultiFileInputProps = {
label: string
files: File[]
files: (File | FileInterface)[]
filesMetadata: FileWithMetadata[]
onFileChange: (value: File[]) => void
onFilesChange: (value: (File | FileInterface)[]) => void
onFilesMetadataChange: (value: FileWithMetadata[]) => void
accepts?: string
disabled?: boolean
Expand All @@ -23,35 +24,36 @@ type MultiFileInputProps = {

export default function MultiFileInput({
label,
onFileChange,
files,
filesMetadata,
onFilesChange,
onFilesMetadataChange,
files,
accepts = '',
disabled = false,
fullWidth = false,
readOnly = false,
}: MultiFileInputProps) {
const htmlId = useMemo(() => `${label.replace(/ /g, '-').toLowerCase()}-file`, [label])

function handleDelete(fileToDelete: File) {
function handleDeleteFile(fileToDelete: File | FileInterface) {
if (files) {
const updatedFileList = files.filter((file) => file.name !== fileToDelete.name)
onFileChange(updatedFileList)
onFilesChange(updatedFileList)
}
}

const handleFileChange = useCallback(
const handleAddFile = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
const newFiles = event.target.files ? Array.from(event.target.files) : []
if (newFiles) {
if (newFiles.length) {
if (files) {
const updatedFiles = newFiles.concat(
files.filter((file) => !newFiles.some((newFile) => newFile.name === file.name)),
)
onFileChange(updatedFiles)
const updatedFiles = [
...files.filter((file) => !newFiles.some((newFile) => newFile.name === file.name)),
...newFiles,
]
onFilesChange(updatedFiles)
} else {
onFileChange(newFiles)
onFilesChange(newFiles)
}
onFilesMetadataChange([
...filesMetadata.filter(
Expand All @@ -61,10 +63,10 @@ export default function MultiFileInput({
])
}
},
[files, filesMetadata, onFileChange, onFilesMetadataChange],
[files, filesMetadata, onFilesChange, onFilesMetadataChange],
)

const handleFileDisplayChange = useCallback(
const handleMetadataChange = useCallback(
(fileWithMetadata: FileWithMetadata) => {
const tempFilesWithMetadata = [...filesMetadata]
const metadataIndex = filesMetadata.findIndex((artefact) => artefact.fileName === fileWithMetadata.fileName)
Expand All @@ -87,14 +89,19 @@ export default function MultiFileInput({
{label}
</Button>
</label>
<Input multiple id={htmlId} type='file' onInput={handleFileChange} accept={accepts} disabled={disabled} />
<Input multiple id={htmlId} type='file' onInput={handleAddFile} accept={accepts} disabled={disabled} />
</>
)}
{files.length > 0 && (
<Stack spacing={1} mt={1}>
{files.map((file) => (
<div key={file.name}>
<MultiFileInputFileDisplay file={file} handleDelete={handleDelete} onChange={handleFileDisplayChange} />
<MultiFileInputFileDisplay
file={file}
readOnly={readOnly}
onDelete={handleDeleteFile}
onMetadataChange={handleMetadataChange}
/>
</div>
))}
</Stack>
Expand Down
34 changes: 21 additions & 13 deletions frontend/src/common/MultiFileInputFileDisplay.tsx
Original file line number Diff line number Diff line change
@@ -1,30 +1,38 @@
import { Chip, Grid, TextField, Tooltip, Typography } from '@mui/material'
import prettyBytes from 'pretty-bytes'
import { ChangeEvent, useCallback, useState } from 'react'
import { ChangeEvent, useState } from 'react'
import { FileWithMetadata } from 'types/interfaces'
import { FileInterface } from 'types/v2/types'

interface MultiFileInputDisplayProps {
file: File
handleDelete: (file: File) => void
onChange: (fileWithMetadata: FileWithMetadata) => void
file: File | FileInterface
onDelete: (file: File | FileInterface) => void
onMetadataChange: (fileWithMetadata: FileWithMetadata) => void
readOnly?: boolean
}

export default function MultiFileInputFileDisplay({ file, handleDelete, onChange }: MultiFileInputDisplayProps) {
export default function MultiFileInputFileDisplay({
file,
onDelete,
onMetadataChange,
readOnly = false,
}: MultiFileInputDisplayProps) {
const [metadata, setMetadata] = useState('')

const handleMetadataChange = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
setMetadata(event.target.value)
onChange({ fileName: file.name, metadata })
},
[onChange, file.name, metadata],
)
const handleDelete = () => {
onDelete(file)
}

const handleMetadataChange = (event: ChangeEvent<HTMLInputElement>) => {
setMetadata(event.target.value)
onMetadataChange({ fileName: file.name, metadata })
}

return (
<Grid container spacing={1} alignItems='center'>
<Grid item xs>
<Tooltip title={file.name}>
<Chip color='primary' label={file.name} onDelete={() => handleDelete(file)} />
<Chip color='primary' label={file.name} onDelete={readOnly ? undefined : handleDelete} />
</Tooltip>
</Grid>
<Grid item xs={7}>
Expand Down
37 changes: 22 additions & 15 deletions frontend/src/model/beta/releases/EditableRelease.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Box, Typography } from '@mui/material'
import { useGetModel } from 'actions/model'
import { putRelease, UpdateReleaseParams, useGetReleasesForModelId } from 'actions/release'
import { postFile, putRelease, UpdateReleaseParams, useGetReleasesForModelId } from 'actions/release'
import { useCallback, useContext, useEffect, useState } from 'react'
import Loading from 'src/common/Loading'
import UnsavedChangesContext from 'src/contexts/unsavedChangesContext'
Expand All @@ -9,6 +9,7 @@ import MessageAlert from 'src/MessageAlert'
import ReleaseForm from 'src/model/beta/releases/ReleaseForm'
import { FileWithMetadata, FlattenedModelImage } from 'types/interfaces'
import { ReleaseInterface } from 'types/types'
import { FileInterface, isFileInterface } from 'types/v2/types'
import { getErrorMessage } from 'utils/fetcher'

type EditableReleaseProps = {
Expand All @@ -20,7 +21,7 @@ export default function EditableRelease({ release }: EditableReleaseProps) {
const [semver, setSemver] = useState(release.semver)
const [releaseNotes, setReleaseNotes] = useState(release.notes)
const [isMinorRelease, setIsMinorRelease] = useState(!!release.minor)
const [files, setFiles] = useState<File[]>([]) // TODO - Default to using the release files (BAI-1026)
const [files, setFiles] = useState<(File | FileInterface)[]>(release.files)
const [filesMetadata, setFilesMetadata] = useState<FileWithMetadata[]>([])
const [imageList, setImageList] = useState<FlattenedModelImage[]>(release.images)
const [errorMessage, setErrorMessage] = useState('')
Expand All @@ -35,10 +36,10 @@ export default function EditableRelease({ release }: EditableReleaseProps) {
setSemver(release.semver)
setReleaseNotes(release.notes)
setIsMinorRelease(!!release.minor)
setFiles([]) // TODO - Reset the release files (BAI-1026)
setFilesMetadata([])
setFiles(release.files)
setFilesMetadata(release.files.map((file) => ({ fileName: file.name, metadata: '' })))
setImageList(release.images)
}, [release.images, release.minor, release.notes, release.semver])
}, [release.semver, release.notes, release.minor, release.files, release.images])

useEffect(() => {
resetForm()
Expand Down Expand Up @@ -68,25 +69,31 @@ export default function EditableRelease({ release }: EditableReleaseProps) {
const handleSubmit = async () => {
setIsLoading(true)

// TODO: Uncomment below and use fileIds instead of release.fileIds when creating updatedRelease (BAI-1026)
/* const fileIds: string[] = []
const fileIds: string[] = []
for (const file of files) {
const postFileResponse = await postFile(file, model.id, file.name, file.type)
if (postFileResponse.ok) {
const res = await postFileResponse.json()
fileIds.push(res.file._id)
} else {
return setErrorMessage(await getErrorMessage(postFileResponse))
if (isFileInterface(file)) {
fileIds.push(file._id)
continue
}
} */

const metadata = filesMetadata.find((fileWithMetadata) => fileWithMetadata.fileName === file.name)?.metadata
const postFileResponse = await postFile(file, model.id, file.name, file.type, metadata)
if (!postFileResponse.ok) {
setErrorMessage(await getErrorMessage(postFileResponse))
return setIsLoading(false)
}

const res = await postFileResponse.json()
fileIds.push(res.file._id)
}

const updatedRelease: UpdateReleaseParams = {
modelId: model.id,
semver,
modelCardVersion: model.card.version,
notes: releaseNotes,
minor: isMinorRelease,
fileIds: release.fileIds, // TODO: Use fileIds from above (BAI-1026)
fileIds,
images: imageList,
}

Expand Down
Loading