Browse Source

Feature: Mobile - Add design for FieldFileInput

Vladimir Sheremet 2 years ago
parent
commit
b91b428d9b

+ 7 - 0
.eslintrc.js

@@ -183,6 +183,13 @@ module.exports = {
             'vitest',
             'vitest',
             path.resolve(__dirname, 'node_modules/vitest/dist/index.mjs'),
             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'],
         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 useAuthenticationChanges from '@shared/composables/useAuthenticationUpdates'
 import DynamicInitializer from '@shared/components/DynamicInitializer/DynamicInitializer.vue'
 import DynamicInitializer from '@shared/components/DynamicInitializer/DynamicInitializer.vue'
 import CommonConfirmation from '@mobile/components/CommonConfirmation/CommonConfirmation.vue'
 import CommonConfirmation from '@mobile/components/CommonConfirmation/CommonConfirmation.vue'
+import CommonImageViewer from '@shared/components/CommonImageViewer/CommonImageViewer.vue'
 
 
 const router = useRouter()
 const router = useRouter()
 
 
@@ -71,6 +72,7 @@ onBeforeUnmount(() => {
   <template v-if="application.loaded">
   <template v-if="application.loaded">
     <CommonNotifications />
     <CommonNotifications />
     <CommonConfirmation />
     <CommonConfirmation />
+    <CommonImageViewer />
   </template>
   </template>
   <div
   <div
     v-if="application.loaded"
     v-if="application.loaded"

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

@@ -11,9 +11,31 @@ const useConfirmation = () => {
     confirmationDialog.value = confirmationOptions
     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 {
   return {
     confirmationDialog,
     confirmationDialog,
     showConfirmation,
     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>
 </script>
 
 
 <template>
 <template>
-  <teleport to="body">
-    <transition
+  <Teleport to="body">
+    <Transition
       leave-active-class="window-open"
       leave-active-class="window-open"
       enter-active-class="window-open"
       enter-active-class="window-open"
       enter-from-class="window-close"
       enter-from-class="window-close"
@@ -76,8 +76,8 @@ onKeyUp(['Escape', 'Spacebar', ' '], (e) => {
           </div>
           </div>
         </div>
         </div>
       </div>
       </div>
-    </transition>
-  </teleport>
+    </Transition>
+  </Teleport>
 </template>
 </template>
 
 
 <style scoped lang="scss">
 <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'
 import CommonButtonGroup from '@mobile/components/CommonButtonGroup/CommonButtonGroup.vue'
 
 
 const linkSchemaRaw = [
 const linkSchemaRaw = [
-  {
-    type: 'date',
-    name: 'date',
-    label: 'Date_Input',
-    props: {
-      link: '/tickets',
-    },
-  },
   {
   {
     type: 'search',
     type: 'search',
     name: 'search',
     name: 'search',
@@ -150,26 +142,15 @@ const linkSchemas = defineFormSchema(linkSchemaRaw)
 const schema = defineFormSchema([
 const schema = defineFormSchema([
   {
   {
     isLayout: true,
     isLayout: true,
-    component: 'FormLayout',
-    props: {
-      columns: 2,
-    },
+    component: 'FormGroup',
     children: [
     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 { useSessionStore } from '@shared/stores/session'
 import type { TicketArticlesQuery } from '@shared/graphql/types'
 import type { TicketArticlesQuery } from '@shared/graphql/types'
 import type { ConfidentTake } from '@shared/types/utils'
 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 { useArticleToggleMore } from '../../composable/useArticleToggleMore'
 import type { TicketArticleAttachment } from '../../types/tickets'
 import type { TicketArticleAttachment } from '../../types/tickets'
-import ArticleAttachment from './ArticleAttachment.vue'
+import { useArticleAttachments } from '../../composable/useArticleAttachments'
 
 
 interface Props {
 interface Props {
   position: 'left' | 'right'
   position: 'left' | 'right'
@@ -88,6 +91,19 @@ const colorsClasses = computed(() => {
 
 
 const { shownMore, bubbleElement, hasShowMore, toggleShowMore } =
 const { shownMore, bubbleElement, hasShowMore, toggleShowMore } =
   useArticleToggleMore()
   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>
 </script>
 
 
 <template>
 <template>
@@ -166,13 +182,18 @@ const { shownMore, bubbleElement, hasShowMore, toggleShowMore } =
             app/assets/javascripts/app/controllers/ticket_zoom/article_view.coffee:147
             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
             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"
           :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>
       <div class="flex h-3 justify-end">
       <div class="flex h-3 justify-end">

Some files were not shown because too many files changed in this diff