feat: move items between lists

This commit is contained in:
2026-04-12 11:06:23 +03:00
parent 7411e421ad
commit cb2cb731aa
8 changed files with 213 additions and 3 deletions

View File

@@ -265,6 +265,7 @@ final class ChecklistController extends OCSController {
* @param bool|null $repeatFromCompletion New recurrence anchor mode.
* @param int|null $imageFileId File id of attached image (0 or negative clears).
* @param int|null $sortOrder New sort order.
* @param int|null $targetListId Move item to a different list (must belong to the same house).
*
* @return DataResponse<Http::STATUS_OK, PantryListItem, array{}>
*
@@ -284,8 +285,9 @@ final class ChecklistController extends OCSController {
?bool $repeatFromCompletion = null,
?int $imageFileId = null,
?int $sortOrder = null,
?int $targetListId = null,
): DataResponse {
return $this->runAction(function () use ($houseId, $listId, $itemId, $name, $description, $categoryId, $quantity, $rrule, $repeatFromCompletion, $imageFileId, $sortOrder): DataResponse {
return $this->runAction(function () use ($houseId, $listId, $itemId, $name, $description, $categoryId, $quantity, $rrule, $repeatFromCompletion, $imageFileId, $sortOrder, $targetListId): DataResponse {
$this->auth->requireMember($houseId, $this->requireUid());
$item = $this->lists->getItem($itemId);
$list = $this->lists->getList($item->getListId());
@@ -323,6 +325,11 @@ final class ChecklistController extends OCSController {
if ($sortOrder !== null) {
$patch['sortOrder'] = $sortOrder;
}
if ($targetListId !== null) {
$targetList = $this->lists->getList($targetListId);
$this->assertListInHouse($targetList->getHouseId(), $houseId);
$patch['listId'] = $targetListId;
}
$updated = $this->lists->updateItem($itemId, $patch);
return new DataResponse($updated->jsonSerialize());
});

View File

@@ -206,6 +206,12 @@ class ChecklistService {
&& (array_key_exists('rrule', $patch) || array_key_exists('repeatFromCompletion', $patch))) {
$item->setNextDueAt($this->computeNextDueAt($item, time())?->getTimestamp());
}
if (isset($patch['listId'])) {
$targetListId = (int)$patch['listId'];
// Ensure the target list exists.
$this->getList($targetListId);
$item->setListId($targetListId);
}
if (isset($patch['sortOrder'])) {
$item->setSortOrder((int)$patch['sortOrder']);
}

View File

@@ -2123,6 +2123,13 @@
"nullable": true,
"default": null,
"description": "New sort order."
},
"targetListId": {
"type": "integer",
"format": "int64",
"nullable": true,
"default": null,
"description": "Move item to a different list (must belong to the same house)."
}
}
}

View File

@@ -57,6 +57,7 @@ export interface ItemInput {
rrule?: string | null
repeatFromCompletion?: boolean
sortOrder?: number
targetListId?: number
}
export async function addItem(

View File

@@ -0,0 +1,109 @@
<template>
<NcDialog
:name="dialogName"
:open="open"
close-on-click-outside
@update:open="$emit('update:open', $event)"
>
<form :id="formId" class="pantry-form" autocomplete="off" @submit.prevent="submit">
<NcTextField
v-model="nameValue"
:label="strings.nameLabel"
:placeholder="strings.namePlaceholder"
autocomplete="off"
/>
<NcTextField
v-model="descriptionValue"
:label="strings.descriptionLabel"
:placeholder="strings.descriptionPlaceholder"
autocomplete="off"
/>
<div>
<label class="pantry-icon-picker__label">{{ strings.iconLabel }}</label>
<div class="pantry-icon-picker__grid">
<button
v-for="opt in CHECKLIST_ICONS"
:key="opt.key"
type="button"
class="pantry-icon-picker__button"
:class="{ 'pantry-icon-picker__button--active': iconValue === opt.key }"
:title="opt.label"
@click="iconValue = opt.key"
>
<component :is="opt.component" :size="20" />
</button>
</div>
</div>
</form>
<template #actions>
<NcButton @click="$emit('update:open', false)">{{ strings.cancel }}</NcButton>
<NcButton :form="formId" type="submit" variant="primary" :disabled="!nameValue.trim()">
{{ list ? strings.save : strings.create }}
</NcButton>
</template>
</NcDialog>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { t } from '@nextcloud/l10n'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcDialog from '@nextcloud/vue/components/NcDialog'
import NcTextField from '@nextcloud/vue/components/NcTextField'
import type { Checklist } from '@/api/types'
import { CHECKLIST_ICONS, DEFAULT_CHECKLIST_ICON_KEY } from './checklistIcons'
const props = defineProps<{
open: boolean
list?: Checklist | null
}>()
const emit = defineEmits<{
'update:open': [value: boolean]
save: [data: { name: string; description: string; icon: string }]
}>()
const formId = 'pantry-checklist-form-dialog'
const nameValue = ref('')
const descriptionValue = ref('')
const iconValue = ref(DEFAULT_CHECKLIST_ICON_KEY)
watch(
() => props.open,
(isOpen) => {
if (isOpen) {
if (props.list) {
nameValue.value = props.list.name
descriptionValue.value = props.list.description ?? ''
iconValue.value = props.list.icon ?? DEFAULT_CHECKLIST_ICON_KEY
} else {
nameValue.value = ''
descriptionValue.value = ''
iconValue.value = DEFAULT_CHECKLIST_ICON_KEY
}
}
},
{ immediate: true },
)
const dialogName = computed(() => (props.list ? strings.editTitle : strings.createTitle))
function submit() {
const name = nameValue.value.trim()
if (!name) return
emit('save', { name, description: descriptionValue.value.trim(), icon: iconValue.value })
}
const strings = {
createTitle: t('pantry', 'Create a checklist'),
editTitle: t('pantry', 'Edit checklist'),
nameLabel: t('pantry', 'Name'),
namePlaceholder: t('pantry', 'e.g. Weekly groceries'),
descriptionLabel: t('pantry', 'Description (optional)'),
descriptionPlaceholder: t('pantry', 'A short description'),
iconLabel: t('pantry', 'Icon:'),
cancel: t('pantry', 'Cancel'),
create: t('pantry', 'Create'),
save: t('pantry', 'Save'),
}
</script>

View File

@@ -4,3 +4,4 @@ export {
checklistIconComponent,
type ChecklistIconOption,
} from './checklistIcons'
export { default as ChecklistFormDialog } from './ChecklistFormDialog.vue'

View File

@@ -46,6 +46,12 @@
</template>
{{ strings.editItem }}
</NcActionButton>
<NcActionButton @click="$emit('move', item)">
<template #icon>
<ArrowRightIcon :size="20" />
</template>
{{ strings.moveItem }}
</NcActionButton>
<NcActionButton @click="$emit('remove', item.id)">
<template #icon>
<DeleteIcon :size="20" />
@@ -68,6 +74,7 @@ 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 ArrowRightIcon from '@icons/ArrowRight.vue'
import { categoryIconComponent } from '@/components/CategoryPicker'
import { itemImagePreviewUrl } from '@/api/images'
import { formatRrule } from '@/utils/rrule'
@@ -87,6 +94,7 @@ const emit = defineEmits<{
toggle: [id: number]
view: [item: ChecklistItem]
edit: [item: ChecklistItem]
move: [item: ChecklistItem]
remove: [id: number]
preview: [item: ChecklistItem]
'drag-start': [itemId: number]
@@ -124,6 +132,7 @@ const strings = {
viewItem: t('pantry', 'View item'),
itemActions: t('pantry', 'Item actions'),
editItem: t('pantry', 'Edit item'),
moveItem: t('pantry', 'Move to list'),
removeItem: t('pantry', 'Remove item'),
}
</script>

View File

@@ -74,6 +74,7 @@
@toggle="handleToggle"
@view="openView"
@edit="startEdit"
@move="startMoveItem"
@remove="handleRemove"
@preview="openPreview"
@drag-start="onItemDragStart"
@@ -145,6 +146,36 @@
:house-id="houseIdNum"
@update:open="showCategoryManager = $event"
/>
<!-- Move item to another list -->
<NcDialog
v-if="movingItem"
:name="strings.moveToList"
:open="!!movingItem"
close-on-click-outside
@update:open="(v) => !v && (movingItem = null)"
>
<div class="pantry-move-list">
<NcButton v-for="cl in otherLists" :key="cl.id" wide @click="submitMoveItem(cl.id)">
<template #icon>
<component :is="checklistIconComponent(cl.icon)" :size="20" />
</template>
{{ cl.name }}
</NcButton>
<NcButton wide @click="createListForMove">
<template #icon>
<PlusIcon :size="20" />
</template>
{{ strings.newList }}
</NcButton>
</div>
</NcDialog>
<ChecklistFormDialog
:open="showCreateForMove"
@update:open="showCreateForMove = $event"
@save="submitCreateListAndMove"
/>
</div>
</template>
@@ -152,11 +183,13 @@
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { t } from '@nextcloud/l10n'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcDialog from '@nextcloud/vue/components/NcDialog'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
import NcActions from '@nextcloud/vue/components/NcActions'
import NcActionButton from '@nextcloud/vue/components/NcActionButton'
import ArrowLeftIcon from '@icons/ArrowLeft.vue'
import PlusIcon from '@icons/Plus.vue'
import SortIcon from '@icons/Sort.vue'
import RadioboxBlankIcon from '@icons/RadioboxBlank.vue'
import RadioboxMarkedIcon from '@icons/RadioboxMarked.vue'
@@ -168,8 +201,8 @@ import { ChecklistItemEditDialog } from '@/components/ChecklistItemEditDialog'
import { ChecklistItemViewDialog } from '@/components/ChecklistItemViewDialog'
import { ChecklistImagePreview } from '@/components/ChecklistImagePreview'
import { CategoryManagerDialog } from '@/components/CategoryManager'
import { checklistIconComponent } from '@/components/ChecklistIconPicker'
import { useChecklistItems } from '@/composables/useChecklist'
import { checklistIconComponent, ChecklistFormDialog } from '@/components/ChecklistIconPicker'
import { useChecklists, useChecklistItems } from '@/composables/useChecklist'
import { useCategories } from '@/composables/useCategories'
import { useTouchReorder } from '@/composables/useTouchReorder'
import { getList } from '@/api/lists'
@@ -517,6 +550,34 @@ function openPreview(item: ChecklistItem) {
const showCategoryManager = ref(false)
// ----- Move item to another list -----
const { lists: allLists, create: createList } = useChecklists(houseIdNum.value)
const otherLists = computed(() => allLists.value.filter((l) => l.id !== listIdNum.value))
const movingItem = ref<ChecklistItem | null>(null)
const showCreateForMove = ref(false)
function startMoveItem(item: ChecklistItem) {
movingItem.value = item
}
async function submitMoveItem(targetListId: number) {
if (!movingItem.value) return
await update(movingItem.value.id, { targetListId })
items.value = items.value.filter((i) => i.id !== movingItem.value!.id)
movingItem.value = null
}
function createListForMove() {
showCreateForMove.value = true
}
async function submitCreateListAndMove(data: { name: string; description: string; icon: string }) {
const newList = await createList(data.name, data.description || null, data.icon || null)
showCreateForMove.value = false
await submitMoveItem(newList.id)
}
const strings = {
back: t('pantry', 'Back to lists'),
emptyTitle: t('pantry', 'No items yet'),
@@ -524,6 +585,8 @@ const strings = {
sortLabel: t('pantry', 'Sort order'),
doneTitle: t('pantry', 'Done'),
manageCategories: t('pantry', 'Manage categories'),
moveToList: t('pantry', 'Move to list'),
newList: t('pantry', 'New list'),
}
</script>
@@ -568,6 +631,13 @@ const strings = {
}
}
.pantry-move-list {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.5rem 0;
}
.pantry-sort-active {
font-weight: 600;
}