mirror of
https://github.com/chenasraf/nextcloud-pantry.git
synced 2026-05-18 01:28:57 +00:00
refactor: extract checklist components
This commit is contained in:
182
src/components/ChecklistAddForm/ChecklistAddForm.test.ts
Normal file
182
src/components/ChecklistAddForm/ChecklistAddForm.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
156
src/components/ChecklistAddForm/ChecklistAddForm.vue
Normal file
156
src/components/ChecklistAddForm/ChecklistAddForm.vue
Normal 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>
|
||||
1
src/components/ChecklistAddForm/index.ts
Normal file
1
src/components/ChecklistAddForm/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as ChecklistAddForm } from './ChecklistAddForm.vue'
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
1
src/components/ChecklistImagePreview/index.ts
Normal file
1
src/components/ChecklistImagePreview/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as ChecklistImagePreview } from './ChecklistImagePreview.vue'
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
1
src/components/ChecklistItemEditDialog/index.ts
Normal file
1
src/components/ChecklistItemEditDialog/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as ChecklistItemEditDialog } from './ChecklistItemEditDialog.vue'
|
||||
217
src/components/ChecklistItemRow/ChecklistItemRow.test.ts
Normal file
217
src/components/ChecklistItemRow/ChecklistItemRow.test.ts
Normal 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])
|
||||
})
|
||||
})
|
||||
})
|
||||
204
src/components/ChecklistItemRow/ChecklistItemRow.vue
Normal file
204
src/components/ChecklistItemRow/ChecklistItemRow.vue
Normal 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">× {{ 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>
|
||||
1
src/components/ChecklistItemRow/index.ts
Normal file
1
src/components/ChecklistItemRow/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as ChecklistItemRow } from './ChecklistItemRow.vue'
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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>× {{ 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>
|
||||
1
src/components/ChecklistItemViewDialog/index.ts
Normal file
1
src/components/ChecklistItemViewDialog/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as ChecklistItemViewDialog } from './ChecklistItemViewDialog.vue'
|
||||
10
src/utils/rrule.ts
Normal file
10
src/utils/rrule.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
>× {{ 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>× {{ 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>
|
||||
|
||||
Reference in New Issue
Block a user