Add drag-and-drop reordering for task checklists

This commit is contained in:
lizerk 2026-08-31 10:37:58 +03:00
parent d6706c518b
commit c8d8d8be31
4 changed files with 294 additions and 8 deletions

View File

@ -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"],

View File

@ -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="Проектный отдел")

View File

@ -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 (
<div
ref={setNodeRef}
style={style}
className={`relative flex min-w-0 items-center gap-2 border-b border-[var(--border-subtle)] px-2 py-2.5 last:border-b-0 ${
isDragging ? "app-surface-elevated opacity-80 shadow-lg" : ""
}`}
>
<button
ref={setActivatorNodeRef}
type="button"
disabled={disabled}
className="app-text-muted inline-flex h-7 w-6 shrink-0 cursor-grab touch-none items-center justify-center rounded transition hover:bg-[var(--surface-tertiary)] hover:text-[var(--foreground)] active:cursor-grabbing disabled:cursor-default disabled:opacity-40"
{...attributes}
{...listeners}
title="Перетащите, чтобы изменить порядок"
aria-label={`Изменить порядок пункта «${item.title}»`}
>
<GripVertical size={15} strokeWidth={2} />
</button>
{children}
</div>
);
}
const activityFieldLabels: Record<string, string> = {
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() {
<Loader2 size={18} className="mx-auto animate-spin text-sky-500" />
</div>
) : taskChecklist.length > 0 ? (
<div className="overflow-hidden rounded-lg border border-[var(--border-subtle)]">
{taskChecklist.map((item) => (
<div
key={item.id}
className="flex min-w-0 items-center gap-3 border-b border-[var(--border-subtle)] px-3 py-2.5 last:border-b-0"
>
<DndContext
sensors={sensors}
collisionDetection={closestCorners}
onDragEnd={(event) => void reorderViewedTaskChecklist(event)}
>
<SortableContext
items={taskChecklist.map((item) => item.id)}
strategy={verticalListSortingStrategy}
>
<div className="overflow-hidden rounded-lg border border-[var(--border-subtle)]">
{taskChecklist.map((item) => (
<SortableTaskChecklistRow
key={item.id}
item={item}
disabled={taskChecklistSaving || editingChecklistItemId !== null}
>
<input
type="checkbox"
checked={item.is_completed}
@ -9750,9 +9862,11 @@ function TasksPageContent() {
</button>
</>
)}
</SortableTaskChecklistRow>
))}
</div>
))}
</div>
</SortableContext>
</DndContext>
) : (
<div className="app-surface-muted rounded-xl border border-dashed border-[var(--border-subtle)] px-3 py-4 text-center">
<p className="app-text-muted text-xs">Пунктов пока нет</p>

View File

@ -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<TaskChecklistItem[]> =>
request(`/api/v1/tasks/${id}/checklist/reorder/`, {
method: "POST",
body: JSON.stringify({
item_ids: itemIds,
moved_item: movedItem,
}),
}),
deleteTaskChecklistItem: (id: number, itemId: number): Promise<void> =>
request(`/api/v1/tasks/${id}/checklist/${itemId}/`, {
method: "DELETE",