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

feat: preview files #1945

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
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)}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe we should use await, insead of .then()

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Help me understand what makes the async way preferable here? Can we agree it does the same thing in the end or did I miss something?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@slavcho ^^^

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure - I will merge it although I don't agree with it.

Following the project style and guidelines makes the code easier to read and understand.
CamelCase and snake_case are both good naming conventions, yet merging them is not a good idea.

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) => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same here...

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',
},
)
}
Loading