mirror of
https://github.com/chenasraf/nextcloud-pantry.git
synced 2026-05-18 01:28:57 +00:00
fix: upload list item image only after save
This commit is contained in:
@@ -98,14 +98,12 @@ const defaultProps = {
|
||||
item: makeItem(),
|
||||
houseId: 10,
|
||||
saving: false,
|
||||
uploadingImage: false,
|
||||
}
|
||||
|
||||
describe('ChecklistItemEditDialog', () => {
|
||||
it('renders edit form with item values pre-filled', () => {
|
||||
const wrapper = mount(ChecklistItemEditDialog, { props: defaultProps })
|
||||
const textFields = wrapper.findAll('.nc-text-field')
|
||||
// First text field is name, second is quantity
|
||||
expect((textFields[0].element as HTMLInputElement).value).toBe('Milk')
|
||||
expect((wrapper.find('.nc-text-area').element as HTMLTextAreaElement).value).toBe('Whole milk')
|
||||
expect((textFields[1].element as HTMLInputElement).value).toBe('2 L')
|
||||
@@ -115,8 +113,7 @@ describe('ChecklistItemEditDialog', () => {
|
||||
const wrapper = mount(ChecklistItemEditDialog, {
|
||||
props: { ...defaultProps, item: makeItem({ name: '' }) },
|
||||
})
|
||||
const buttons = wrapper.findAll('.nc-button')
|
||||
const saveButton = buttons.find((b) => b.text() === 'Save')!
|
||||
const saveButton = wrapper.findAll('.nc-button').find((b) => b.text() === 'Save')!
|
||||
expect(saveButton.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
@@ -124,33 +121,37 @@ describe('ChecklistItemEditDialog', () => {
|
||||
const wrapper = mount(ChecklistItemEditDialog, {
|
||||
props: { ...defaultProps, saving: true },
|
||||
})
|
||||
const buttons = wrapper.findAll('.nc-button')
|
||||
const saveButton = buttons.find((b) => b.text() === 'Save')!
|
||||
const saveButton = wrapper.findAll('.nc-button').find((b) => b.text() === 'Save')!
|
||||
expect(saveButton.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it("emits 'save' with itemId and patch on submit", async () => {
|
||||
it('emits save with patch, null image, and no clear on submit', async () => {
|
||||
const wrapper = mount(ChecklistItemEditDialog, { props: defaultProps })
|
||||
await wrapper.find('#pantry-edit-item-form').trigger('submit')
|
||||
expect(wrapper.emitted('save')).toBeTruthy()
|
||||
const [itemId, patch] = wrapper.emitted('save')![0] as [number, Record<string, unknown>]
|
||||
const [itemId, patch, pendingImage, clearImage] = wrapper.emitted('save')![0] as [
|
||||
number,
|
||||
Record<string, unknown>,
|
||||
File | null,
|
||||
boolean,
|
||||
]
|
||||
expect(itemId).toBe(42)
|
||||
expect(patch.name).toBe('Milk')
|
||||
expect(patch.description).toBe('Whole milk')
|
||||
expect(patch.quantity).toBe('2 L')
|
||||
expect(patch.categoryId).toBe(3)
|
||||
expect(pendingImage).toBeNull()
|
||||
expect(clearImage).toBe(false)
|
||||
})
|
||||
|
||||
it("emits 'update:open' false on cancel click", async () => {
|
||||
it('emits update:open false on cancel click', async () => {
|
||||
const wrapper = mount(ChecklistItemEditDialog, { props: defaultProps })
|
||||
const buttons = wrapper.findAll('.nc-button')
|
||||
const cancelButton = buttons.find((b) => b.text() === 'Cancel')!
|
||||
const cancelButton = wrapper.findAll('.nc-button').find((b) => b.text() === 'Cancel')!
|
||||
await cancelButton.trigger('click')
|
||||
expect(wrapper.emitted('update:open')).toBeTruthy()
|
||||
expect(wrapper.emitted('update:open')![0]).toEqual([false])
|
||||
})
|
||||
|
||||
it('shows image preview when item has imageFileId', () => {
|
||||
it('shows server image preview when item has imageFileId', () => {
|
||||
const wrapper = mount(ChecklistItemEditDialog, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
@@ -167,17 +168,27 @@ describe('ChecklistItemEditDialog', () => {
|
||||
expect(wrapper.find('.edit-item-form__image-preview').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it("emits 'clear-image' when remove image clicked", async () => {
|
||||
it('hides image after clicking remove and emits clearImage true on save', async () => {
|
||||
const wrapper = mount(ChecklistItemEditDialog, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
item: makeItem({ imageFileId: 99, imageUploadedBy: 'admin' }),
|
||||
},
|
||||
})
|
||||
expect(wrapper.find('.edit-item-form__image-preview').exists()).toBe(true)
|
||||
const removeButton = wrapper.findAll('.nc-button').find((b) => b.text() === 'Remove image')!
|
||||
await removeButton.trigger('click')
|
||||
expect(wrapper.emitted('clear-image')).toBeTruthy()
|
||||
expect(wrapper.emitted('clear-image')![0]).toEqual([42])
|
||||
expect(wrapper.find('.edit-item-form__image-preview').exists()).toBe(false)
|
||||
|
||||
await wrapper.find('#pantry-edit-item-form').trigger('submit')
|
||||
const [, , pendingImage, clearImage] = wrapper.emitted('save')![0] as [
|
||||
number,
|
||||
Record<string, unknown>,
|
||||
File | null,
|
||||
boolean,
|
||||
]
|
||||
expect(pendingImage).toBeNull()
|
||||
expect(clearImage).toBe(true)
|
||||
})
|
||||
|
||||
it('shows description field', () => {
|
||||
|
||||
@@ -46,29 +46,18 @@
|
||||
<span class="edit-item-form__label">{{ strings.imageLabel }}</span>
|
||||
<div class="edit-item-form__image-row">
|
||||
<img
|
||||
v-if="item.imageFileId"
|
||||
v-if="previewImageUrl"
|
||||
class="edit-item-form__image-preview"
|
||||
:src="thumbUrl"
|
||||
:src="previewImageUrl"
|
||||
:alt="item.name"
|
||||
/>
|
||||
<NcButton
|
||||
variant="tertiary"
|
||||
type="button"
|
||||
:disabled="uploadingImage"
|
||||
@click="triggerImagePick"
|
||||
>
|
||||
<NcButton variant="tertiary" type="button" @click="triggerImagePick">
|
||||
<template #icon>
|
||||
<UploadIcon :size="20" />
|
||||
</template>
|
||||
{{ item.imageFileId ? strings.replaceImage : strings.uploadImage }}
|
||||
{{ hasImage ? strings.replaceImage : strings.uploadImage }}
|
||||
</NcButton>
|
||||
<NcButton
|
||||
v-if="item.imageFileId"
|
||||
variant="tertiary"
|
||||
type="button"
|
||||
:disabled="uploadingImage"
|
||||
@click="$emit('clear-image', item.id)"
|
||||
>
|
||||
<NcButton v-if="hasImage" variant="tertiary" type="button" @click="clearPendingImage">
|
||||
<template #icon>
|
||||
<DeleteIcon :size="20" />
|
||||
</template>
|
||||
@@ -106,7 +95,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import NcDialog from '@nextcloud/vue/components/NcDialog'
|
||||
import NcButton from '@nextcloud/vue/components/NcButton'
|
||||
@@ -126,14 +115,11 @@ const props = defineProps<{
|
||||
item: ChecklistItem
|
||||
houseId: number
|
||||
saving: boolean
|
||||
uploadingImage: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:open': [value: boolean]
|
||||
save: [itemId: number, patch: Partial<ItemInput>]
|
||||
'upload-image': [itemId: number, file: File]
|
||||
'clear-image': [itemId: number]
|
||||
save: [itemId: number, patch: Partial<ItemInput>, pendingImage: File | null, clearImage: boolean]
|
||||
}>()
|
||||
|
||||
const editName = ref('')
|
||||
@@ -145,12 +131,40 @@ const editRepeatFromCompletion = ref(false)
|
||||
const showRecurrenceEditor = ref(false)
|
||||
const imageInputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const thumbUrl = computed(() =>
|
||||
const pendingImageFile = ref<File | null>(null)
|
||||
const pendingImageObjectUrl = ref<string | null>(null)
|
||||
const pendingClearImage = ref(false)
|
||||
|
||||
const serverThumbUrl = computed(() =>
|
||||
props.item.imageFileId
|
||||
? itemImagePreviewUrl(props.houseId, props.item.imageFileId!, props.item.imageUploadedBy!, 96)
|
||||
: '',
|
||||
: null,
|
||||
)
|
||||
|
||||
const hasImage = computed(() => {
|
||||
if (pendingClearImage.value) return !!pendingImageFile.value
|
||||
return !!pendingImageFile.value || !!props.item.imageFileId
|
||||
})
|
||||
|
||||
const previewImageUrl = computed(() => {
|
||||
if (pendingImageObjectUrl.value) return pendingImageObjectUrl.value
|
||||
if (pendingClearImage.value) return null
|
||||
return serverThumbUrl.value
|
||||
})
|
||||
|
||||
function revokeObjectUrl() {
|
||||
if (pendingImageObjectUrl.value) {
|
||||
URL.revokeObjectURL(pendingImageObjectUrl.value)
|
||||
pendingImageObjectUrl.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function resetImageState() {
|
||||
revokeObjectUrl()
|
||||
pendingImageFile.value = null
|
||||
pendingClearImage.value = false
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(v) => {
|
||||
@@ -161,22 +175,31 @@ watch(
|
||||
editCategoryId.value = props.item.categoryId ?? null
|
||||
editRrule.value = props.item.rrule ?? null
|
||||
editRepeatFromCompletion.value = props.item.repeatFromCompletion ?? false
|
||||
resetImageState()
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onBeforeUnmount(revokeObjectUrl)
|
||||
|
||||
function submitEdit() {
|
||||
const name = editName.value.trim()
|
||||
if (!name) return
|
||||
emit('save', props.item.id, {
|
||||
name,
|
||||
description: editDescription.value.trim() || null,
|
||||
quantity: editQuantity.value.trim() || null,
|
||||
categoryId: editCategoryId.value,
|
||||
rrule: editRrule.value,
|
||||
repeatFromCompletion: editRepeatFromCompletion.value,
|
||||
})
|
||||
emit(
|
||||
'save',
|
||||
props.item.id,
|
||||
{
|
||||
name,
|
||||
description: editDescription.value.trim() || null,
|
||||
quantity: editQuantity.value.trim() || null,
|
||||
categoryId: editCategoryId.value,
|
||||
rrule: editRrule.value,
|
||||
repeatFromCompletion: editRepeatFromCompletion.value,
|
||||
},
|
||||
pendingImageFile.value,
|
||||
pendingClearImage.value,
|
||||
)
|
||||
}
|
||||
|
||||
function triggerImagePick() {
|
||||
@@ -187,10 +210,19 @@ function onImagePicked(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
if (!file) return
|
||||
emit('upload-image', props.item.id, file)
|
||||
revokeObjectUrl()
|
||||
pendingImageFile.value = file
|
||||
pendingImageObjectUrl.value = URL.createObjectURL(file)
|
||||
pendingClearImage.value = false
|
||||
input.value = ''
|
||||
}
|
||||
|
||||
function clearPendingImage() {
|
||||
revokeObjectUrl()
|
||||
pendingImageFile.value = null
|
||||
pendingClearImage.value = true
|
||||
}
|
||||
|
||||
const strings = {
|
||||
title: t('pantry', 'Edit item'),
|
||||
save: t('pantry', 'Save'),
|
||||
|
||||
@@ -53,11 +53,8 @@
|
||||
:item="editing"
|
||||
:house-id="houseIdNum"
|
||||
:saving="savingEdit"
|
||||
:uploading-image="uploadingImage"
|
||||
@update:open="(v) => !v && (editing = null)"
|
||||
@save="handleSaveEdit"
|
||||
@upload-image="handleUploadImage"
|
||||
@clear-image="handleClearImage"
|
||||
/>
|
||||
|
||||
<ChecklistItemViewDialog
|
||||
@@ -169,44 +166,31 @@ async function handleRemove(itemId: number) {
|
||||
|
||||
const editing = ref<ChecklistItem | null>(null)
|
||||
const savingEdit = ref(false)
|
||||
const uploadingImage = ref(false)
|
||||
|
||||
function startEdit(item: ChecklistItem) {
|
||||
editing.value = item
|
||||
}
|
||||
|
||||
async function handleSaveEdit(itemId: number, patch: Partial<ItemInput>) {
|
||||
async function handleSaveEdit(
|
||||
itemId: number,
|
||||
patch: Partial<ItemInput>,
|
||||
pendingImage: File | null,
|
||||
shouldClearImage: boolean,
|
||||
) {
|
||||
savingEdit.value = true
|
||||
try {
|
||||
await update(itemId, patch)
|
||||
if (pendingImage) {
|
||||
await uploadImage(itemId, pendingImage)
|
||||
} else if (shouldClearImage) {
|
||||
await clearImage(itemId)
|
||||
}
|
||||
editing.value = null
|
||||
} finally {
|
||||
savingEdit.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUploadImage(itemId: number, file: File) {
|
||||
uploadingImage.value = true
|
||||
try {
|
||||
await uploadImage(itemId, file)
|
||||
const refreshed = items.value.find((i) => i.id === editing.value?.id)
|
||||
if (refreshed) editing.value = refreshed
|
||||
} finally {
|
||||
uploadingImage.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClearImage(itemId: number) {
|
||||
uploadingImage.value = true
|
||||
try {
|
||||
await clearImage(itemId)
|
||||
const refreshed = items.value.find((i) => i.id === editing.value?.id)
|
||||
if (refreshed) editing.value = refreshed
|
||||
} finally {
|
||||
uploadingImage.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ----- View / Preview -----
|
||||
|
||||
const viewing = ref<ChecklistItem | null>(null)
|
||||
|
||||
Reference in New Issue
Block a user