Skip to content

Commit

Permalink
feat: preview files
Browse files Browse the repository at this point in the history
- download and preview file for a campaign application
- create a UploadedFilesList component that takes the fetch and delete query and labels and shows a list of uploaded files with download/preview/delete actions
  • Loading branch information
gparlakov committed Sep 28, 2024
1 parent 3a912bf commit ab6e038
Show file tree
Hide file tree
Showing 8 changed files with 302 additions and 5 deletions.
6 changes: 6 additions & 0 deletions public/locales/bg/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,12 @@
"agree-with-newsletter": "Съгласявам се да получавам известия.",
"agree-with-newsletter-campaign": "Съгласявам се да получавам новини за тази кампания и известия от Подкрепи.бг."
},
"files": {
"attached-files": "Прикачени файлове",
"download": "Изтегляне",
"errorDeletingFile": "Грешка при изтриване на файл",
"deletedFile": "Успешно изтрит файл"
},
"cookieConsent": "Подкрепи.бг не използва бисквитки, освен тези от трети страни, нужни за аналитичните компоненти Google Analytics и HotJar. Приемането на бисквитките ще ни помогне да подобрим вашето потребителско преживяване.",
"cookieConsentButton": "Приемам",
"cookieRejectButton": "Отхвърлям",
Expand Down
6 changes: 6 additions & 0 deletions public/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,12 @@
"agree-with-newsletter": "I agree to receive news.",
"agree-with-newsletter-campaign": "I agree to receive news about this campaign and news by Podkrepi.bg."
},
"files": {
"attached-files": "Attached files",
"download": "Download",
"errorDeletingFile": "Failure deleting file",
"deletedFile": "Successfully deleted file"
},

"cookieConsent": "Podkrepi.bg doesn't use cookies, except the third-party cookies required for the analytics components Google Analytics and HotJar. Accepting the cookies will help us improve your user experience.",
"cookieConsentButton": "Accept",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,16 @@ import {
import { CamAppDetail } from 'components/client/campaign-application/steps/CampaignApplicationSummary'
import CheckboxField from 'components/common/form/CheckboxField'
import OrganizerCanEditAt from './CampaignApplicationOrganizerCanEditAt'
import { UploadedFile } from 'components/common/file-upload/UploadedFilesList'
import UploadedCampaignApplicationFiles from './UploadedCampaignApplicationFiles'

export default function CampaignApplicationAdminPropsEdit({ id }: { id: string }) {
export default function CampaignApplicationAdminPropsEdit({
id,
files,
}: {
id: string
files: UploadedFile[]
}) {
const { t } = useTranslation('campaign-application')
return (
<Grid container spacing={6} justifyContent="center" direction="column" alignContent="center">
Expand Down Expand Up @@ -40,6 +48,9 @@ export default function CampaignApplicationAdminPropsEdit({ id }: { id: string }
value={<OrganizerCanEditAt id={id} />}
/>
</Grid>
<Grid item>
<UploadedCampaignApplicationFiles files={files} campaignApplicationId={id} />
</Grid>
</Grid>
)
}
4 changes: 2 additions & 2 deletions src/components/admin/campaign-applications/EditPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Box, CircularProgress, Grid, Typography } from '@mui/material'
import { red } from '@mui/material/colors'
import { CampaignApplicationFormData } from 'components/client/campaign-application/helpers/campaignApplication.types'
import { ActionSubmitButton } from 'components/client/campaign-application/helpers/campaignApplicationFormActions.styled'
import { campaignApplicationAdminValidationSchema } from 'components/client/campaign-application/helpers/validation-schema'
import CampaignApplicationBasic from 'components/client/campaign-application/steps/CampaignApplicationBasic'
import CampaignApplicationDetails from 'components/client/campaign-application/steps/CampaignApplicationDetails'
import CampaignApplicationOrganizer from 'components/client/campaign-application/steps/CampaignApplicationOrganizer'
Expand All @@ -21,7 +22,6 @@ import {
} from 'service/campaign-application'
import CampaignApplicationAdminPropsEdit from './CampaignApplicationAdminPropsEdit'
import OrganizerCanEditAt from './CampaignApplicationOrganizerCanEditAt'
import { campaignApplicationAdminValidationSchema } from 'components/client/campaign-application/helpers/validation-schema'

export type Props = {
id: string
Expand Down Expand Up @@ -83,7 +83,7 @@ export function EditLoadedCampaign({ campaign }: { campaign: CampaignApplication
}}
initialValues={c.initialValues}
validationSchema={campaignApplicationAdminValidationSchema.defined()}>
<CampaignApplicationAdminPropsEdit id={campaign.id} />
<CampaignApplicationAdminPropsEdit id={campaign.id} files={campaign.documents} />
<CampaignApplicationOrganizer isAdmin={true} />
<CampaignApplicationBasic />
<CampaignApplicationDetails files={c.files} setFiles={c.setFiles} />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { AxiosError } from 'axios'
import { useTranslation } from 'next-i18next'

import { endpoints } from 'service/apiEndpoints'
import { ApiErrors } from 'service/apiErrors'

import { UploadedFile, UploadedFilesList } from 'components/common/file-upload/UploadedFilesList'

import { useSession } from 'next-auth/react'
import {
fetchCampaignApplicationFile,
useDeleteCampaignApplicationFile,
} from 'service/campaign-application'
import { AlertStore } from 'stores/AlertStore'

type Props = {
campaignApplicationId: string
files: UploadedFile[]
}

export default function UploadedCampaignApplicationFiles({ files, campaignApplicationId }: Props) {
const { t } = useTranslation(['common', 'campaign-applications'])
const queryClient = useQueryClient()
const { data: session } = useSession()

const del = useMutation<unknown, AxiosError<ApiErrors>, string>({
mutationFn: (fileId) => useDeleteCampaignApplicationFile(session)(fileId),
onError: () => AlertStore.show(t('common:alerts.errorDeletingFile'), 'error'),
onSuccess: () => {
AlertStore.show(t('common:files.deletedFile'), 'success')
queryClient.invalidateQueries([endpoints.campaignApplication.view(campaignApplicationId).url])
},
})

return (
<UploadedFilesList
files={files}
downloadQuery={(f) => fetchCampaignApplicationFile(f.id, session).then((r) => r.data)}
deleteMutation={(f) => del.mutateAsync(f.id)}
/>
)
}
218 changes: 218 additions & 0 deletions src/components/common/file-upload/UploadedFilesList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
import { Close, Delete, FilePresent, Preview } from '@mui/icons-material'
import {
Avatar,
Button,
IconButton,
List,
ListItem,
ListItemAvatar,
ListItemText,
Tooltip,
} from '@mui/material'
import { useTranslation } from 'next-i18next'
import { createContext, useContext, useEffect, useState } from 'react'
import CenteredSpinner from '../CenteredSpinner'

export interface FilesListContext {
deleteMutation: (file: UploadedFile) => Promise<unknown>
files: UploadedFile[]
getBlobUrl: (file: UploadedFile) => Promise<string>
download: (file: UploadedFile) => void
}

const UploadedFilesContext = createContext<FilesListContext>({
files: [],
download: () => Promise.reject('download query missing'),
deleteMutation: () => Promise.reject('delete mutation missing'),
getBlobUrl: () => Promise.reject('Function not implemented.'),
})

export interface UploadedFilesListProps {
title?: string | JSX.Element | undefined
files: UploadedFile[]
downloadQuery: (f: UploadedFile) => Promise<Blob>
deleteMutation: (f: UploadedFile) => Promise<unknown>
downloadText?: string
}

export function UploadedFilesList({
title: maybeTitle,
files,
downloadQuery,
deleteMutation,
downloadText: maybeDownloadText,
}: UploadedFilesListProps) {
const { t } = useTranslation('common')
const filesCtx = filesListContext({
files,
downloadQuery,
deleteMutation,
})

const title = maybeTitle != null ?? t('files.attached-files')
const downloadText = maybeDownloadText ?? t('files.download')

return (
<UploadedFilesContext.Provider value={filesCtx}>
<List dense>
{typeof title === 'string' ? <ListItemText primary={title} /> : title}
{(files ?? []).map((file) => (
<UploadedFileView key={file.id} file={file} downloadText={downloadText} />
))}
</List>
</UploadedFilesContext.Provider>
)
}

export type UploadedFileViewProps = {
file: UploadedFile
role?: string
downloadText: string
}

export function UploadedFileView({ file, role, downloadText }: UploadedFileViewProps) {
const { deleteMutation, download } = useContext(UploadedFilesContext)

return (
<ListItem key={file.id}>
<ListItemAvatar>
<Avatar>
<FilePresent />
</Avatar>
</ListItemAvatar>
<ListItemText primary={file.filename} />
{role && <ListItemText primary={role} sx={{ textAlign: 'right', pr: 'inherit' }} />}
<></>
<Tooltip title={'download'}>
<Button onClick={() => download(file)}>{downloadText}</Button>
</Tooltip>
<FilePreview {...file} />
<IconButton edge="end" aria-label="delete" onClick={() => deleteMutation(file)}>
<Delete />
</IconButton>
</ListItem>
)
}

export function FilePreview(f: UploadedFile) {
const { getBlobUrl } = useContext(UploadedFilesContext)
const [fetch, setFetch] = useState(false)
const [display, setDisplay] = useState<'block' | 'none'>('none')
const [blobUrl, setBlobUrl] = useState<string | undefined>()

useEffect(() => {
if (fetch) {
setDisplay('block')
getBlobUrl(f)
.then(setBlobUrl)
.then(() => setFetch(false))
.catch((e) => {
console.log(e)
})
}
}, [fetch])
return (
<>
<Tooltip title={'preview'}>
<IconButton edge="end" aria-label="preview" onClick={() => setFetch(true)}>
<Preview />
</IconButton>
</Tooltip>
<div
style={{
position: 'fixed',
top: '5vw',
left: '5vh',
boxShadow: '2px 4px 5px',
width: '90vw',
height: '90vh',
zIndex: 99999,
display,
backgroundColor: 'white',
}}>
{display === 'block' ? (
<iframe
id={f.id}
src={blobUrl}
allowFullScreen={true}
style={{ width: '100%', height: '100%' }}
/>
) : (
<div style={{ width: '100%', height: '100%' }}>
<CenteredSpinner />
</div>
)}
<Button
color="secondary"
variant="outlined"
sx={{ position: 'absolute', right: '-2rem', top: '-2.5rem' }}
onClick={() => setDisplay('none')}>
<Close />
</Button>
</div>
</>
)
}

export interface FilesListContextInput {
files: UploadedFile[]
downloadQuery: (file: UploadedFile) => Promise<Blob>
deleteMutation: (file: UploadedFile) => Promise<unknown>
}
export function filesListContext({ files, downloadQuery, deleteMutation }: FilesListContextInput) {
const blobUrls: Record<string, string> = {}
async function downloadAndGetBlobUrl(file: UploadedFile) {
if (blobUrls[file.id]) {
return blobUrls[file.id]
} else {
return downloadQuery(file)
.then((blob) => {
const b = window.URL.createObjectURL(new Blob([blob], { type: blob.type }))
blobUrls[file.id] = b
return b
})
.catch((error) => {
console.error(error)
// don't store it so next time we'll retry fetching
return window.URL.createObjectURL(
new Blob(
[
`<html>
<body>
<h3 style="text-align:center; margin-top: 10%">
Could not fetch the file. Please retry
</h3>
</body>
</html>`,
],
{
type: 'text/html',
},
),
)
})
}
}

async function download(f: UploadedFile) {
const blobUrlForDownload = await downloadAndGetBlobUrl(f)
const link = document.createElement('a')
link.href = blobUrlForDownload
link.setAttribute('download', `${f.filename}`)
link.click()
}

const context: FilesListContext = {
deleteMutation,
files,
download,
getBlobUrl: downloadAndGetBlobUrl,
}

return context
}

export interface UploadedFile {
id: string
filename: string
}
2 changes: 2 additions & 0 deletions src/service/apiEndpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -433,5 +433,7 @@ export const endpoints = {
<Endpoint>{ url: `/campaign-application/fileById/${fileId}`, method: 'DELETE' },
view: (id: string) => <Endpoint>{ url: `/campaign-application/byId/${id}`, method: 'GET' },
listAllForAdmin: <Endpoint>{ url: `/campaign-application/list`, method: 'GET' },
getFile: (fileId: string) =>
<Endpoint>{ url: `/campaign-application/fileById/${fileId}`, method: 'GET' },
},
}
15 changes: 13 additions & 2 deletions src/service/campaign-application.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { apiClient } from 'service/apiClient'
import { endpoints } from 'service/apiEndpoints'
import { authConfig, authQueryFnFactory } from 'service/restRequests'
import { ApiErrors } from './apiErrors'
import { Session } from 'next-auth'

export const useCreateCampaignApplication = () => {
const { data: session } = useSession()
Expand Down Expand Up @@ -51,8 +52,8 @@ export const useUploadCampaignApplicationFiles = () => {
}
}

export const useDeleteCampaignApplicationFile = () => {
const { data: session } = useSession()
export const useDeleteCampaignApplicationFile = (s?: Session | null) => {
const { data: session } = s != null ? { data: s } : useSession()
return async (id: string) =>
await apiClient.delete<UploadCampaignApplicationFilesRequest>(
endpoints.campaignApplication.deleteFile(id).url,
Expand Down Expand Up @@ -300,3 +301,13 @@ export function mapCreateOrEditInput(i: CampaignApplicationFormData): CampaignAp
...(i.admin ?? {}), // server disregards admin-only props if the user is not admin
}
}

export function fetchCampaignApplicationFile(fileId: string, session: Session | null) {
return apiClient.get<string, AxiosResponse<Blob>>(
endpoints.campaignApplication.getFile(fileId).url,
{
...authConfig(session?.accessToken),
responseType: 'blob',
},
)
}

0 comments on commit ab6e038

Please sign in to comment.