From c8d8d8be31c2e9187e7a78324d742db5b8ed8717 Mon Sep 17 00:00:00 2001 From: lizerk Date: Mon, 31 Aug 2026 10:37:58 +0300 Subject: [PATCH] Add drag-and-drop reordering for task checklists --- .../api/v1/tasks/viewsets/mixins/checklist.py | 86 ++++++++++++ backend/tests/api/v1/tasks/test_tasks_api.py | 74 ++++++++++ frontend/src/app/tasks/page.tsx | 130 ++++++++++++++++-- frontend/src/lib/api/tasks.ts | 12 ++ 4 files changed, 294 insertions(+), 8 deletions(-) diff --git a/backend/api/v1/tasks/viewsets/mixins/checklist.py b/backend/api/v1/tasks/viewsets/mixins/checklist.py index 51a6527c..355b4ff0 100644 --- a/backend/api/v1/tasks/viewsets/mixins/checklist.py +++ b/backend/api/v1/tasks/viewsets/mixins/checklist.py @@ -14,6 +14,7 @@ from tasks.models import ( TaskActivityObjectKind, TaskCover, TaskCoverType, + TaskChecklistItem, TaskExternalLink, ) from tasks.realtime import send_task_board_update, send_task_checklist_update @@ -65,6 +66,91 @@ class TaskChecklistAndLinksMixin: status=status.HTTP_201_CREATED, ) + @action( + detail=True, + methods=["post"], + url_path="checklist/reorder", + url_name="checklist-reorder", + ) + def checklist_reorder(self, request, pk=None): + """Atomically save the complete order of one task checklist.""" + task = self.get_object() + raw_item_ids = request.data.get("item_ids") + if not isinstance(raw_item_ids, list) or not raw_item_ids: + return Response( + {"item_ids": ["Передайте полный порядок пунктов чек-листа."]}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + item_ids = [int(item_id) for item_id in raw_item_ids] + except (TypeError, ValueError): + return Response( + {"item_ids": ["Идентификаторы пунктов должны быть числами."]}, + status=status.HTTP_400_BAD_REQUEST, + ) + if len(item_ids) != len(set(item_ids)): + return Response( + {"item_ids": ["Пункты в списке не должны повторяться."]}, + status=status.HTTP_400_BAD_REQUEST, + ) + + with transaction.atomic(): + items = list( + task.checklist_items.select_for_update() + .select_related("created_by", "completed_by") + .order_by("position", "id") + ) + items_by_id = {item.id: item for item in items} + if set(item_ids) != set(items_by_id): + return Response( + { + "item_ids": [ + "Порядок чек-листа изменился. Обновите его и повторите перенос." + ] + }, + status=status.HTTP_409_CONFLICT, + ) + + previous_item_ids = [item.id for item in items] + if item_ids == previous_item_ids: + return Response(TaskChecklistItemSerializer(items, many=True).data) + + updated_at = timezone.now() + ordered_items = [] + for index, item_id in enumerate(item_ids, start=1): + item = items_by_id[item_id] + item.position = index * 1000 + item.updated_at = updated_at + ordered_items.append(item) + TaskChecklistItem.objects.bulk_update( + ordered_items, + ["position", "updated_at"], + ) + + moved_item_id = request.data.get("moved_item") + try: + moved_item_id = int(moved_item_id) + except (TypeError, ValueError): + moved_item_id = item_ids[0] + if moved_item_id not in items_by_id: + moved_item_id = item_ids[0] + moved_item = items_by_id[moved_item_id] + create_task_activity( + task, + request.user, + TaskActivityAction.CHECKLIST_ITEM_UPDATED, + object_kind=TaskActivityObjectKind.CHECKLIST_ITEM, + object_id=moved_item.id, + metadata={ + "item_title": moved_item.title, + "fields": ["position"], + }, + ) + send_task_checklist_update(task, "checklist_updated", moved_item.id) + + return Response(TaskChecklistItemSerializer(ordered_items, many=True).data) + @action( detail=True, methods=["patch", "delete"], diff --git a/backend/tests/api/v1/tasks/test_tasks_api.py b/backend/tests/api/v1/tasks/test_tasks_api.py index f1973ac8..9fc1dd77 100644 --- a/backend/tests/api/v1/tasks/test_tasks_api.py +++ b/backend/tests/api/v1/tasks/test_tasks_api.py @@ -3078,6 +3078,80 @@ def test_task_checklist_follows_board_access_and_supports_full_lifecycle( }.issubset({item["action"] for item in activity_response.data}) +def test_task_checklist_reorder_is_atomic_and_requires_complete_current_order( + api_client, +): + owner = make_user("task-checklist-reorder-owner@example.com", "+79994440108") + outsider = make_user("task-checklist-reorder-outsider@example.com", "+79994440109") + board = TaskBoard.objects.create( + name="Доска с порядком", + created_by=owner, + access_scope="restricted", + ) + column = TaskColumn.objects.create(board=board, name="Новые", position=1000) + task = Task.objects.create( + board=board, + column=column, + title="Упорядочить выпуск", + created_by=owner, + ) + items = [ + TaskChecklistItem.objects.create( + task=task, + title=title, + position=index * 1000, + created_by=owner, + ) + for index, title in enumerate(("Первый", "Второй", "Третий"), start=1) + ] + reorder_url = reverse( + "api:v1:tasks:task-checklist-reorder", + kwargs={"pk": task.id}, + ) + + api_client.force_authenticate(user=owner) + reordered_ids = [items[2].id, items[0].id, items[1].id] + response = api_client.post( + reorder_url, + {"item_ids": reordered_ids, "moved_item": items[2].id}, + format="json", + ) + + assert response.status_code == status.HTTP_200_OK + assert [item["id"] for item in response.data] == reordered_ids + assert [item["position"] for item in response.data] == [1000, 2000, 3000] + assert list( + task.checklist_items.order_by("position", "id").values_list("id", flat=True) + ) == reordered_ids + + incomplete_response = api_client.post( + reorder_url, + {"item_ids": reordered_ids[:-1]}, + format="json", + ) + assert incomplete_response.status_code == status.HTTP_409_CONFLICT + assert list( + task.checklist_items.order_by("position", "id").values_list("id", flat=True) + ) == reordered_ids + + duplicate_response = api_client.post( + reorder_url, + {"item_ids": [items[0].id, items[0].id, items[1].id]}, + format="json", + ) + assert duplicate_response.status_code == status.HTTP_400_BAD_REQUEST + + api_client.force_authenticate(user=outsider) + assert ( + api_client.post( + reorder_url, + {"item_ids": reordered_ids}, + format="json", + ).status_code + == status.HTTP_404_NOT_FOUND + ) + + def test_create_board_accepts_members_and_departments(api_client, user): member = make_user("task-member@example.com", "+79994440002") department = Department.objects.create(name="Проектный отдел") diff --git a/frontend/src/app/tasks/page.tsx b/frontend/src/app/tasks/page.tsx index 29c1d066..40b5e946 100644 --- a/frontend/src/app/tasks/page.tsx +++ b/frontend/src/app/tasks/page.tsx @@ -26,6 +26,7 @@ import { arrayMove, horizontalListSortingStrategy, useSortable, + verticalListSortingStrategy, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { @@ -41,6 +42,7 @@ import { Flag, FileSignature, FileText, + GripVertical, History, ImagePlus, Kanban, @@ -3512,6 +3514,59 @@ function TaskChecklistFormRow({ } +function SortableTaskChecklistRow({ + item, + disabled, + children, +}: { + item: TaskChecklistItem; + disabled?: boolean; + children: ReactNode; +}) { + const { + attributes, + listeners, + setActivatorNodeRef, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ + id: item.id, + disabled, + }); + const style: CSSProperties = { + transform: CSS.Transform.toString(transform), + transition, + zIndex: isDragging ? 20 : undefined, + }; + + return ( +
+ + {children} +
+ ); +} + + const activityFieldLabels: Record = { title: "название", description: "описание", @@ -6720,6 +6775,53 @@ function TasksPageContent() { viewTask, ]); + const reorderViewedTaskChecklist = useCallback(async (event: DragEndEvent) => { + if ( + !viewTask + || taskChecklistSaving + || editingChecklistItemId !== null + || !event.over + ) return; + + const activeItemId = Number(event.active.id); + const overItemId = Number(event.over.id); + const oldIndex = taskChecklist.findIndex((item) => item.id === activeItemId); + const newIndex = taskChecklist.findIndex((item) => item.id === overItemId); + if (oldIndex < 0 || newIndex < 0 || oldIndex === newIndex) return; + + const previousChecklist = taskChecklist; + const reorderedChecklist = arrayMove(taskChecklist, oldIndex, newIndex).map( + (item, index) => ({ ...item, position: (index + 1) * 1000 }), + ); + setTaskChecklist(reorderedChecklist); + setTaskChecklistSaving(true); + setError(null); + try { + const savedChecklist = await apiClient.reorderTaskChecklist( + viewTask.id, + reorderedChecklist.map((item) => item.id), + activeItemId, + ); + setTaskChecklist(savedChecklist); + setTaskChecklistLoaded(true); + if (activityOpen) await loadTaskActivity(viewTask.id); + } catch (checklistError) { + setTaskChecklist(previousChecklist); + setError(getTaskError(checklistError, "Не удалось изменить порядок чек-листа")); + await loadTaskChecklist(viewTask.id); + } finally { + setTaskChecklistSaving(false); + } + }, [ + activityOpen, + editingChecklistItemId, + loadTaskActivity, + loadTaskChecklist, + taskChecklist, + taskChecklistSaving, + viewTask, + ]); + const toggleViewedTaskChecklistItem = useCallback(async (item: TaskChecklistItem) => { if (!board || !viewTask || taskChecklistSaving) return; @@ -9658,12 +9760,22 @@ function TasksPageContent() { ) : taskChecklist.length > 0 ? ( -
- {taskChecklist.map((item) => ( -
+ void reorderViewedTaskChecklist(event)} + > + item.id)} + strategy={verticalListSortingStrategy} + > +
+ {taskChecklist.map((item) => ( + )} + + ))}
- ))} -
+ + ) : (

Пунктов пока нет

diff --git a/frontend/src/lib/api/tasks.ts b/frontend/src/lib/api/tasks.ts index 8528f9fe..b6da2938 100644 --- a/frontend/src/lib/api/tasks.ts +++ b/frontend/src/lib/api/tasks.ts @@ -335,6 +335,18 @@ export function createTasksApi(request: RequestFn, requestRaw: RawRequestFn) { method: "PATCH", body: JSON.stringify(data), }), + reorderTaskChecklist: ( + id: number, + itemIds: number[], + movedItem: number, + ): Promise => + request(`/api/v1/tasks/${id}/checklist/reorder/`, { + method: "POST", + body: JSON.stringify({ + item_ids: itemIds, + moved_item: movedItem, + }), + }), deleteTaskChecklistItem: (id: number, itemId: number): Promise => request(`/api/v1/tasks/${id}/checklist/${itemId}/`, { method: "DELETE",