feat: organize lists with drag and drop
ci / gitleaks (push) Successful in 7s
ci / docker (push) Successful in 3m22s

This commit is contained in:
2026-09-09 20:28:29 +08:00
parent 30705e1cec
commit 0e291ddb9c
8 changed files with 679 additions and 8 deletions
+150 -1
View File
@@ -37,7 +37,9 @@ from .schemas import (
FolderOut,
InitializeRequest,
ListCreate,
ListMove,
ListOut,
ListReorder,
LoginRequest,
NameUpdate,
SessionOut,
@@ -320,6 +322,12 @@ async def delete_folder(
return Response(status_code=204)
async def _lock_list_order(db: AsyncSession, user_id: UUID) -> None:
# Serialize list creation/moves/reorders per user so max-position allocation
# and full-scope reorder validation cannot interleave.
await db.scalar(select(User.id).where(User.id == user_id).with_for_update())
@app.post("/api/v1/lists", response_model=ListOut, status_code=201)
async def create_list(
payload: ListCreate,
@@ -334,7 +342,26 @@ async def create_list(
)
):
raise HTTPException(status_code=404, detail="文件夹不存在")
item = TaskList(user_id=user.id, folder_id=payload.folder_id, name=payload.name)
await _lock_list_order(db, user.id)
folder_scope = (
TaskList.folder_id == payload.folder_id
if payload.folder_id is not None
else TaskList.folder_id.is_(None)
)
max_position = await db.scalar(
select(func.max(TaskList.position)).where(
TaskList.user_id == user.id,
folder_scope,
TaskList.is_inbox.is_(False),
TaskList.deleted_at.is_(None),
)
)
item = TaskList(
user_id=user.id,
folder_id=payload.folder_id,
name=payload.name,
position=(max_position if max_position is not None else -1) + 1,
)
db.add(item)
await db.flush()
audit(db, user.id, "create", "list", item.id)
@@ -371,6 +398,128 @@ async def _owned_list(
return item
@app.put("/api/v1/lists/reorder", status_code=204)
async def reorder_lists(
payload: ListReorder,
user: User = Depends(current_user),
db: AsyncSession = Depends(get_db),
):
if payload.folder_id is not None:
folder = await db.scalar(
select(Folder).where(
Folder.id == payload.folder_id,
Folder.user_id == user.id,
Folder.deleted_at.is_(None),
)
)
if folder is None:
raise HTTPException(status_code=404, detail="文件夹不存在")
await _lock_list_order(db, user.id)
folder_scope = (
TaskList.folder_id == payload.folder_id
if payload.folder_id is not None
else TaskList.folder_id.is_(None)
)
scope_rows = list(
(
await db.scalars(
select(TaskList)
.where(
TaskList.user_id == user.id,
folder_scope,
TaskList.is_inbox.is_(False),
TaskList.deleted_at.is_(None),
)
.order_by(TaskList.position, TaskList.created_at, TaskList.id)
.with_for_update()
)
).all()
)
if any(list_id not in {row.id for row in scope_rows} for list_id in payload.list_ids):
inbox_requested = await db.scalar(
select(TaskList.id).where(
TaskList.id.in_(payload.list_ids),
TaskList.user_id == user.id,
TaskList.is_inbox.is_(True),
)
)
if inbox_requested:
raise HTTPException(status_code=409, detail="系统收集箱不能排序")
raise HTTPException(status_code=409, detail="清单不属于指定作用域")
if set(payload.list_ids) != {row.id for row in scope_rows}:
raise HTTPException(status_code=409, detail="清单顺序已变化,请刷新后重试")
rows_by_id = {row.id: row for row in scope_rows}
for position, list_id in enumerate(payload.list_ids):
rows_by_id[list_id].position = position
await db.commit()
return Response(status_code=204)
@app.put("/api/v1/lists/{list_id}/move", response_model=ListOut)
async def move_list_to_folder(
list_id: UUID,
payload: ListMove,
user: User = Depends(current_user),
db: AsyncSession = Depends(get_db),
):
await _lock_list_order(db, user.id)
item = await _owned_list(db, user.id, list_id)
if item.is_inbox:
raise HTTPException(status_code=409, detail="系统收集箱不能移动")
if payload.folder_id is not None:
folder = await db.scalar(
select(Folder).where(
Folder.id == payload.folder_id,
Folder.user_id == user.id,
Folder.deleted_at.is_(None),
)
)
if folder is None:
raise HTTPException(status_code=404, detail="文件夹不存在")
target_scope = (
TaskList.folder_id == payload.folder_id
if payload.folder_id is not None
else TaskList.folder_id.is_(None)
)
target_rows = list(
(
await db.scalars(
select(TaskList)
.where(
TaskList.user_id == user.id,
target_scope,
TaskList.is_inbox.is_(False),
TaskList.deleted_at.is_(None),
TaskList.id != item.id,
)
.order_by(TaskList.position, TaskList.created_at, TaskList.id)
.with_for_update()
)
).all()
)
expected_ids = {row.id for row in target_rows} | {item.id}
if set(payload.list_ids) != expected_ids:
raise HTTPException(status_code=409, detail="目标清单顺序已变化,请刷新后重试")
rows_by_id = {row.id: row for row in target_rows}
rows_by_id[item.id] = item
item.folder_id = payload.folder_id
for position, ordered_id in enumerate(payload.list_ids):
rows_by_id[ordered_id].position = position
await db.flush()
audit(
db,
user.id,
"move",
"list",
item.id,
folder_id=str(payload.folder_id) if payload.folder_id else None,
)
await db.commit()
await db.refresh(item)
return item
@app.patch("/api/v1/lists/{list_id}", response_model=ListOut)
async def rename_list(
list_id: UUID,
+22
View File
@@ -74,8 +74,30 @@ class ListOut(BaseModel):
folder_id: UUID | None
name: str
is_inbox: bool
position: int
class ListMove(BaseModel):
folder_id: UUID | None
list_ids: list[UUID] = Field(min_length=1)
@model_validator(mode="after")
def unique_ids(self):
if len(self.list_ids) != len(set(self.list_ids)):
raise ValueError("list_ids must be unique")
return self
class ListReorder(BaseModel):
folder_id: UUID | None
list_ids: list[UUID] = Field(min_length=1)
@model_validator(mode="after")
def unique_ids(self):
if len(self.list_ids) != len(set(self.list_ids)):
raise ValueError("list_ids must be unique")
return self
class TaskCreate(BaseModel):
title: str = Field(min_length=1, max_length=500)
+145 -7
View File
@@ -9,6 +9,7 @@ import { buildTaskRrule, defaultTaskDueAt, filterTasks, fromDateTimeLocal, group
import { formatApiErrorDetail, isTaskView, nextTotalAfterLocalTaskAdd, normalizeRequiredName, readStoredBoolean, readStoredNavigation, shouldToggleRowSwipe, writeCountdownCache, writeStoredBoolean, writeStoredNavigation } from './lib/mvp-utils'
import { csrfHeader } from './lib/csrf'
import { createCompletionPulse } from './lib/completion-motion'
import { captureListDragPointer, getAdjacentListMove, hasExceededLongPressMovement, moveListToScope, snapshotListDragPointer, type ListDragPointer } from './lib/list-drag'
import MvpPanel from './MvpPanel.vue'
import CountdownPanel from './CountdownPanel.vue'
import FloatingAddButton from './components/FloatingAddButton.vue'
@@ -39,6 +40,14 @@ const activeView = ref<View>(restoredNavigation.view)
const selectedTask = ref<Task | null>(null)
const sidebarCreateOpen = ref(false)
const sidebarAction = ref<{ kind: 'folders' | 'lists'; item: FolderItem | TaskList } | null>(null)
const listMoveMenuOpen = ref(false)
const listDrag = ref<{ id: string; pointerId: number; startX: number; startY: number; offsetY: number; lastX: number; lastY: number } | null>(null)
const listDropFolderId = ref<string | null | undefined>(undefined)
const listReorderTarget = ref('')
const listReorderPlacement = ref<'before' | 'after'>('after')
let listHandleLongPressTimer: number | undefined
let listHandlePending: { id: string; pointer: ListDragPointer } | undefined
let suppressListClickId = ''
const query = ref('')
const error = ref('')
const notice = ref('')
@@ -792,8 +801,137 @@ function runSidebarCreate(kind: 'folder' | 'list') {
sidebarCreateOpen.value = false
kind === 'folder' ? void createFolder() : void createList(null)
}
function closeSidebarAction() { sidebarAction.value = null }
function closeSidebarAction() { sidebarAction.value = null; listMoveMenuOpen.value = false }
function toggleFolder(id: string) { const next = new Set(expandedFolders.value); next.has(id) ? next.delete(id) : next.add(id); expandedFolders.value = next }
function beginListDrag(list: TaskList, pointer: ListDragPointer) {
if (list.is_inbox) return
clearListHandlePress()
listDrag.value = { id: list.id, pointerId: pointer.pointerId, startX: pointer.clientX, startY: pointer.clientY, offsetY: 0, lastX: pointer.clientX, lastY: pointer.clientY }
listDropFolderId.value = list.folder_id
listReorderTarget.value = list.id
captureListDragPointer(pointer)
}
function startListHandlePress(list: TaskList, event: PointerEvent) {
const pointer = snapshotListDragPointer(event)
if (event.pointerType !== 'touch') {
beginListDrag(list, pointer)
return
}
clearListHandlePress()
listHandlePending = { id: list.id, pointer }
listHandleLongPressTimer = window.setTimeout(() => beginListDrag(list, pointer), 450)
}
function moveListHandle(list: TaskList, event: PointerEvent) {
if (!listDrag.value && listHandlePending?.id === list.id && listHandlePending.pointer.pointerId === event.pointerId) {
if (hasExceededLongPressMovement(
{ x: listHandlePending.pointer.clientX, y: listHandlePending.pointer.clientY },
{ x: event.clientX, y: event.clientY },
5,
)) clearListHandlePress()
return
}
moveListDrag(list, event)
}
function clearListHandlePress() {
if (listHandleLongPressTimer) window.clearTimeout(listHandleLongPressTimer)
listHandleLongPressTimer = undefined
listHandlePending = undefined
}
function resolveListDrop(event: PointerEvent) {
const activeId = listDrag.value?.id
const elements = document.elementsFromPoint(event.clientX, event.clientY)
const row = elements.map((element) => element.closest<HTMLElement>('[data-list-id]')).find((element) => element?.dataset.listId !== activeId)
if (row?.dataset.listId) {
const target = lists.value.find((item) => item.id === row.dataset.listId)
if (target && !target.is_inbox) {
listDropFolderId.value = target.folder_id
listReorderTarget.value = target.id
const rect = row.getBoundingClientRect()
listReorderPlacement.value = event.clientY < rect.top + rect.height / 2 ? 'before' : 'after'
return
}
}
const folder = elements.map((element) => element.closest<HTMLElement>('[data-folder-id]')).find(Boolean)
if (folder?.dataset.folderId) {
listDropFolderId.value = folder.dataset.folderId
listReorderTarget.value = ''
expandedFolders.value = new Set(expandedFolders.value).add(folder.dataset.folderId)
return
}
if (elements.some((element) => element.closest('.list-root-drop'))) {
listDropFolderId.value = null
listReorderTarget.value = ''
}
}
function moveListDrag(list: TaskList, event: PointerEvent) {
const drag = listDrag.value
if (!drag || drag.id !== list.id) return
drag.offsetY = event.clientY - drag.startY
drag.lastX = event.clientX
drag.lastY = event.clientY
resolveListDrop(event)
}
async function persistListMove(list: TaskList, folderId: string | null, targetId?: string, placement: 'before' | 'after' = 'after') {
if (list.is_inbox) return
const previous = lists.value
const result = moveListToScope(previous, list.id, folderId, targetId, placement)
if (result.items === previous) return
lists.value = result.items
if (folderId) expandedFolders.value = new Set(expandedFolders.value).add(folderId)
try {
if (list.folder_id !== folderId) {
await api(`/lists/${list.id}/move`, { method: 'PUT', body: JSON.stringify({ folder_id: folderId, list_ids: result.orderedIds }) })
} else {
await api('/lists/reorder', { method: 'PUT', body: JSON.stringify({ folder_id: folderId, list_ids: result.orderedIds }) })
}
toast(folderId ? '清单已移动' : '清单已移出文件夹')
} catch (reason) {
lists.value = previous
fail(reason)
}
}
function finishListDrag(list: TaskList, event: PointerEvent) {
clearListHandlePress()
const drag = listDrag.value
if (!drag || drag.id !== list.id) return
const moved = Math.hypot(event.clientX - drag.startX, event.clientY - drag.startY) > 5
const folderId = listDropFolderId.value ?? null
const targetId = listReorderTarget.value && listReorderTarget.value !== list.id ? listReorderTarget.value : undefined
const placement = listReorderPlacement.value
cancelListDrag()
if (!moved && folderId === list.folder_id && !targetId) return
suppressListClickId = list.id
window.setTimeout(() => { if (suppressListClickId === list.id) suppressListClickId = '' }, 400)
void persistListMove(list, folderId, targetId, placement)
}
function selectListUnlessDragged(list: TaskList) {
if (suppressListClickId === list.id) { suppressListClickId = ''; return }
void switchView('tasks', list.id)
}
function cancelListDrag() {
clearListHandlePress()
listDrag.value = null
listDropFolderId.value = undefined
listReorderTarget.value = ''
}
function openListMoveMenu() { listMoveMenuOpen.value = !listMoveMenuOpen.value }
function moveListFromMenu(folderId: string | null) {
const item = sidebarAction.value?.kind === 'lists' ? sidebarAction.value.item as TaskList : null
if (!item) return
listMoveMenuOpen.value = false
void persistListMove(item, folderId)
closeSidebarAction()
}
function canMoveListWithinScope(item: TaskList, direction: 'up' | 'down') {
return getAdjacentListMove(lists.value, item.id, direction) !== null
}
function moveListWithinScope(item: TaskList, direction: 'up' | 'down') {
const move = getAdjacentListMove(lists.value, item.id, direction)
if (!move) return
void persistListMove(item, item.folder_id, move.targetId, move.placement)
closeSidebarAction()
}
function formatDue(value: string | null, hasTime = true) {
if (!value) return ''
const date = new Date(value)
@@ -837,13 +975,13 @@ onMounted(bootstrap)
<button :class="{ active: activeView==='habits' }" @click="switchView('habits')"><Repeat2 />习惯</button>
<button :class="{ active: activeView==='countdowns' }" @click="switchView('countdowns')"><CalendarHeart />倒数日</button>
</nav>
<div class="section-title"><span>我的清单</span><span class="sidebar-create-wrap"><button class="mini-icon list-create-trigger" aria-label="新建清单或文件夹" :aria-expanded="sidebarCreateOpen" @click="toggleSidebarCreate"><Plus /></button><span v-if="sidebarCreateOpen" class="sidebar-popover sidebar-create-menu"><button @click="runSidebarCreate('list')"><ListTodo/>新建清单</button><button @click="runSidebarCreate('folder')"><Folder/>新建文件夹</button></span></span></div>
<div class="section-title list-root-drop" :class="{'list-drop-target':listDrag&&listDropFolderId===null&&!listReorderTarget}" @pointermove="listDrag&&resolveListDrop($event)"><span>我的清单</span><span class="sidebar-create-wrap"><button class="mini-icon list-create-trigger" aria-label="新建清单或文件夹" :aria-expanded="sidebarCreateOpen" @click="toggleSidebarCreate"><Plus /></button><span v-if="sidebarCreateOpen" class="sidebar-popover sidebar-create-menu"><button @click="runSidebarCreate('list')"><ListTodo/>新建清单</button><button @click="runSidebarCreate('folder')"><Folder/>新建文件夹</button></span></span></div>
<div class="folders">
<div v-for="folder in folders" :key="folder.id" class="folder-block">
<div class="folder-row"><button :title="folder.name" :aria-label="folder.name" @click="toggleFolder(folder.id)"><ChevronDown v-if="expandedFolders.has(folder.id)"/><ChevronRight v-else/><Folder/><span>{{folder.name}}</span></button><span class="row-actions"><button aria-label="打开文件夹操作" :aria-expanded="sidebarAction?.item.id===folder.id" @click="openSidebarAction('folders',folder)"><Ellipsis/></button></span></div>
<div v-for="list in lists.filter(l=>l.folder_id===folder.id && !l.is_inbox)" v-show="expandedFolders.has(folder.id)" :key="list.id" class="list-row" :class="{active:activeView==='tasks'&&activeList===list.id}"><button class="list-row-main" :title="list.name" :aria-label="list.name" @click="switchView('tasks',list.id)"><i/><span>{{list.name}}</span></button><span class="row-actions"><button aria-label="打开清单操作" :aria-expanded="sidebarAction?.item.id===list.id" @click="openSidebarAction('lists',list)"><Ellipsis/></button></span></div>
<div v-for="folder in folders" :key="folder.id" class="folder-block" :data-folder-id="folder.id">
<div class="folder-row" :class="{'list-drop-target':listDrag&&listDropFolderId===folder.id&&!listReorderTarget}" @pointermove="listDrag&&resolveListDrop($event)"><button :title="folder.name" :aria-label="folder.name" @click="toggleFolder(folder.id)"><ChevronDown v-if="expandedFolders.has(folder.id)"/><ChevronRight v-else/><Folder/><span>{{folder.name}}</span></button><span class="row-actions"><button aria-label="打开文件夹操作" :aria-expanded="sidebarAction?.item.id===folder.id" @click="openSidebarAction('folders',folder)"><Ellipsis/></button></span></div>
<div v-for="list in lists.filter(l=>l.folder_id===folder.id && !l.is_inbox)" v-show="expandedFolders.has(folder.id)" :key="list.id" :data-list-id="list.id" class="list-row" :class="{active:activeView==='tasks'&&activeList===list.id,'list-dragging':listDrag?.id===list.id,'list-reorder-target':listReorderTarget===list.id&&listDrag?.id!==list.id}" :style="{'--list-drag-y':`${listDrag?.id===list.id?listDrag.offsetY:0}px`}" @pointermove="moveListDrag(list,$event)" @pointerup="finishListDrag(list,$event)" @pointercancel="cancelListDrag"><button class="list-drag-handle" aria-label="拖动清单排序或移动到文件夹" title="长按拖动清单" @pointerdown.stop="startListHandlePress(list,$event)" @pointermove.stop="moveListHandle(list,$event)" @pointerup.stop="finishListDrag(list,$event)" @pointercancel.stop="cancelListDrag"><GripVertical/></button><button class="list-row-main" :title="list.name" :aria-label="list.name" @click="selectListUnlessDragged(list)"><i/><span>{{list.name}}</span></button><span class="row-actions"><button aria-label="打开清单操作" :aria-expanded="sidebarAction?.item.id===list.id" @click="openSidebarAction('lists',list)"><Ellipsis/></button></span></div>
</div>
<div v-for="list in lists.filter(l=>!l.folder_id&&!l.is_inbox)" :key="list.id" class="list-row" :class="{active:activeView==='tasks'&&activeList===list.id}"><button class="list-row-main" :title="list.name" :aria-label="list.name" @click="switchView('tasks',list.id)"><i/><span>{{list.name}}</span></button><span class="row-actions"><button aria-label="打开清单操作" :aria-expanded="sidebarAction?.item.id===list.id" @click="openSidebarAction('lists',list)"><Ellipsis/></button></span></div>
<div v-for="list in lists.filter(l=>!l.folder_id&&!l.is_inbox)" :key="list.id" :data-list-id="list.id" class="list-row" :class="{active:activeView==='tasks'&&activeList===list.id,'list-dragging':listDrag?.id===list.id,'list-reorder-target':listReorderTarget===list.id&&listDrag?.id!==list.id}" :style="{'--list-drag-y':`${listDrag?.id===list.id?listDrag.offsetY:0}px`}" @pointermove="moveListDrag(list,$event)" @pointerup="finishListDrag(list,$event)" @pointercancel="cancelListDrag"><button class="list-drag-handle" aria-label="拖动清单排序或移动到文件夹" title="长按拖动清单" @pointerdown.stop="startListHandlePress(list,$event)" @pointermove.stop="moveListHandle(list,$event)" @pointerup.stop="finishListDrag(list,$event)" @pointercancel.stop="cancelListDrag"><GripVertical/></button><button class="list-row-main" :title="list.name" :aria-label="list.name" @click="selectListUnlessDragged(list)"><i/><span>{{list.name}}</span></button><span class="row-actions"><button aria-label="打开清单操作" :aria-expanded="sidebarAction?.item.id===list.id" @click="openSidebarAction('lists',list)"><Ellipsis/></button></span></div>
<template v-if="archivedLists.length">
<div class="section-title"><span>已归档清单</span></div>
<div v-for="list in archivedLists" :key="list.id" class="list-row archived-row"><div class="list-row-main archived-row-label" :title="list.name" :aria-label="`已归档清单:${list.name}`"><ArchiveRestore/><span>{{list.name}}</span></div><span class="row-actions archived-actions"><button aria-label="恢复清单" @click="restoreList(list)"><ArchiveRestore/>恢复</button></span></div>
@@ -853,7 +991,7 @@ onMounted(bootstrap)
<button :class="{active:activeView==='trash'}" @click="switchView('trash')"><Trash2 />回收站</button>
<button :class="{active:activeView==='settings'}" @click="switchView('settings')"><Settings />设置</button>
</nav>
<div v-if="sidebarAction" class="sidebar-action-mask app-sheet-mask" @click.self="closeSidebarAction"><section class="sidebar-action-sheet app-sheet app-sheet--actions" role="dialog" aria-modal="true" :aria-label="`${sidebarAction.item.name}操作`"><header class="app-sheet__header"><b>{{sidebarAction.item.name}}</b><button class="icon" aria-label="关闭清单操作" @click="closeSidebarAction"><X/></button></header><div class="app-sheet__body"><button @click="renameEntity(sidebarAction.kind,sidebarAction.item);closeSidebarAction()"><Pencil/>重命名</button><button v-if="sidebarAction.kind==='folders'" @click="createList(sidebarAction.item.id);closeSidebarAction()"><Plus/>新建清单</button><button class="danger" @click="deleteEntity(sidebarAction.kind,sidebarAction.item);closeSidebarAction()"><component :is="sidebarAction.kind==='folders' ? Trash2 : ArchiveRestore"/>{{sidebarAction.kind==='folders'?'删除文件夹':'归档清单'}}</button></div></section></div>
<div v-if="sidebarAction" class="sidebar-action-mask app-sheet-mask" @click.self="closeSidebarAction"><section class="sidebar-action-sheet app-sheet app-sheet--actions" role="dialog" aria-modal="true" :aria-label="`${sidebarAction.item.name}操作`"><header class="app-sheet__header"><b>{{sidebarAction.item.name}}</b><button class="icon" aria-label="关闭清单操作" @click="closeSidebarAction"><X/></button></header><div class="app-sheet__body"><button @click="renameEntity(sidebarAction.kind,sidebarAction.item);closeSidebarAction()"><Pencil/>重命名</button><button v-if="sidebarAction.kind==='folders'" @click="createList(sidebarAction.item.id);closeSidebarAction()"><Plus/>新建清单</button><template v-if="sidebarAction.kind==='lists'"><button aria-label="上移清单" :disabled="!canMoveListWithinScope(sidebarAction.item as TaskList, 'up')" @click="moveListWithinScope(sidebarAction.item as TaskList, 'up')">上移</button><button aria-label="下移清单" :disabled="!canMoveListWithinScope(sidebarAction.item as TaskList, 'down')" @click="moveListWithinScope(sidebarAction.item as TaskList, 'down')">下移</button><button aria-label="移动到文件夹" aria-haspopup="menu" :aria-expanded="listMoveMenuOpen" @click="openListMoveMenu"><Folder/>移动到文件夹</button><div v-if="listMoveMenuOpen" class="list-move-menu" role="menu" aria-label="选择目标文件夹"><button v-if="(sidebarAction.item as TaskList).folder_id" role="menuitem" @click="moveListFromMenu(null)">移出文件夹</button><button v-for="folder in folders" :key="folder.id" role="menuitem" :disabled="(sidebarAction.item as TaskList).folder_id===folder.id" @click="moveListFromMenu(folder.id)">{{folder.name}}</button></div></template><button class="danger" @click="deleteEntity(sidebarAction.kind,sidebarAction.item);closeSidebarAction()"><component :is="sidebarAction.kind==='folders' ? Trash2 : ArchiveRestore"/>{{sidebarAction.kind==='folders'?'删除文件夹':'归档清单'}}</button></div></section></div>
</aside>
<main>
+75
View File
@@ -0,0 +1,75 @@
import { describe, expect, it, vi } from 'vitest'
import { captureListDragPointer, getAdjacentListMove, hasExceededLongPressMovement, moveListToScope, snapshotListDragPointer } from './list-drag'
type ListItem = { id: string; folder_id: string | null; name: string; is_inbox: boolean }
const rows: ListItem[] = [
{ id: 'inbox', folder_id: null, name: '收集箱', is_inbox: true },
{ id: 'a', folder_id: 'work', name: 'A', is_inbox: false },
{ id: 'b', folder_id: 'work', name: 'B', is_inbox: false },
{ id: 'c', folder_id: null, name: 'C', is_inbox: false },
]
describe('list drag ordering', () => {
it('moves a list into a folder and appends it to that scope', () => {
const result = moveListToScope(rows, 'c', 'work')
expect(result.items.map((item) => [item.id, item.folder_id])).toEqual([
['inbox', null], ['a', 'work'], ['b', 'work'], ['c', 'work'],
])
expect(result.orderedIds).toEqual(['a', 'b', 'c'])
})
it('moves a list out to My Lists without including Inbox in ordering', () => {
const result = moveListToScope(rows, 'a', null)
expect(result.items.map((item) => [item.id, item.folder_id])).toEqual([
['inbox', null], ['b', 'work'], ['c', null], ['a', null],
])
expect(result.orderedIds).toEqual(['c', 'a'])
})
it('sorts within one folder before a target', () => {
const result = moveListToScope(rows, 'b', 'work', 'a', 'before')
expect(result.items.filter((item) => item.folder_id === 'work').map((item) => item.id)).toEqual(['b', 'a'])
expect(result.orderedIds).toEqual(['b', 'a'])
})
it('does not allow Inbox to move', () => {
const result = moveListToScope(rows, 'inbox', 'work')
expect(result.items).toBe(rows)
expect(result.orderedIds).toEqual([])
})
it('finds only legal adjacent moves inside the current folder scope', () => {
expect(getAdjacentListMove(rows, 'a', 'up')).toBeNull()
expect(getAdjacentListMove(rows, 'a', 'down')).toEqual({ targetId: 'b', placement: 'after' })
expect(getAdjacentListMove(rows, 'b', 'up')).toEqual({ targetId: 'a', placement: 'before' })
expect(getAdjacentListMove(rows, 'b', 'down')).toBeNull()
expect(getAdjacentListMove(rows, 'c', 'up')).toBeNull()
expect(getAdjacentListMove(rows, 'inbox', 'down')).toBeNull()
})
it('captures the pointer from the stable pointerdown target after currentTarget is cleared', () => {
const setPointerCapture = vi.fn()
const row = { setPointerCapture } as unknown as Element
const event = {
pointerId: 7,
clientX: 120,
clientY: 240,
currentTarget: row,
} as unknown as PointerEvent
const pointer = snapshotListDragPointer(event)
Object.defineProperty(event, 'currentTarget', { value: null })
captureListDragPointer(pointer)
expect(pointer).toMatchObject({ pointerId: 7, clientX: 120, clientY: 240, captureTarget: row })
expect(setPointerCapture).toHaveBeenCalledOnce()
expect(setPointerCapture).toHaveBeenCalledWith(7)
})
it('cancels a pending long press from cumulative client-coordinate movement', () => {
expect(hasExceededLongPressMovement({ x: 10, y: 20 }, { x: 14, y: 23 }, 5)).toBe(false)
expect(hasExceededLongPressMovement({ x: 10, y: 20 }, { x: 14, y: 24 }, 5)).toBe(true)
expect(hasExceededLongPressMovement({ x: 10, y: 20 }, { x: 4, y: 20 }, 5)).toBe(true)
})
})
+74
View File
@@ -0,0 +1,74 @@
export type DraggableList = {
id: string
folder_id: string | null
is_inbox: boolean
}
export type ListDragPointer = {
pointerId: number
clientX: number
clientY: number
captureTarget: Element | null
}
export function snapshotListDragPointer(event: PointerEvent): ListDragPointer {
return {
pointerId: event.pointerId,
clientX: event.clientX,
clientY: event.clientY,
captureTarget: event.currentTarget as Element | null,
}
}
export function captureListDragPointer(pointer: ListDragPointer) {
try { pointer.captureTarget?.setPointerCapture(pointer.pointerId) } catch { /* synthetic events */ }
}
export function hasExceededLongPressMovement(
start: { x: number; y: number },
current: { x: number; y: number },
threshold: number,
) {
return Math.hypot(current.x - start.x, current.y - start.y) > threshold
}
export function getAdjacentListMove<T extends DraggableList>(
items: T[],
sourceId: string,
direction: 'up' | 'down',
): { targetId: string; placement: 'before' | 'after' } | null {
const source = items.find((item) => item.id === sourceId)
if (!source || source.is_inbox) return null
const scope = items.filter((item) => !item.is_inbox && item.folder_id === source.folder_id)
const index = scope.findIndex((item) => item.id === sourceId)
const target = scope[index + (direction === 'up' ? -1 : 1)]
if (!target) return null
return { targetId: target.id, placement: direction === 'up' ? 'before' : 'after' }
}
export function moveListToScope<T extends DraggableList>(
items: T[],
sourceId: string,
folderId: string | null,
targetId?: string,
placement: 'before' | 'after' = 'after',
) {
const source = items.find((item) => item.id === sourceId)
if (!source || source.is_inbox) return { items, orderedIds: [] as string[] }
const withoutSource = items.filter((item) => item.id !== sourceId)
const moved = { ...source, folder_id: folderId }
const scope = withoutSource.filter((item) => !item.is_inbox && item.folder_id === folderId)
const targetIndex = targetId ? scope.findIndex((item) => item.id === targetId) : -1
const insertAt = targetIndex < 0 ? scope.length : targetIndex + (placement === 'after' ? 1 : 0)
scope.splice(insertAt, 0, moved)
const iterator = scope[Symbol.iterator]()
const next = withoutSource.map((item) => {
if (item.is_inbox || item.folder_id !== folderId) return item
return iterator.next().value!
})
const remaining = [...iterator]
if (remaining.length) next.push(...remaining)
return { items: next, orderedIds: scope.map((item) => item.id) }
}
File diff suppressed because one or more lines are too long
+48
View File
@@ -676,6 +676,54 @@ describe('sidebar layout', () => {
expect(app).toContain(':title="list.name" :aria-label="list.name"')
})
it('supports dragging movable lists into folders, out to My Lists, and within one scope', () => {
expect(app).toContain('class="section-title list-root-drop"')
expect(app).toContain(':data-folder-id="folder.id"')
expect(app).toContain(':data-list-id="list.id"')
expect(app).toContain('class="list-drag-handle"')
expect(app).toContain("api(`/lists/${list.id}/move`, { method: 'PUT', body: JSON.stringify({ folder_id: folderId, list_ids: result.orderedIds }) })")
expect(app).toContain("} else {\n await api('/lists/reorder'")
expect(app).toContain('lists.value = previous')
expect(app).toContain('expandedFolders.value = new Set(expandedFolders.value).add(folderId)')
expect(app).not.toContain('list.is_inbox" class="list-drag-handle"')
})
it('uses the handle-only touch contract and exposes same-scope move controls', () => {
expect(app).not.toContain('@pointerdown="startListLongPress(list, $event)"')
expect(app).not.toContain('function startListLongPress')
expect(app).not.toContain('listLongPressTimer')
expect(app).toContain('@pointerdown.stop="startListHandlePress(list,$event)"')
expect(app).toContain('listHandlePending = { id: list.id, pointer }')
expect(app).toContain('window.setTimeout(() => beginListDrag(list, pointer), 450)')
expect(app).toContain('@pointermove.stop="moveListHandle(list,$event)"')
expect(app).toContain("if (!listDrag.value && listHandlePending?.id === list.id && listHandlePending.pointer.pointerId === event.pointerId)")
expect(app).toContain('hasExceededLongPressMovement(')
expect(app).toContain('clearListHandlePress()')
expect(app).toContain('moveListDrag(list, event)')
expect(app).toContain('@pointerup.stop="finishListDrag(list,$event)"')
expect(app).toContain('@pointercancel.stop="cancelListDrag"')
expect(app).toContain('listHandlePending = undefined')
expect(app).toContain('aria-label="上移清单"')
expect(app).toContain('aria-label="下移清单"')
expect(app).toContain("moveListWithinScope(sidebarAction.item as TaskList, 'up')")
expect(app).toContain("moveListWithinScope(sidebarAction.item as TaskList, 'down')")
expect(app).toContain('aria-label="移动到文件夹"')
expect(app).toContain('role="menu"')
expect(app).toContain('role="menuitem"')
expect(app).toContain('移出文件夹')
})
it('shows drag lift, folder highlighting, and insertion targets', () => {
expect(css).toContain('.list-row.list-dragging{')
expect(css).toContain('.folder-row.list-drop-target{')
expect(css).toContain('.list-root-drop.list-drop-target{')
expect(css).toContain('.list-row.list-reorder-target{')
expect(css).toContain('.list-drag-handle{width:44px;min-width:44px;height:44px;')
expect(css).toContain('.list-row{touch-action:pan-y}')
expect(css).not.toContain('.list-drag-handle{display:none}')
expect(css).not.toContain('.list-row.list-dragging{touch-action:none}')
})
it('only restores archived lists from the explicit restore action', () => {
expect(app).toContain('class="list-row-main archived-row-label"')
expect(app).not.toContain(':aria-label="`恢复清单:${list.name}`" @click="restoreList(list)"')
+164
View File
@@ -0,0 +1,164 @@
from uuid import uuid4
import pytest
from backend.db import get_engine
from backend.models import Folder, User
def initialized_client(client):
response = client.post(
"/api/v1/setup/initialize",
json={"username": "owner", "password": "correct horse battery staple"},
)
assert response.status_code == 201
return client
def create_list(client, name, folder_id=None):
response = client.post("/api/v1/lists", json={"name": name, "folder_id": folder_id})
assert response.status_code == 201
return response.json()
@pytest.mark.asyncio
async def test_move_list_validates_folder_ownership_and_is_atomic(client):
client = initialized_client(client)
task_list = create_list(client, "项目")
from sqlalchemy.ext.asyncio import async_sessionmaker
session_factory = async_sessionmaker(get_engine(), expire_on_commit=False)
async with session_factory() as db:
other = User(username="other", password_hash="not-used")
db.add(other)
await db.flush()
foreign_folder = Folder(user_id=other.id, name="别人的文件夹")
db.add(foreign_folder)
await db.commit()
foreign_folder_id = str(foreign_folder.id)
response = client.put(
f"/api/v1/lists/{task_list['id']}/move",
json={"folder_id": foreign_folder_id, "list_ids": [task_list["id"]]},
)
assert response.status_code == 404
unchanged = next(row for row in client.get("/api/v1/lists").json() if row["id"] == task_list["id"])
assert unchanged["folder_id"] is None
def test_move_list_between_folder_and_root_applies_target_order_atomically(client):
client = initialized_client(client)
folder = client.post("/api/v1/folders", json={"name": "工作"}).json()
root_a = create_list(client, "根 A")
root_b = create_list(client, "根 B")
folder_a = create_list(client, "文件夹 A", folder["id"])
moved = client.put(
f"/api/v1/lists/{root_a['id']}/move",
json={"folder_id": folder["id"], "list_ids": [root_a["id"], folder_a["id"]]},
)
assert moved.status_code == 200
assert moved.json()["folder_id"] == folder["id"]
folder_rows = [row for row in client.get("/api/v1/lists").json() if row["folder_id"] == folder["id"]]
assert [row["id"] for row in folder_rows] == [root_a["id"], folder_a["id"]]
moved_to_root = client.put(
f"/api/v1/lists/{root_a['id']}/move",
json={"folder_id": None, "list_ids": [root_b["id"], root_a["id"]]},
)
assert moved_to_root.status_code == 200
assert moved_to_root.json()["folder_id"] is None
root_rows = [
row for row in client.get("/api/v1/lists").json()
if not row["is_inbox"] and row["folder_id"] is None
]
assert [row["id"] for row in root_rows] == [root_b["id"], root_a["id"]]
def test_move_list_rejects_invalid_target_order_without_moving(client):
client = initialized_client(client)
folder = client.post("/api/v1/folders", json={"name": "工作"}).json()
root_a = create_list(client, "根 A")
folder_a = create_list(client, "文件夹 A", folder["id"])
response = client.put(
f"/api/v1/lists/{root_a['id']}/move",
json={"folder_id": folder["id"], "list_ids": [root_a["id"]]},
)
assert response.status_code == 409
rows = client.get("/api/v1/lists").json()
unchanged = next(row for row in rows if row["id"] == root_a["id"])
assert unchanged["folder_id"] is None
assert [row["id"] for row in rows if row["folder_id"] == folder["id"]] == [folder_a["id"]]
def test_inbox_cannot_be_moved_or_reordered(client):
client = initialized_client(client)
inbox = client.get("/api/v1/lists").json()[0]
folder = client.post("/api/v1/folders", json={"name": "工作"}).json()
moved = client.put(
f"/api/v1/lists/{inbox['id']}/move",
json={"folder_id": folder["id"], "list_ids": [inbox["id"]]},
)
assert moved.status_code == 409
reordered = client.put(
"/api/v1/lists/reorder", json={"folder_id": None, "list_ids": [inbox["id"]]}
)
assert reordered.status_code == 409
def test_reorder_lists_is_scoped_and_persists(client):
client = initialized_client(client)
folder = client.post("/api/v1/folders", json={"name": "工作"}).json()
root_a = create_list(client, "根 A")
root_b = create_list(client, "根 B")
folder_a = create_list(client, "文件夹 A", folder["id"])
folder_b = create_list(client, "文件夹 B", folder["id"])
assert client.put(
"/api/v1/lists/reorder",
json={"folder_id": None, "list_ids": [root_b["id"], root_a["id"]]},
).status_code == 204
assert client.put(
"/api/v1/lists/reorder",
json={"folder_id": folder["id"], "list_ids": [folder_b["id"], folder_a["id"]]},
).status_code == 204
rows = client.get("/api/v1/lists").json()
root_rows = [row for row in rows if not row["is_inbox"] and row["folder_id"] is None]
folder_rows = [row for row in rows if row["folder_id"] == folder["id"]]
assert [row["id"] for row in root_rows] == [root_b["id"], root_a["id"]]
assert [row["id"] for row in folder_rows] == [folder_b["id"], folder_a["id"]]
assert [row["position"] for row in root_rows] == [0, 1]
assert [row["position"] for row in folder_rows] == [0, 1]
def test_reorder_rejects_stale_or_mixed_scope_without_partial_write(client):
client = initialized_client(client)
folder = client.post("/api/v1/folders", json={"name": "工作"}).json()
root_a = create_list(client, "根 A")
root_b = create_list(client, "根 B")
folder_a = create_list(client, "文件夹 A", folder["id"])
stale = client.put(
"/api/v1/lists/reorder", json={"folder_id": None, "list_ids": [root_b["id"]]}
)
assert stale.status_code == 409
mixed = client.put(
"/api/v1/lists/reorder",
json={"folder_id": None, "list_ids": [root_b["id"], folder_a["id"]]},
)
assert mixed.status_code == 409
missing = client.put(
"/api/v1/lists/reorder",
json={"folder_id": str(uuid4()), "list_ids": [root_a["id"], root_b["id"]]},
)
assert missing.status_code == 404
rows = client.get("/api/v1/lists").json()
root_rows = [row for row in rows if not row["is_inbox"] and row["folder_id"] is None]
assert [row["id"] for row in root_rows] == [root_a["id"], root_b["id"]]