FieldFileInput.vue 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360
  1. <!-- Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ -->
  2. <script setup lang="ts">
  3. import { useDropZone } from '@vueuse/core'
  4. import { useTemplateRef } from 'vue'
  5. import { toRef, computed, ref, type ComputedRef } from 'vue'
  6. import CommonFilePreview from '#shared/components/CommonFilePreview/CommonFilePreview.vue'
  7. import type { FormFieldContext } from '#shared/components/Form/types/field.ts'
  8. import { useAppName } from '#shared/composables/useAppName.ts'
  9. import { useConfirmation } from '#shared/composables/useConfirmation.ts'
  10. import { useImageViewer } from '#shared/composables/useImageViewer.ts'
  11. import { useSharedVisualConfig } from '#shared/composables/useSharedVisualConfig.ts'
  12. import { useTraverseOptions } from '#shared/composables/useTraverseOptions.ts'
  13. import { MutationHandler } from '#shared/server/apollo/handler/index.ts'
  14. import { convertFileList } from '#shared/utils/files.ts'
  15. import { useFileUploadProcessing } from '../../composables/useFileUploadProcessing.ts'
  16. import { useFileValidation } from './composable/useFileValidation.ts'
  17. import { useFormUploadCacheAddMutation } from './graphql/mutations/uploadCache/add.api.ts'
  18. import { useFormUploadCacheRemoveMutation } from './graphql/mutations/uploadCache/remove.api.ts'
  19. import { getFileClasses } from './initializeFileClasses.ts'
  20. import type { FieldFileProps, FileUploaded } from './types.ts'
  21. import type { SetOptional } from 'type-fest'
  22. export interface Props {
  23. context: FormFieldContext<FieldFileProps>
  24. }
  25. const props = defineProps<Props>()
  26. const contextReactive = toRef(props, 'context')
  27. const { validateFileSize } = useFileValidation()
  28. // TODO: later we need to check how file content from prefilled upload cache is working
  29. // Switch to direct url for preview?
  30. const uploadFiles = computed<FileUploaded[]>({
  31. get() {
  32. return contextReactive.value._value || []
  33. },
  34. set(value) {
  35. props.context.node.input(value)
  36. },
  37. })
  38. const contentFiles = ref<Record<string, string>>({})
  39. const loadingFiles = ref<SetOptional<FileUploaded, 'id'>[]>([])
  40. // TODO: We improved now the upload cache endpoint also working for show, so maybe we could use this for preview.
  41. const uploadFilesWithContent = computed(() => {
  42. return uploadFiles.value.map((file) => {
  43. const content = contentFiles.value[file.id]
  44. return { ...file, content }
  45. })
  46. })
  47. const addFileMutation = new MutationHandler(useFormUploadCacheAddMutation({}))
  48. const addFileLoading = addFileMutation.loading()
  49. const removeFileMutation = new MutationHandler(
  50. useFormUploadCacheRemoveMutation({}),
  51. )
  52. const removeFileLoading = addFileMutation.loading()
  53. const canInteract = computed(
  54. () =>
  55. !props.context.disabled &&
  56. !addFileLoading.value &&
  57. !removeFileLoading.value,
  58. )
  59. const { setFileUploadProcessing, removeFileUploadProcessing } =
  60. useFileUploadProcessing(props.context.formId, props.context.node.name)
  61. const fileInput = useTemplateRef('file-input')
  62. const reset = () => {
  63. loadingFiles.value = []
  64. const input = fileInput.value
  65. if (!input) return
  66. input.value = ''
  67. input.files = null
  68. removeFileUploadProcessing()
  69. }
  70. const loadFiles = async (files: FileList | File[]) => {
  71. loadingFiles.value = Array.from(files || []).map((file) => ({
  72. name: file.name,
  73. size: file.size,
  74. type: file.type,
  75. }))
  76. setFileUploadProcessing()
  77. const uploads = await convertFileList(files)
  78. const data = await addFileMutation
  79. .send({
  80. formId: props.context.formId,
  81. files: uploads,
  82. })
  83. .catch(() => {
  84. reset()
  85. })
  86. const uploadedFiles = data?.formUploadCacheAdd?.uploadedFiles
  87. if (!uploadedFiles) {
  88. reset()
  89. return
  90. }
  91. const previewableFile = uploadedFiles.reduce(
  92. (filesContent: Record<string, string>, file, index) => {
  93. filesContent[file.id] = uploads[index].content
  94. return filesContent
  95. },
  96. {},
  97. )
  98. contentFiles.value = { ...contentFiles.value, ...previewableFile }
  99. uploadFiles.value = [...uploadFiles.value, ...uploadedFiles]
  100. reset()
  101. }
  102. Object.assign(props.context, {
  103. uploadFiles: loadFiles,
  104. })
  105. const onFileChanged = async ($event: Event) => {
  106. const input = $event.target as HTMLInputElement
  107. const { files } = input
  108. if (
  109. props.context.allowedFiles &&
  110. files &&
  111. !validateFileSize(props.context.node, files, props.context.allowedFiles)
  112. ) {
  113. return
  114. }
  115. if (!files) return
  116. await loadFiles(files)
  117. }
  118. const { waitForConfirmation } = useConfirmation()
  119. const removeFile = async (file: FileUploaded) => {
  120. const fileId = file.id
  121. const confirmed = await waitForConfirmation(
  122. __('Are you sure you want to delete "%s"?'),
  123. {
  124. textPlaceholder: [file.name],
  125. buttonLabel: __('Delete'),
  126. buttonVariant: 'danger',
  127. },
  128. )
  129. if (!confirmed) return
  130. if (!fileId) {
  131. uploadFiles.value = uploadFiles.value.filter((elem) => elem !== file)
  132. return
  133. }
  134. const toBeDeletedFile = uploadFiles.value.find((file) => file.id === fileId)
  135. if (toBeDeletedFile) {
  136. toBeDeletedFile.isProcessing = true
  137. }
  138. removeFileMutation
  139. .send({ formId: props.context.formId, fileIds: [fileId] })
  140. .then((data) => {
  141. if (data?.formUploadCacheRemove?.success) {
  142. uploadFiles.value = uploadFiles.value.filter((elem) => {
  143. return elem.id !== fileId
  144. })
  145. }
  146. })
  147. }
  148. const uploadTitle = computed(() => {
  149. if (!props.context.multiple) {
  150. return __('Attach file')
  151. }
  152. if (uploadFiles.value.length === 0) {
  153. return __('Attach files')
  154. }
  155. return __('Attach another file')
  156. })
  157. const reachedUploadLimit = computed(() => {
  158. return (
  159. !props.context.multiple &&
  160. (uploadFiles.value.length >= 1 || loadingFiles.value.length >= 1)
  161. )
  162. })
  163. const bottomGradientOpacity = ref('1')
  164. const onFilesScroll = (event: UIEvent) => {
  165. const target = event.target as HTMLElement
  166. const scrollMin = 20
  167. const bottomMax = target.scrollHeight - target.clientHeight
  168. const bottomMin = bottomMax - scrollMin
  169. const { scrollTop } = target
  170. if (scrollTop <= bottomMin) {
  171. bottomGradientOpacity.value = '1'
  172. return
  173. }
  174. const opacityPart = (scrollTop - bottomMin) / scrollMin
  175. bottomGradientOpacity.value = (1 - opacityPart).toFixed(2)
  176. }
  177. const { showImage } = useImageViewer(uploadFilesWithContent)
  178. const filesContainer = useTemplateRef('files-container')
  179. useTraverseOptions(filesContainer, {
  180. direction: 'vertical',
  181. })
  182. const appName = useAppName()
  183. const classMap = getFileClasses()
  184. const { fieldFile: fieldFileConfig } = useSharedVisualConfig()
  185. const showDivider = computed(() => {
  186. return (
  187. classMap.divider &&
  188. !reachedUploadLimit.value &&
  189. (uploadFiles.value.length || loadingFiles.value.length)
  190. )
  191. })
  192. const showGradient = computed(() => {
  193. return (
  194. appName === 'mobile' &&
  195. (uploadFiles.value.length > 2 || loadingFiles.value.length > 2)
  196. )
  197. })
  198. const acceptableFileTypes = computed(() => props.context.accept?.split(','))
  199. const dropZoneElement = useTemplateRef('drop-zone')
  200. const { isOverDropZone } = useDropZone(dropZoneElement, {
  201. dataTypes: acceptableFileTypes as ComputedRef<string[]>, // TODO: Maybe add a PR in vueuse, that the ref can also be undefined.
  202. onDrop: (files: File[] | null) => {
  203. if (!files) return
  204. loadFiles(files)
  205. },
  206. })
  207. </script>
  208. <template>
  209. <div class="relative" :class="context.classes.input">
  210. <div ref="drop-zone">
  211. <div v-if="showGradient" class="relative w-full">
  212. <div
  213. class="file-list show-gradient top-gradient absolute h-5 w-full"
  214. ></div>
  215. </div>
  216. <div
  217. v-if="uploadFiles.length || loadingFiles.length"
  218. ref="files-container"
  219. role="list"
  220. class="overflow-auto"
  221. :class="{
  222. 'opacity-60': !canInteract,
  223. 'pb-4': reachedUploadLimit,
  224. [classMap.listContainer]: true,
  225. }"
  226. @scroll.passive="onFilesScroll($event as UIEvent)"
  227. >
  228. <CommonFilePreview
  229. v-for="(uploadFile, idx) of uploadFilesWithContent"
  230. :key="uploadFile.id || `${uploadFile.name}-${idx}`"
  231. :file="uploadFile"
  232. role="listitem"
  233. :class="{ 'pointer-events-none opacity-75': uploadFile.isProcessing }"
  234. :no-remove="uploadFile.isProcessing"
  235. :loading="uploadFile.isProcessing"
  236. :preview-url="uploadFile.preview || uploadFile.content"
  237. :download-url="uploadFile.content"
  238. @preview="canInteract && showImage(uploadFile)"
  239. @remove="canInteract && removeFile(uploadFile)"
  240. />
  241. <CommonFilePreview
  242. v-for="(uploadFile, idx) of loadingFiles"
  243. :key="uploadFile.id || `${uploadFile.name}${idx}`"
  244. role="listitem"
  245. :file="uploadFile"
  246. loading
  247. no-remove
  248. />
  249. </div>
  250. <div v-if="showGradient" class="relative w-full">
  251. <div
  252. class="file-list show-gradient bottom-gradient absolute h-5 w-full"
  253. :style="{ opacity: bottomGradientOpacity }"
  254. ></div>
  255. </div>
  256. <div v-if="showDivider" class="w-full px-2.5">
  257. <hr class="h-px w-full border-0" :class="classMap.divider" />
  258. </div>
  259. <div class="w-full p-1 text-center">
  260. <component
  261. :is="fieldFileConfig?.buttonComponent"
  262. v-if="!reachedUploadLimit"
  263. :class="classMap.button"
  264. type="button"
  265. size="medium"
  266. variant="secondary"
  267. prefix-icon="attachment"
  268. :disabled="!canInteract"
  269. @click="canInteract && fileInput?.click()"
  270. >
  271. {{ $t(uploadTitle) }}
  272. </component>
  273. <input
  274. :id="context.id"
  275. ref="file-input"
  276. data-test-id="fileInput"
  277. type="file"
  278. :name="context.node.name"
  279. :aria-describedby="context.describedBy"
  280. :v-bind="context.attrs"
  281. class="hidden"
  282. tabindex="-1"
  283. aria-hidden="true"
  284. :accept="context.accept"
  285. :capture="context.capture"
  286. :multiple="context.multiple"
  287. @change="canInteract && onFileChanged($event)"
  288. />
  289. </div>
  290. </div>
  291. <div
  292. v-if="classMap.dropZoneContainer && isOverDropZone"
  293. class="pointer-events-none absolute inset-0 z-10 flex items-center justify-center p-2.5"
  294. :class="classMap.dropZoneContainer"
  295. >
  296. <div
  297. class="flex h-full w-full items-center justify-center rounded border-2 border-dashed"
  298. :class="classMap.dropZoneBorder"
  299. >
  300. <CommonLabel
  301. class="text-blue-800"
  302. :size="uploadFiles.length || loadingFiles.length ? 'large' : 'medium'"
  303. >{{ $t('Drop files here') }}</CommonLabel
  304. >
  305. </div>
  306. </div>
  307. </div>
  308. </template>