mirror of
https://github.com/chenasraf/nextcloud-pantry.git
synced 2026-05-17 17:28:01 +00:00
feat: move items between lists
This commit is contained in:
@@ -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());
|
||||
});
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
|
||||
@@ -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)."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@ export interface ItemInput {
|
||||
rrule?: string | null
|
||||
repeatFromCompletion?: boolean
|
||||
sortOrder?: number
|
||||
targetListId?: number
|
||||
}
|
||||
|
||||
export async function addItem(
|
||||
|
||||
109
src/components/ChecklistIconPicker/ChecklistFormDialog.vue
Normal file
109
src/components/ChecklistIconPicker/ChecklistFormDialog.vue
Normal 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>
|
||||
@@ -4,3 +4,4 @@ export {
|
||||
checklistIconComponent,
|
||||
type ChecklistIconOption,
|
||||
} from './checklistIcons'
|
||||
export { default as ChecklistFormDialog } from './ChecklistFormDialog.vue'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user