From 78c54aac0b20bbc2e8b78b3d8bb68500c92a1f26 Mon Sep 17 00:00:00 2001 From: lizerk Date: Thu, 6 Aug 2026 16:41:41 +0300 Subject: [PATCH] fix(tasks): stabilize board headers and task covers --- backend/api/v1/tasks/serializers.py | 6 + backend/api/v1/tasks/views.py | 39 ++++-- .../0034_cleanup_empty_checklist_covers.py | 24 ++++ backend/tests/api/v1/tasks/test_tasks_api.py | 23 ++++ frontend/src/app/tasks/page.tsx | 119 ++++++++++-------- .../src/components/tasks/TaskCoverPreview.tsx | 3 +- 6 files changed, 150 insertions(+), 64 deletions(-) create mode 100644 backend/tasks/migrations/0034_cleanup_empty_checklist_covers.py diff --git a/backend/api/v1/tasks/serializers.py b/backend/api/v1/tasks/serializers.py index 86162d16..5a1e4f3d 100644 --- a/backend/api/v1/tasks/serializers.py +++ b/backend/api/v1/tasks/serializers.py @@ -630,6 +630,12 @@ class TaskSerializer(serializers.ModelSerializer): cover = obj.cover except TaskCover.DoesNotExist: cover = None + if ( + cover is not None + and cover.kind == TaskCoverType.CHECKLIST + and not obj.checklist_items.exists() + ): + cover = None payload = ( TaskCoverSerializer(cover, context=self.context).data if cover is not None diff --git a/backend/api/v1/tasks/views.py b/backend/api/v1/tasks/views.py index 2be28bfd..3a52c773 100644 --- a/backend/api/v1/tasks/views.py +++ b/backend/api/v1/tasks/views.py @@ -169,19 +169,22 @@ def normalize_column_positions(column, row=None): Task.objects.filter(id=task_id).update(position=(index + 1) * 1000) -def send_task_checklist_update(task, event, item_id): +def send_task_checklist_update(task, event, item_id, *, cover_cleared=False): + extra = { + "task_id": task.id, + "checklist_total": task.checklist_items.count(), + "checklist_completed": task.checklist_items.filter( + is_completed=True + ).count(), + } + if cover_cleared: + extra["cover"] = None send_task_board_update( task.board, event, "checklist_item", item_id, - extra={ - "task_id": task.id, - "checklist_total": task.checklist_items.count(), - "checklist_completed": task.checklist_items.filter( - is_completed=True - ).count(), - }, + extra=extra, ) @@ -1809,7 +1812,18 @@ class TaskViewSet(viewsets.ModelViewSet): if request.method == "DELETE": item_id = item.id item_title = item.title - item.delete() + with transaction.atomic(): + item.delete() + task._prefetched_objects_cache.pop("checklist_items", None) + cover_cleared = False + if not task.checklist_items.exists(): + cover_cleared, _ = TaskCover.objects.filter( + task=task, + kind=TaskCoverType.CHECKLIST, + ).delete() + cover_cleared = bool(cover_cleared) + if cover_cleared: + task.save(update_fields=["updated_at"]) create_task_activity( task, request.user, @@ -1818,7 +1832,12 @@ class TaskViewSet(viewsets.ModelViewSet): object_id=item_id, metadata={"item_title": item_title}, ) - send_task_checklist_update(task, "checklist_updated", item_id) + send_task_checklist_update( + task, + "checklist_updated", + item_id, + cover_cleared=cover_cleared, + ) return Response(status=status.HTTP_204_NO_CONTENT) serializer = TaskChecklistItemSerializer( diff --git a/backend/tasks/migrations/0034_cleanup_empty_checklist_covers.py b/backend/tasks/migrations/0034_cleanup_empty_checklist_covers.py new file mode 100644 index 00000000..c214c7f1 --- /dev/null +++ b/backend/tasks/migrations/0034_cleanup_empty_checklist_covers.py @@ -0,0 +1,24 @@ +from django.db import migrations + + +def remove_empty_checklist_covers(apps, schema_editor): + TaskChecklistItem = apps.get_model("tasks", "TaskChecklistItem") + TaskCover = apps.get_model("tasks", "TaskCover") + database = schema_editor.connection.alias + task_ids_with_checklists = TaskChecklistItem.objects.using(database).values("task_id") + TaskCover.objects.using(database).filter(kind="checklist").exclude( + task_id__in=task_ids_with_checklists, + ).delete() + + +class Migration(migrations.Migration): + dependencies = [ + ("tasks", "0033_universal_task_covers"), + ] + + operations = [ + migrations.RunPython( + remove_empty_checklist_covers, + migrations.RunPython.noop, + ), + ] diff --git a/backend/tests/api/v1/tasks/test_tasks_api.py b/backend/tests/api/v1/tasks/test_tasks_api.py index d03dffeb..06744275 100644 --- a/backend/tests/api/v1/tasks/test_tasks_api.py +++ b/backend/tests/api/v1/tasks/test_tasks_api.py @@ -1738,6 +1738,29 @@ def test_task_cover_supports_checklist_comment_link_and_linked_object( ], } + delete_checklist_response = api_client.delete( + reverse( + "api:v1:tasks:task-checklist-item-detail", + kwargs={"pk": task.id, "item_id": checklist_item.id}, + ) + ) + assert delete_checklist_response.status_code == status.HTTP_204_NO_CONTENT + assert not TaskCover.objects.filter(task=task).exists() + + TaskCover.objects.create( + task=task, + kind=TaskCoverType.CHECKLIST, + created_by=user, + ) + board_response = api_client.get( + reverse("api:v1:tasks:task-board-detail", kwargs={"pk": board.id}) + ) + assert board_response.status_code == status.HTTP_200_OK + serialized_task = next( + item for item in board_response.data["tasks"] if item["id"] == task.id + ) + assert serialized_task["cover"] is None + comment_response = api_client.post( reverse("api:v1:tasks:task-comments", kwargs={"pk": task.id}), {"text": "Комментарий на обложке"}, diff --git a/frontend/src/app/tasks/page.tsx b/frontend/src/app/tasks/page.tsx index 14125000..de52eafa 100644 --- a/frontend/src/app/tasks/page.tsx +++ b/frontend/src/app/tasks/page.tsx @@ -893,6 +893,7 @@ function BoardColumn({ fillAvailableHeight = false, hideOnMobile = false, nested = false, + headerSticky = true, quickTaskComposer, }: { column: TaskColumn; @@ -932,6 +933,7 @@ function BoardColumn({ fillAvailableHeight?: boolean; hideOnMobile?: boolean; nested?: boolean; + headerSticky?: boolean; quickTaskComposer?: ReactNode; }) { const { @@ -981,7 +983,7 @@ function BoardColumn({ }`} >
@@ -1194,7 +1196,7 @@ function BoardColumnGroup({ }} className={`${hideOnMobile ? "hidden xl:flex" : "flex"} min-w-0 flex-col ${isDragging ? "opacity-70" : ""}`} > -
+
void; + attachedToHeaders?: boolean; }) { return ( -
+
{headers ? (
( ); + const baseColumns = ( + 0} + tasksCount={baseTasksCount} + gridTemplateColumns={gridTemplateColumns} + expanded={!boardViewState.collapsed_base_column_ids.includes(column.id)} + onToggle={() => toggleBoardViewState("base", column.id)} + attachedToHeaders + > + {previewCellColumns.map((item) => ( + renderCell(item, null, false, false, true) + ))} + + ); + + const subcolumnHeaders = subcolumns.length ? ( + `column-${item.id}`)} + strategy={horizontalListSortingStrategy} + > + {subcolumns.map((item) => renderCell(item, null, true, true, false, false))} + + ) : renderCell(column, null, false, true, false, false); + return ( - <> - 0} - tasksCount={baseTasksCount} - gridTemplateColumns={gridTemplateColumns} - expanded={!boardViewState.collapsed_base_column_ids.includes(column.id)} - onToggle={() => toggleBoardViewState("base", column.id)} - headers={columnRows.length > 0 ? ( - subcolumns.length ? ( - `column-${item.id}`)} - strategy={horizontalListSortingStrategy} - > - {subcolumns.map((item) => renderCell(item, null, true, true, false))} - - ) : renderCell(column, null, false, true, false) - ) : undefined} +
+
- {columnRows.length > 0 ? ( - previewCellColumns.map((item) => ( - renderCell(item, null, false, false, true) - )) - ) : subcolumns.length ? ( - `column-${item.id}`)} - strategy={horizontalListSortingStrategy} - > - {subcolumns.map((item) => renderCell(item, null, true))} - - ) : renderCell(column, null, false)} - - {columnRows.map((row) => { - const rowTasksCount = filteredTasks.filter((task) => task.row === row.id).length; - return ( - { - if (cellColumns[0]) startQuickTask(cellColumns[0].id, row.id); - }} - onEdit={() => openEditColumnRow(row)} - onDelete={() => void deleteColumnRow(row)} - expanded={!boardViewState.collapsed_row_ids.includes(row.id)} - onToggle={() => toggleBoardViewState("row", row.id)} - > - {previewCellColumns.map((item) => renderCell(item, row.id, false))} - - ); - })} - + {subcolumnHeaders} +
+ {baseColumns} +
+ {columnRows.map((row) => { + const rowTasksCount = filteredTasks.filter((task) => task.row === row.id).length; + return ( + { + if (cellColumns[0]) startQuickTask(cellColumns[0].id, row.id); + }} + onEdit={() => openEditColumnRow(row)} + onDelete={() => void deleteColumnRow(row)} + expanded={!boardViewState.collapsed_row_ids.includes(row.id)} + onToggle={() => toggleBoardViewState("row", row.id)} + > + {previewCellColumns.map((item) => renderCell(item, row.id, false))} + + ); + })} +
+
); })()} diff --git a/frontend/src/components/tasks/TaskCoverPreview.tsx b/frontend/src/components/tasks/TaskCoverPreview.tsx index e2330d31..f4731e33 100644 --- a/frontend/src/components/tasks/TaskCoverPreview.tsx +++ b/frontend/src/components/tasks/TaskCoverPreview.tsx @@ -133,7 +133,8 @@ export default function TaskCoverPreview({ if (cover.kind === "checklist" && cover.checklist) { const { completed, items, total } = cover.checklist; - const progress = total > 0 ? Math.round((completed / total) * 100) : 0; + if (total <= 0 || items.length === 0) return null; + const progress = Math.round((completed / total) * 100); return (