From c945e92a790b9955e2eb18c4de7f54d9543237b2 Mon Sep 17 00:00:00 2001 From: lizerk Date: Thu, 6 Aug 2026 07:19:23 +0300 Subject: [PATCH] fix task board navigation and completion behavior --- backend/api/v1/tasks/serializers.py | 1 + backend/api/v1/tasks/views.py | 53 +++++++++--- .../0028_unique_done_column_per_board.py | 40 +++++++++ backend/tasks/models.py | 5 ++ backend/tests/api/v1/tasks/test_tasks_api.py | 84 +++++++++++++++++-- frontend/src/app/tasks/page.tsx | 47 ++++++----- .../messages/MessagesChatListItem.tsx | 41 +++++++++ .../messages/MessagesFocusSidebar.tsx | 84 +++++++++++++++++-- 8 files changed, 308 insertions(+), 47 deletions(-) create mode 100644 backend/tasks/migrations/0028_unique_done_column_per_board.py diff --git a/backend/api/v1/tasks/serializers.py b/backend/api/v1/tasks/serializers.py index bd083a2d..a64e8034 100644 --- a/backend/api/v1/tasks/serializers.py +++ b/backend/api/v1/tasks/serializers.py @@ -170,6 +170,7 @@ class TaskColumnSerializer(serializers.ModelSerializer): raise serializers.ValidationError( {"parent": "Нельзя добавить подколонку в архивную колонку."} ) + attrs["is_done"] = parent.is_done if ( self.instance diff --git a/backend/api/v1/tasks/views.py b/backend/api/v1/tasks/views.py index edc1f7d5..d173c04a 100644 --- a/backend/api/v1/tasks/views.py +++ b/backend/api/v1/tasks/views.py @@ -633,6 +633,18 @@ class TaskColumnViewSet(viewsets.ModelViewSet): parent = serializer.validated_data.get("parent") if parent: parent = TaskColumn.objects.select_for_update().get(pk=parent.pk) + elif serializer.validated_data.get("is_done"): + board = serializer.validated_data["board"] + TaskColumn.objects.select_for_update().filter( + board=board, + parent__isnull=True, + is_done=True, + ).update(is_done=False) + TaskColumn.objects.filter( + board=board, + parent__isnull=False, + is_done=True, + ).update(is_done=False) column = serializer.save(parent=parent) if parent: task_updates = {"column": column} @@ -644,7 +656,28 @@ class TaskColumnViewSet(viewsets.ModelViewSet): send_task_board_update(column.board, "created", "column", column.id) def perform_update(self, serializer): - column = serializer.save() + with transaction.atomic(): + current_column = TaskColumn.objects.select_for_update().get( + pk=serializer.instance.pk + ) + requested_is_done = serializer.validated_data.get( + "is_done", + current_column.is_done, + ) + if current_column.parent_id is None and requested_is_done: + TaskColumn.objects.filter( + board=current_column.board, + parent__isnull=True, + is_done=True, + ).exclude(pk=current_column.pk).update(is_done=False) + TaskColumn.objects.filter( + board=current_column.board, + parent__isnull=False, + is_done=True, + ).exclude(parent_id=current_column.pk).update(is_done=False) + column = serializer.save() + if column.parent_id is None: + column.subcolumns.update(is_done=column.is_done) send_task_board_update(column.board, "updated", "column", column.id) def destroy(self, request, *args, **kwargs): @@ -1247,27 +1280,27 @@ class TaskViewSet(viewsets.ModelViewSet): if task.column.is_done: return Response(self.get_serializer(task).data) - done_column = ( + done_parent = ( TaskColumn.objects.filter( board_id=task.board_id, is_done=True, is_archived=False, + parent__isnull=True, ) - .annotate( - active_subcolumns_count=Count( - "subcolumns", - filter=Q(subcolumns__is_archived=False), - ) - ) - .filter(active_subcolumns_count=0) .order_by("position", "id") .first() ) - if done_column is None: + if done_parent is None: return Response( {"detail": "На доске не настроена завершающая колонка."}, status=status.HTTP_400_BAD_REQUEST, ) + done_column = ( + done_parent.subcolumns.filter(is_archived=False) + .order_by("position", "id") + .first() + or done_parent + ) task = self._move_task(task, done_column, None, None, request.user) return Response(self.get_serializer(task).data) diff --git a/backend/tasks/migrations/0028_unique_done_column_per_board.py b/backend/tasks/migrations/0028_unique_done_column_per_board.py new file mode 100644 index 00000000..8c78634f --- /dev/null +++ b/backend/tasks/migrations/0028_unique_done_column_per_board.py @@ -0,0 +1,40 @@ +from django.db import migrations, models + + +def normalize_done_columns(apps, schema_editor): + TaskBoard = apps.get_model("tasks", "TaskBoard") + TaskColumn = apps.get_model("tasks", "TaskColumn") + + for board_id in TaskBoard.objects.values_list("id", flat=True).iterator(): + done_columns = TaskColumn.objects.filter( + board_id=board_id, + parent_id=None, + is_done=True, + ).order_by("-updated_at", "-id") + selected_column = done_columns.first() + if selected_column is None: + continue + done_columns.exclude(id=selected_column.id).update(is_done=False) + TaskColumn.objects.filter( + board_id=board_id, + parent_id__isnull=False, + ).update(is_done=False) + TaskColumn.objects.filter(parent_id=selected_column.id).update(is_done=True) + + +class Migration(migrations.Migration): + dependencies = [ + ("tasks", "0027_taskboardplacement_view_state"), + ] + + operations = [ + migrations.RunPython(normalize_done_columns, migrations.RunPython.noop), + migrations.AddConstraint( + model_name="taskcolumn", + constraint=models.UniqueConstraint( + condition=models.Q(parent__isnull=True, is_done=True), + fields=("board",), + name="uniq_task_done_column_per_board", + ), + ), + ] diff --git a/backend/tasks/models.py b/backend/tasks/models.py index 071bed1a..45a1fb9e 100644 --- a/backend/tasks/models.py +++ b/backend/tasks/models.py @@ -214,6 +214,11 @@ class TaskColumn(models.Model): condition=models.Q(parent__isnull=False), name="uniq_task_subcolumn_parent_name", ), + models.UniqueConstraint( + fields=["board"], + condition=models.Q(parent__isnull=True, is_done=True), + name="uniq_task_done_column_per_board", + ), ] def __str__(self): diff --git a/backend/tests/api/v1/tasks/test_tasks_api.py b/backend/tests/api/v1/tasks/test_tasks_api.py index 08131d84..a7cb0c8d 100644 --- a/backend/tests/api/v1/tasks/test_tasks_api.py +++ b/backend/tests/api/v1/tasks/test_tasks_api.py @@ -1183,7 +1183,7 @@ def test_member_can_claim_unassigned_task(api_client, user): assert completed_task.assignee_id is None -def test_only_assignee_can_complete_task_in_first_done_column(api_client, user): +def test_only_assignee_can_complete_task_in_configured_done_column(api_client, user): assignee = make_user("task-assignee@example.com", "+79994440093") member = make_user("task-member@example.com", "+79994440094") board = TaskBoard.objects.create(name="Доска завершения", created_by=user) @@ -1193,17 +1193,16 @@ def test_only_assignee_can_complete_task_in_first_done_column(api_client, user): name="В работе", position=1000, ) - first_done_column = TaskColumn.objects.create( + previous_done_column = TaskColumn.objects.create( board=board, name="Готово", position=2000, is_done=True, ) - TaskColumn.objects.create( + configured_done_column = TaskColumn.objects.create( board=board, - name="Архив завершенных", + name="Опубликовано", position=3000, - is_done=True, ) task = Task.objects.create( board=board, @@ -1214,6 +1213,26 @@ def test_only_assignee_can_complete_task_in_first_done_column(api_client, user): ) complete_url = reverse("api:v1:tasks:task-complete", kwargs={"pk": task.id}) + api_client.force_authenticate(user=user) + configure_response = api_client.patch( + reverse( + "api:v1:tasks:task-column-detail", + kwargs={"pk": configured_done_column.id}, + ), + {"is_done": True}, + format="json", + ) + assert configure_response.status_code == status.HTTP_200_OK + previous_done_column.refresh_from_db() + configured_done_column.refresh_from_db() + assert previous_done_column.is_done is False + assert configured_done_column.is_done is True + assert TaskColumn.objects.filter( + board=board, + parent__isnull=True, + is_done=True, + ).count() == 1 + api_client.force_authenticate(user=member) forbidden_response = api_client.post(complete_url) assert forbidden_response.status_code == status.HTTP_403_FORBIDDEN @@ -1229,18 +1248,18 @@ def test_only_assignee_can_complete_task_in_first_done_column(api_client, user): complete_response = api_client.post(complete_url) assert complete_response.status_code == status.HTTP_200_OK - assert complete_response.data["column"] == first_done_column.id + assert complete_response.data["column"] == configured_done_column.id assert complete_response.data["completed_at"] is not None task.refresh_from_db() - assert task.column_id == first_done_column.id + assert task.column_id == configured_done_column.id assert task.completed_at is not None moved_activity = task.activities.get(action="moved") assert moved_activity.actor_id == assignee.id assert moved_activity.metadata == { "from_column_id": source_column.id, "from_column": source_column.name, - "to_column_id": first_done_column.id, - "to_column": first_done_column.name, + "to_column_id": configured_done_column.id, + "to_column": configured_done_column.name, } notify_moved.assert_called_once() send_update.assert_called_once() @@ -1257,6 +1276,53 @@ def test_only_assignee_can_complete_task_in_first_done_column(api_client, user): send_update.assert_not_called() +def test_complete_task_uses_first_subcolumn_of_configured_done_column( + api_client, + user, +): + board = TaskBoard.objects.create(name="Финал с подколонками", created_by=user) + source_column = TaskColumn.objects.create( + board=board, + name="В работе", + position=1000, + ) + done_parent = TaskColumn.objects.create( + board=board, + name="Завершено", + position=2000, + is_done=True, + ) + api_client.force_authenticate(user=user) + child_response = api_client.post( + reverse("api:v1:tasks:task-column-list"), + { + "board": board.id, + "parent": done_parent.id, + "name": "Принято", + "position": 1000, + }, + format="json", + ) + assert child_response.status_code == status.HTTP_201_CREATED + done_child = TaskColumn.objects.get(id=child_response.data["id"]) + assert done_child.is_done is True + task = Task.objects.create( + board=board, + column=source_column, + title="Переместить в финальную подколонку", + created_by=user, + assignee=user, + ) + + response = api_client.post( + reverse("api:v1:tasks:task-complete", kwargs={"pk": task.id}) + ) + + assert response.status_code == status.HTTP_200_OK + assert response.data["column"] == done_child.id + assert response.data["completed_at"] is not None + + def test_assignee_cannot_complete_task_without_done_column(api_client, user): board = TaskBoard.objects.create(name="Доска без финала", created_by=user) column = TaskColumn.objects.create(board=board, name="В работе", position=1000) diff --git a/frontend/src/app/tasks/page.tsx b/frontend/src/app/tasks/page.tsx index 3a6552d0..96e6bac8 100644 --- a/frontend/src/app/tasks/page.tsx +++ b/frontend/src/app/tasks/page.tsx @@ -7,6 +7,7 @@ import { DragOverlay, PointerSensor, closestCorners, + pointerWithin, useDraggable, useSensor, useSensors, @@ -5434,8 +5435,18 @@ function TasksPageContent() { }, []); const boardCollisionDetection = useCallback((args) => { + if (args.active.data.current?.type !== "column") { + const acceptsTasks = (collision: { id: string | number }) => ( + args.droppableContainers.find( + (container) => container.id === collision.id, + )?.data.current?.acceptsTasks === true + ); + const pointerCollisions = pointerWithin(args).filter(acceptsTasks); + if (pointerCollisions.length > 0) return pointerCollisions; + return closestCorners(args).filter(acceptsTasks); + } + const collisions = closestCorners(args); - if (args.active.data.current?.type !== "column") return collisions; const activeColumnId = Number(args.active.data.current?.columnId); const activeColumn = activeColumns.find((column) => column.id === activeColumnId); @@ -5562,8 +5573,8 @@ function TasksPageContent() { desktopWideMode={desktopWideMode} onDesktopWideModeChange={changeDesktopWideMode} > -
-
+
+
-
-
+
+
diff --git a/frontend/src/components/messages/MessagesChatListItem.tsx b/frontend/src/components/messages/MessagesChatListItem.tsx index 883f9b1b..364bc55d 100644 --- a/frontend/src/components/messages/MessagesChatListItem.tsx +++ b/frontend/src/components/messages/MessagesChatListItem.tsx @@ -35,6 +35,7 @@ type MessagesChatListItemProps = { currentUserId?: number; identity: ChatIdentity; active?: boolean; + compact?: boolean; onChatUpdated?: (chatId: number) => void | Promise; }; @@ -78,6 +79,7 @@ export default function MessagesChatListItem({ currentUserId, identity, active = false, + compact = false, onChatUpdated, }: MessagesChatListItemProps) { const title = getChatTitle(chat, currentUserId, identity); @@ -132,6 +134,45 @@ export default function MessagesChatListItem({ } }; + if (compact) { + return ( + + + + {avatar ? ( + {title} + ) : ( + getChatInitials(chat, currentUserId, identity) + )} + + + {getChatTypeIcon(chat)} + + {unread > 0 ? ( + + {unread > 99 ? "99+" : unread} + + ) : null} + + + ); + } + return ( <>
void; }; +const SIDEBAR_COLLAPSED_STORAGE_PREFIX = "messages.chat-list-collapsed"; +const SIDEBAR_COLLAPSED_CHANGE_EVENT = "messages-chat-list-collapsed-change"; + export default function MessagesFocusSidebar({ activeChatId, mode, @@ -23,12 +27,73 @@ export default function MessagesFocusSidebar({ onCreateChat, }: MessagesFocusSidebarProps) { const { user, identity, visibleChats, chatTypeCounts, loading, refreshChat } = useMessagesDirectory(); + const sidebarStorageKey = user?.id + ? `${SIDEBAR_COLLAPSED_STORAGE_PREFIX}.${user.id}` + : null; + const subscribeToSidebarState = useCallback((notify: () => void) => { + if (!sidebarStorageKey) return () => undefined; + const handleStorage = (event: StorageEvent) => { + if (event.key === sidebarStorageKey) notify(); + }; + window.addEventListener("storage", handleStorage); + window.addEventListener(SIDEBAR_COLLAPSED_CHANGE_EVENT, notify); + return () => { + window.removeEventListener("storage", handleStorage); + window.removeEventListener(SIDEBAR_COLLAPSED_CHANGE_EVENT, notify); + }; + }, [sidebarStorageKey]); + const getSidebarState = useCallback( + () => Boolean( + sidebarStorageKey + && window.localStorage.getItem(sidebarStorageKey) === "1" + ), + [sidebarStorageKey], + ); + const sidebarCollapsed = useSyncExternalStore( + subscribeToSidebarState, + getSidebarState, + () => false, + ); + + const toggleSidebarCollapsed = () => { + if (!sidebarStorageKey) return; + window.localStorage.setItem( + sidebarStorageKey, + sidebarCollapsed ? "0" : "1", + ); + window.dispatchEvent(new Event(SIDEBAR_COLLAPSED_CHANGE_EVENT)); + }; return ( -