Skip to content

Commit

Permalink
Maintenance: Desktop - Add new form field for image upload with preview.
Browse files Browse the repository at this point in the history
  • Loading branch information
dvuckovic committed Jan 10, 2024
1 parent 7d010ac commit 24f384b
Show file tree
Hide file tree
Showing 22 changed files with 399 additions and 14 deletions.
10 changes: 10 additions & 0 deletions app/frontend/apps/desktop/components/CommonButton/CommonButton.vue
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,15 @@ const variantClasses = computed(() => {
'dark:hover:bg-red-900',
'text-red-500',
]
case 'remove':
return [
'btn-info',
'bg-red-400',
'hover:bg-red-400',
'dark:bg-red-600',
'dark:hover:bg-red-600',
'text-white',
]
case 'secondary':
default:
return [
Expand Down Expand Up @@ -138,6 +147,7 @@ const iconSizeClass = computed(() => {
borderRadiusClass,
{
'btn-block': block,
'w-min': !block,
},
]"
:type="type"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,10 @@ describe('CommonButton.vue', () => {
variant: 'danger',
classes: ['btn-error'],
},
{
variant: 'remove',
classes: ['btn-info'],
},
])('supports $variant variant', async ({ variant, classes }) => {
const view = renderComponent(CommonButton, {
props: {
Expand Down
1 change: 1 addition & 0 deletions app/frontend/apps/desktop/components/CommonButton/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export type ButtonVariant =
| 'tertiary'
| 'submit'
| 'danger'
| 'remove'

export type ButtonType = 'button' | 'reset' | 'submit'
export type ButtonSize = 'small' | 'medium' | 'large'
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<!-- Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ -->

<script setup lang="ts">
import type { DividerOrientation } from './types.ts'

interface Props {
orientation?: DividerOrientation
padding?: boolean
}

const props = withDefaults(defineProps<Props>(), {
orientation: 'horizontal',
})
</script>

<template>
<div
:class="{
'w-full': props.orientation === 'horizontal',
'h-full': props.orientation === 'vertical',
'px-2.5': props.padding && props.orientation === 'horizontal',
'py-2.5': props.padding && props.orientation === 'vertical',
}"
>
<hr
class="bg-neutral-100 dark:bg-gray-900 border-0"
:class="{
'w-full h-px': props.orientation === 'horizontal',
'w-px h-full': props.orientation === 'vertical',
}"
/>
</div>
</template>
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/

import { renderComponent } from '#tests/support/components/index.ts'
import CommonDivider from '../CommonDivider.vue'

describe('CommonDivider.vue', () => {
it('renders with default prop values', async () => {
const view = renderComponent(CommonDivider)

expect(view.getByRole('separator')).toHaveClasses(['w-full', 'h-px'])
})

it('supports vertical orientation', async () => {
const view = renderComponent(CommonDivider, {
props: {
orientation: 'vertical',
},
})

expect(view.getByRole('separator')).toHaveClasses(['w-px', 'h-full'])
})

it('supports padding prop', async () => {
const view = renderComponent(CommonDivider, {
props: {
padding: true,
},
})

const container = view.getByRole('separator').parentElement

expect(container).toHaveClass('px-2.5')

await view.rerender({
orientation: 'vertical',
})

expect(container).not.toHaveClass('px-2.5')
expect(container).toHaveClass('py-2.5')
})
})
3 changes: 3 additions & 0 deletions app/frontend/apps/desktop/components/CommonDivider/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/

export type DividerOrientation = 'horizontal' | 'vertical'
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
<!-- Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ -->
<script setup lang="ts">
import { computed, ref, toRef } from 'vue'
import { useDropZone } from '@vueuse/core'
import CommonButton from '#desktop/components/CommonButton/CommonButton.vue'
import CommonDivider from '#desktop/components/CommonDivider/CommonDivider.vue'
import { i18n } from '#shared/i18n.ts'
import type { FormFieldContext } from '#shared/components/Form/types/field.ts'

export interface Props {
context: FormFieldContext
}

const props = defineProps<Props>()

const contextReactive = toRef(props, 'context')

const imageUpload = computed<string>({
get() {
return contextReactive.value._value || ''
},
set(value) {
props.context.node.input(value)
},
})

const MAX_IMAGE_SIZE_IN_MB = 8

const imageUploadInput = ref<HTMLInputElement>()

const reset = () => {
imageUpload.value = ''
const input = imageUploadInput.value
if (!input) return
input.value = ''
input.files = null
}

const loadImages = async (files: FileList | File[] | null) => {
Array.from(files || []).forEach((file) => {
const reader = new FileReader()

reader.onload = (e) => {
if (!e.target || !e.target.result) return

imageUpload.value = e.target.result as string
}

if (file.size && file.size > 1024 * 1024 * MAX_IMAGE_SIZE_IN_MB) {
props.context.node.setErrors(
i18n.t(
'File too big, max. %s MB allowed.',
MAX_IMAGE_SIZE_IN_MB.toString(),
),
)
return
}

reader.readAsDataURL(file)
})
}

const onFileChanged = async ($event: Event) => {
const input = $event.target as HTMLInputElement
const { files } = input
if (files) await loadImages(files)
}

const dropZoneRef = ref<HTMLDivElement>()

const { isOverDropZone } = useDropZone(dropZoneRef, {
onDrop: loadImages,
dataTypes: (types) => types.every((type) => type.startsWith('image/')),
})
</script>

<template>
<div
ref="dropZoneRef"
class="w-full flex flex-col items-center gap-2 p-2"
:class="context.classes.input"
>
<div
v-if="isOverDropZone"
class="w-full rounded outline-dashed outline-1 outline-blue-800 text-center"
>
<CommonLabel class="py-2 !text-blue-800" prefix-icon="upload">
{{ $t('Drop image file here') }}
</CommonLabel>
</div>
<template v-else>
<template v-if="imageUpload">
<div
class="w-full p-2.5 grid grid-cols-[20px_auto_20px] gap-2.5 justify-items-center items-center"
>
<img
class="max-h-32 col-start-2"
:src="imageUpload"
:alt="$t('Image preview')"
/>
<CommonButton
variant="remove"
size="small"
icon="x-lg"
:aria-label="$t('Remove image')"
@click="!context.disabled && reset()"
/>
</div>
<CommonDivider padding />
</template>
<CommonButton
variant="secondary"
size="medium"
prefix-icon="image"
:disabled="context.disabled"
@click="!context.disabled && imageUploadInput?.click()"
@blur="context.handlers.blur"
>{{ $t('Upload image') }}</CommonButton
>
</template>
<input
:id="context.id"
ref="imageUploadInput"
data-test-id="imageUploadInput"
type="file"
:name="context.node.name"
:disabled="context.disabled"
class="hidden"
tabindex="-1"
aria-hidden="true"
accept="image/*"
v-bind="{
...context.attrs,
onBlur: undefined,
}"
@change="!context.disabled && onFileChanged($event)"
/>
</div>
</template>
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
// Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/

import { getNode } from '@formkit/core'
import { FormKit } from '@formkit/vue'
import { renderComponent } from '#tests/support/components/index.ts'

const renderImageUploadInput = (props: Record<string, unknown> = {}) => {
return renderComponent(FormKit, {
props: {
id: 'imageUpload',
type: 'imageUpload',
name: 'imageUpload',
label: 'Image Upload',
formId: 'form',
...props,
},
form: true,
router: true,
})
}

const dataURItoBlob = (dataURI: string) => {
const byteString = atob(dataURI.split(',')[1])
const mimeString = dataURI.split(',')[0].split(':')[1].split(';')[0]

const ab = new ArrayBuffer(byteString.length)
const ia = new Uint8Array(ab)
for (let i = 0; i < byteString.length; i += 1) {
ia[i] = byteString.charCodeAt(i)
}

return new Blob([ab], { type: mimeString })
}

describe('Fields - FieldImageUpload', () => {
it('renders upload image file button', async () => {
const view = renderImageUploadInput()

const uploadImageButton = view.getByRole('button', { name: 'Upload image' })
expect(uploadImageButton).toBeInTheDocument()

const imageUploadInput = view.getByTestId('imageUploadInput')

const clickSpy = vi.spyOn(imageUploadInput, 'click')

await view.events.click(uploadImageButton)

expect(
clickSpy,
'trigger click on button, which normally opens a window to choose file',
).toHaveBeenCalled()
})

it('renders preview of the uploaded image file', async () => {
const view = renderImageUploadInput()

const imageUploadInput = view.getByTestId('imageUploadInput')

const testValue =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVQYV2NgYAAAAAMAAWgmWQ0AAAAASUVORK5CYII='

const testFile = new File([dataURItoBlob(testValue)], 'foo.png', {
type: 'image/png',
})

await view.events.upload(imageUploadInput, testFile)

await vi.waitFor(() => {
expect(getNode('imageUpload')?._value).toEqual(testValue)

const uploadImage = view.getByRole('img', { name: 'Image preview' })

expect(uploadImage).toHaveAttribute('src', testValue)
})
})

it('renders passed value as image preview', async () => {
const testValue = '/api/v1/system_assets/product_logo/1704708731'

const view = renderImageUploadInput({
value: testValue,
})

const uploadImage = view.getByRole('img', { name: 'Image preview' })

expect(uploadImage).toHaveAttribute('src', testValue)
})

it('supports removal of the uploaded image', async () => {
const view = renderImageUploadInput({
value: '/api/v1/system_assets/product_logo/1704708731',
})

const removeImageButton = view.getByRole('button', { name: 'Remove image' })

await view.events.click(removeImageButton)

expect(
view.queryByRole('button', { name: 'Remove image' }),
).not.toBeInTheDocument()

expect(getNode('imageUpload')?._value).toEqual('')
})

it('supports disabled prop', async () => {
const view = renderImageUploadInput({
disabled: true,
})

const clickEvent = vi.fn()
HTMLInputElement.prototype.click = clickEvent

const uploadImageButton = view.getByRole('button', { name: 'Upload image' })

expect(uploadImageButton).toBeDisabled()

await view.events.click(uploadImageButton)

expect(clickEvent).not.toHaveBeenCalled()
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/

import createInput from '#shared/form/core/createInput.ts'
import FieldImageUploadInput from './FieldImageUploadInput.vue'

const fieldDefinition = createInput(FieldImageUploadInput)

export default {
fieldType: 'imageUpload',
definition: fieldDefinition,
}
Loading

0 comments on commit 24f384b

Please sign in to comment.