Merge branch 'develop'
This commit is contained in:
commit
3f44534760
@ -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"],
|
||||
|
||||
@ -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="Проектный отдел")
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user