fix(tasks): stabilize board headers and task covers

This commit is contained in:
lizerk 2026-08-06 16:41:41 +03:00
parent ee48bf870e
commit 78c54aac0b
6 changed files with 150 additions and 64 deletions

View File

@ -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

View File

@ -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(

View File

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

View File

@ -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": "Комментарий на обложке"},

View File

@ -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({
}`}
>
<div
className={`${showHeader ? "flex" : "hidden"} items-center justify-between gap-3 rounded-xl border bg-[var(--surface-primary)] px-3 py-3 shadow-sm transition xl:sticky ${nested ? "xl:top-[4.75rem]" : "xl:top-0"} xl:z-10 ${columnBorderClass} ${
className={`${showHeader ? "flex" : "hidden"} items-center justify-between gap-3 rounded-xl border bg-[var(--surface-primary)] px-3 py-3 shadow-sm transition ${headerSticky ? `xl:sticky ${nested ? "xl:top-[4.75rem]" : "xl:top-0"} xl:z-10` : ""} ${columnBorderClass} ${
isDragging ? "shadow-xl" : ""
}`}
>
@ -1194,7 +1196,7 @@ function BoardColumnGroup({
}}
className={`${hideOnMobile ? "hidden xl:flex" : "flex"} min-w-0 flex-col ${isDragging ? "opacity-70" : ""}`}
>
<div className="app-surface xl:sticky xl:top-0 xl:z-20 flex items-center justify-between gap-3 rounded-xl border border-[var(--border-subtle)] px-3 py-3 shadow-sm">
<div className="app-surface xl:sticky xl:top-0 xl:z-30 flex items-center justify-between gap-3 rounded-xl border border-[var(--border-subtle)] px-3 py-3 shadow-sm">
<div
ref={setActivatorNodeRef}
className="min-w-0 flex-1 cursor-grab touch-none active:cursor-grabbing"
@ -1402,6 +1404,7 @@ function BoardBaseColumnsView({
gridTemplateColumns,
expanded,
onToggle,
attachedToHeaders = false,
}: {
collapsible: boolean;
tasksCount: number;
@ -1410,9 +1413,14 @@ function BoardBaseColumnsView({
gridTemplateColumns: string;
expanded: boolean;
onToggle: () => void;
attachedToHeaders?: boolean;
}) {
return (
<section className="min-w-0 rounded-xl border border-[var(--border-subtle)] bg-[var(--surface-muted)] p-2">
<section
className={`min-w-0 border border-[var(--border-subtle)] bg-[var(--surface-muted)] p-2 ${
attachedToHeaders ? "-mt-px rounded-b-xl border-t-0 pt-2" : "rounded-xl"
}`}
>
{headers ? (
<div
className="flex min-w-0 flex-col gap-2 xl:grid xl:w-max"
@ -6909,6 +6917,7 @@ function TasksPageContent() {
sortable: boolean,
showHeaderValue = rowId === null,
showBodyValue = true,
headerStickyValue = true,
) => (
<BoardColumn
key={`${cellColumn.id}:${rowId ?? "base"}`}
@ -6944,61 +6953,65 @@ function TasksPageContent() {
synchronizedColumnDrag={activeColumnDragId !== null}
fillAvailableHeight={desktopWideMode && rowId === null}
nested={Boolean(cellColumn.parent)}
headerSticky={headerStickyValue}
quickTaskComposer={renderQuickTaskComposer(cellColumn, rowId)}
/>
);
const baseColumns = (
<BoardBaseColumnsView
collapsible={columnRows.length > 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)
))}
</BoardBaseColumnsView>
);
const subcolumnHeaders = subcolumns.length ? (
<SortableContext
items={subcolumns.map((item) => `column-${item.id}`)}
strategy={horizontalListSortingStrategy}
>
{subcolumns.map((item) => renderCell(item, null, true, true, false, false))}
</SortableContext>
) : renderCell(column, null, false, true, false, false);
return (
<>
<BoardBaseColumnsView
collapsible={columnRows.length > 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 ? (
<SortableContext
items={subcolumns.map((item) => `column-${item.id}`)}
strategy={horizontalListSortingStrategy}
>
{subcolumns.map((item) => renderCell(item, null, true, true, false))}
</SortableContext>
) : renderCell(column, null, false, true, false)
) : undefined}
<div className="flex min-w-0 flex-col xl:w-max">
<div
className="z-20 flex min-w-0 flex-col gap-2 rounded-t-xl border border-b-0 border-[var(--border-subtle)] bg-[var(--surface-muted)] p-2 pb-0 xl:sticky xl:top-[4.75rem] xl:grid xl:w-max"
style={{ gridTemplateColumns }}
>
{columnRows.length > 0 ? (
previewCellColumns.map((item) => (
renderCell(item, null, false, false, true)
))
) : subcolumns.length ? (
<SortableContext
items={subcolumns.map((item) => `column-${item.id}`)}
strategy={horizontalListSortingStrategy}
>
{subcolumns.map((item) => renderCell(item, null, true))}
</SortableContext>
) : renderCell(column, null, false)}
</BoardBaseColumnsView>
{columnRows.map((row) => {
const rowTasksCount = filteredTasks.filter((task) => task.row === row.id).length;
return (
<BoardColumnRowView
key={row.id}
row={{ ...row, tasks_count: rowTasksCount }}
gridTemplateColumns={gridTemplateColumns}
onCreateTask={() => {
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))}
</BoardColumnRowView>
);
})}
</>
{subcolumnHeaders}
</div>
{baseColumns}
<div className="mt-2 flex min-w-0 flex-col gap-2">
{columnRows.map((row) => {
const rowTasksCount = filteredTasks.filter((task) => task.row === row.id).length;
return (
<BoardColumnRowView
key={row.id}
row={{ ...row, tasks_count: rowTasksCount }}
gridTemplateColumns={gridTemplateColumns}
onCreateTask={() => {
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))}
</BoardColumnRowView>
);
})}
</div>
</div>
);
})()}
</BoardColumnGroup>

View File

@ -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 (
<div className={`app-surface-muted flex flex-col justify-center overflow-hidden p-2 ${className}`}>
<div className="flex items-center gap-2">