fix(tasks): stabilize board headers and task covers
This commit is contained in:
parent
ee48bf870e
commit
78c54aac0b
@ -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
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
),
|
||||
]
|
||||
@ -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": "Комментарий на обложке"},
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user