FieldFileInput.vue 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289
  1. <!-- Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ -->
  2. <script setup lang="ts">
  3. import { toRef, computed, ref } from 'vue'
  4. import type { FileUploaded } from '#shared/components/Form/fields/FieldFile/types.ts'
  5. import type { FormFieldContext } from '#shared/components/Form/types/field.ts'
  6. import { useConfirmation } from '#shared/composables/useConfirmation.ts'
  7. import { useImageViewer } from '#shared/composables/useImageViewer.ts'
  8. import { useTraverseOptions } from '#shared/composables/useTraverseOptions.ts'
  9. import { MutationHandler } from '#shared/server/apollo/handler/index.ts'
  10. import { convertFileList } from '#shared/utils/files.ts'
  11. import CommonFilePreview from '#mobile/components/CommonFilePreview/CommonFilePreview.vue'
  12. import { useFileValidation } from '#mobile/components/Form/fields/FieldFile/composable/useFileValidation.ts'
  13. import { useFormUploadCacheAddMutation } from './graphql/mutations/uploadCache/add.api.ts'
  14. import { useFormUploadCacheRemoveMutation } from './graphql/mutations/uploadCache/remove.api.ts'
  15. import type { FieldFileProps } from './types.ts'
  16. export interface Props {
  17. context: FormFieldContext<FieldFileProps>
  18. }
  19. const props = defineProps<Props>()
  20. const contextReactive = toRef(props, 'context')
  21. const { validateFileSize } = useFileValidation()
  22. const uploadFiles = computed<FileUploaded[]>({
  23. get() {
  24. return contextReactive.value._value || []
  25. },
  26. set(value) {
  27. props.context.node.input(value)
  28. },
  29. })
  30. const loadingFiles = ref<FileUploaded[]>([])
  31. const addFileMutation = new MutationHandler(useFormUploadCacheAddMutation({}))
  32. const addFileLoading = addFileMutation.loading()
  33. const removeFileMutation = new MutationHandler(
  34. useFormUploadCacheRemoveMutation({}),
  35. )
  36. const removeFileLoading = addFileMutation.loading()
  37. const canInteract = computed(
  38. () =>
  39. !props.context.disabled &&
  40. !addFileLoading.value &&
  41. !removeFileLoading.value,
  42. )
  43. const fileInput = ref<HTMLInputElement>()
  44. const reset = () => {
  45. loadingFiles.value = []
  46. const input = fileInput.value
  47. if (!input) return
  48. input.value = ''
  49. input.files = null
  50. }
  51. const loadFiles = async (files: FileList | File[]) => {
  52. loadingFiles.value = Array.from(files || []).map((file) => ({
  53. name: file.name,
  54. size: file.size,
  55. type: file.type,
  56. }))
  57. const uploads = await convertFileList(files)
  58. const data = await addFileMutation
  59. .send({
  60. formId: props.context.formId,
  61. files: uploads,
  62. })
  63. .catch(() => {
  64. reset()
  65. })
  66. const uploadedFiles = data?.formUploadCacheAdd?.uploadedFiles
  67. if (!uploadedFiles) {
  68. reset()
  69. return
  70. }
  71. const previewableFile = uploadedFiles.map((file, index) => ({
  72. ...file,
  73. content: uploads[index].content,
  74. }))
  75. uploadFiles.value = [...uploadFiles.value, ...previewableFile]
  76. reset()
  77. }
  78. Object.assign(props.context, {
  79. uploadFiles: loadFiles,
  80. })
  81. const onFileChanged = async ($event: Event) => {
  82. const input = $event.target as HTMLInputElement
  83. const { files } = input
  84. if (
  85. props.context.allowedFiles &&
  86. files &&
  87. !validateFileSize(props.context.node, files, props.context.allowedFiles)
  88. ) {
  89. return
  90. }
  91. if (!files) return
  92. await loadFiles(files)
  93. }
  94. const { waitForConfirmation } = useConfirmation()
  95. const removeFile = async (file: FileUploaded) => {
  96. const fileId = file.id
  97. const confirmed = await waitForConfirmation(
  98. __('Are you sure you want to delete "%s"?'),
  99. {
  100. textPlaceholder: [file.name],
  101. buttonLabel: __('Delete'),
  102. buttonVariant: 'danger',
  103. },
  104. )
  105. if (!confirmed) return
  106. if (!fileId) {
  107. uploadFiles.value = uploadFiles.value.filter((elem) => elem !== file)
  108. return
  109. }
  110. const toBeDeletedFile = uploadFiles.value.find((file) => file.id === fileId)
  111. if (toBeDeletedFile) {
  112. toBeDeletedFile.isProcessing = true
  113. }
  114. removeFileMutation
  115. .send({ formId: props.context.formId, fileIds: [fileId] })
  116. .then((data) => {
  117. if (data?.formUploadCacheRemove?.success) {
  118. uploadFiles.value = uploadFiles.value.filter((elem) => {
  119. return elem.id !== fileId
  120. })
  121. }
  122. })
  123. }
  124. const uploadTitle = computed(() => {
  125. if (!props.context.multiple) {
  126. return __('Attach file')
  127. }
  128. if (uploadFiles.value.length === 0) {
  129. return __('Attach files')
  130. }
  131. return __('Attach another file')
  132. })
  133. const reachedUploadLimit = computed(() => {
  134. return !props.context.multiple && uploadFiles.value.length >= 1
  135. })
  136. const bottomGradientOpacity = ref('1')
  137. const onFilesScroll = (event: UIEvent) => {
  138. const target = event.target as HTMLElement
  139. const scrollMin = 20
  140. const bottomMax = target.scrollHeight - target.clientHeight
  141. const bottomMin = bottomMax - scrollMin
  142. const { scrollTop } = target
  143. if (scrollTop <= bottomMin) {
  144. bottomGradientOpacity.value = '1'
  145. return
  146. }
  147. const opacityPart = (scrollTop - bottomMin) / scrollMin
  148. bottomGradientOpacity.value = (1 - opacityPart).toFixed(2)
  149. }
  150. const { showImage } = useImageViewer(uploadFiles)
  151. const filesContainer = ref<HTMLDivElement>()
  152. useTraverseOptions(filesContainer, {
  153. direction: 'vertical',
  154. })
  155. </script>
  156. <template>
  157. <div v-if="uploadFiles.length > 2" class="relative w-full">
  158. <div class="ShadowGradient TopGradient absolute h-5 w-full"></div>
  159. </div>
  160. <div
  161. v-if="uploadFiles.length || loadingFiles.length"
  162. ref="filesContainer"
  163. role="list"
  164. class="max-h-48 overflow-auto px-4 pt-4"
  165. :class="{
  166. 'opacity-60': !canInteract,
  167. 'pb-4': reachedUploadLimit,
  168. }"
  169. @scroll.passive="onFilesScroll($event as UIEvent)"
  170. >
  171. <CommonFilePreview
  172. v-for="(uploadFile, idx) of uploadFiles"
  173. :key="uploadFile.id || `${uploadFile.name}${idx}`"
  174. :file="uploadFile"
  175. role="listitem"
  176. :class="{ 'pointer-events-none opacity-75': uploadFile.isProcessing }"
  177. :no-remove="uploadFile.isProcessing"
  178. :loading="uploadFile.isProcessing"
  179. :preview-url="uploadFile.preview || uploadFile.content"
  180. :download-url="uploadFile.content"
  181. @preview="canInteract && showImage(uploadFile)"
  182. @remove="canInteract && removeFile(uploadFile)"
  183. />
  184. <CommonFilePreview
  185. v-for="(uploadFile, idx) of loadingFiles"
  186. :key="uploadFile.id || `${uploadFile.name}${idx}`"
  187. role="listitem"
  188. :file="uploadFile"
  189. loading
  190. no-remove
  191. />
  192. </div>
  193. <div v-if="uploadFiles.length > 2" class="relative w-full">
  194. <div
  195. class="ShadowGradient BottomGradient absolute h-5 w-full"
  196. :style="{ opacity: bottomGradientOpacity }"
  197. ></div>
  198. </div>
  199. <button
  200. v-if="!reachedUploadLimit"
  201. class="text-blue flex w-full items-center justify-center gap-1 p-4"
  202. type="button"
  203. tabindex="0"
  204. :class="{
  205. 'text-blue/60': !canInteract,
  206. }"
  207. :disabled="!canInteract"
  208. @click="canInteract && fileInput?.click()"
  209. >
  210. <CommonIcon name="attachment" size="base" decorative />
  211. <span class="text-base">
  212. {{ $t(uploadTitle) }}
  213. </span>
  214. </button>
  215. <input
  216. ref="fileInput"
  217. data-test-id="fileInput"
  218. type="file"
  219. :name="context.node.name"
  220. class="hidden"
  221. tabindex="-1"
  222. aria-hidden="true"
  223. :accept="context.accept"
  224. :capture="context.capture"
  225. :multiple="context.multiple"
  226. @change="canInteract && onFileChanged($event)"
  227. />
  228. </template>
  229. <style scoped>
  230. .ShadowGradient::before {
  231. content: '';
  232. position: absolute;
  233. left: 0;
  234. right: 0;
  235. bottom: 1.25rem;
  236. height: 30px;
  237. pointer-events: none;
  238. }
  239. .BottomGradient::before {
  240. bottom: 1.25rem;
  241. background: linear-gradient(rgba(255, 255, 255, 0), theme('colors.gray.500'));
  242. }
  243. .TopGradient::before {
  244. top: 0;
  245. background: linear-gradient(theme('colors.gray.500'), rgba(255, 255, 255, 0));
  246. }
  247. </style>