fix: upload list item image only after save

This commit is contained in:
2026-04-08 13:15:33 +03:00
parent d1905fb66c
commit 978a8ff4bb
3 changed files with 102 additions and 75 deletions

View File

@@ -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', () => {

View File

@@ -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'),

View File

@@ -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)