forked from zammad/zammad
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Maintenance: Desktop - Add new form field for image upload with preview.
- Loading branch information
Showing
22 changed files
with
399 additions
and
14 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
33 changes: 33 additions & 0 deletions
33
app/frontend/apps/desktop/components/CommonDivider/CommonDivider.vue
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> |
41 changes: 41 additions & 0 deletions
41
app/frontend/apps/desktop/components/CommonDivider/__tests__/CommonDivider.spec.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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') | ||
}) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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' |
139 changes: 139 additions & 0 deletions
139
app/frontend/apps/desktop/components/Form/fields/FieldImageUpload/FieldImageUploadInput.vue
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> |
121 changes: 121 additions & 0 deletions
121
...s/desktop/components/Form/fields/FieldImageUpload/__tests__/FieldImageUploadInput.spec.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() | ||
}) | ||
}) |
11 changes: 11 additions & 0 deletions
11
app/frontend/apps/desktop/components/Form/fields/FieldImageUpload/index.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
} |
Oops, something went wrong.