feat(fullstack): Add file upload
This commit is contained in:
@@ -15,6 +15,8 @@
|
||||
:disabled="props.disabled"
|
||||
:all-form-elements="props.allFormElements"
|
||||
:table-row-preset="formElementItem.formElement.tableRowPreset"
|
||||
:application-form-id="props.applicationFormId"
|
||||
:form-element-reference="formElementItem.formElement.reference"
|
||||
@update:form-options="updateFormOptions($event, formElementItem)"
|
||||
/>
|
||||
<div v-if="formElementItem.formElement.isClonable && !props.disabled" class="mt-3">
|
||||
@@ -171,6 +173,8 @@ function getResolvedComponent(formElement: FormElementDto) {
|
||||
return resolveComponent('TheDate')
|
||||
case 'TABLE':
|
||||
return resolveComponent('TheTable')
|
||||
case 'FILE_UPLOAD':
|
||||
return resolveComponent('TheFileUpload')
|
||||
default:
|
||||
return resolveComponent('Unimplemented')
|
||||
}
|
||||
|
||||
234
legalconsenthub/app/components/formelements/TheFileUpload.vue
Normal file
234
legalconsenthub/app/components/formelements/TheFileUpload.vue
Normal file
@@ -0,0 +1,234 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<!-- Upload Area -->
|
||||
<div>
|
||||
<UFileUpload
|
||||
v-model="selectedFiles"
|
||||
:accept="allowedFileTypes"
|
||||
:multiple="true"
|
||||
:disabled="isUploading || disabled"
|
||||
:label="t('applicationForms.formElements.fileUpload.label')"
|
||||
:description="t('applicationForms.formElements.fileUpload.allowedTypes')"
|
||||
variant="area"
|
||||
layout="list"
|
||||
position="inside"
|
||||
@change="handleFileSelect"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Upload Progress -->
|
||||
<div v-if="isUploading" class="space-y-2">
|
||||
<UProgress :value="uploadProgress" />
|
||||
<p class="text-sm text-gray-600">
|
||||
{{ t('applicationForms.formElements.fileUpload.uploading') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<UAlert
|
||||
v-if="errorMessage"
|
||||
color="error"
|
||||
variant="soft"
|
||||
:title="t('applicationForms.formElements.fileUpload.uploadError')"
|
||||
:description="errorMessage"
|
||||
:close-button="{ icon: 'i-ph-x', color: 'red', variant: 'link' }"
|
||||
@close="errorMessage = ''"
|
||||
/>
|
||||
|
||||
<!-- Uploaded Files List -->
|
||||
<div v-if="uploadedFiles.length > 0" class="space-y-2">
|
||||
<p class="text-sm font-medium">
|
||||
{{ t('applicationForms.formElements.fileUpload.uploadedFiles') }}
|
||||
</p>
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
v-for="file in uploadedFiles"
|
||||
:key="file.fileId"
|
||||
class="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
|
||||
>
|
||||
<div class="flex items-center gap-3 flex-1 min-w-0">
|
||||
<UIcon :name="getFileIcon(file.mimeType)" class="text-xl flex-shrink-0" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium truncate">{{ file.filename }}</p>
|
||||
<p class="text-xs text-gray-500">{{ formatFileSize(file.size) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
<UButton
|
||||
v-if="isViewableInBrowser(file.mimeType)"
|
||||
icon="i-ph-eye"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:title="t('applicationForms.formElements.fileUpload.view')"
|
||||
@click="viewFile(file.fileId)"
|
||||
/>
|
||||
<UButton
|
||||
icon="i-ph-download"
|
||||
color="info"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:disabled="isDownloading"
|
||||
@click="downloadFile(file.fileId, file.filename)"
|
||||
/>
|
||||
<UButton
|
||||
icon="i-ph-trash"
|
||||
color="error"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:disabled="disabled || isDeleting"
|
||||
@click="deleteFile(file.fileId)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { EmployeeDataCategory, type FormOptionDto, ProcessingPurpose } from '~~/.api-client'
|
||||
import { useFile, type UploadedFileMetadata } from '~/composables/file/useFile'
|
||||
import { useUserStore } from '~~/stores/useUserStore'
|
||||
|
||||
const props = defineProps<{
|
||||
formOptions: FormOptionDto[]
|
||||
disabled?: boolean
|
||||
applicationFormId?: string
|
||||
formElementReference?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:formOptions', value: FormOptionDto[]): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const {
|
||||
uploadFile: uploadFileApi,
|
||||
downloadFile: downloadFileApi,
|
||||
viewFile: viewFileApi,
|
||||
deleteFile: deleteFileApi,
|
||||
parseUploadedFiles,
|
||||
createFileMetadata,
|
||||
getFileIcon,
|
||||
formatFileSize,
|
||||
isViewableInBrowser
|
||||
} = useFile()
|
||||
|
||||
const isUploading = ref(false)
|
||||
const isDownloading = ref(false)
|
||||
const isDeleting = ref(false)
|
||||
const uploadProgress = ref(0)
|
||||
const errorMessage = ref('')
|
||||
const selectedFiles = ref<File[] | null>(null)
|
||||
|
||||
const allowedFileTypes = '.pdf,.docx,.doc,.odt,.jpg,.jpeg,.png,.zip'
|
||||
|
||||
// Parse uploaded files from formOptions
|
||||
const uploadedFiles = computed<UploadedFileMetadata[]>(() => {
|
||||
const values = props.formOptions.map((option) => option.value)
|
||||
return parseUploadedFiles(values)
|
||||
})
|
||||
|
||||
const userStore = useUserStore()
|
||||
const organizationId = computed(() => userStore.selectedOrganization?.id)
|
||||
|
||||
const handleFileSelect = async () => {
|
||||
if (!selectedFiles.value || selectedFiles.value.length === 0) return
|
||||
|
||||
const files = Array.isArray(selectedFiles.value) ? selectedFiles.value : [selectedFiles.value]
|
||||
errorMessage.value = ''
|
||||
|
||||
for (const file of files) {
|
||||
const maxFileSize = 10 * 1024 * 1024 // 10 MB
|
||||
if (file.size > maxFileSize) {
|
||||
errorMessage.value = t('applicationForms.formElements.fileUpload.fileTooLarge', {
|
||||
filename: file.name,
|
||||
maxSize: formatFileSize(maxFileSize)
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
await uploadFile(file)
|
||||
}
|
||||
|
||||
selectedFiles.value = null
|
||||
}
|
||||
|
||||
const uploadFile = async (file: File) => {
|
||||
if (!props.formElementReference) {
|
||||
errorMessage.value = 'Missing required context: formElementReference'
|
||||
return
|
||||
}
|
||||
|
||||
isUploading.value = true
|
||||
uploadProgress.value = 0
|
||||
|
||||
try {
|
||||
const response = await uploadFileApi({
|
||||
file,
|
||||
applicationFormId: props.applicationFormId,
|
||||
formElementReference: props.formElementReference,
|
||||
organizationId: organizationId.value
|
||||
})
|
||||
|
||||
const metadata = createFileMetadata(response)
|
||||
|
||||
const newOption: FormOptionDto = {
|
||||
value: JSON.stringify(metadata),
|
||||
label: props.formElementReference,
|
||||
processingPurpose: props.formOptions[0]?.processingPurpose ?? ProcessingPurpose.None,
|
||||
employeeDataCategory: props.formOptions[0]?.employeeDataCategory ?? EmployeeDataCategory.None
|
||||
}
|
||||
|
||||
const updatedOptions = [...props.formOptions, newOption]
|
||||
emit('update:formOptions', updatedOptions)
|
||||
|
||||
uploadProgress.value = 100
|
||||
} catch (error: unknown) {
|
||||
errorMessage.value = error instanceof Error ? error.message : String(error)
|
||||
} finally {
|
||||
isUploading.value = false
|
||||
uploadProgress.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
const downloadFile = async (fileId: string, filename: string) => {
|
||||
isDownloading.value = true
|
||||
try {
|
||||
await downloadFileApi(fileId, filename)
|
||||
} catch (error: unknown) {
|
||||
errorMessage.value = error instanceof Error ? error.message : String(error)
|
||||
} finally {
|
||||
isDownloading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const viewFile = (fileId: string) => {
|
||||
viewFileApi(fileId)
|
||||
}
|
||||
|
||||
const deleteFile = async (fileId: string) => {
|
||||
if (!confirm(t('common.confirmDelete'))) return
|
||||
|
||||
isDeleting.value = true
|
||||
try {
|
||||
await deleteFileApi(fileId)
|
||||
|
||||
// Remove from formOptions
|
||||
const updatedOptions = props.formOptions.filter((option) => {
|
||||
try {
|
||||
const metadata = JSON.parse(option.value) as UploadedFileMetadata
|
||||
return metadata.fileId !== fileId
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
})
|
||||
emit('update:formOptions', updatedOptions)
|
||||
} catch (error: unknown) {
|
||||
errorMessage.value = error instanceof Error ? error.message : String(error)
|
||||
} finally {
|
||||
isDeleting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -6,9 +6,56 @@ export function useApplicationForm() {
|
||||
const applicationFormApi = useApplicationFormApi()
|
||||
const logger = useLogger().withTag('applicationForm')
|
||||
|
||||
/**
|
||||
* Extract all file IDs from FILE_UPLOAD form elements
|
||||
*/
|
||||
function extractFileIdsFromForm(applicationFormDto: ApplicationFormDto): string[] {
|
||||
const fileIds: string[] = []
|
||||
|
||||
applicationFormDto.formElementSections?.forEach((section) => {
|
||||
section.formElementSubSections?.forEach((subsection) => {
|
||||
subsection.formElements?.forEach((element) => {
|
||||
if (element.type === 'FILE_UPLOAD') {
|
||||
element.options?.forEach((option) => {
|
||||
try {
|
||||
const metadata = JSON.parse(option.value)
|
||||
if (metadata.fileId) {
|
||||
fileIds.push(metadata.fileId)
|
||||
}
|
||||
} catch {
|
||||
// Ignore parsing errors
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
return fileIds
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an application form with atomic file association.
|
||||
*
|
||||
* File IDs are included in the DTO and associated atomically on the backend.
|
||||
* If file association fails, the entire operation rolls back (form is not created).
|
||||
*/
|
||||
async function createApplicationForm(applicationFormDto: ApplicationFormDto): Promise<ApplicationFormDto> {
|
||||
try {
|
||||
return await applicationFormApi.createApplicationForm(applicationFormDto)
|
||||
// Extract all temporary file IDs and include them in the DTO for atomic association
|
||||
const fileIds = extractFileIdsFromForm(applicationFormDto)
|
||||
|
||||
// Single atomic API call - backend handles form creation and file association transactionally
|
||||
const createdForm = await applicationFormApi.createApplicationForm({
|
||||
...applicationFormDto,
|
||||
fileIds: fileIds.length > 0 ? fileIds : undefined
|
||||
})
|
||||
|
||||
if (fileIds.length > 0) {
|
||||
logger.debug(`Created form ${createdForm.id} with ${fileIds.length} files atomically associated`)
|
||||
}
|
||||
|
||||
return createdForm
|
||||
} catch (e: unknown) {
|
||||
logger.error('Failed creating application form:', e)
|
||||
return Promise.reject(e)
|
||||
|
||||
150
legalconsenthub/app/composables/file/useFile.ts
Normal file
150
legalconsenthub/app/composables/file/useFile.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import type { UploadedFileDto } from '~~/.api-client'
|
||||
import { useFileApi } from './useFileApi'
|
||||
import { useLogger } from '../useLogger'
|
||||
|
||||
export interface UploadedFileMetadata {
|
||||
fileId: string
|
||||
filename: string
|
||||
size: number
|
||||
mimeType: string
|
||||
uploadedAt: string
|
||||
}
|
||||
|
||||
export function useFile() {
|
||||
const fileApi = useFileApi()
|
||||
const logger = useLogger().withTag('file')
|
||||
const { t } = useI18n()
|
||||
|
||||
async function uploadFile(params: {
|
||||
file: File
|
||||
applicationFormId?: string
|
||||
formElementReference: string
|
||||
organizationId?: string
|
||||
}): Promise<UploadedFileDto> {
|
||||
try {
|
||||
logger.debug('Uploading file:', params.file.name)
|
||||
return await fileApi.uploadFile(params)
|
||||
} catch (e: unknown) {
|
||||
logger.error('Failed uploading file:', e)
|
||||
|
||||
// Enhanced error handling with user-friendly messages
|
||||
if (e && typeof e === 'object' && 'status' in e) {
|
||||
const error = e as { status: number }
|
||||
if (error.status === 413) {
|
||||
return Promise.reject(
|
||||
new Error(
|
||||
t('applicationForms.formElements.fileUpload.fileTooLarge', {
|
||||
filename: params.file.name,
|
||||
maxSize: '10MB'
|
||||
})
|
||||
)
|
||||
)
|
||||
} else if (error.status === 415) {
|
||||
return Promise.reject(new Error(t('applicationForms.formElements.fileUpload.unsupportedType')))
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(t('applicationForms.formElements.fileUpload.uploadFailed')))
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadFile(id: string, filename: string): Promise<void> {
|
||||
try {
|
||||
logger.debug('Downloading file:', id)
|
||||
const blob = await fileApi.downloadFileContent(id)
|
||||
|
||||
// Create download link
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = filename
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
window.URL.revokeObjectURL(url)
|
||||
} catch (e: unknown) {
|
||||
logger.error('Failed downloading file:', e)
|
||||
return Promise.reject(new Error(t('applicationForms.formElements.fileUpload.downloadFailed')))
|
||||
}
|
||||
}
|
||||
|
||||
function isViewableInBrowser(mimeType: string): boolean {
|
||||
return mimeType === 'application/pdf' || mimeType.startsWith('image/')
|
||||
}
|
||||
|
||||
function viewFile(id: string): void {
|
||||
const url = fileApi.getFileViewUrl(id)
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
|
||||
async function deleteFile(id: string): Promise<void> {
|
||||
try {
|
||||
logger.debug('Deleting file:', id)
|
||||
return await fileApi.deleteFile(id)
|
||||
} catch (e: unknown) {
|
||||
logger.error('Failed deleting file:', e)
|
||||
return Promise.reject(new Error(t('applicationForms.formElements.fileUpload.deleteFailed')))
|
||||
}
|
||||
}
|
||||
|
||||
async function associateFilesWithApplicationForm(applicationFormId: string, fileIds: string[]): Promise<void> {
|
||||
try {
|
||||
logger.debug('Associating files with application form:', { applicationFormId, fileIds })
|
||||
return await fileApi.associateFilesWithApplicationForm(applicationFormId, fileIds)
|
||||
} catch (e: unknown) {
|
||||
logger.error('Failed associating files with application form:', e)
|
||||
return Promise.reject(e)
|
||||
}
|
||||
}
|
||||
|
||||
function parseUploadedFiles(formOptionsValues: string[]): UploadedFileMetadata[] {
|
||||
return formOptionsValues
|
||||
.map((value) => {
|
||||
try {
|
||||
return JSON.parse(value) as UploadedFileMetadata
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})
|
||||
.filter((file): file is UploadedFileMetadata => file !== null)
|
||||
}
|
||||
|
||||
function createFileMetadata(response: UploadedFileDto): UploadedFileMetadata {
|
||||
return {
|
||||
fileId: response.id,
|
||||
filename: response.originalFilename,
|
||||
size: response.size,
|
||||
mimeType: response.mimeType,
|
||||
uploadedAt: response.uploadedAt.toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
function getFileIcon(mimeType: string): string {
|
||||
if (mimeType.startsWith('image/')) return 'i-ph-image'
|
||||
if (mimeType === 'application/pdf') return 'i-ph-file-pdf'
|
||||
if (mimeType.includes('word')) return 'i-ph-file-doc'
|
||||
if (mimeType.includes('zip')) return 'i-ph-file-zip'
|
||||
return 'i-ph-file'
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`
|
||||
}
|
||||
|
||||
return {
|
||||
uploadFile,
|
||||
downloadFile,
|
||||
viewFile,
|
||||
deleteFile,
|
||||
associateFilesWithApplicationForm,
|
||||
parseUploadedFiles,
|
||||
createFileMetadata,
|
||||
getFileIcon,
|
||||
formatFileSize,
|
||||
isViewableInBrowser
|
||||
}
|
||||
}
|
||||
64
legalconsenthub/app/composables/file/useFileApi.ts
Normal file
64
legalconsenthub/app/composables/file/useFileApi.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import {
|
||||
FileApi,
|
||||
Configuration,
|
||||
type UploadedFileDto,
|
||||
type AssociateFilesWithApplicationFormRequest
|
||||
} from '~~/.api-client'
|
||||
import { cleanDoubleSlashes, withoutTrailingSlash } from 'ufo'
|
||||
import { wrappedFetchWrap } from '~/utils/wrappedFetch'
|
||||
|
||||
export function useFileApi() {
|
||||
const appBaseUrl = useRuntimeConfig().app.baseURL
|
||||
const { serverApiBasePath, clientProxyBasePath } = useRuntimeConfig().public
|
||||
|
||||
const basePath = withoutTrailingSlash(
|
||||
cleanDoubleSlashes(import.meta.client ? appBaseUrl + clientProxyBasePath : clientProxyBasePath + serverApiBasePath)
|
||||
)
|
||||
|
||||
const fileApiClient = new FileApi(new Configuration({ basePath, fetchApi: wrappedFetchWrap(useRequestFetch()) }))
|
||||
|
||||
async function uploadFile(params: {
|
||||
file: File
|
||||
applicationFormId?: string
|
||||
formElementReference: string
|
||||
organizationId?: string
|
||||
}): Promise<UploadedFileDto> {
|
||||
return fileApiClient.uploadFile(params)
|
||||
}
|
||||
|
||||
async function downloadFileContent(id: string, inline = false): Promise<Blob> {
|
||||
return fileApiClient.downloadFileContent({ id, inline })
|
||||
}
|
||||
|
||||
function getFileViewUrl(id: string): string {
|
||||
return `${basePath}/files/${id}/content?inline=true`
|
||||
}
|
||||
|
||||
async function deleteFile(id: string): Promise<void> {
|
||||
return fileApiClient.deleteFile({ id })
|
||||
}
|
||||
|
||||
async function getFilesByApplicationForm(
|
||||
applicationFormId: string,
|
||||
formElementReference?: string
|
||||
): Promise<UploadedFileDto[]> {
|
||||
return fileApiClient.getFilesByApplicationForm({ applicationFormId, formElementReference })
|
||||
}
|
||||
|
||||
async function associateFilesWithApplicationForm(applicationFormId: string, fileIds: string[]): Promise<void> {
|
||||
const associateFilesWithApplicationFormRequest: AssociateFilesWithApplicationFormRequest = { fileIds }
|
||||
return fileApiClient.associateFilesWithApplicationForm({
|
||||
applicationFormId,
|
||||
associateFilesWithApplicationFormRequest
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
uploadFile,
|
||||
downloadFileContent,
|
||||
getFileViewUrl,
|
||||
deleteFile,
|
||||
getFilesByApplicationForm,
|
||||
associateFilesWithApplicationForm
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user