UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: индикатор новых изменений во внешних контурах
This commit is contained in:
parent
0a584abf26
commit
4dff521f7f
|
|
@ -99,10 +99,10 @@
|
|||
- отображение целевого проекта
|
||||
- отображение исполнителей целевого контура
|
||||
- отображение фактической даты последнего изменения
|
||||
- индикатор новых изменений в source-side списке на базе unread уведомлений
|
||||
- открытие source-side detail экрана
|
||||
|
||||
Что еще остается на следующие этапы:
|
||||
- индикатор новых изменений
|
||||
- полноценная зеркальная activity/history
|
||||
- уведомления
|
||||
|
||||
|
|
@ -195,6 +195,7 @@
|
|||
- notification payload несет `external contour request id` и `target issue id`
|
||||
- список уведомлений помечает такие записи как `is_external_contour = true`
|
||||
- notification preview может открыть source-side карточку внешнего контура, а не обычный issue preview
|
||||
- открытие source-side карточки помечает связанные unread уведомления как прочитанные и снимает индикатор новых изменений в списке
|
||||
|
||||
Что остается:
|
||||
- in-app уведомления на явные source-side решения `Принять / Отклонить`
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ from .project import ProjectLiteSerializer
|
|||
from .state import StateLiteSerializer
|
||||
from .user import UserLiteSerializer
|
||||
from plane.app.serializers.issue import LabelSerializer
|
||||
from plane.db.models import FileAsset, IntakeIssue, Issue, IssueActivity, IssueComment, Label, Project
|
||||
from plane.db.models import FileAsset, IntakeIssue, Issue, IssueActivity, IssueComment, Label, Notification, Project
|
||||
|
||||
|
||||
class ExternalContourIssuePayloadSerializer(serializers.Serializer):
|
||||
|
|
@ -149,6 +149,7 @@ class ExternalContourMirroredActivitySerializer(BaseSerializer):
|
|||
|
||||
|
||||
class ExternalContourRequestSerializer(BaseSerializer):
|
||||
has_unread_updates = serializers.SerializerMethodField()
|
||||
issue = ExternalContourIssueSerializer(read_only=True)
|
||||
mirrored_activity = serializers.SerializerMethodField()
|
||||
mirrored_attachments = serializers.SerializerMethodField()
|
||||
|
|
@ -172,6 +173,7 @@ class ExternalContourRequestSerializer(BaseSerializer):
|
|||
"created_at",
|
||||
"updated_at",
|
||||
"created_by",
|
||||
"has_unread_updates",
|
||||
"issue",
|
||||
"mirrored_activity",
|
||||
"mirrored_attachments",
|
||||
|
|
@ -193,6 +195,20 @@ class ExternalContourRequestSerializer(BaseSerializer):
|
|||
def get_source_project_id(self, obj):
|
||||
return obj.extra.get("source_project_id")
|
||||
|
||||
def get_has_unread_updates(self, obj):
|
||||
request = self.context.get("request")
|
||||
user = getattr(request, "user", None)
|
||||
user_id = getattr(user, "id", None)
|
||||
if not user_id:
|
||||
return False
|
||||
|
||||
return Notification.objects.filter(
|
||||
receiver_id=user_id,
|
||||
sender__startswith="in_app:external_contours:",
|
||||
read_at__isnull=True,
|
||||
data__issue__id=str(obj.id),
|
||||
).exists()
|
||||
|
||||
def get_mirrored_activity(self, obj):
|
||||
if not self.context.get("include_mirror_data") or not obj.issue_id:
|
||||
return []
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ from plane.api.serializers import (
|
|||
from plane.api.serializers.issue import IssueSerializer as IssueCreateSerializer
|
||||
from plane.app.permissions import ProjectLitePermission
|
||||
from plane.app.views.base import BaseAPIView
|
||||
from plane.db.models import FileAsset, Intake, IntakeIssue, Label, Project, ProjectMember, State, StateGroup
|
||||
from plane.db.models import FileAsset, Intake, IntakeIssue, Label, Notification, Project, ProjectMember, State, StateGroup
|
||||
from plane.db.models.intake import IntakeIssueStatus, SourceType
|
||||
from plane.settings.storage import S3Storage
|
||||
from plane.utils.external_contours import create_external_contour_issue_comment
|
||||
|
|
@ -61,7 +61,11 @@ class ExternalContourListCreateEndpoint(BaseAPIView):
|
|||
)
|
||||
|
||||
def get(self, request, slug, project_id):
|
||||
serializer = ExternalContourRequestSerializer(self.get_queryset(), many=True)
|
||||
serializer = ExternalContourRequestSerializer(
|
||||
self.get_queryset(),
|
||||
many=True,
|
||||
context={"request": request},
|
||||
)
|
||||
return Response(
|
||||
{
|
||||
"results": serializer.data,
|
||||
|
|
@ -174,7 +178,8 @@ class ExternalContourListCreateEndpoint(BaseAPIView):
|
|||
"issue__created_by",
|
||||
)
|
||||
.prefetch_related("issue__issue_assignee__assignee", "issue__label_issue__label")
|
||||
.get(pk=intake_issue.id)
|
||||
.get(pk=intake_issue.id),
|
||||
context={"request": request},
|
||||
)
|
||||
return Response(response_serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
|
|
@ -273,14 +278,28 @@ class ExternalContourDetailEndpoint(BaseAPIView):
|
|||
.prefetch_related("issue__issue_assignee__assignee", "issue__label_issue__label")
|
||||
)
|
||||
|
||||
def mark_request_notifications_read(self, user, contour_request):
|
||||
user_id = getattr(user, "id", None)
|
||||
if not user_id:
|
||||
return
|
||||
|
||||
Notification.objects.filter(
|
||||
receiver_id=user_id,
|
||||
sender__startswith="in_app:external_contours:",
|
||||
read_at__isnull=True,
|
||||
data__issue__id=str(contour_request.id),
|
||||
).update(read_at=timezone.now())
|
||||
|
||||
def get(self, request, slug, project_id, request_id):
|
||||
contour_request = get_object_or_404(self.get_queryset())
|
||||
self.mark_request_notifications_read(request.user, contour_request)
|
||||
serializer = ExternalContourRequestSerializer(
|
||||
contour_request,
|
||||
context={
|
||||
"include_mirror_data": True,
|
||||
"workspace_slug": slug,
|
||||
"source_project_id": str(project_id),
|
||||
"request": request,
|
||||
},
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
|
@ -366,6 +385,12 @@ class ExternalContourDecisionEndpoint(BaseAPIView):
|
|||
contour_request.save(update_fields=["extra", "updated_at"])
|
||||
|
||||
contour_request.refresh_from_db()
|
||||
Notification.objects.filter(
|
||||
receiver_id=request.user.id,
|
||||
sender__startswith="in_app:external_contours:",
|
||||
read_at__isnull=True,
|
||||
data__issue__id=str(contour_request.id),
|
||||
).update(read_at=timezone.now())
|
||||
serializer = ExternalContourRequestSerializer(
|
||||
IntakeIssue.objects.select_related(
|
||||
"issue",
|
||||
|
|
@ -379,6 +404,7 @@ class ExternalContourDecisionEndpoint(BaseAPIView):
|
|||
"include_mirror_data": True,
|
||||
"workspace_slug": slug,
|
||||
"source_project_id": str(project_id),
|
||||
"request": request,
|
||||
},
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
|
@ -425,12 +451,19 @@ class ExternalContourReplyEndpoint(BaseAPIView):
|
|||
return Response({"error": exc.message_dict if hasattr(exc, "message_dict") else str(exc)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
contour_request.refresh_from_db()
|
||||
Notification.objects.filter(
|
||||
receiver_id=request.user.id,
|
||||
sender__startswith="in_app:external_contours:",
|
||||
read_at__isnull=True,
|
||||
data__issue__id=str(contour_request.id),
|
||||
).update(read_at=timezone.now())
|
||||
response_serializer = ExternalContourRequestSerializer(
|
||||
contour_request,
|
||||
context={
|
||||
"include_mirror_data": True,
|
||||
"workspace_slug": slug,
|
||||
"source_project_id": str(project_id),
|
||||
"request": request,
|
||||
},
|
||||
)
|
||||
return Response(response_serializer.data, status=status.HTTP_200_OK)
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ from plane.api.serializers import (
|
|||
from plane.api.serializers.issue import IssueSerializer as IssueCreateSerializer
|
||||
from plane.app.permissions import ProjectLitePermission
|
||||
from plane.app.views.base import BaseAPIView
|
||||
from plane.db.models import FileAsset, Intake, IntakeIssue, Label, Project, ProjectMember, State, StateGroup
|
||||
from plane.db.models import FileAsset, Intake, IntakeIssue, Label, Notification, Project, ProjectMember, State, StateGroup
|
||||
from plane.db.models.intake import IntakeIssueStatus, SourceType
|
||||
from plane.settings.storage import S3Storage
|
||||
from plane.utils.external_contours import create_external_contour_issue_comment
|
||||
|
|
@ -61,7 +61,11 @@ class ExternalContourListCreateEndpoint(BaseAPIView):
|
|||
)
|
||||
|
||||
def get(self, request, slug, project_id):
|
||||
serializer = ExternalContourRequestSerializer(self.get_queryset(), many=True)
|
||||
serializer = ExternalContourRequestSerializer(
|
||||
self.get_queryset(),
|
||||
many=True,
|
||||
context={"request": request},
|
||||
)
|
||||
return Response(
|
||||
{
|
||||
"results": serializer.data,
|
||||
|
|
@ -174,7 +178,8 @@ class ExternalContourListCreateEndpoint(BaseAPIView):
|
|||
"issue__created_by",
|
||||
)
|
||||
.prefetch_related("issue__issue_assignee__assignee", "issue__label_issue__label")
|
||||
.get(pk=intake_issue.id)
|
||||
.get(pk=intake_issue.id),
|
||||
context={"request": request},
|
||||
)
|
||||
return Response(response_serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
|
|
@ -273,14 +278,28 @@ class ExternalContourDetailEndpoint(BaseAPIView):
|
|||
.prefetch_related("issue__issue_assignee__assignee", "issue__label_issue__label")
|
||||
)
|
||||
|
||||
def mark_request_notifications_read(self, user, contour_request):
|
||||
user_id = getattr(user, "id", None)
|
||||
if not user_id:
|
||||
return
|
||||
|
||||
Notification.objects.filter(
|
||||
receiver_id=user_id,
|
||||
sender__startswith="in_app:external_contours:",
|
||||
read_at__isnull=True,
|
||||
data__issue__id=str(contour_request.id),
|
||||
).update(read_at=timezone.now())
|
||||
|
||||
def get(self, request, slug, project_id, request_id):
|
||||
contour_request = get_object_or_404(self.get_queryset())
|
||||
self.mark_request_notifications_read(request.user, contour_request)
|
||||
serializer = ExternalContourRequestSerializer(
|
||||
contour_request,
|
||||
context={
|
||||
"include_mirror_data": True,
|
||||
"workspace_slug": slug,
|
||||
"source_project_id": str(project_id),
|
||||
"request": request,
|
||||
},
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
|
@ -366,6 +385,12 @@ class ExternalContourDecisionEndpoint(BaseAPIView):
|
|||
contour_request.save(update_fields=["extra", "updated_at"])
|
||||
|
||||
contour_request.refresh_from_db()
|
||||
Notification.objects.filter(
|
||||
receiver_id=request.user.id,
|
||||
sender__startswith="in_app:external_contours:",
|
||||
read_at__isnull=True,
|
||||
data__issue__id=str(contour_request.id),
|
||||
).update(read_at=timezone.now())
|
||||
serializer = ExternalContourRequestSerializer(
|
||||
IntakeIssue.objects.select_related(
|
||||
"issue",
|
||||
|
|
@ -379,6 +404,7 @@ class ExternalContourDecisionEndpoint(BaseAPIView):
|
|||
"include_mirror_data": True,
|
||||
"workspace_slug": slug,
|
||||
"source_project_id": str(project_id),
|
||||
"request": request,
|
||||
},
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
|
@ -425,12 +451,19 @@ class ExternalContourReplyEndpoint(BaseAPIView):
|
|||
return Response({"error": exc.message_dict if hasattr(exc, "message_dict") else str(exc)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
contour_request.refresh_from_db()
|
||||
Notification.objects.filter(
|
||||
receiver_id=request.user.id,
|
||||
sender__startswith="in_app:external_contours:",
|
||||
read_at__isnull=True,
|
||||
data__issue__id=str(contour_request.id),
|
||||
).update(read_at=timezone.now())
|
||||
response_serializer = ExternalContourRequestSerializer(
|
||||
contour_request,
|
||||
context={
|
||||
"include_mirror_data": True,
|
||||
"workspace_slug": slug,
|
||||
"source_project_id": str(project_id),
|
||||
"request": request,
|
||||
},
|
||||
)
|
||||
return Response(response_serializer.data, status=status.HTTP_200_OK)
|
||||
|
|
|
|||
|
|
@ -65,6 +65,11 @@ export const ExternalContoursListItem = observer(function ExternalContoursListIt
|
|||
{issue.project_detail?.identifier || "REQ"}-{issue.sequence_id}
|
||||
</span>
|
||||
{issue.project_detail?.name && <span className="truncate text-placeholder">{issue.project_detail.name}</span>}
|
||||
{request.has_unread_updates && (
|
||||
<Tooltip tooltipHeading={t("external_contours_page.list.unread_updates")} isMobile={isMobile}>
|
||||
<span className="size-2 rounded-full bg-accent-primary" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<ExternalContourStatePill request={request} />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -296,10 +296,11 @@ export default {
|
|||
open: "Open",
|
||||
closed: "Closed",
|
||||
},
|
||||
list: {
|
||||
last_updated: "Last updated",
|
||||
unassigned: "Unassigned",
|
||||
},
|
||||
list: {
|
||||
last_updated: "Last updated",
|
||||
unassigned: "Unassigned",
|
||||
unread_updates: "New updates available",
|
||||
},
|
||||
empty_state: {
|
||||
title: "External contours module is ready for the next stage",
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -453,10 +453,11 @@ export default {
|
|||
open: "Открытые",
|
||||
closed: "Закрытые",
|
||||
},
|
||||
list: {
|
||||
last_updated: "Последнее изменение",
|
||||
unassigned: "Не назначено",
|
||||
},
|
||||
list: {
|
||||
last_updated: "Последнее изменение",
|
||||
unassigned: "Не назначено",
|
||||
unread_updates: "Есть новые изменения",
|
||||
},
|
||||
empty_state: {
|
||||
title: "Модуль внешних контуров подготовлен",
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ export type TExternalContourMirroredActivity = {
|
|||
export type TExternalContourRequest = {
|
||||
created_at: string;
|
||||
created_by: string | null;
|
||||
has_unread_updates?: boolean;
|
||||
id: string;
|
||||
issue: TExternalContourIssue;
|
||||
mirrored_activity?: TExternalContourMirroredActivity[];
|
||||
|
|
|
|||
Loading…
Reference in New Issue