refactor: extract checklist components

This commit is contained in:
2026-04-08 13:07:38 +03:00
parent 8ae50c9743
commit d1905fb66c
17 changed files with 1824 additions and 697 deletions

View File

@@ -0,0 +1,182 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { createIconMock, nextcloudL10nMock } from '@/test-utils'
vi.mock('@nextcloud/l10n', () => nextcloudL10nMock)
vi.mock('@icons/Plus.vue', () => createIconMock('PlusIcon'))
vi.mock('@icons/Repeat.vue', () => createIconMock('RepeatIcon'))
vi.mock('@icons/ChevronDown.vue', () => createIconMock('ChevronDownIcon'))
vi.mock('@nextcloud/vue/components/NcButton', () => ({
default: {
name: 'NcButton',
template:
'<button class="nc-button" :type="type" :disabled="disabled" :aria-label="ariaLabel"><slot name="icon" /><slot /></button>',
props: ['variant', 'type', 'disabled', 'ariaLabel'],
},
}))
vi.mock('@nextcloud/vue/components/NcTextField', () => ({
default: {
name: 'NcTextField',
template:
'<input class="nc-text-field" :value="modelValue" :placeholder="placeholder" @input="$emit(\'update:modelValue\', $event.target.value)" />',
props: ['modelValue', 'label', 'placeholder', 'autocomplete'],
emits: ['update:modelValue'],
},
}))
vi.mock('@/components/AutoResizeTextarea', () => ({
AutoResizeTextarea: {
name: 'AutoResizeTextarea',
template:
'<textarea class="nc-text-area" :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />',
props: ['modelValue', 'label', 'placeholder', 'maxHeight', 'rows'],
emits: ['update:modelValue'],
methods: {
getTextareaEl() {
return this.$el?.tagName === 'TEXTAREA' ? this.$el : this.$el?.querySelector('textarea')
},
resize() {},
},
},
}))
vi.mock('@/components/RecurrenceEditor', () => ({
default: {
name: 'RecurrenceEditor',
template: '<div class="mock-recurrence-editor" />',
props: ['modelValue', 'open', 'fromCompletion'],
emits: ['update:modelValue', 'update:open', 'update:fromCompletion'],
},
}))
vi.mock('@/components/CategoryPicker', () => ({
default: {
name: 'CategoryPicker',
template: '<div class="mock-category-picker" />',
props: ['modelValue', 'houseId', 'placeholder'],
emits: ['update:modelValue'],
},
}))
import ChecklistAddForm from './ChecklistAddForm.vue'
function mountForm(props: { houseId?: number; adding?: boolean } = {}) {
return mount(ChecklistAddForm, {
props: {
houseId: props.houseId ?? 1,
adding: props.adding ?? false,
},
})
}
describe('ChecklistAddForm', () => {
it('renders the form with all fields', () => {
const wrapper = mountForm()
const textFields = wrapper.findAll('.nc-text-field')
expect(textFields).toHaveLength(2)
expect(wrapper.find('.mock-category-picker').exists()).toBe(true)
expect(wrapper.find('.mock-recurrence-editor').exists()).toBe(true)
// Submit button exists
const submitBtn = wrapper.findAll('.nc-button').find((b) => b.attributes('type') === 'submit')
expect(submitBtn).toBeTruthy()
})
it('submit button is disabled when name is empty', () => {
const wrapper = mountForm()
const submitBtn = wrapper.findAll('.nc-button').find((b) => b.attributes('type') === 'submit')!
expect(submitBtn.attributes('disabled')).toBeDefined()
})
it('submit button is disabled when adding prop is true', async () => {
const wrapper = mountForm({ adding: true })
// Set a name so the only reason for disabled is the adding prop
const nameInput = wrapper.findAll('.nc-text-field').at(0)!
await nameInput.setValue('Milk')
const submitBtn = wrapper.findAll('.nc-button').find((b) => b.attributes('type') === 'submit')!
expect(submitBtn.attributes('disabled')).toBeDefined()
})
it('emits add event with correct ItemInput on submit', async () => {
const wrapper = mountForm()
const textFields = wrapper.findAll('.nc-text-field')
await textFields.at(0)!.setValue('Milk')
await textFields.at(1)!.setValue('2 L')
await wrapper.find('form').trigger('submit')
expect(wrapper.emitted('add')).toBeTruthy()
const payload = wrapper.emitted('add')![0][0]
expect(payload).toEqual({
name: 'Milk',
description: null,
quantity: '2 L',
categoryId: null,
rrule: null,
repeatFromCompletion: false,
})
})
it('resets all fields after submit', async () => {
const wrapper = mountForm()
const textFields = wrapper.findAll('.nc-text-field')
await textFields.at(0)!.setValue('Milk')
await textFields.at(1)!.setValue('2 L')
await wrapper.find('form').trigger('submit')
const textFieldsAfter = wrapper.findAll('.nc-text-field')
expect((textFieldsAfter.at(0)!.element as HTMLInputElement).value).toBe('')
expect((textFieldsAfter.at(1)!.element as HTMLInputElement).value).toBe('')
})
it('description field is hidden by default', () => {
const wrapper = mountForm()
expect(wrapper.find('.nc-text-area').exists()).toBe(false)
})
it('clicking chevron toggles description visibility', async () => {
const wrapper = mountForm()
const chevronBtn = wrapper
.findAll('.nc-button')
.find((b) => b.find('.mock-chevron-down-icon').exists())!
expect(wrapper.find('.nc-text-area').exists()).toBe(false)
await chevronBtn.trigger('click')
expect(wrapper.find('.nc-text-area').exists()).toBe(true)
await chevronBtn.trigger('click')
expect(wrapper.find('.nc-text-area').exists()).toBe(false)
})
it('description is included in the emitted add event when provided', async () => {
const wrapper = mountForm()
// Set name
await wrapper.findAll('.nc-text-field').at(0)!.setValue('Milk')
// Open description and fill it
const chevronBtn = wrapper
.findAll('.nc-button')
.find((b) => b.find('.mock-chevron-down-icon').exists())!
await chevronBtn.trigger('click')
await wrapper.find('.nc-text-area').setValue('Whole milk preferred')
await wrapper.find('form').trigger('submit')
const payload = wrapper.emitted('add')![0][0]
expect(payload.description).toBe('Whole milk preferred')
})
it('description area collapses after submit', async () => {
const wrapper = mountForm()
await wrapper.findAll('.nc-text-field').at(0)!.setValue('Milk')
// Open description
const chevronBtn = wrapper
.findAll('.nc-button')
.find((b) => b.find('.mock-chevron-down-icon').exists())!
await chevronBtn.trigger('click')
expect(wrapper.find('.nc-text-area').exists()).toBe(true)
await wrapper.find('form').trigger('submit')
expect(wrapper.find('.nc-text-area').exists()).toBe(false)
})
})

View File

@@ -0,0 +1,156 @@
<template>
<form class="checklist-add" autocomplete="off" @submit.prevent="submitAdd">
<NcTextField
v-model="name"
:label="strings.nameLabel"
:placeholder="strings.namePlaceholder"
autocomplete="off"
/>
<NcTextField
v-model="quantity"
:label="strings.quantityLabel"
:placeholder="strings.quantityPlaceholder"
autocomplete="off"
/>
<CategoryPicker
v-model="categoryId"
:house-id="houseId"
:placeholder="strings.categoryPlaceholder"
/>
<NcButton variant="tertiary" @click="showRecurrenceEditor = true">
<template #icon>
<RepeatIcon :size="20" />
</template>
{{ rrule ? strings.recurrenceSet : strings.recurrenceButton }}
</NcButton>
<NcButton
variant="tertiary"
:aria-label="strings.descriptionToggle"
@click="showDescription = !showDescription"
>
<template #icon>
<ChevronDownIcon
:size="20"
class="checklist-add__chevron"
:class="{ 'checklist-add__chevron--open': showDescription }"
/>
</template>
</NcButton>
<NcButton type="submit" variant="primary" :disabled="!name.trim() || adding">
<template #icon>
<PlusIcon :size="20" />
</template>
{{ strings.add }}
</NcButton>
<AutoResizeTextarea
v-if="showDescription"
v-model="description"
:label="strings.descriptionLabel"
:placeholder="strings.descriptionPlaceholder"
class="checklist-add__description"
autocomplete="off"
/>
<RecurrenceEditor
v-model:open="showRecurrenceEditor"
v-model="rrule"
v-model:from-completion="repeatFromCompletion"
/>
</form>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { t } from '@nextcloud/l10n'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcTextField from '@nextcloud/vue/components/NcTextField'
import PlusIcon from '@icons/Plus.vue'
import RepeatIcon from '@icons/Repeat.vue'
import ChevronDownIcon from '@icons/ChevronDown.vue'
import { AutoResizeTextarea } from '@/components/AutoResizeTextarea'
import RecurrenceEditor from '@/components/RecurrenceEditor'
import CategoryPicker from '@/components/CategoryPicker'
import type { ItemInput } from '@/api/lists'
defineProps<{
houseId: number
adding: boolean
}>()
const emit = defineEmits<{
add: [input: ItemInput]
}>()
const name = ref('')
const description = ref('')
const quantity = ref('')
const categoryId = ref<number | null>(null)
const rrule = ref<string | null>(null)
const repeatFromCompletion = ref(false)
const showDescription = ref(false)
const showRecurrenceEditor = ref(false)
function submitAdd() {
const trimmedName = name.value.trim()
if (!trimmedName) return
emit('add', {
name: trimmedName,
description: description.value.trim() || null,
quantity: quantity.value.trim() || null,
categoryId: categoryId.value,
rrule: rrule.value,
repeatFromCompletion: repeatFromCompletion.value,
})
name.value = ''
description.value = ''
quantity.value = ''
categoryId.value = null
rrule.value = null
repeatFromCompletion.value = false
showDescription.value = false
}
const strings = {
add: t('pantry', 'Add'),
nameLabel: t('pantry', 'Item name'),
namePlaceholder: t('pantry', 'e.g. Milk'),
quantityLabel: t('pantry', 'Quantity'),
quantityPlaceholder: t('pantry', 'e.g. 2 L'),
categoryPlaceholder: t('pantry', 'Category'),
recurrenceButton: t('pantry', 'Repeat …'),
recurrenceSet: t('pantry', 'Repeat: set'),
descriptionLabel: t('pantry', 'Description'),
descriptionPlaceholder: t('pantry', 'Add a description …'),
descriptionToggle: t('pantry', 'Toggle description'),
}
</script>
<style scoped lang="scss">
.checklist-add {
display: grid;
grid-template-columns: 2fr 1fr 1fr auto auto auto;
gap: 0.75rem;
align-items: end;
margin-bottom: 1.5rem;
:deep(.v-select.select) {
margin-bottom: 0;
}
@media (max-width: 900px) {
grid-template-columns: 1fr 1fr;
}
&__description {
grid-column: 1 / -1;
}
&__chevron {
transition: transform 0.2s ease;
&--open {
transform: rotate(180deg);
}
}
}
</style>

View File

@@ -0,0 +1 @@
export { default as ChecklistAddForm } from './ChecklistAddForm.vue'

View File

@@ -0,0 +1,83 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { nextcloudL10nMock } from '@/test-utils'
import type { ChecklistItem } from '@/api/types'
vi.mock('@nextcloud/l10n', () => nextcloudL10nMock)
vi.mock('@nextcloud/vue/components/NcDialog', () => ({
default: {
name: 'NcDialog',
template: '<div class="nc-dialog"><slot /><slot name="actions" /></div>',
props: ['name', 'open', 'size'],
},
}))
vi.mock('@/api/images', () => ({
itemImagePreviewUrl: (houseId: number, fileId: number, owner: string, size: number) =>
`/preview/${houseId}/${fileId}/${owner}/${size}`,
}))
import ChecklistImagePreview from './ChecklistImagePreview.vue'
function makeItem(overrides: Partial<ChecklistItem> = {}): ChecklistItem {
return {
id: 42,
listId: 1,
name: 'Milk',
description: null,
categoryId: null,
quantity: null,
done: false,
doneAt: null,
doneBy: null,
rrule: null,
repeatFromCompletion: false,
nextDueAt: null,
imageFileId: 77,
imageUploadedBy: 'admin',
sortOrder: 0,
createdAt: 0,
updatedAt: 0,
...overrides,
}
}
const defaultProps = {
open: true,
item: makeItem(),
houseId: 10,
}
describe('ChecklistImagePreview', () => {
it('renders image when item has imageFileId', () => {
const wrapper = mount(ChecklistImagePreview, { props: defaultProps })
const img = wrapper.find('.image-preview img')
expect(img.exists()).toBe(true)
expect(img.attributes('src')).toBe('/preview/10/77/admin/1600')
expect(img.attributes('alt')).toBe('Milk')
})
it('passes item name as dialog name', () => {
const wrapper = mount(ChecklistImagePreview, {
props: { ...defaultProps, item: makeItem({ name: 'Eggs' }) },
})
const dialog = wrapper.findComponent({ name: 'NcDialog' })
expect(dialog.props('name')).toBe('Eggs')
})
it("emits 'update:open' false on dialog close", async () => {
const wrapper = mount(ChecklistImagePreview, { props: defaultProps })
wrapper.findComponent({ name: 'NcDialog' }).vm.$emit('update:open', false)
await wrapper.vm.$nextTick()
expect(wrapper.emitted('update:open')).toBeTruthy()
expect(wrapper.emitted('update:open')![0]).toEqual([false])
})
it('uses large size image URL', () => {
const wrapper = mount(ChecklistImagePreview, { props: defaultProps })
const img = wrapper.find('.image-preview img')
// The URL should contain size 1600 (large)
expect(img.attributes('src')).toContain('/1600')
})
})

View File

@@ -0,0 +1,52 @@
<template>
<NcDialog
:name="item.name"
:open="open"
close-on-click-outside
size="large"
@update:open="(v) => !v && $emit('update:open', false)"
>
<div class="image-preview">
<img v-if="item.imageFileId" :src="largeUrl" :alt="item.name" />
</div>
</NcDialog>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import NcDialog from '@nextcloud/vue/components/NcDialog'
import { itemImagePreviewUrl } from '@/api/images'
import type { ChecklistItem } from '@/api/types'
const props = defineProps<{
open: boolean
item: ChecklistItem
houseId: number
}>()
defineEmits<{
'update:open': [value: boolean]
}>()
const largeUrl = computed(() =>
props.item.imageFileId
? itemImagePreviewUrl(props.houseId, props.item.imageFileId!, props.item.imageUploadedBy!, 1600)
: '',
)
</script>
<style scoped lang="scss">
.image-preview {
display: flex;
justify-content: center;
align-items: center;
padding: 0.5rem;
img {
max-width: 100%;
max-height: 80vh;
object-fit: contain;
border-radius: var(--border-radius, 8px);
}
}
</style>

View File

@@ -0,0 +1 @@
export { default as ChecklistImagePreview } from './ChecklistImagePreview.vue'

View File

@@ -0,0 +1,187 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { createIconMock, nextcloudL10nMock } from '@/test-utils'
import type { ChecklistItem } from '@/api/types'
vi.mock('@nextcloud/l10n', () => nextcloudL10nMock)
vi.mock('@icons/Repeat.vue', () => createIconMock('RepeatIcon'))
vi.mock('@icons/Upload.vue', () => createIconMock('UploadIcon'))
vi.mock('@icons/Delete.vue', () => createIconMock('DeleteIcon'))
vi.mock('@nextcloud/vue/components/NcDialog', () => ({
default: {
name: 'NcDialog',
template: '<div class="nc-dialog"><slot /><slot name="actions" /></div>',
props: ['name', 'open', 'size'],
},
}))
vi.mock('@nextcloud/vue/components/NcButton', () => ({
default: {
name: 'NcButton',
template:
'<button class="nc-button" :disabled="disabled" :type="type"><slot name="icon" /><slot /></button>',
props: ['variant', 'form', 'type', 'disabled'],
},
}))
vi.mock('@nextcloud/vue/components/NcTextField', () => ({
default: {
name: 'NcTextField',
template:
'<input class="nc-text-field" :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />',
props: ['modelValue', 'label', 'placeholder', 'autocomplete'],
emits: ['update:modelValue'],
},
}))
vi.mock('@/components/AutoResizeTextarea', () => ({
AutoResizeTextarea: {
name: 'AutoResizeTextarea',
template:
'<textarea class="nc-text-area" :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />',
props: ['modelValue', 'label', 'placeholder', 'autocomplete'],
emits: ['update:modelValue'],
methods: {
getTextareaEl() {
return this.$el?.tagName === 'TEXTAREA' ? this.$el : this.$el?.querySelector('textarea')
},
resize() {},
},
},
}))
vi.mock('@/components/RecurrenceEditor', () => ({
default: {
name: 'RecurrenceEditor',
template: '<div class="mock-recurrence-editor" />',
props: ['open', 'modelValue', 'fromCompletion'],
},
}))
vi.mock('@/components/CategoryPicker', () => ({
default: {
name: 'CategoryPicker',
template: '<div class="mock-category-picker" />',
props: ['modelValue', 'houseId', 'label', 'placeholder'],
},
categoryIconComponent: { name: 'CategoryIcon', template: '<span />' },
}))
vi.mock('@/api/images', () => ({
itemImagePreviewUrl: (houseId: number, fileId: number, owner: string, size: number) =>
`/preview/${houseId}/${fileId}/${owner}/${size}`,
}))
import ChecklistItemEditDialog from './ChecklistItemEditDialog.vue'
function makeItem(overrides: Partial<ChecklistItem> = {}): ChecklistItem {
return {
id: 42,
listId: 1,
name: 'Milk',
description: 'Whole milk',
categoryId: 3,
quantity: '2 L',
done: false,
doneAt: null,
doneBy: null,
rrule: null,
repeatFromCompletion: false,
nextDueAt: null,
imageFileId: null,
imageUploadedBy: null,
sortOrder: 0,
createdAt: 0,
updatedAt: 0,
...overrides,
}
}
const defaultProps = {
open: true,
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')
})
it('save button is disabled when name is empty', async () => {
const wrapper = mount(ChecklistItemEditDialog, {
props: { ...defaultProps, item: makeItem({ name: '' }) },
})
const buttons = wrapper.findAll('.nc-button')
const saveButton = buttons.find((b) => b.text() === 'Save')!
expect(saveButton.attributes('disabled')).toBeDefined()
})
it('save button is disabled when saving prop is true', () => {
const wrapper = mount(ChecklistItemEditDialog, {
props: { ...defaultProps, saving: true },
})
const buttons = wrapper.findAll('.nc-button')
const saveButton = buttons.find((b) => b.text() === 'Save')!
expect(saveButton.attributes('disabled')).toBeDefined()
})
it("emits 'save' with itemId and patch 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>]
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)
})
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')!
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', () => {
const wrapper = mount(ChecklistItemEditDialog, {
props: {
...defaultProps,
item: makeItem({ imageFileId: 99, imageUploadedBy: 'admin' }),
},
})
const img = wrapper.find('.edit-item-form__image-preview')
expect(img.exists()).toBe(true)
expect(img.attributes('src')).toBe('/preview/10/99/admin/96')
})
it('does not show image preview when no imageFileId', () => {
const wrapper = mount(ChecklistItemEditDialog, { props: defaultProps })
expect(wrapper.find('.edit-item-form__image-preview').exists()).toBe(false)
})
it("emits 'clear-image' when remove image clicked", async () => {
const wrapper = mount(ChecklistItemEditDialog, {
props: {
...defaultProps,
item: makeItem({ imageFileId: 99, imageUploadedBy: 'admin' }),
},
})
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])
})
it('shows description field', () => {
const wrapper = mount(ChecklistItemEditDialog, { props: defaultProps })
expect(wrapper.find('.nc-text-area').exists()).toBe(true)
})
})

View File

@@ -0,0 +1,252 @@
<template>
<NcDialog
:name="strings.title"
:open="open"
close-on-click-outside
@update:open="(v) => !v && $emit('update:open', false)"
>
<form
id="pantry-edit-item-form"
class="edit-item-form"
autocomplete="off"
@submit.prevent="submitEdit"
>
<NcTextField
v-model="editName"
:label="strings.nameLabel"
:placeholder="strings.namePlaceholder"
autocomplete="off"
/>
<AutoResizeTextarea
v-model="editDescription"
:label="strings.descriptionLabel"
:placeholder="strings.descriptionPlaceholder"
autocomplete="off"
/>
<NcTextField
v-model="editQuantity"
:label="strings.quantityLabel"
:placeholder="strings.quantityPlaceholder"
autocomplete="off"
/>
<CategoryPicker
v-model="editCategoryId"
:house-id="houseId"
:label="strings.categoryLabel"
:placeholder="strings.categoryPlaceholder"
/>
<NcButton variant="tertiary" type="button" @click="showRecurrenceEditor = true">
<template #icon>
<RepeatIcon :size="20" />
</template>
{{ editRrule ? strings.recurrenceSet : strings.recurrenceButton }}
</NcButton>
<div class="edit-item-form__image">
<span class="edit-item-form__label">{{ strings.imageLabel }}</span>
<div class="edit-item-form__image-row">
<img
v-if="item.imageFileId"
class="edit-item-form__image-preview"
:src="thumbUrl"
:alt="item.name"
/>
<NcButton
variant="tertiary"
type="button"
:disabled="uploadingImage"
@click="triggerImagePick"
>
<template #icon>
<UploadIcon :size="20" />
</template>
{{ item.imageFileId ? strings.replaceImage : strings.uploadImage }}
</NcButton>
<NcButton
v-if="item.imageFileId"
variant="tertiary"
type="button"
:disabled="uploadingImage"
@click="$emit('clear-image', item.id)"
>
<template #icon>
<DeleteIcon :size="20" />
</template>
{{ strings.removeImage }}
</NcButton>
<input
ref="imageInputRef"
type="file"
accept="image/*"
class="edit-item-form__image-input"
@change="onImagePicked"
/>
</div>
</div>
</form>
<RecurrenceEditor
v-model:open="showRecurrenceEditor"
v-model="editRrule"
v-model:from-completion="editRepeatFromCompletion"
/>
<template #actions>
<NcButton @click="$emit('update:open', false)">{{ strings.cancel }}</NcButton>
<NcButton
form="pantry-edit-item-form"
type="submit"
variant="primary"
:disabled="!editName.trim() || saving"
>
{{ strings.save }}
</NcButton>
</template>
</NcDialog>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { t } from '@nextcloud/l10n'
import NcDialog from '@nextcloud/vue/components/NcDialog'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcTextField from '@nextcloud/vue/components/NcTextField'
import RepeatIcon from '@icons/Repeat.vue'
import UploadIcon from '@icons/Upload.vue'
import DeleteIcon from '@icons/Delete.vue'
import { AutoResizeTextarea } from '@/components/AutoResizeTextarea'
import RecurrenceEditor from '@/components/RecurrenceEditor'
import CategoryPicker from '@/components/CategoryPicker'
import { itemImagePreviewUrl } from '@/api/images'
import type { ChecklistItem } from '@/api/types'
import type { ItemInput } from '@/api/lists'
const props = defineProps<{
open: boolean
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]
}>()
const editName = ref('')
const editDescription = ref('')
const editQuantity = ref('')
const editCategoryId = ref<number | null>(null)
const editRrule = ref<string | null>(null)
const editRepeatFromCompletion = ref(false)
const showRecurrenceEditor = ref(false)
const imageInputRef = ref<HTMLInputElement | null>(null)
const thumbUrl = computed(() =>
props.item.imageFileId
? itemImagePreviewUrl(props.houseId, props.item.imageFileId!, props.item.imageUploadedBy!, 96)
: '',
)
watch(
() => props.open,
(v) => {
if (v) {
editName.value = props.item.name
editDescription.value = props.item.description ?? ''
editQuantity.value = props.item.quantity ?? ''
editCategoryId.value = props.item.categoryId ?? null
editRrule.value = props.item.rrule ?? null
editRepeatFromCompletion.value = props.item.repeatFromCompletion ?? false
}
},
{ immediate: true },
)
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,
})
}
function triggerImagePick() {
imageInputRef.value?.click()
}
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)
input.value = ''
}
const strings = {
title: t('pantry', 'Edit item'),
save: t('pantry', 'Save'),
cancel: t('pantry', 'Cancel'),
nameLabel: t('pantry', 'Item name'),
namePlaceholder: t('pantry', 'e.g. Milk'),
descriptionLabel: t('pantry', 'Description'),
descriptionPlaceholder: t('pantry', 'Add a description …'),
quantityLabel: t('pantry', 'Quantity'),
quantityPlaceholder: t('pantry', 'e.g. 2 L'),
categoryLabel: t('pantry', 'Category'),
categoryPlaceholder: t('pantry', 'Category'),
recurrenceButton: t('pantry', 'Repeat …'),
recurrenceSet: t('pantry', 'Repeat: set'),
imageLabel: t('pantry', 'Image'),
uploadImage: t('pantry', 'Upload image'),
replaceImage: t('pantry', 'Replace image'),
removeImage: t('pantry', 'Remove image'),
}
</script>
<style scoped lang="scss">
.edit-item-form {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 0.5rem 0;
&__image {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
&__label {
font-size: 0.85rem;
color: var(--color-text-maxcontrast);
}
&__image-row {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
&__image-preview {
width: 72px;
height: 72px;
object-fit: cover;
border-radius: var(--border-radius, 6px);
border: 1px solid var(--color-border);
}
&__image-input {
display: none;
}
}
</style>

View File

@@ -0,0 +1 @@
export { default as ChecklistItemEditDialog } from './ChecklistItemEditDialog.vue'

View File

@@ -0,0 +1,217 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { createIconMock, nextcloudL10nMock } from '@/test-utils'
import type { ChecklistItem, Category } from '@/api/types'
vi.mock('@nextcloud/l10n', () => nextcloudL10nMock)
vi.mock('@icons/Repeat.vue', () => createIconMock('RepeatIcon'))
vi.mock('@icons/Pencil.vue', () => createIconMock('PencilIcon'))
vi.mock('@icons/Eye.vue', () => createIconMock('EyeIcon'))
vi.mock('@icons/Delete.vue', () => createIconMock('DeleteIcon'))
vi.mock('@nextcloud/vue/components/NcButton', () => ({
default: {
name: 'NcButton',
template:
'<button class="nc-button" :aria-label="ariaLabel" @click="$emit(\'click\')"><slot name="icon" /><slot /></button>',
props: ['variant', 'ariaLabel'],
},
}))
vi.mock('@nextcloud/vue/components/NcCheckboxRadioSwitch', () => ({
default: {
name: 'NcCheckboxRadioSwitch',
template:
'<label class="nc-checkbox"><input type="checkbox" :checked="modelValue" @change="$emit(\'update:modelValue\', !modelValue)" /><slot /></label>',
props: ['modelValue'],
},
}))
vi.mock('@nextcloud/vue/components/NcActions', () => ({
default: {
name: 'NcActions',
template: '<div class="nc-actions"><slot /></div>',
props: ['ariaLabel'],
},
}))
vi.mock('@nextcloud/vue/components/NcActionButton', () => ({
default: {
name: 'NcActionButton',
template:
'<button class="nc-action-button" @click="$emit(\'click\')"><slot name="icon" /><slot /></button>',
},
}))
vi.mock('@/components/CategoryPicker', () => ({
categoryIconComponent: () => ({
name: 'MockCategoryIcon',
template: '<span class="mock-category-icon" />',
props: ['size'],
}),
}))
vi.mock('@/api/images', () => ({
itemImagePreviewUrl: (houseId: number, fileId: number, uploadedBy: string, size: number) =>
`/mock/preview/${houseId}/${fileId}/${uploadedBy}/${size}`,
}))
vi.mock('@/utils/rrule', () => ({
formatRrule: (rrule: string) => rrule,
}))
import ChecklistItemRow from './ChecklistItemRow.vue'
function makeItem(overrides: Partial<ChecklistItem> = {}): ChecklistItem {
return {
id: 1,
listId: 10,
name: 'Milk',
description: null,
categoryId: null,
quantity: null,
done: false,
doneAt: null,
doneBy: null,
rrule: null,
repeatFromCompletion: false,
nextDueAt: null,
imageFileId: null,
imageUploadedBy: null,
sortOrder: 0,
createdAt: 0,
updatedAt: 0,
...overrides,
}
}
function makeCategory(overrides: Partial<Category> = {}): Category {
return {
id: 1,
houseId: 1,
name: 'Dairy',
icon: 'cow',
color: '#3366ff',
sortOrder: 0,
createdAt: 0,
updatedAt: 0,
...overrides,
}
}
const defaultProps = {
item: makeItem(),
category: null as Category | null,
houseId: 1,
}
describe('ChecklistItemRow', () => {
describe('rendering', () => {
it('renders item name', () => {
const wrapper = mount(ChecklistItemRow, {
props: { ...defaultProps, item: makeItem({ name: 'Eggs' }) },
})
expect(wrapper.find('.checklist-row__name').text()).toBe('Eggs')
})
it('shows done styling when item.done is true', () => {
const wrapper = mount(ChecklistItemRow, {
props: { ...defaultProps, item: makeItem({ done: true, doneAt: 1000, doneBy: 'admin' }) },
})
expect(wrapper.find('.checklist-row').classes()).toContain('checklist-row--done')
})
it('shows quantity badge when present', () => {
const wrapper = mount(ChecklistItemRow, {
props: { ...defaultProps, item: makeItem({ quantity: '3' }) },
})
const qty = wrapper.find('.checklist-row__quantity')
expect(qty.exists()).toBe(true)
expect(qty.text()).toContain('3')
})
it('shows category badge with color when category is provided', () => {
const category = makeCategory({ name: 'Dairy', color: '#ff0000' })
const wrapper = mount(ChecklistItemRow, {
props: { ...defaultProps, item: makeItem({ categoryId: category.id }), category },
})
const cat = wrapper.find('.checklist-row__category')
expect(cat.exists()).toBe(true)
expect(cat.text()).toContain('Dairy')
expect(cat.attributes('style')).toContain('color: #ff0000')
})
it('shows recurrence badge when rrule is present', () => {
const wrapper = mount(ChecklistItemRow, {
props: { ...defaultProps, item: makeItem({ rrule: 'FREQ=WEEKLY' }) },
})
const rec = wrapper.find('.checklist-row__recurrence')
expect(rec.exists()).toBe(true)
expect(rec.text()).toContain('FREQ=WEEKLY')
})
it('shows image thumbnail when imageFileId is present', () => {
const wrapper = mount(ChecklistItemRow, {
props: {
...defaultProps,
item: makeItem({ imageFileId: 42, imageUploadedBy: 'admin' }),
},
})
const thumb = wrapper.find('.checklist-row__thumb')
expect(thumb.exists()).toBe(true)
expect(thumb.find('img').attributes('src')).toBe('/mock/preview/1/42/admin/64')
})
it('does not show thumbnail when no imageFileId', () => {
const wrapper = mount(ChecklistItemRow, { props: defaultProps })
expect(wrapper.find('.checklist-row__thumb').exists()).toBe(false)
})
})
describe('events', () => {
it('emits toggle with item id on checkbox change', async () => {
const item = makeItem({ id: 5 })
const wrapper = mount(ChecklistItemRow, { props: { ...defaultProps, item } })
await wrapper.find('input[type="checkbox"]').trigger('change')
expect(wrapper.emitted('toggle')).toBeTruthy()
expect(wrapper.emitted('toggle')![0]).toEqual([5])
})
it('emits view with item on view button click', async () => {
const item = makeItem()
const wrapper = mount(ChecklistItemRow, { props: { ...defaultProps, item } })
const viewBtn = wrapper
.findAll('.nc-button')
.find((b) => b.attributes('aria-label') === 'View item')!
await viewBtn.trigger('click')
expect(wrapper.emitted('view')).toBeTruthy()
expect(wrapper.emitted('view')![0]).toEqual([item])
})
it('emits edit with item on edit action click', async () => {
const item = makeItem()
const wrapper = mount(ChecklistItemRow, { props: { ...defaultProps, item } })
const editBtn = wrapper.findAll('.nc-action-button').find((b) => b.text() === 'Edit item')!
await editBtn.trigger('click')
expect(wrapper.emitted('edit')).toBeTruthy()
expect(wrapper.emitted('edit')![0]).toEqual([item])
})
it('emits remove with item id on remove action click', async () => {
const item = makeItem({ id: 9 })
const wrapper = mount(ChecklistItemRow, { props: { ...defaultProps, item } })
const removeBtn = wrapper
.findAll('.nc-action-button')
.find((b) => b.text() === 'Remove item')!
await removeBtn.trigger('click')
expect(wrapper.emitted('remove')).toBeTruthy()
expect(wrapper.emitted('remove')![0]).toEqual([9])
})
it('emits preview with item on thumbnail click', async () => {
const item = makeItem({ imageFileId: 42, imageUploadedBy: 'admin' })
const wrapper = mount(ChecklistItemRow, { props: { ...defaultProps, item } })
await wrapper.find('.checklist-row__thumb').trigger('click')
expect(wrapper.emitted('preview')).toBeTruthy()
expect(wrapper.emitted('preview')![0]).toEqual([item])
})
})
})

View File

@@ -0,0 +1,204 @@
<template>
<li class="checklist-row" :class="{ 'checklist-row--done': item.done }">
<NcCheckboxRadioSwitch :model-value="item.done" @update:model-value="$emit('toggle', item.id)">
<span class="checklist-row__label">
<button
v-if="item.imageFileId"
type="button"
class="checklist-row__thumb"
:aria-label="strings.viewImage"
@click.stop.prevent="$emit('preview', item)"
>
<img :src="thumbUrl" :alt="item.name" />
</button>
<span class="checklist-row__name">{{ item.name }}</span>
</span>
</NcCheckboxRadioSwitch>
<div class="checklist-row__meta">
<span v-if="item.quantity" class="checklist-row__quantity">&times; {{ item.quantity }}</span>
<span v-if="category" class="checklist-row__category" :style="{ color: category.color }">
<component :is="categoryIconComponent(category.icon)" :size="14" />
{{ category.name }}
</span>
<span v-if="item.rrule" class="checklist-row__recurrence" :title="item.rrule">
<RepeatIcon :size="14" />
{{ formatRrule(item.rrule) }}
</span>
</div>
<div class="checklist-row__actions">
<NcButton variant="tertiary" :aria-label="strings.viewItem" @click="$emit('view', item)">
<template #icon>
<EyeIcon :size="18" />
</template>
</NcButton>
<NcActions :aria-label="strings.itemActions">
<NcActionButton @click="$emit('edit', item)">
<template #icon>
<PencilIcon :size="20" />
</template>
{{ strings.editItem }}
</NcActionButton>
<NcActionButton @click="$emit('remove', item.id)">
<template #icon>
<DeleteIcon :size="20" />
</template>
{{ strings.removeItem }}
</NcActionButton>
</NcActions>
</div>
</li>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { t } from '@nextcloud/l10n'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
import NcActions from '@nextcloud/vue/components/NcActions'
import NcActionButton from '@nextcloud/vue/components/NcActionButton'
import RepeatIcon from '@icons/Repeat.vue'
import PencilIcon from '@icons/Pencil.vue'
import EyeIcon from '@icons/Eye.vue'
import DeleteIcon from '@icons/Delete.vue'
import { categoryIconComponent } from '@/components/CategoryPicker'
import { itemImagePreviewUrl } from '@/api/images'
import { formatRrule } from '@/utils/rrule'
import type { ChecklistItem, Category } from '@/api/types'
const props = defineProps<{
item: ChecklistItem
category: Category | null
houseId: number
}>()
defineEmits<{
toggle: [id: number]
view: [item: ChecklistItem]
edit: [item: ChecklistItem]
remove: [id: number]
preview: [item: ChecklistItem]
}>()
const thumbUrl = computed(() =>
props.item.imageFileId
? itemImagePreviewUrl(props.houseId, props.item.imageFileId!, props.item.imageUploadedBy!, 64)
: '',
)
const strings = {
viewImage: t('pantry', 'View image'),
viewItem: t('pantry', 'View item'),
itemActions: t('pantry', 'Item actions'),
editItem: t('pantry', 'Edit item'),
removeItem: t('pantry', 'Remove item'),
}
</script>
<style scoped lang="scss">
.checklist-row {
display: grid;
grid-template-columns: 1fr auto auto;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: var(--border-radius, 8px);
background: var(--color-main-background);
@media (max-width: 600px) {
grid-template-columns: 1fr auto;
grid-template-areas:
'check actions'
'meta meta';
gap: 0.25rem 0.5rem;
:deep(.checkbox-radio-switch) {
grid-area: check;
}
.checklist-row__actions {
grid-area: actions;
}
.checklist-row__meta {
grid-area: meta;
}
}
&--done {
opacity: 0.6;
.checklist-row__name {
text-decoration: line-through;
}
}
:deep(.checkbox-content__icon) {
margin-block: auto !important;
}
:deep(.checkbox-radio-switch__content) {
width: 100%;
max-width: unset;
}
&__label {
display: inline-flex;
align-items: center;
gap: 0.6rem;
}
&__thumb {
width: 40px;
height: 40px;
padding: 0;
border: 1px solid var(--color-border);
border-radius: var(--border-radius, 6px);
background: var(--color-background-hover);
cursor: zoom-in;
overflow: hidden;
flex-shrink: 0;
img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
&:hover,
&:focus-visible {
border-color: var(--color-primary-element);
}
}
&__name {
font-weight: 500;
}
&__meta {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
color: var(--color-text-maxcontrast);
font-size: 0.85rem;
}
&__actions {
display: flex;
align-items: center;
gap: 0.25rem;
}
&__quantity,
&__category,
&__recurrence {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 999px;
background: var(--color-background-hover);
}
}
</style>

View File

@@ -0,0 +1 @@
export { default as ChecklistItemRow } from './ChecklistItemRow.vue'

View File

@@ -0,0 +1,224 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { createIconMock, nextcloudL10nMock } from '@/test-utils'
vi.mock('@nextcloud/l10n', () => nextcloudL10nMock)
vi.mock('@icons/Repeat.vue', () => createIconMock('RepeatIcon'))
vi.mock('@icons/Pencil.vue', () => createIconMock('PencilIcon'))
vi.mock('@nextcloud/vue/components/NcDialog', () => ({
default: {
name: 'NcDialog',
template: '<div class="nc-dialog"><slot /><slot name="actions" /></div>',
props: ['name', 'open', 'size'],
},
}))
vi.mock('@nextcloud/vue/components/NcButton', () => ({
default: {
name: 'NcButton',
template:
'<button class="nc-button" :disabled="disabled"><slot name="icon" /><slot /></button>',
props: ['variant', 'form', 'type', 'disabled', 'ariaLabel'],
},
}))
vi.mock('@nextcloud/vue/components/NcRichText', () => ({
default: {
name: 'NcRichText',
template: '<div class="nc-rich-text">{{ text }}</div>',
props: ['text', 'useMarkdown', 'useExtendedMarkdown'],
},
}))
vi.mock('@/components/CategoryPicker', () => ({
categoryIconComponent: () => ({
name: 'CategoryIcon',
template: '<span class="mock-category-icon" />',
props: ['size'],
}),
}))
vi.mock('@/api/images', () => ({
itemImagePreviewUrl: (houseId: number, fileId: number, uploadedBy: string, size: number) =>
`/preview/${houseId}/${fileId}/${uploadedBy}/${size}`,
}))
vi.mock('@/utils/rrule', () => ({
formatRrule: (rrule: string) => rrule,
}))
import ChecklistItemViewDialog from './ChecklistItemViewDialog.vue'
import type { ChecklistItem, Category } from '@/api/types'
function makeItem(overrides: Partial<ChecklistItem> = {}): ChecklistItem {
return {
id: 1,
listId: 1,
name: 'Test Item',
description: null,
categoryId: null,
quantity: null,
done: false,
doneAt: null,
doneBy: null,
rrule: null,
repeatFromCompletion: false,
nextDueAt: null,
imageFileId: null,
imageUploadedBy: null,
sortOrder: 0,
createdAt: 0,
updatedAt: 0,
...overrides,
}
}
function makeCategory(overrides: Partial<Category> = {}): Category {
return {
id: 1,
houseId: 1,
name: 'Dairy',
icon: 'cow',
color: '#4caf50',
sortOrder: 0,
createdAt: 0,
updatedAt: 0,
...overrides,
}
}
const defaultProps = {
open: true,
item: makeItem(),
category: null,
houseId: 1,
}
describe('ChecklistItemViewDialog', () => {
it('renders item name in dialog', () => {
const wrapper = mount(ChecklistItemViewDialog, {
props: { ...defaultProps, item: makeItem({ name: 'Milk' }) },
})
const dialog = wrapper.findComponent({ name: 'NcDialog' })
expect(dialog.props('name')).toBe('Milk')
})
it('shows cover image when imageFileId is present', () => {
const wrapper = mount(ChecklistItemViewDialog, {
props: {
...defaultProps,
item: makeItem({ imageFileId: 42, imageUploadedBy: 'admin', name: 'Milk' }),
},
})
const img = wrapper.find('.item-view__image')
expect(img.exists()).toBe(true)
expect(img.attributes('src')).toBe('/preview/1/42/admin/1600')
expect(img.attributes('alt')).toBe('Milk')
})
it('does not show image button when no imageFileId', () => {
const wrapper = mount(ChecklistItemViewDialog, {
props: { ...defaultProps, item: makeItem({ imageFileId: null }) },
})
expect(wrapper.find('.item-view__image-btn').exists()).toBe(false)
})
it('renders description with NcRichText when present', () => {
const wrapper = mount(ChecklistItemViewDialog, {
props: { ...defaultProps, item: makeItem({ description: 'Buy **organic** milk' }) },
})
const richText = wrapper.findComponent({ name: 'NcRichText' })
expect(richText.exists()).toBe(true)
expect(richText.props('text')).toBe('Buy **organic** milk')
})
it('does not render description section when null', () => {
const wrapper = mount(ChecklistItemViewDialog, {
props: { ...defaultProps, item: makeItem({ description: null }) },
})
expect(wrapper.find('.item-view__description').exists()).toBe(false)
})
it('shows quantity row when present', () => {
const wrapper = mount(ChecklistItemViewDialog, {
props: { ...defaultProps, item: makeItem({ quantity: '3' }) },
})
const rows = wrapper.findAll('.item-view__row')
const quantityRow = rows.find((r) => r.text().includes('Quantity'))
expect(quantityRow).toBeDefined()
expect(quantityRow!.text()).toContain('3')
})
it('shows category row with color when category provided', () => {
const category = makeCategory({ name: 'Dairy', color: '#4caf50' })
const wrapper = mount(ChecklistItemViewDialog, {
props: { ...defaultProps, item: makeItem({ categoryId: 1 }), category },
})
const rows = wrapper.findAll('.item-view__row')
const categoryRow = rows.find((r) => r.text().includes('Category'))
expect(categoryRow).toBeDefined()
const badge = categoryRow!.find('.item-view__badge')
expect(badge.attributes('style')).toContain('color: #4caf50')
expect(badge.text()).toContain('Dairy')
})
it('shows recurrence row when rrule present', () => {
const wrapper = mount(ChecklistItemViewDialog, {
props: { ...defaultProps, item: makeItem({ rrule: 'FREQ=WEEKLY' }) },
})
const rows = wrapper.findAll('.item-view__row')
const recurrenceRow = rows.find((r) => r.text().includes('Recurrence'))
expect(recurrenceRow).toBeDefined()
expect(recurrenceRow!.text()).toContain('FREQ=WEEKLY')
})
it('shows done status row when item is done', () => {
const wrapper = mount(ChecklistItemViewDialog, {
props: { ...defaultProps, item: makeItem({ done: true }) },
})
const rows = wrapper.findAll('.item-view__row')
const statusRow = rows.find((r) => r.text().includes('Status'))
expect(statusRow).toBeDefined()
expect(statusRow!.text()).toContain('Done')
})
it('does not show done row when item is not done', () => {
const wrapper = mount(ChecklistItemViewDialog, {
props: { ...defaultProps, item: makeItem({ done: false }) },
})
const rows = wrapper.findAll('.item-view__row')
const statusRow = rows.find((r) => r.text().includes('Status'))
expect(statusRow).toBeUndefined()
})
it('emits edit with item when edit button clicked', async () => {
const item = makeItem({ name: 'Eggs' })
const wrapper = mount(ChecklistItemViewDialog, {
props: { ...defaultProps, item },
})
const editBtn = wrapper.findAll('.nc-button').find((b) => b.find('.mock-pencil-icon').exists())!
await editBtn.trigger('click')
expect(wrapper.emitted('edit')).toBeTruthy()
expect(wrapper.emitted('edit')![0][0]).toEqual(item)
})
it('emits preview with item when image clicked', async () => {
const item = makeItem({ imageFileId: 42, imageUploadedBy: 'admin' })
const wrapper = mount(ChecklistItemViewDialog, {
props: { ...defaultProps, item },
})
await wrapper.find('.item-view__image-btn').trigger('click')
expect(wrapper.emitted('preview')).toBeTruthy()
expect(wrapper.emitted('preview')![0][0]).toEqual(item)
})
it('emits update:open false when dialog closes', async () => {
const wrapper = mount(ChecklistItemViewDialog, {
props: defaultProps,
})
wrapper.findComponent({ name: 'NcDialog' }).vm.$emit('update:open', false)
await wrapper.vm.$nextTick()
expect(wrapper.emitted('update:open')).toBeTruthy()
expect(wrapper.emitted('update:open')![0][0]).toBe(false)
})
})

View File

@@ -0,0 +1,164 @@
<template>
<NcDialog
:name="item.name"
:open="open"
close-on-click-outside
size="normal"
@update:open="(v) => !v && $emit('update:open', false)"
>
<div class="item-view">
<button
v-if="item.imageFileId"
type="button"
class="item-view__image-btn"
:aria-label="strings.viewImage"
@click="$emit('preview', item)"
>
<img class="item-view__image" :src="largeUrl" :alt="item.name" />
</button>
<div v-if="item.description" class="item-view__description">
<NcRichText :text="item.description" :use-markdown="true" :use-extended-markdown="true" />
</div>
<div class="item-view__details">
<div v-if="item.quantity" class="item-view__row">
<span class="item-view__label">{{ strings.quantity }}:</span>
<span>&times; {{ item.quantity }}</span>
</div>
<div v-if="category" class="item-view__row">
<span class="item-view__label">{{ strings.category }}:</span>
<span class="item-view__badge" :style="{ color: category.color }">
<component :is="categoryIconComponent(category.icon)" :size="14" />
{{ category.name }}
</span>
</div>
<div v-if="item.rrule" class="item-view__row">
<span class="item-view__label">{{ strings.recurrence }}:</span>
<span class="item-view__badge">
<RepeatIcon :size="14" />
{{ formatRrule(item.rrule) }}
</span>
</div>
<div v-if="item.done" class="item-view__row">
<span class="item-view__label">{{ strings.status }}:</span>
<span>{{ strings.done }}</span>
</div>
</div>
</div>
<template #actions>
<NcButton variant="tertiary" @click="$emit('edit', item)">
<template #icon>
<PencilIcon :size="20" />
</template>
{{ strings.editItem }}
</NcButton>
</template>
</NcDialog>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { t } from '@nextcloud/l10n'
import NcDialog from '@nextcloud/vue/components/NcDialog'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcRichText from '@nextcloud/vue/components/NcRichText'
import RepeatIcon from '@icons/Repeat.vue'
import PencilIcon from '@icons/Pencil.vue'
import { categoryIconComponent } from '@/components/CategoryPicker'
import { itemImagePreviewUrl } from '@/api/images'
import { formatRrule } from '@/utils/rrule'
import type { ChecklistItem, Category } from '@/api/types'
const props = defineProps<{
open: boolean
item: ChecklistItem
category: Category | null
houseId: number
}>()
defineEmits<{
'update:open': [value: boolean]
edit: [item: ChecklistItem]
preview: [item: ChecklistItem]
}>()
const largeUrl = computed(() =>
props.item.imageFileId
? itemImagePreviewUrl(props.houseId, props.item.imageFileId!, props.item.imageUploadedBy!, 1600)
: '',
)
const strings = {
viewImage: t('pantry', 'View image'),
quantity: t('pantry', 'Quantity'),
category: t('pantry', 'Category'),
recurrence: t('pantry', 'Recurrence'),
status: t('pantry', 'Status'),
done: t('pantry', 'Done'),
editItem: t('pantry', 'Edit item'),
}
</script>
<style scoped lang="scss">
.item-view {
display: flex;
flex-direction: column;
gap: 1rem;
&__image-btn {
display: block;
width: 100%;
padding: 0;
border: 0;
background: none;
cursor: zoom-in;
border-radius: var(--border-radius, 8px);
overflow: hidden;
}
&__image {
width: 100%;
max-height: 300px;
object-fit: cover;
display: block;
border-radius: var(--border-radius, 8px);
}
&__description {
line-height: 1.6;
font-size: 0.95rem;
:deep(*) {
color: inherit;
}
}
&__details {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
&__row {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
}
&__label {
color: var(--color-text-maxcontrast);
font-weight: 500;
}
&__badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 999px;
background: var(--color-background-hover);
}
}
</style>

View File

@@ -0,0 +1 @@
export { default as ChecklistItemViewDialog } from './ChecklistItemViewDialog.vue'

10
src/utils/rrule.ts Normal file
View File

@@ -0,0 +1,10 @@
import { RRule } from 'rrule'
export function formatRrule(rrule: string): string {
try {
const rule = RRule.fromString('RRULE:' + rrule.replace(/^RRULE:/i, ''))
return rule.toText()
} catch {
return rrule
}
}

View File

@@ -15,60 +15,9 @@
</PageToolbar>
<div class="pantry-detail__body">
<form class="pantry-detail__add" autocomplete="off" @submit.prevent="submitAdd">
<NcTextField
v-model="newName"
:label="strings.newItemLabel"
:placeholder="strings.newItemPlaceholder"
autocomplete="off"
/>
<NcTextField
v-model="newQuantity"
:label="strings.quantityLabel"
:placeholder="strings.quantityPlaceholder"
autocomplete="off"
/>
<CategoryPicker
v-model="newCategoryId"
:house-id="houseIdNum"
:placeholder="strings.categoryPlaceholder"
/>
<NcButton variant="tertiary" @click="showRecurrenceEditor = true">
<template #icon>
<RepeatIcon :size="20" />
</template>
{{ newRrule ? strings.recurrenceSet : strings.recurrenceButton }}
</NcButton>
<NcButton
variant="tertiary"
:aria-label="strings.descriptionToggle"
@click="showNewDescription = !showNewDescription"
>
<template #icon>
<ChevronDownIcon
:size="20"
class="pantry-detail__chevron"
:class="{ 'pantry-detail__chevron--open': showNewDescription }"
/>
</template>
</NcButton>
<NcButton type="submit" variant="primary" :disabled="!newName.trim() || adding">
<template #icon>
<PlusIcon :size="20" />
</template>
{{ strings.add }}
</NcButton>
<AutoResizeTextarea
v-if="showNewDescription"
v-model="newDescription"
:label="strings.descriptionLabel"
:placeholder="strings.descriptionPlaceholder"
class="pantry-detail__add-description"
autocomplete="off"
/>
</form>
<ChecklistAddForm :house-id="houseIdNum" :adding="adding" @add="handleAdd" />
<div v-if="loading" class="pantry-center">
<div v-if="loading" class="pantry-detail__center">
<NcLoadingIcon :size="36" />
</div>
@@ -82,266 +31,53 @@
</template>
</NcEmptyContent>
<ul v-else class="pantry-items">
<li
<ul v-else class="pantry-detail__items">
<ChecklistItemRow
v-for="item in sortedItems"
:key="item.id"
class="pantry-item"
:class="{ 'pantry-item--done': item.done }"
>
<NcCheckboxRadioSwitch
:model-value="item.done"
@update:model-value="handleToggle(item.id)"
>
<span class="pantry-item__label">
<button
v-if="item.imageFileId"
type="button"
class="pantry-item__thumb"
:aria-label="strings.viewImage"
@click.stop.prevent="openPreview(item)"
>
<img :src="thumbUrl(item)" :alt="item.name" />
</button>
<span class="pantry-item__name">{{ item.name }}</span>
</span>
</NcCheckboxRadioSwitch>
<div class="pantry-item__meta">
<span v-if="item.quantity" class="pantry-item__quantity"
>&times; {{ item.quantity }}</span
>
<span
v-if="categoryFor(item.categoryId)"
class="pantry-item__category"
:style="{ color: categoryFor(item.categoryId)!.color }"
>
<component
:is="categoryIconComponent(categoryFor(item.categoryId)!.icon)"
:size="14"
/>
{{ categoryFor(item.categoryId)!.name }}
</span>
<span v-if="item.rrule" class="pantry-item__recurrence" :title="item.rrule">
<RepeatIcon :size="14" />
{{ formatRrule(item.rrule) }}
</span>
</div>
<div class="pantry-item__actions">
<NcButton variant="tertiary" :aria-label="strings.viewItem" @click="openView(item)">
<template #icon>
<EyeIcon :size="18" />
</template>
</NcButton>
<NcActions :aria-label="strings.itemActions">
<NcActionButton @click="startEdit(item)">
<template #icon>
<PencilIcon :size="20" />
</template>
{{ strings.editItem }}
</NcActionButton>
<NcActionButton @click="handleRemove(item.id)">
<template #icon>
<DeleteIcon :size="20" />
</template>
{{ strings.removeItem }}
</NcActionButton>
</NcActions>
</div>
</li>
:item="item"
:category="categoryFor(item.categoryId)"
:house-id="houseIdNum"
@toggle="handleToggle"
@view="openView"
@edit="startEdit"
@remove="handleRemove"
@preview="openPreview"
/>
</ul>
</div>
<RecurrenceEditor
v-model:open="showRecurrenceEditor"
v-model="newRrule"
v-model:from-completion="newRepeatFromCompletion"
/>
<NcDialog
<ChecklistItemEditDialog
v-if="editing"
:name="strings.editDialogTitle"
:open="!!editing"
close-on-click-outside
:item="editing"
:house-id="houseIdNum"
:saving="savingEdit"
:uploading-image="uploadingImage"
@update:open="(v) => !v && (editing = null)"
>
<form
id="pantry-edit-item-form"
class="pantry-form"
autocomplete="off"
@submit.prevent="submitEdit"
>
<NcTextField
v-model="editName"
:label="strings.newItemLabel"
:placeholder="strings.newItemPlaceholder"
autocomplete="off"
/>
<AutoResizeTextarea
v-model="editDescription"
:label="strings.descriptionLabel"
:placeholder="strings.descriptionPlaceholder"
autocomplete="off"
/>
<NcTextField
v-model="editQuantity"
:label="strings.quantityLabel"
:placeholder="strings.quantityPlaceholder"
autocomplete="off"
/>
<CategoryPicker
v-model="editCategoryId"
:house-id="houseIdNum"
:label="strings.categoryLabel"
:placeholder="strings.categoryPlaceholder"
/>
<NcButton variant="tertiary" type="button" @click="showEditRecurrenceEditor = true">
<template #icon>
<RepeatIcon :size="20" />
</template>
{{ editRrule ? strings.recurrenceSet : strings.recurrenceButton }}
</NcButton>
<div class="pantry-form__image">
<span class="pantry-form__label">{{ strings.imageLabel }}</span>
<div class="pantry-form__image-row">
<img
v-if="editing?.imageFileId"
class="pantry-form__image-preview"
:src="thumbUrl(editing, 96)"
:alt="editing.name"
/>
<NcButton
variant="tertiary"
type="button"
:disabled="uploadingImage"
@click="triggerImagePick"
>
<template #icon>
<UploadIcon :size="20" />
</template>
{{ editing?.imageFileId ? strings.replaceImage : strings.uploadImage }}
</NcButton>
<NcButton
v-if="editing?.imageFileId"
variant="tertiary"
type="button"
:disabled="uploadingImage"
@click="removeImage"
>
<template #icon>
<DeleteIcon :size="20" />
</template>
{{ strings.removeImage }}
</NcButton>
<input
ref="imageInputRef"
type="file"
accept="image/*"
class="pantry-form__image-input"
@change="onImagePicked"
/>
</div>
</div>
</form>
<template #actions>
<NcButton @click="editing = null">{{ strings.cancel }}</NcButton>
<NcButton
form="pantry-edit-item-form"
type="submit"
variant="primary"
:disabled="!editName.trim() || savingEdit"
>
{{ strings.save }}
</NcButton>
</template>
</NcDialog>
<RecurrenceEditor
v-model:open="showEditRecurrenceEditor"
v-model="editRrule"
v-model:from-completion="editRepeatFromCompletion"
@save="handleSaveEdit"
@upload-image="handleUploadImage"
@clear-image="handleClearImage"
/>
<NcDialog
<ChecklistItemViewDialog
v-if="viewing"
:name="viewing.name"
:open="!!viewing"
close-on-click-outside
size="normal"
:item="viewing"
:category="categoryFor(viewing.categoryId)"
:house-id="houseIdNum"
@update:open="(v) => !v && (viewing = null)"
>
<div class="pantry-view">
<button
v-if="viewing.imageFileId"
type="button"
class="pantry-view__image-btn"
:aria-label="strings.viewImage"
@click="previewing = viewing"
>
<img class="pantry-view__image" :src="largeUrl(viewing)" :alt="viewing.name" />
</button>
@edit="viewToEdit"
@preview="openPreview"
/>
<div v-if="viewing.description" class="pantry-view__description">
<NcRichText
:text="viewing.description"
:use-markdown="true"
:use-extended-markdown="true"
/>
</div>
<div class="pantry-view__details">
<div v-if="viewing.quantity" class="pantry-view__row">
<span class="pantry-view__label">{{ strings.quantityLabel }}:</span>
<span>&times; {{ viewing.quantity }}</span>
</div>
<div v-if="categoryFor(viewing.categoryId)" class="pantry-view__row">
<span class="pantry-view__label">{{ strings.categoryLabel }}:</span>
<span
class="pantry-item__category"
:style="{ color: categoryFor(viewing.categoryId)!.color }"
>
<component
:is="categoryIconComponent(categoryFor(viewing.categoryId)!.icon)"
:size="14"
/>
{{ categoryFor(viewing.categoryId)!.name }}
</span>
</div>
<div v-if="viewing.rrule" class="pantry-view__row">
<span class="pantry-view__label">{{ strings.recurrenceLabel }}:</span>
<span class="pantry-item__recurrence">
<RepeatIcon :size="14" />
{{ formatRrule(viewing.rrule) }}
</span>
</div>
<div v-if="viewing.done" class="pantry-view__row">
<span class="pantry-view__label">{{ strings.status }}:</span>
<span>{{ strings.done }}</span>
</div>
</div>
</div>
<template #actions>
<NcButton variant="tertiary" @click="viewToEdit">
<template #icon>
<PencilIcon :size="20" />
</template>
{{ strings.editItem }}
</NcButton>
</template>
</NcDialog>
<NcDialog
<ChecklistImagePreview
v-if="previewing"
:name="previewing.name"
:open="!!previewing"
close-on-click-outside
size="large"
:item="previewing"
:house-id="houseIdNum"
@update:open="(v) => !v && (previewing = null)"
>
<div class="pantry-preview">
<img v-if="previewing.imageFileId" :src="largeUrl(previewing)" :alt="previewing.name" />
</div>
</NcDialog>
/>
</div>
</template>
@@ -349,34 +85,21 @@
import { computed, onMounted, ref, watch } from 'vue'
import { t } from '@nextcloud/l10n'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcTextField from '@nextcloud/vue/components/NcTextField'
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
import NcDialog from '@nextcloud/vue/components/NcDialog'
import NcActions from '@nextcloud/vue/components/NcActions'
import NcActionButton from '@nextcloud/vue/components/NcActionButton'
import { itemImagePreviewUrl } from '@/api/images'
import PageToolbar from '@/components/PageToolbar'
import PlusIcon from '@icons/Plus.vue'
import ArrowLeftIcon from '@icons/ArrowLeft.vue'
import DeleteIcon from '@icons/Delete.vue'
import PencilIcon from '@icons/Pencil.vue'
import EyeIcon from '@icons/Eye.vue'
import RepeatIcon from '@icons/Repeat.vue'
import ChevronDownIcon from '@icons/ChevronDown.vue'
import { AutoResizeTextarea } from '@/components/AutoResizeTextarea'
import PageToolbar from '@/components/PageToolbar'
import { ChecklistAddForm } from '@/components/ChecklistAddForm'
import { ChecklistItemRow } from '@/components/ChecklistItemRow'
import { ChecklistItemEditDialog } from '@/components/ChecklistItemEditDialog'
import { ChecklistItemViewDialog } from '@/components/ChecklistItemViewDialog'
import { ChecklistImagePreview } from '@/components/ChecklistImagePreview'
import { checklistIconComponent } from '@/components/ChecklistIconPicker'
import UploadIcon from '@icons/Upload.vue'
import RecurrenceEditor from '@/components/RecurrenceEditor'
import CategoryPicker from '@/components/CategoryPicker'
import { categoryIconComponent } from '@/components/CategoryPicker'
import NcRichText from '@nextcloud/vue/components/NcRichText'
import { useChecklistItems } from '@/composables/useChecklist'
import { useCategories } from '@/composables/useCategories'
import { getList } from '@/api/lists'
import type { ItemInput } from '@/api/lists'
import type { Checklist, ChecklistItem } from '@/api/types'
import { RRule } from 'rrule'
const props = defineProps<{ houseId: string; listId: string }>()
@@ -392,15 +115,7 @@ function categoryFor(id: number | null) {
return categories.findById(id) ?? null
}
const newName = ref('')
const newDescription = ref('')
const newQuantity = ref('')
const newCategoryId = ref<number | null>(null)
const showNewDescription = ref(false)
const newRrule = ref<string | null>(null)
const newRepeatFromCompletion = ref<boolean>(false)
const adding = ref(false)
const showRecurrenceEditor = ref(false)
// ----- Loading -----
async function loadList() {
list.value = await getList(houseIdNum.value, listIdNum.value)
@@ -417,6 +132,8 @@ watch(
},
)
// ----- Sorting -----
const sortedItems = computed(() => {
return [...items.value].sort((a, b) => {
if (a.done !== b.done) return a.done ? 1 : -1
@@ -425,31 +142,21 @@ const sortedItems = computed(() => {
})
})
async function submitAdd() {
const name = newName.value.trim()
if (!name) return
// ----- Add -----
const adding = ref(false)
async function handleAdd(input: ItemInput) {
adding.value = true
try {
await add({
name,
description: newDescription.value.trim() || null,
quantity: newQuantity.value.trim() || null,
categoryId: newCategoryId.value,
rrule: newRrule.value,
repeatFromCompletion: newRepeatFromCompletion.value,
})
newName.value = ''
newDescription.value = ''
newQuantity.value = ''
newCategoryId.value = null
newRrule.value = null
newRepeatFromCompletion.value = false
showNewDescription.value = false
await add(input)
} finally {
adding.value = false
}
}
// ----- Toggle / Remove -----
async function handleToggle(itemId: number) {
await toggle(itemId)
}
@@ -458,47 +165,50 @@ async function handleRemove(itemId: number) {
await remove(itemId)
}
// ----- Edit -----
const editing = ref<ChecklistItem | null>(null)
const editName = ref('')
const editDescription = ref('')
const editQuantity = ref('')
const editCategoryId = ref<number | null>(null)
const editRrule = ref<string | null>(null)
const editRepeatFromCompletion = ref<boolean>(false)
const showEditRecurrenceEditor = ref(false)
const savingEdit = ref(false)
const uploadingImage = ref(false)
function startEdit(item: ChecklistItem) {
editing.value = item
editName.value = item.name
editDescription.value = item.description ?? ''
editQuantity.value = item.quantity ?? ''
editCategoryId.value = item.categoryId ?? null
editRrule.value = item.rrule ?? null
editRepeatFromCompletion.value = item.repeatFromCompletion ?? false
}
async function submitEdit() {
const target = editing.value
if (!target) return
const name = editName.value.trim()
if (!name) return
async function handleSaveEdit(itemId: number, patch: Partial<ItemInput>) {
savingEdit.value = true
try {
await update(target.id, {
name,
description: editDescription.value.trim() || null,
quantity: editQuantity.value.trim() || null,
categoryId: editCategoryId.value,
rrule: editRrule.value,
repeatFromCompletion: editRepeatFromCompletion.value,
})
await update(itemId, patch)
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)
const previewing = ref<ChecklistItem | null>(null)
@@ -506,9 +216,7 @@ function openView(item: ChecklistItem) {
viewing.value = item
}
function viewToEdit() {
if (!viewing.value) return
const item = viewing.value
function viewToEdit(item: ChecklistItem) {
viewing.value = null
startEdit(item)
}
@@ -517,87 +225,8 @@ function openPreview(item: ChecklistItem) {
previewing.value = item
}
function thumbUrl(item: ChecklistItem, size = 64): string {
return itemImagePreviewUrl(houseIdNum.value, item.imageFileId!, item.imageUploadedBy!, size)
}
function largeUrl(item: ChecklistItem): string {
return itemImagePreviewUrl(houseIdNum.value, item.imageFileId!, item.imageUploadedBy!, 1600)
}
const imageInputRef = ref<HTMLInputElement | null>(null)
const uploadingImage = ref(false)
function triggerImagePick() {
imageInputRef.value?.click()
}
async function onImagePicked(e: Event) {
const input = e.target as HTMLInputElement
const file = input.files?.[0]
if (!file || !editing.value) return
uploadingImage.value = true
try {
await uploadImage(editing.value.id, file)
// Refresh the local editing ref with the updated item so the preview appears.
const refreshed = items.value.find((i) => i.id === editing.value?.id)
if (refreshed) editing.value = refreshed
} finally {
uploadingImage.value = false
input.value = ''
}
}
async function removeImage() {
if (!editing.value) return
uploadingImage.value = true
try {
await clearImage(editing.value.id)
const refreshed = items.value.find((i) => i.id === editing.value?.id)
if (refreshed) editing.value = refreshed
} finally {
uploadingImage.value = false
}
}
function formatRrule(rrule: string): string {
try {
const rule = RRule.fromString('RRULE:' + rrule.replace(/^RRULE:/i, ''))
return rule.toText()
} catch {
return rrule
}
}
const strings = {
back: t('pantry', 'Back to lists'),
add: t('pantry', 'Add'),
save: t('pantry', 'Save'),
cancel: t('pantry', 'Cancel'),
newItemLabel: t('pantry', 'Item name'),
newItemPlaceholder: t('pantry', 'e.g. Milk'),
quantityLabel: t('pantry', 'Quantity'),
quantityPlaceholder: t('pantry', 'e.g. 2 L'),
categoryLabel: t('pantry', 'Category'),
categoryPlaceholder: t('pantry', 'Category'),
recurrenceButton: t('pantry', 'Repeat …'),
recurrenceSet: t('pantry', 'Repeat: set'),
descriptionLabel: t('pantry', 'Description'),
descriptionPlaceholder: t('pantry', 'Add a description …'),
descriptionToggle: t('pantry', 'Toggle description'),
itemActions: t('pantry', 'Item actions'),
viewItem: t('pantry', 'View item'),
editItem: t('pantry', 'Edit item'),
editDialogTitle: t('pantry', 'Edit item'),
recurrenceLabel: t('pantry', 'Recurrence'),
status: t('pantry', 'Status'),
done: t('pantry', 'Done'),
imageLabel: t('pantry', 'Image'),
uploadImage: t('pantry', 'Upload image'),
replaceImage: t('pantry', 'Replace image'),
removeImage: t('pantry', 'Remove image'),
viewImage: t('pantry', 'View image'),
removeItem: t('pantry', 'Remove item'),
emptyTitle: t('pantry', 'No items yet'),
emptyBody: t('pantry', 'Add items using the form above.'),
}
@@ -610,257 +239,19 @@ const strings = {
margin: 0 auto;
}
&__add {
display: grid;
grid-template-columns: 2fr 1fr 1fr auto auto auto;
gap: 0.75rem;
align-items: end;
margin-bottom: 1.5rem;
:deep(.v-select.select) {
margin-bottom: 0;
}
@media (max-width: 900px) {
grid-template-columns: 1fr 1fr;
}
&__center {
display: flex;
justify-content: center;
padding: 2rem;
}
&__add-description {
grid-column: 1 / -1;
}
&__chevron {
transition: transform 0.2s ease;
&--open {
transform: rotate(180deg);
}
}
}
.pantry-center {
display: flex;
justify-content: center;
padding: 2rem;
}
.pantry-items {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.pantry-form {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 0.5rem 0;
&__image {
&__items {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
&__label {
font-size: 0.85rem;
color: var(--color-text-maxcontrast);
}
&__image-row {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
&__image-preview {
width: 72px;
height: 72px;
object-fit: cover;
border-radius: var(--border-radius, 6px);
border: 1px solid var(--color-border);
}
&__image-input {
display: none;
}
}
.pantry-view {
display: flex;
flex-direction: column;
gap: 1rem;
&__image-btn {
display: block;
width: 100%;
padding: 0;
border: 0;
background: none;
cursor: zoom-in;
border-radius: var(--border-radius, 8px);
overflow: hidden;
}
&__image {
width: 100%;
max-height: 300px;
object-fit: cover;
display: block;
border-radius: var(--border-radius, 8px);
}
&__description {
line-height: 1.6;
font-size: 0.95rem;
:deep(*) {
color: inherit;
}
}
&__details {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
&__row {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
}
&__label {
color: var(--color-text-maxcontrast);
font-weight: 500;
}
}
.pantry-preview {
display: flex;
justify-content: center;
align-items: center;
padding: 0.5rem;
img {
max-width: 100%;
max-height: 80vh;
object-fit: contain;
border-radius: var(--border-radius, 8px);
}
}
.pantry-item {
display: grid;
grid-template-columns: 1fr auto auto;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: var(--border-radius, 8px);
background: var(--color-main-background);
@media (max-width: 600px) {
grid-template-columns: 1fr auto;
grid-template-areas:
'check actions'
'meta meta';
gap: 0.25rem 0.5rem;
:deep(.checkbox-radio-switch) {
grid-area: check;
}
.pantry-item__actions {
grid-area: actions;
}
.pantry-item__meta {
grid-area: meta;
}
}
&--done {
opacity: 0.6;
.pantry-item__name {
text-decoration: line-through;
}
}
:deep(.checkbox-content__icon) {
margin-block: auto !important;
}
:deep(.checkbox-radio-switch__content) {
width: 100%;
max-width: unset;
}
&__label {
display: inline-flex;
align-items: center;
gap: 0.6rem;
}
&__thumb {
width: 40px;
height: 40px;
padding: 0;
border: 1px solid var(--color-border);
border-radius: var(--border-radius, 6px);
background: var(--color-background-hover);
cursor: zoom-in;
overflow: hidden;
flex-shrink: 0;
img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
&:hover,
&:focus-visible {
border-color: var(--color-primary-element);
}
}
&__name {
font-weight: 500;
}
&__meta {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
color: var(--color-text-maxcontrast);
font-size: 0.85rem;
}
&__actions {
display: flex;
align-items: center;
gap: 0.25rem;
}
&__quantity,
&__category,
&__recurrence {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 999px;
background: var(--color-background-hover);
}
}
</style>