feat(tasks): share board groups collaboratively

This commit is contained in:
lizerk 2026-08-07 17:51:07 +03:00
parent f34f92cd7d
commit 8cad1832cd
11 changed files with 591 additions and 116 deletions

View File

@ -1,6 +1,6 @@
from urllib.parse import urlsplit
from django.db.models import Count, Max
from django.db.models import Count, Max, Q
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.urls import reverse
@ -860,22 +860,19 @@ class TaskBoardSerializer(serializers.ModelSerializer):
@extend_schema_field(serializers.ListField(child=serializers.IntegerField()))
def get_group_ids(self, obj):
placements = getattr(obj, "current_user_placements", None)
if placements is not None:
placement = placements[0] if placements else None
else:
groups = getattr(obj, "current_user_groups", None)
if groups is None:
request = self.context.get("request")
user = getattr(request, "user", None)
if not user or not user.is_authenticated:
return []
placement = (
TaskBoardPlacement.objects.filter(user=user, board=obj)
.prefetch_related("groups")
.first()
groups = TaskBoardGroup.objects.filter(
Q(user=user) | Q(is_shared=True),
boards=obj,
).exclude(
hidden_by=user,
)
if not placement:
return []
return sorted(group.id for group in placement.groups.all())
return sorted(group.id for group in groups)
@extend_schema_field(serializers.IntegerField(allow_null=True))
def get_group_id(self, obj):
@ -1003,6 +1000,8 @@ class TaskBoardSummarySerializer(TaskBoardSerializer):
class TaskBoardGroupSerializer(serializers.ModelSerializer):
boards_count = serializers.IntegerField(read_only=True, default=0)
is_default_for_current_user = serializers.SerializerMethodField()
is_hidden = serializers.SerializerMethodField()
is_owner = serializers.SerializerMethodField()
class Meta:
model = TaskBoardGroup
@ -1010,8 +1009,11 @@ class TaskBoardGroupSerializer(serializers.ModelSerializer):
"id",
"name",
"position",
"is_shared",
"boards_count",
"is_default_for_current_user",
"is_hidden",
"is_owner",
"created_at",
"updated_at",
]
@ -1022,10 +1024,12 @@ class TaskBoardGroupSerializer(serializers.ModelSerializer):
if not name:
raise serializers.ValidationError("Укажите название группы.")
request = self.context.get("request")
queryset = TaskBoardGroup.objects.filter(
user=getattr(request, "user", None),
name__iexact=name,
owner_id = (
self.instance.user_id
if self.instance
else getattr(getattr(request, "user", None), "id", None)
)
queryset = TaskBoardGroup.objects.filter(user_id=owner_id, name__iexact=name)
if self.instance:
queryset = queryset.exclude(id=self.instance.id)
if queryset.exists():
@ -1048,6 +1052,25 @@ class TaskBoardGroupSerializer(serializers.ModelSerializer):
)
return self._default_group_id == obj.id
@extend_schema_field(serializers.BooleanField())
def get_is_hidden(self, obj):
hidden_by = getattr(obj, "current_user_hidden_by", None)
if hidden_by is not None:
return bool(hidden_by)
request = self.context.get("request")
user = getattr(request, "user", None)
return bool(
user
and getattr(user, "is_authenticated", False)
and obj.hidden_by.filter(id=user.id).exists()
)
@extend_schema_field(serializers.BooleanField())
def get_is_owner(self, obj):
request = self.context.get("request")
user = getattr(request, "user", None)
return bool(user and obj.user_id == getattr(user, "id", None))
class TaskLinkedPostSerializer(serializers.ModelSerializer):
created_by = EmployeeBriefSerializer(read_only=True)

View File

@ -37,6 +37,7 @@ from rest_framework.response import Response
from schedule.models import Event
from tasks.access import (
task_board_group_access_q,
task_board_access_q,
user_can_access_calendar_event,
user_can_access_document,
@ -339,9 +340,20 @@ class TaskBoardViewSet(viewsets.ModelViewSet):
"user_placements",
queryset=TaskBoardPlacement.objects.filter(
user=self.request.user,
).prefetch_related("groups"),
),
to_attr="current_user_placements",
),
Prefetch(
"board_groups",
queryset=(
TaskBoardGroup.objects.filter(
Q(user=self.request.user) | Q(is_shared=True),
)
.exclude(hidden_by=self.request.user)
.distinct()
),
to_attr="current_user_groups",
),
)
.annotate(
current_user_is_pinned=Coalesce(
@ -627,9 +639,8 @@ class TaskBoardViewSet(viewsets.ModelViewSet):
status=status.HTTP_400_BAD_REQUEST,
)
group = get_object_or_404(
TaskBoardGroup,
TaskBoardGroup.objects.filter(task_board_group_access_q(request.user)),
id=group_id,
user=request.user,
)
return Response(self._set_board_groups(board, [group]))
@ -649,12 +660,12 @@ class TaskBoardViewSet(viewsets.ModelViewSet):
{"groups": ["Идентификаторы групп должны быть числами."]},
status=status.HTTP_400_BAD_REQUEST,
)
groups = list(
TaskBoardGroup.objects.filter(
user=request.user,
id__in=group_ids,
).order_by("id")
available_groups = (
TaskBoardGroup.objects.filter(task_board_group_access_q(request.user))
.exclude(hidden_by=request.user)
.distinct()
)
groups = list(available_groups.filter(id__in=group_ids).order_by("id"))
if len(groups) != len(group_ids):
return Response(
{"groups": ["Одна или несколько групп не найдены."]},
@ -664,27 +675,30 @@ class TaskBoardViewSet(viewsets.ModelViewSet):
def _set_board_groups(self, board, groups):
with transaction.atomic():
placement = TaskBoardPlacement.objects.select_for_update().filter(
user=self.request.user,
board=board,
).first()
if placement is None and groups:
placement = TaskBoardPlacement.objects.create(
user=self.request.user,
board=board,
TaskBoard.objects.select_for_update().get(pk=board.pk)
editable_groups = (
TaskBoardGroup.objects.filter(
task_board_group_access_q(self.request.user),
boards=board,
)
if placement:
placement.groups.set(groups)
if (
not groups
and not placement.is_pinned
and placement.notifications_enabled
and not placement.view_state
):
placement.delete()
else:
placement.save(update_fields=["updated_at"])
group_ids = sorted(group.id for group in groups)
.exclude(hidden_by=self.request.user)
.distinct()
)
desired_ids = {group.id for group in groups}
removable_groups = list(editable_groups.exclude(id__in=desired_ids))
if removable_groups:
board.board_groups.remove(*removable_groups)
if groups:
board.board_groups.add(*groups)
group_ids = sorted(
TaskBoardGroup.objects.filter(
Q(user=self.request.user) | Q(is_shared=True),
boards=board,
)
.exclude(hidden_by=self.request.user)
.values_list("id", flat=True)
.distinct()
)
return {
"board": board.id,
"group_ids": group_ids,
@ -703,7 +717,6 @@ class TaskBoardViewSet(viewsets.ModelViewSet):
is_pinned = placement.is_pinned
if (
not placement.is_pinned
and not placement.groups.exists()
and placement.notifications_enabled
and not placement.view_state
):
@ -723,7 +736,6 @@ class TaskBoardViewSet(viewsets.ModelViewSet):
notifications_enabled = placement.notifications_enabled
if (
placement.notifications_enabled
and not placement.groups.exists()
and not placement.is_pinned
and not placement.view_state
):
@ -803,7 +815,6 @@ class TaskBoardViewSet(viewsets.ModelViewSet):
placement.view_state = state
if (
not state
and not placement.groups.exists()
and not placement.is_pinned
and placement.notifications_enabled
):
@ -840,9 +851,28 @@ class TaskBoardGroupViewSet(viewsets.ModelViewSet):
serializer_class = TaskBoardGroupSerializer
def get_queryset(self):
accessible_boards = TaskBoard.objects.filter(
task_board_access_q(self.request.user)
)
return (
TaskBoardGroup.objects.filter(user=self.request.user)
.annotate(boards_count=Count("board_placements", distinct=True))
TaskBoardGroup.objects.filter(
task_board_group_access_q(self.request.user)
)
.prefetch_related(
Prefetch(
"hidden_by",
queryset=Employee.objects.filter(id=self.request.user.id),
to_attr="current_user_hidden_by",
)
)
.annotate(
boards_count=Count(
"boards",
filter=Q(boards__in=accessible_boards),
distinct=True,
)
)
.distinct()
.order_by("position", "name", "id")
)
@ -855,6 +885,7 @@ class TaskBoardGroupViewSet(viewsets.ModelViewSet):
@action(detail=True, methods=["post"], url_path="set-default")
def set_default(self, request, pk=None):
group = self.get_object()
group.hidden_by.remove(request.user)
settings_obj, _ = TaskUserSettings.objects.get_or_create(user=request.user)
is_default = settings_obj.default_group_id != group.id
settings_obj.default_group = group if is_default else None
@ -867,16 +898,25 @@ class TaskBoardGroupViewSet(viewsets.ModelViewSet):
}
)
def perform_destroy(self, instance):
user = self.request.user
instance.delete()
TaskBoardPlacement.objects.filter(
user=user,
groups__isnull=True,
is_pinned=False,
notifications_enabled=True,
view_state={},
).delete()
@action(detail=True, methods=["post"], url_path="toggle-hidden")
def toggle_hidden(self, request, pk=None):
group = self.get_object()
is_hidden = group.hidden_by.filter(id=request.user.id).exists()
if is_hidden:
group.hidden_by.remove(request.user)
else:
group.hidden_by.add(request.user)
TaskUserSettings.objects.filter(
user=request.user,
default_group=group,
).update(default_group=None)
group.current_user_hidden_by = [] if is_hidden else [request.user]
return Response(
{
"group": self.get_serializer(group).data,
"is_hidden": not is_hidden,
}
)
class TaskColumnViewSet(viewsets.ModelViewSet):

View File

@ -51,6 +51,15 @@ def task_board_access_q(user):
return q
def task_board_group_access_q(user):
"""Groups owned by the user or shared through at least one accessible board."""
accessible_boards = TaskBoard.objects.filter(task_board_access_q(user))
return Q(user=user) | Q(
is_shared=True,
boards__in=accessible_boards,
)
def user_can_access_task_board(user, board: TaskBoard | None) -> bool:
if not user or not user.is_authenticated or board is None:
return False

View File

@ -43,30 +43,26 @@ class TaskBoardAdmin(admin.ModelAdmin):
@admin.register(TaskBoardGroup)
class TaskBoardGroupAdmin(admin.ModelAdmin):
list_display = ["name", "user", "position", "created_at"]
list_display = ["name", "user", "is_shared", "position", "created_at"]
list_filter = ["is_shared"]
search_fields = ["name", "user__email"]
autocomplete_fields = ["user"]
autocomplete_fields = ["user", "boards", "hidden_by"]
@admin.register(TaskBoardPlacement)
class TaskBoardPlacementAdmin(admin.ModelAdmin):
list_display = [
"board",
"group_names",
"user",
"is_pinned",
"notifications_enabled",
"updated_at",
]
list_filter = ["is_pinned", "notifications_enabled", "groups"]
search_fields = ["board__name", "groups__name", "user__email"]
autocomplete_fields = ["board", "groups", "user"]
list_filter = ["is_pinned", "notifications_enabled"]
search_fields = ["board__name", "user__email"]
autocomplete_fields = ["board", "user"]
readonly_fields = ["view_state"]
@admin.display(description="Группы")
def group_names(self, obj):
return ", ".join(obj.groups.values_list("name", flat=True)) or "—"
@admin.register(TaskColumn)
class TaskColumnAdmin(admin.ModelAdmin):

View File

@ -0,0 +1,95 @@
# Generated by Django 5.2.4 on 2026-08-07 14:41
from django.conf import settings
from django.db import migrations, models
def copy_personal_group_boards(apps, schema_editor):
placement_model = apps.get_model("tasks", "TaskBoardPlacement")
group_model = apps.get_model("tasks", "TaskBoardGroup")
placement_boards = dict(
placement_model.objects.values_list("id", "board_id")
)
old_through = placement_model.groups.through
new_through = group_model.boards.through
links = []
for placement_id, group_id in old_through.objects.values_list(
"taskboardplacement_id",
"taskboardgroup_id",
).iterator():
board_id = placement_boards.get(placement_id)
if board_id:
links.append(
new_through(
taskboardgroup_id=group_id,
taskboard_id=board_id,
)
)
new_through.objects.bulk_create(links, ignore_conflicts=True)
def restore_personal_group_placements(apps, schema_editor):
placement_model = apps.get_model("tasks", "TaskBoardPlacement")
group_model = apps.get_model("tasks", "TaskBoardGroup")
old_through = placement_model.groups.through
new_through = group_model.boards.through
group_owners = dict(group_model.objects.values_list("id", "user_id"))
for group_id, board_id in new_through.objects.values_list(
"taskboardgroup_id",
"taskboard_id",
).iterator():
owner_id = group_owners.get(group_id)
if not owner_id:
continue
placement, _ = placement_model.objects.get_or_create(
user_id=owner_id,
board_id=board_id,
)
old_through.objects.get_or_create(
taskboardplacement_id=placement.id,
taskboardgroup_id=group_id,
)
class Migration(migrations.Migration):
dependencies = [
('tasks', '0036_taskboardplacement_multiple_groups'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name="taskboardgroup",
name="boards",
field=models.ManyToManyField(
blank=True,
related_name="board_groups",
to="tasks.taskboard",
verbose_name="Доски",
),
),
migrations.AddField(
model_name="taskboardgroup",
name="hidden_by",
field=models.ManyToManyField(
blank=True,
related_name="hidden_task_board_groups",
to=settings.AUTH_USER_MODEL,
verbose_name="Скрыта пользователями",
),
),
migrations.AddField(
model_name="taskboardgroup",
name="is_shared",
field=models.BooleanField(default=False, verbose_name="Общая"),
),
migrations.RunPython(
copy_personal_group_boards,
restore_personal_group_placements,
),
migrations.RemoveField(
model_name="taskboardplacement",
name="groups",
),
]

View File

@ -114,6 +114,19 @@ class TaskBoardGroup(models.Model):
related_name="task_board_groups",
verbose_name="Пользователь",
)
boards = models.ManyToManyField(
TaskBoard,
blank=True,
related_name="board_groups",
verbose_name="Доски",
)
is_shared = models.BooleanField("Общая", default=False)
hidden_by = models.ManyToManyField(
settings.AUTH_USER_MODEL,
blank=True,
related_name="hidden_task_board_groups",
verbose_name="Скрыта пользователями",
)
position = models.PositiveIntegerField("Позиция", default=0)
created_at = models.DateTimeField("Создано", auto_now_add=True)
updated_at = models.DateTimeField("Обновлено", auto_now=True)
@ -146,12 +159,6 @@ class TaskBoardPlacement(models.Model):
related_name="user_placements",
verbose_name="Доска",
)
groups = models.ManyToManyField(
TaskBoardGroup,
blank=True,
related_name="board_placements",
verbose_name="Группы",
)
is_pinned = models.BooleanField("Закреплена", default=False)
pinned_at = models.DateTimeField("Закреплена в", null=True, blank=True)
notifications_enabled = models.BooleanField("Уведомления", default=True)

View File

@ -338,8 +338,7 @@ def test_board_groups_are_personal_and_board_placement_is_independent(
)
assert groups_response.status_code == status.HTTP_200_OK
assert groups_response.data["group_ids"] == sorted([group_id, second_group_id])
placement = TaskBoardPlacement.objects.get(user=user, board=board)
assert set(placement.groups.values_list("id", flat=True)) == {
assert set(board.board_groups.values_list("id", flat=True)) == {
group_id,
second_group_id,
}
@ -390,8 +389,7 @@ def test_board_groups_are_personal_and_board_placement_is_independent(
)
assert delete_response.status_code == status.HTTP_204_NO_CONTENT
assert not TaskBoardGroup.objects.filter(id=group_id).exists()
placement.refresh_from_db()
assert list(placement.groups.values_list("id", flat=True)) == [second_group_id]
assert list(board.board_groups.values_list("id", flat=True)) == [second_group_id]
grouped_summary_response = api_client.get(
reverse("api:v1:tasks:task-board-list"),
@ -411,6 +409,164 @@ def test_board_groups_are_personal_and_board_placement_is_independent(
assert not TaskBoardPlacement.objects.filter(user=user, board=board).exists()
def test_shared_board_group_is_collaborative_without_granting_board_access(
api_client,
):
owner = make_user("shared-group-owner@example.com", "+79994440110")
first_member = make_user("shared-group-first@example.com", "+79994440111")
second_member = make_user("shared-group-second@example.com", "+79994440112")
role_member = make_user("shared-group-role@example.com", "+79994440113")
outsider = make_user("shared-group-outsider@example.com", "+79994440114")
department = Department.objects.create(name="Общий проектный отдел")
role = department.roles.create(name="Участник общей группы")
RoleAssignment.objects.create(
employee=role_member,
role=role,
is_active=True,
)
first_board = TaskBoard.objects.create(
name="Первая закрытая доска",
created_by=owner,
access_scope="restricted",
)
first_board.members.add(first_member)
role_board = TaskBoard.objects.create(
name="Доска отдела",
created_by=owner,
access_scope="restricted",
)
role_board.departments.add(department)
second_board = TaskBoard.objects.create(
name="Добавленная участником доска",
created_by=first_member,
access_scope="restricted",
)
second_board.members.add(second_member)
api_client.force_authenticate(user=owner)
create_response = api_client.post(
reverse("api:v1:tasks:task-board-group-list"),
{"name": "Общая работа", "is_shared": True},
format="json",
)
assert create_response.status_code == status.HTTP_201_CREATED
group_id = create_response.data["id"]
for board in (first_board, role_board):
response = api_client.post(
reverse("api:v1:tasks:task-board-set-groups", kwargs={"pk": board.id}),
{"groups": [group_id]},
format="json",
)
assert response.status_code == status.HTTP_200_OK
api_client.force_authenticate(user=first_member)
group_response = api_client.get(
reverse("api:v1:tasks:task-board-group-list")
)
assert group_response.data["results"][0]["id"] == group_id
assert group_response.data["results"][0]["boards_count"] == 1
board_response = api_client.get(
reverse("api:v1:tasks:task-board-list"),
{"view": "summary"},
)
assert {item["id"] for item in board_response.data["results"]} == {
first_board.id,
second_board.id,
}
assert next(
item for item in board_response.data["results"] if item["id"] == first_board.id
)["group_ids"] == [group_id]
assert next(
item for item in board_response.data["results"] if item["id"] == second_board.id
)["group_ids"] == []
add_response = api_client.post(
reverse(
"api:v1:tasks:task-board-set-groups",
kwargs={"pk": second_board.id},
),
{"groups": [group_id]},
format="json",
)
assert add_response.status_code == status.HTTP_200_OK
rename_response = api_client.patch(
reverse("api:v1:tasks:task-board-group-detail", kwargs={"pk": group_id}),
{"name": "Переименована участником"},
format="json",
)
assert rename_response.status_code == status.HTTP_200_OK
hide_response = api_client.post(
reverse("api:v1:tasks:task-board-group-toggle-hidden", kwargs={"pk": group_id})
)
assert hide_response.status_code == status.HTTP_200_OK
assert hide_response.data["is_hidden"] is True
hidden_boards_response = api_client.get(
reverse("api:v1:tasks:task-board-list"),
{"view": "summary"},
)
assert all(
group_id not in item["group_ids"]
for item in hidden_boards_response.data["results"]
)
unhide_response = api_client.post(
reverse("api:v1:tasks:task-board-group-toggle-hidden", kwargs={"pk": group_id})
)
assert unhide_response.data["is_hidden"] is False
default_response = api_client.post(
reverse("api:v1:tasks:task-board-group-set-default", kwargs={"pk": group_id})
)
assert default_response.status_code == status.HTTP_200_OK
assert TaskUserSettings.objects.get(user=first_member).default_group_id == group_id
remove_board_response = api_client.post(
reverse(
"api:v1:tasks:task-board-set-groups",
kwargs={"pk": first_board.id},
),
{"groups": []},
format="json",
)
assert remove_board_response.status_code == status.HTTP_200_OK
assert not first_board.board_groups.filter(id=group_id).exists()
api_client.force_authenticate(user=role_member)
role_groups_response = api_client.get(
reverse("api:v1:tasks:task-board-group-list")
)
assert role_groups_response.data["results"][0]["id"] == group_id
role_boards_response = api_client.get(
reverse("api:v1:tasks:task-board-list"),
{"view": "summary"},
)
assert [item["id"] for item in role_boards_response.data["results"]] == [
role_board.id
]
assert role_boards_response.data["results"][0]["group_ids"] == [group_id]
api_client.force_authenticate(user=outsider)
outsider_groups_response = api_client.get(
reverse("api:v1:tasks:task-board-group-list")
)
assert outsider_groups_response.data["results"] == []
outsider_update_response = api_client.patch(
reverse("api:v1:tasks:task-board-group-detail", kwargs={"pk": group_id}),
{"name": "Недоступное изменение"},
format="json",
)
assert outsider_update_response.status_code == status.HTTP_404_NOT_FOUND
api_client.force_authenticate(user=second_member)
second_groups_response = api_client.get(
reverse("api:v1:tasks:task-board-group-list")
)
assert second_groups_response.data["results"][0]["id"] == group_id
delete_response = api_client.delete(
reverse("api:v1:tasks:task-board-group-detail", kwargs={"pk": group_id})
)
assert delete_response.status_code == status.HTTP_204_NO_CONTENT
assert not TaskBoardGroup.objects.filter(id=group_id).exists()
def test_user_can_toggle_personal_default_board_group(api_client, user):
other_user = make_user("task-default-group-other@example.com", "+79994440095")
api_client.force_authenticate(user=user)
@ -496,7 +652,6 @@ def test_board_pin_and_notifications_are_personal_and_keep_ungrouped_settings(
assert notifications_response.status_code == status.HTTP_200_OK
assert notifications_response.data["notifications_enabled"] is False
placement = TaskBoardPlacement.objects.get(user=user, board=pinned_board)
assert not placement.groups.exists()
assert placement.is_pinned is True
assert placement.notifications_enabled is False
@ -522,7 +677,6 @@ def test_board_pin_and_notifications_are_personal_and_keep_ungrouped_settings(
format="json",
)
placement.refresh_from_db()
assert not placement.groups.exists()
assert placement.is_pinned is True
assert placement.notifications_enabled is False

View File

@ -161,6 +161,7 @@ type BoardFormState = {
type BoardGroupFormState = {
id: number | null;
name: string;
is_shared: boolean;
};
type ColumnFormState = {
@ -280,6 +281,7 @@ const emptyBoardForm: BoardFormState = {
const emptyBoardGroupForm: BoardGroupFormState = {
id: null,
name: "",
is_shared: false,
};
const taskDescriptionPreviewClass =
@ -5171,7 +5173,11 @@ function TasksPageContent() {
}, []);
const openEditBoardGroup = useCallback((group: TaskBoardGroup) => {
setBoardGroupForm({ id: group.id, name: group.name });
setBoardGroupForm({
id: group.id,
name: group.name,
is_shared: group.is_shared,
});
setBoardGroupModalOpen(true);
}, []);
@ -5508,11 +5514,17 @@ function TasksPageContent() {
setError(null);
try {
if (boardGroupForm.id) {
await apiClient.updateTaskBoardGroup(boardGroupForm.id, { name });
await apiClient.updateTaskBoardGroup(boardGroupForm.id, {
name,
is_shared: boardGroupForm.is_shared,
});
} else {
await apiClient.createTaskBoardGroup({ name });
await apiClient.createTaskBoardGroup({
name,
is_shared: boardGroupForm.is_shared,
});
}
await loadBoardGroups();
await Promise.all([loadBoardGroups(), loadBoards()]);
setBoardGroupModalOpen(false);
setBoardGroupForm(emptyBoardGroupForm);
} catch (groupError) {
@ -5520,7 +5532,14 @@ function TasksPageContent() {
} finally {
setSaving(false);
}
}, [boardGroupForm.id, boardGroupForm.name, loadBoardGroups, saving]);
}, [
boardGroupForm.id,
boardGroupForm.is_shared,
boardGroupForm.name,
loadBoardGroups,
loadBoards,
saving,
]);
const deleteBoardGroup = useCallback(async (group: TaskBoardGroup) => {
if (saving) return;
@ -5560,6 +5579,20 @@ function TasksPageContent() {
}
}, [saving]);
const toggleBoardGroupHidden = useCallback(async (group: TaskBoardGroup) => {
if (saving) return;
setSaving(true);
setError(null);
try {
await apiClient.toggleTaskBoardGroupHidden(group.id);
await Promise.all([loadBoardGroups(), loadBoards()]);
} catch (groupError) {
setError(getTaskError(groupError, "Не удалось изменить видимость группы"));
} finally {
setSaving(false);
}
}, [loadBoardGroups, loadBoards, saving]);
const setBoardGroupMemberships = useCallback(async (
boardId: number,
groupIds: number[],
@ -7338,6 +7371,7 @@ function TasksPageContent() {
onEditGroup={openEditBoardGroup}
onDeleteGroup={deleteBoardGroup}
onToggleDefaultGroup={(group) => void toggleDefaultBoardGroup(group)}
onToggleHiddenGroup={(group) => void toggleBoardGroupHidden(group)}
onEditBoard={openEditBoard}
onDeleteBoard={(targetBoard) => void deleteBoard(targetBoard)}
onSetDefaultBoard={(targetBoard) => void setDefaultBoard(targetBoard)}
@ -8127,22 +8161,43 @@ function TasksPageContent() {
</div>
)}
>
<label className="block">
<span className="app-text-muted mb-1 block text-xs font-medium">Название группы</span>
<input
value={boardGroupForm.name}
onChange={(event) => setBoardGroupForm((current) => ({
...current,
name: event.target.value,
}))}
onKeyDown={(event) => {
if (event.key === "Enter") void saveBoardGroup();
}}
className="app-input w-full rounded-xl px-3 py-2 text-sm"
autoFocus
maxLength={120}
/>
</label>
<div className="space-y-4">
<label className="block">
<span className="app-text-muted mb-1 block text-xs font-medium">Название группы</span>
<input
value={boardGroupForm.name}
onChange={(event) => setBoardGroupForm((current) => ({
...current,
name: event.target.value,
}))}
onKeyDown={(event) => {
if (event.key === "Enter") void saveBoardGroup();
}}
className="app-input w-full rounded-xl px-3 py-2 text-sm"
autoFocus
maxLength={120}
/>
</label>
<label className="app-surface-muted flex cursor-pointer items-start gap-3 rounded-xl p-3">
<input
type="checkbox"
checked={boardGroupForm.is_shared}
onChange={(event) => setBoardGroupForm((current) => ({
...current,
is_shared: event.target.checked,
}))}
className="mt-0.5 h-4 w-4"
/>
<span>
<span className="block text-sm font-medium text-[var(--foreground)]">
Общая группа
</span>
<span className="app-text-muted mt-1 block text-xs leading-5">
Участники увидят группу только вместе с доступными им досками.
</span>
</span>
</label>
</div>
</Modal>
<Modal

View File

@ -10,6 +10,8 @@ import {
Filter,
Folder,
FolderInput,
Eye,
EyeOff,
Kanban,
LockKeyhole,
Maximize2,
@ -57,6 +59,7 @@ type TaskBoardNavigatorProps = {
onEditGroup: (group: TaskBoardGroup) => void;
onDeleteGroup: (group: TaskBoardGroup) => void;
onToggleDefaultGroup: (group: TaskBoardGroup) => void;
onToggleHiddenGroup: (group: TaskBoardGroup) => void;
onEditBoard: (board: TaskBoardSummary) => void;
onDeleteBoard: (board: TaskBoardSummary) => void;
onSetDefaultBoard: (board: TaskBoardSummary) => void;
@ -304,6 +307,7 @@ export default function TaskBoardNavigator({
onEditGroup,
onDeleteGroup,
onToggleDefaultGroup,
onToggleHiddenGroup,
onEditBoard,
onDeleteBoard,
onSetDefaultBoard,
@ -328,11 +332,20 @@ export default function TaskBoardNavigator({
const [moveGroupQuery, setMoveGroupQuery] = useState("");
const [moveSubmitting, setMoveSubmitting] = useState(false);
const [participantsBoard, setParticipantsBoard] = useState<TaskBoardSummary | null>(null);
const [hiddenGroupsOpen, setHiddenGroupsOpen] = useState(false);
const menusRef = useRef<HTMLDivElement>(null);
const defaultGroupInitializedRef = useRef(false);
const normalizedQuery = query.trim().toLowerCase();
const visibleGroups = useMemo(
() => groups.filter((group) => !group.is_hidden),
[groups],
);
const hiddenGroups = useMemo(
() => groups.filter((group) => group.is_hidden),
[groups],
);
const effectiveGroupFilter = (
typeof groupFilter !== "number" || groups.some((group) => group.id === groupFilter)
typeof groupFilter !== "number" || visibleGroups.some((group) => group.id === groupFilter)
) ? groupFilter : "all";
const filteredBoards = useMemo(() => boards.filter((board) => {
const unread = unreadCounts[board.id] || 0;
@ -359,31 +372,31 @@ export default function TaskBoardNavigator({
return {
all: buildStats(filteredBoards),
ungrouped: buildStats(filteredBoards.filter((board) => boardGroupIds(board).length === 0)),
byId: new Map(groups.map((group) => [
byId: new Map(visibleGroups.map((group) => [
group.id,
buildStats(filteredBoards.filter((board) => boardGroupIds(board).includes(group.id))),
])),
};
}, [filteredBoards, groups, unreadCounts]);
}, [filteredBoards, unreadCounts, visibleGroups]);
const groupNames = useMemo(
() => new Map(groups.map((group) => [group.id, group.name])),
[groups],
() => new Map(visibleGroups.map((group) => [group.id, group.name])),
[visibleGroups],
);
const activeFiltersCount = [unreadFilter !== "all", accessFilter !== "all"].filter(Boolean).length;
const compactNavigator = wideMode && navigatorCollapsed;
const filteredMoveGroups = useMemo(() => {
const moveQuery = moveGroupQuery.trim().toLowerCase();
return moveQuery
? groups.filter((group) => group.name.toLowerCase().includes(moveQuery))
: groups;
}, [groups, moveGroupQuery]);
? visibleGroups.filter((group) => group.name.toLowerCase().includes(moveQuery))
: visibleGroups;
}, [moveGroupQuery, visibleGroups]);
useEffect(() => {
if (defaultGroupInitializedRef.current || groups.length === 0) return;
const defaultGroup = groups.find((group) => group.is_default_for_current_user);
if (defaultGroupInitializedRef.current || visibleGroups.length === 0) return;
const defaultGroup = visibleGroups.find((group) => group.is_default_for_current_user);
setGroupFilter(defaultGroup?.id ?? "all");
defaultGroupInitializedRef.current = true;
}, [groups]);
}, [visibleGroups]);
useEffect(() => {
if (!userId) {
@ -612,7 +625,7 @@ export default function TaskBoardNavigator({
</button>
);
})}
{groups.map((group) => {
{visibleGroups.map((group) => {
const active = effectiveGroupFilter === group.id;
const stats = groupStats.byId.get(group.id) || { total: 0, unread: 0 };
return (
@ -628,6 +641,9 @@ export default function TaskBoardNavigator({
className="inline-flex min-w-0 items-center gap-1.5 py-1.5 pl-3 pr-7 text-xs font-medium"
>
<span className="max-w-28 truncate">{group.name}</span>
{group.is_shared ? (
<Users size={11} className="app-text-muted shrink-0" />
) : null}
<span className={`app-badge inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-bold ${
active ? "app-pill-count-active" : "app-pill-count"
}`}>
@ -679,6 +695,18 @@ export default function TaskBoardNavigator({
<Pencil size={14} />
Редактировать
</button>
<button
type="button"
onClick={() => {
if (groupFilter === group.id) setGroupFilter("all");
onToggleHiddenGroup(group);
setGroupMenuId(null);
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
>
<EyeOff size={14} />
Скрыть лично
</button>
<button
type="button"
onClick={() => {
@ -705,6 +733,20 @@ export default function TaskBoardNavigator({
>
<Plus size={14} />
</button>
{hiddenGroups.length > 0 ? (
<button
type="button"
onClick={() => setHiddenGroupsOpen(true)}
className="app-pill inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition"
title="Показать скрытые группы"
>
<EyeOff size={12} />
<span>Скрытые</span>
<span className="app-badge app-pill-count px-1.5 py-0.5 text-[10px] font-bold">
{hiddenGroups.length}
</span>
</button>
) : null}
</div>
</div>
) : null}
@ -774,6 +816,51 @@ export default function TaskBoardNavigator({
</div>
</aside>
<Modal
isOpen={hiddenGroupsOpen}
onClose={() => setHiddenGroupsOpen(false)}
title="Скрытые группы"
size="sm"
closeOnClickOutside
>
<div className="space-y-2 pb-5">
{hiddenGroups.map((group) => (
<div
key={group.id}
className="app-surface-muted flex items-center gap-3 rounded-xl p-3"
>
<Folder size={16} className="app-text-muted shrink-0" />
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-1.5">
<span className="truncate text-sm font-medium text-[var(--foreground)]">
{group.name}
</span>
{group.is_shared ? (
<Users size={12} className="app-text-muted shrink-0" />
) : null}
</div>
<p className="app-text-muted mt-0.5 text-xs">
{group.boards_count} досок
</p>
</div>
<button
type="button"
onClick={() => onToggleHiddenGroup(group)}
className="app-action-secondary inline-flex items-center gap-1.5 rounded-lg px-3 py-2 text-xs font-medium"
>
<Eye size={14} />
Показать
</button>
</div>
))}
{hiddenGroups.length === 0 ? (
<p className="app-text-muted py-6 text-center text-sm">
Скрытых групп нет
</p>
) : null}
</div>
</Modal>
<Modal
isOpen={Boolean(participantsBoard)}
onClose={() => setParticipantsBoard(null)}
@ -963,7 +1050,7 @@ export default function TaskBoardNavigator({
</div>
) : null}
{groups.length > 5 ? (
{visibleGroups.length > 5 ? (
<label className="relative block">
<Search
size={15}

View File

@ -109,14 +109,14 @@ export function createTasksApi(request: RequestFn, requestRaw: RawRequestFn) {
TaskBoardGroup[] | { results: TaskBoardGroup[] }
> =>
request("/api/v1/tasks/board-groups/"),
createTaskBoardGroup: (data: { name: string }): Promise<TaskBoardGroup> =>
createTaskBoardGroup: (data: { name: string; is_shared?: boolean }): Promise<TaskBoardGroup> =>
request("/api/v1/tasks/board-groups/", {
method: "POST",
body: JSON.stringify(data),
}),
updateTaskBoardGroup: (
id: number,
data: { name: string },
data: { name: string; is_shared?: boolean },
): Promise<TaskBoardGroup> =>
request(`/api/v1/tasks/board-groups/${id}/`, {
method: "PATCH",
@ -133,6 +133,12 @@ export function createTasksApi(request: RequestFn, requestRaw: RawRequestFn) {
request(`/api/v1/tasks/board-groups/${id}/set-default/`, {
method: "POST",
}),
toggleTaskBoardGroupHidden: (
id: number,
): Promise<{ group: TaskBoardGroup; is_hidden: boolean }> =>
request(`/api/v1/tasks/board-groups/${id}/toggle-hidden/`, {
method: "POST",
}),
markTaskBoardNotificationsRead: (
id: number,
): Promise<{ status: string; count: number; notification_ids: number[] }> =>

View File

@ -400,8 +400,11 @@ export interface TaskBoardGroup {
id: number;
name: string;
position: number;
is_shared: boolean;
boards_count: number;
is_default_for_current_user: boolean;
is_hidden: boolean;
is_owner: boolean;
created_at: string;
updated_at: string;
}