Просмотр исходного кода

Feature: Mobile - Add design for FieldFileInput

Vladimir Sheremet 2 лет назад
Родитель
Сommit
b91b428d9b

+ 7 - 0
.eslintrc.js

@@ -183,6 +183,13 @@ module.exports = {
             'vitest',
             path.resolve(__dirname, 'node_modules/vitest/dist/index.mjs'),
           ],
+          [
+            'vue-easy-lightbox/dist/external-css/vue-easy-lightbox.css',
+            path.resolve(
+              __dirname,
+              'node_modules/vue-easy-lightbox/dist/external-css/vue-easy-lightbox.css',
+            ),
+          ],
         ],
         extensions: ['.js', '.jsx', '.ts', '.tsx', '.vue'],
       },

+ 2 - 0
app/frontend/apps/mobile/App.vue

@@ -16,6 +16,7 @@ import { useAppTheme } from '@shared/composables/useAppTheme'
 import useAuthenticationChanges from '@shared/composables/useAuthenticationUpdates'
 import DynamicInitializer from '@shared/components/DynamicInitializer/DynamicInitializer.vue'
 import CommonConfirmation from '@mobile/components/CommonConfirmation/CommonConfirmation.vue'
+import CommonImageViewer from '@shared/components/CommonImageViewer/CommonImageViewer.vue'
 
 const router = useRouter()
 
@@ -71,6 +72,7 @@ onBeforeUnmount(() => {
   <template v-if="application.loaded">
     <CommonNotifications />
     <CommonConfirmation />
+    <CommonImageViewer />
   </template>
   <div
     v-if="application.loaded"

+ 22 - 0
app/frontend/apps/mobile/components/CommonConfirmation/composable.ts

@@ -11,9 +11,31 @@ const useConfirmation = () => {
     confirmationDialog.value = confirmationOptions
   }
 
+  const waitForConfirmation = (
+    heading: string,
+    options: Pick<
+      ConfirmationOptions,
+      'buttonTextColorClass' | 'buttonTitle'
+    > = {},
+  ) => {
+    return new Promise<boolean>((resolve) => {
+      showConfirmation({
+        ...options,
+        heading,
+        confirmCallback() {
+          resolve(true)
+        },
+        cancelCallback() {
+          resolve(false)
+        },
+      })
+    })
+  }
+
   return {
     confirmationDialog,
     showConfirmation,
+    waitForConfirmation,
   }
 }
 

+ 20 - 0
app/frontend/apps/mobile/components/CommonFilePreview/CommonFilePreview.stories.ts

@@ -0,0 +1,20 @@
+// Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
+
+import createTemplate from '@stories/support/createTemplate'
+import CommonFilePreview, { type Props } from './CommonFilePreview.vue'
+
+export default {
+  title: 'Apps/Mobile/CommonFilePreview',
+  component: CommonFilePreview,
+}
+
+const Template = createTemplate<Props>(CommonFilePreview)
+
+export const Default = Template.create({
+  file: {
+    name: 'test file.txt',
+    type: 'text/plain',
+    size: 12343,
+  },
+  downloadUrl: '/',
+})

+ 120 - 0
app/frontend/apps/mobile/components/CommonFilePreview/CommonFilePreview.vue

@@ -0,0 +1,120 @@
+<!-- Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/ -->
+
+<script setup lang="ts">
+import { computed, getCurrentInstance, ref } from 'vue'
+import type { StoredFile } from '@shared/graphql/types'
+import { canDownloadFile, canPreviewFile } from '@shared/utils/files'
+import { humanizeFileSize } from '@shared/utils/helpers'
+import { getIconByContentType } from '@shared/utils/icons'
+import { i18n } from '@shared/i18n'
+
+export interface Props {
+  file: Pick<StoredFile, 'type' | 'name' | 'size'>
+
+  downloadUrl?: string
+  previewUrl?: string
+
+  noPreview?: boolean
+  noRemove?: boolean
+
+  wrapperClass?: string
+  iconClass?: string
+  sizeClass?: string
+}
+
+const props = withDefaults(defineProps<Props>(), {
+  wrapperClass: 'border-gray-300',
+  iconClass: 'border-gray-300',
+  sizeClass: 'text-white/80',
+})
+
+defineEmits<{
+  (e: 'remove'): void
+  (e: 'preview', $event: Event): void
+}>()
+
+const imageFailed = ref(false)
+
+const canPreview = computed(() => {
+  const { file, previewUrl } = props
+
+  if (!previewUrl || imageFailed.value) return false
+
+  return canPreviewFile(file.type)
+})
+
+const canDownload = computed(() => canDownloadFile(props.file.type))
+const icon = computed(() => getIconByContentType(props.file.type))
+
+const componentType = computed(() => {
+  if (props.downloadUrl) return 'CommonLink'
+  if (canPreview.value) return 'button'
+  return 'div'
+})
+
+const vm = getCurrentInstance()
+
+const ariaLabel = computed(() => {
+  const listensForPreview = !!vm?.vnode.props?.onPreview
+  if (canPreview.value && listensForPreview)
+    return i18n.t('Preview %s', props.file.name) // opens a preview on the same page
+  if (props.downloadUrl && canDownload.value)
+    return i18n.t('Download %s', props.file.name) // directly downloads file
+  if (props.downloadUrl && !canDownload.value)
+    return i18n.t('Open %s', props.file.name) // opens file in another tab
+  return props.file.name // cannot download and preview, probably just uploaded pdf
+})
+</script>
+
+<template>
+  <div
+    class="mb-2 flex w-full items-center gap-2 rounded-2xl border-[0.5px] p-3 last:mb-0"
+    :class="wrapperClass"
+  >
+    <Component
+      :is="componentType"
+      class="flex w-full select-none items-center gap-2 overflow-hidden text-left"
+      :class="{ 'cursor-pointer': componentType !== 'div' }"
+      :aria-label="ariaLabel"
+      :link="downloadUrl"
+      :download="canDownload ? true : undefined"
+      :target="!canDownload ? '_blank' : ''"
+      @click="canPreview && $emit('preview', $event)"
+    >
+      <div
+        v-if="!noPreview"
+        class="flex h-9 w-9 items-center justify-center rounded border-[0.5px] p-1"
+        :class="iconClass"
+      >
+        <img
+          v-if="canPreview"
+          class="max-h-8"
+          :src="previewUrl"
+          :alt="$t('Image of %s', file.name)"
+          @error="imageFailed = true"
+        />
+        <CommonIcon v-else size="base" :name="icon" />
+      </div>
+      <div class="flex flex-1 flex-col overflow-hidden leading-4">
+        <span class="overflow-hidden text-ellipsis whitespace-nowrap">
+          {{ file.name }}
+        </span>
+        <span v-if="file.size" class="whitespace-nowrap" :class="sizeClass">
+          {{ humanizeFileSize(file.size) }}
+        </span>
+      </div>
+    </Component>
+
+    <button
+      v-if="!noRemove"
+      :aria-label="i18n.t('Remove %s', file.name)"
+      @click.stop.prevent="$emit('remove')"
+    >
+      <CommonIcon
+        :fixed-size="{ width: 24, height: 24 }"
+        class="text-gray ltr:right-2 rtl:left-2"
+        name="close-small"
+      />
+    </button>
+  </div>
+</template>

+ 174 - 0
app/frontend/apps/mobile/components/CommonFilePreview/__tests__/CommonFilePreview.spec.ts

@@ -0,0 +1,174 @@
+// Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
+
+import { renderComponent } from '@tests/support/components'
+import { getByIconName } from '@tests/support/components/iconQueries'
+import { mockApplicationConfig } from '@tests/support/mock-applicationConfig'
+import CommonFilePreview, { type Props } from '../CommonFilePreview.vue'
+
+const renderFilePreview = (
+  props: Props & { onPreview?(event: Event): void },
+) => {
+  return renderComponent(CommonFilePreview, {
+    props,
+    router: true,
+    store: true,
+  })
+}
+
+describe('preview file component', () => {
+  beforeEach(() => {
+    mockApplicationConfig({
+      ui_ticket_zoom_attachments_preview: true,
+      api_path: '/api',
+      'active_storage.web_image_content_types': [
+        'image/png',
+        'image/jpeg',
+        'image/jpg',
+        'image/gif',
+      ],
+    })
+  })
+
+  it('renders previewable image', async () => {
+    const previewMock = vi.fn((event: Event) => event.preventDefault())
+
+    const view = renderFilePreview({
+      file: {
+        name: 'name.png',
+        type: 'image/png',
+        size: 1025,
+      },
+      downloadUrl: '/api/url',
+      previewUrl: '/api/url?preview',
+      onPreview: previewMock,
+    })
+
+    const link = view.getByRole('link')
+
+    expect(link).toHaveAttribute('aria-label', 'Preview name.png')
+    expect(link).toHaveAttribute('download')
+    expect(link).toHaveAttribute('href', '/api/url')
+
+    const thumbnail = view.getByAltText('Image of name.png')
+    expect(thumbnail).toHaveAttribute('src', '/api/url?preview')
+
+    await view.events.click(link)
+
+    expect(view.emitted().preview).toBeTruthy()
+    expect(previewMock).toHaveBeenCalled()
+  })
+
+  it('renders downloadble file', async () => {
+    const view = renderFilePreview({
+      file: {
+        name: 'name.word',
+        type: 'application/msword',
+        size: 1025,
+      },
+      downloadUrl: '#/api/url',
+      previewUrl: '#/api/url?preview',
+      onPreview: vi.fn(),
+    })
+
+    const link = view.getByRole('link')
+
+    expect(link).toHaveAttribute('aria-label', 'Download name.word')
+    expect(link).toHaveAttribute('download')
+    expect(link).toHaveAttribute('href', '#/api/url')
+
+    expect(view.getByIconName('file-word')).toBeInTheDocument()
+
+    await view.events.click(link)
+
+    expect(view.emitted().preview).toBeFalsy()
+  })
+
+  it('renders pdf/html', async () => {
+    const view = renderFilePreview({
+      file: {
+        name: 'name.pdf',
+        type: 'application/pdf',
+        size: 1025,
+      },
+      downloadUrl: '#/api/url',
+      previewUrl: '#/api/url?preview',
+      onPreview: vi.fn(),
+    })
+
+    const link = view.getByRole('link')
+
+    expect(link).toHaveAttribute('aria-label', 'Open name.pdf')
+    expect(link).not.toHaveAttribute('download')
+    expect(link).toHaveAttribute('href', '#/api/url')
+    expect(link).toHaveAttribute('target', '_blank')
+  })
+
+  it('renders uploaded image', async () => {
+    const view = renderFilePreview({
+      file: {
+        name: 'name.png',
+        type: 'image/png',
+        size: 1025,
+      },
+      previewUrl: 'data:image/png;base64,',
+      onPreview: vi.fn(),
+    })
+
+    const button = view.getByRole('button', { name: 'Preview name.png' })
+
+    const thumbnail = view.getByAltText('Image of name.png')
+    expect(thumbnail).toHaveAttribute('src', 'data:image/png;base64,')
+
+    await view.events.click(button)
+
+    expect(view.emitted().preview).toBeTruthy()
+  })
+
+  it('renders uploaded non-image', async () => {
+    const view = renderFilePreview({
+      file: {
+        name: 'name.word',
+        type: 'application/msword',
+        size: 1025,
+      },
+      previewUrl: 'data:application/msword;base64,',
+      onPreview: vi.fn(),
+    })
+
+    const div = view.getByLabelText('name.word')
+
+    expect(div.tagName, 'not interactable link').not.toBe('A')
+    expect(div.tagName, 'not interactable button').not.toBe('BUTTON')
+
+    expect(view.getByIconName('file-word')).toBeInTheDocument()
+
+    await view.events.click(div)
+
+    expect(view.emitted().preview).toBeFalsy()
+  })
+
+  it('can remove file', async () => {
+    const view = renderFilePreview({
+      file: {
+        name: 'name.word',
+        type: 'application/msword',
+        size: 1025,
+      },
+    })
+
+    const button = view.getByRole('button', { name: 'Remove name.word' })
+
+    expect(button).toBeInTheDocument()
+    expect(getByIconName(button, 'close-small')).toBeInTheDocument()
+
+    await view.events.click(button)
+
+    expect(view.emitted().remove).toBeTruthy()
+
+    await view.rerender({ noRemove: true })
+
+    expect(
+      view.queryByRole('button', { name: 'Remove name.word' }),
+    ).not.toBeInTheDocument()
+  })
+})

+ 4 - 4
app/frontend/apps/mobile/components/CommonSectionPopup/CommonSectionPopup.vue

@@ -40,8 +40,8 @@ onKeyUp(['Escape', 'Spacebar', ' '], (e) => {
 </script>
 
 <template>
-  <teleport to="body">
-    <transition
+  <Teleport to="body">
+    <Transition
       leave-active-class="window-open"
       enter-active-class="window-open"
       enter-from-class="window-close"
@@ -76,8 +76,8 @@ onKeyUp(['Escape', 'Spacebar', ' '], (e) => {
           </div>
         </div>
       </div>
-    </transition>
-  </teleport>
+    </Transition>
+  </Teleport>
 </template>
 
 <style scoped lang="scss">

+ 7 - 26
app/frontend/apps/mobile/modules/playground/views/PlaygroundOverview.vue

@@ -9,14 +9,6 @@ import { useDialog } from '@shared/composables/useDialog'
 import CommonButtonGroup from '@mobile/components/CommonButtonGroup/CommonButtonGroup.vue'
 
 const linkSchemaRaw = [
-  {
-    type: 'date',
-    name: 'date',
-    label: 'Date_Input',
-    props: {
-      link: '/tickets',
-    },
-  },
   {
     type: 'search',
     name: 'search',
@@ -150,26 +142,15 @@ const linkSchemas = defineFormSchema(linkSchemaRaw)
 const schema = defineFormSchema([
   {
     isLayout: true,
-    component: 'FormLayout',
-    props: {
-      columns: 2,
-    },
+    component: 'FormGroup',
     children: [
       {
-        isLayout: true,
-        component: 'FormGroup',
-        children: [
-          {
-            type: 'text',
-            name: 'text22',
-            label: 'Some_Label',
-          },
-        ],
-      },
-      {
-        type: 'text',
-        name: 'text23',
-        label: 'Some Label3',
+        type: 'file',
+        name: 'file',
+        // label: 'File',
+        props: {
+          link: '/tickets',
+        },
       },
     ],
   },

+ 0 - 91
app/frontend/apps/mobile/modules/ticket/components/TicketDetailView/ArticleAttachment.vue

@@ -1,91 +0,0 @@
-<!-- Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/ -->
-
-<script setup lang="ts">
-import { useApplicationStore } from '@shared/stores/application'
-import { canDownloadFile } from '@shared/utils/files'
-import { humanizeFileSize } from '@shared/utils/helpers'
-import { getIconByContentType } from '@shared/utils/icons'
-import { computed } from 'vue'
-import type { TicketArticleAttachment } from '../../types/tickets'
-
-interface Colors {
-  file: string
-  icon: string
-  amount: string
-}
-
-interface Props {
-  attachment: TicketArticleAttachment
-  colors: Colors
-  ticketInternalId: number
-  articleInternalId: number
-}
-
-const props = defineProps<Props>()
-
-const application = useApplicationStore()
-
-const canPreview = computed<boolean>(() => {
-  const { attachment } = props
-
-  if (!attachment.type) return false
-
-  const allowedPriviewContentTypes =
-    (application.config[
-      'active_storage.web_image_content_types'
-    ] as string[]) || []
-
-  return allowedPriviewContentTypes.includes(attachment.type)
-})
-
-const baseAttachmentUrl = computed(() => {
-  const { ticketInternalId, articleInternalId, attachment } = props
-  return `/ticket_attachment/${ticketInternalId}/${articleInternalId}/${attachment.internalId}`
-})
-
-const canDownload = computed(() => canDownloadFile(props.attachment.type))
-
-const previewUrl = computed(() => `${baseAttachmentUrl.value}?view=preview`)
-const attachmentUrl = computed(() => {
-  const dispositionParams = canDownload.value ? '?disposition=attachment' : ''
-  return `${baseAttachmentUrl.value}${dispositionParams}`
-})
-
-const icon = computed(() => getIconByContentType(props.attachment.type))
-</script>
-
-<template>
-  <!-- maybe it's better to preview in the current page instead of opening new page? -->
-  <CommonLink
-    class="mb-2 flex w-full cursor-pointer items-center gap-2 rounded-2xl border-[0.5px] p-3 last:mb-0"
-    :class="colors.file"
-    :aria-label="$t('Download %s', attachment.name)"
-    :link="attachmentUrl"
-    :download="canDownload ? true : null"
-    :target="!canDownload ? '_blank' : ''"
-    rest-api
-  >
-    <div
-      v-if="$c.ui_ticket_zoom_attachments_preview"
-      class="flex h-9 w-9 items-center justify-center rounded border-[0.5px] p-1"
-      :class="colors.icon"
-    >
-      <img
-        v-if="canPreview"
-        :src="`${$c.api_path}${previewUrl}`"
-        :alt="$t('Image of %s', attachment.name)"
-      />
-      <CommonIcon v-else size="base" :name="icon" />
-    </div>
-    <span class="break-words line-clamp-1">
-      {{ attachment.name }}
-    </span>
-    <span
-      v-if="attachment.size"
-      class="whitespace-nowrap"
-      :class="colors.amount"
-    >
-      {{ humanizeFileSize(attachment.size) }}
-    </span>
-  </CommonLink>
-</template>

+ 28 - 7
app/frontend/apps/mobile/modules/ticket/components/TicketDetailView/ArticleBubble.vue

@@ -10,9 +10,12 @@ import { textToHtml } from '@shared/utils/helpers'
 import { useSessionStore } from '@shared/stores/session'
 import type { TicketArticlesQuery } from '@shared/graphql/types'
 import type { ConfidentTake } from '@shared/types/utils'
+import useImageViewer from '@shared/composables/useImageViewer'
+import CommonFilePreview from '@mobile/components/CommonFilePreview/CommonFilePreview.vue'
+import stopEvent from '@shared/utils/events'
 import { useArticleToggleMore } from '../../composable/useArticleToggleMore'
 import type { TicketArticleAttachment } from '../../types/tickets'
-import ArticleAttachment from './ArticleAttachment.vue'
+import { useArticleAttachments } from '../../composable/useArticleAttachments'
 
 interface Props {
   position: 'left' | 'right'
@@ -88,6 +91,19 @@ const colorsClasses = computed(() => {
 
 const { shownMore, bubbleElement, hasShowMore, toggleShowMore } =
   useArticleToggleMore()
+
+const { attachments: articleAttachments } = useArticleAttachments({
+  ticketInternalId: props.ticketInternalId,
+  articleInternalId: props.articleInternalId,
+  attachments: computed(() => props.attachments),
+})
+
+const { showImage } = useImageViewer(articleAttachments)
+
+const previewImage = (event: Event, attachment: TicketArticleAttachment) => {
+  stopEvent(event)
+  showImage(attachment)
+}
 </script>
 
 <template>
@@ -166,13 +182,18 @@ const { shownMore, bubbleElement, hasShowMore, toggleShowMore } =
             app/assets/javascripts/app/controllers/ticket_zoom/article_view.coffee:147
             we would need internal ID for this url to work, or update url to allow GQL IDs
           -->
-        <ArticleAttachment
-          v-for="attachment of attachments"
+        <CommonFilePreview
+          v-for="attachment of articleAttachments"
           :key="attachment.internalId"
-          :attachment="attachment"
-          :colors="colorsClasses"
-          :ticket-internal-id="ticketInternalId"
-          :article-internal-id="articleInternalId"
+          :file="attachment"
+          :download-url="attachment.downloadUrl"
+          :preview-url="attachment.content"
+          :no-preview="!$c.ui_ticket_zoom_attachments_preview"
+          :wrapper-class="colorsClasses.file"
+          :icon-class="colorsClasses.icon"
+          :size-class="colorsClasses.amount"
+          no-remove
+          @preview="previewImage($event, attachment)"
         />
       </div>
       <div class="flex h-3 justify-end">

Некоторые файлы не были показаны из-за большого количества измененных файлов