UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: индикатор новых изменений во внешних контурах
This commit is contained in:
parent
0a584abf26
commit
4dff521f7f
|
|
@ -99,10 +99,10 @@
|
||||||
- отображение целевого проекта
|
- отображение целевого проекта
|
||||||
- отображение исполнителей целевого контура
|
- отображение исполнителей целевого контура
|
||||||
- отображение фактической даты последнего изменения
|
- отображение фактической даты последнего изменения
|
||||||
|
- индикатор новых изменений в source-side списке на базе unread уведомлений
|
||||||
- открытие source-side detail экрана
|
- открытие source-side detail экрана
|
||||||
|
|
||||||
Что еще остается на следующие этапы:
|
Что еще остается на следующие этапы:
|
||||||
- индикатор новых изменений
|
|
||||||
- полноценная зеркальная activity/history
|
- полноценная зеркальная activity/history
|
||||||
- уведомления
|
- уведомления
|
||||||
|
|
||||||
|
|
@ -195,6 +195,7 @@
|
||||||
- notification payload несет `external contour request id` и `target issue id`
|
- notification payload несет `external contour request id` и `target issue id`
|
||||||
- список уведомлений помечает такие записи как `is_external_contour = true`
|
- список уведомлений помечает такие записи как `is_external_contour = true`
|
||||||
- notification preview может открыть source-side карточку внешнего контура, а не обычный issue preview
|
- notification preview может открыть source-side карточку внешнего контура, а не обычный issue preview
|
||||||
|
- открытие source-side карточки помечает связанные unread уведомления как прочитанные и снимает индикатор новых изменений в списке
|
||||||
|
|
||||||
Что остается:
|
Что остается:
|
||||||
- in-app уведомления на явные source-side решения `Принять / Отклонить`
|
- in-app уведомления на явные source-side решения `Принять / Отклонить`
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ from .project import ProjectLiteSerializer
|
||||||
from .state import StateLiteSerializer
|
from .state import StateLiteSerializer
|
||||||
from .user import UserLiteSerializer
|
from .user import UserLiteSerializer
|
||||||
from plane.app.serializers.issue import LabelSerializer
|
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):
|
class ExternalContourIssuePayloadSerializer(serializers.Serializer):
|
||||||
|
|
@ -149,6 +149,7 @@ class ExternalContourMirroredActivitySerializer(BaseSerializer):
|
||||||
|
|
||||||
|
|
||||||
class ExternalContourRequestSerializer(BaseSerializer):
|
class ExternalContourRequestSerializer(BaseSerializer):
|
||||||
|
has_unread_updates = serializers.SerializerMethodField()
|
||||||
issue = ExternalContourIssueSerializer(read_only=True)
|
issue = ExternalContourIssueSerializer(read_only=True)
|
||||||
mirrored_activity = serializers.SerializerMethodField()
|
mirrored_activity = serializers.SerializerMethodField()
|
||||||
mirrored_attachments = serializers.SerializerMethodField()
|
mirrored_attachments = serializers.SerializerMethodField()
|
||||||
|
|
@ -172,6 +173,7 @@ class ExternalContourRequestSerializer(BaseSerializer):
|
||||||
"created_at",
|
"created_at",
|
||||||
"updated_at",
|
"updated_at",
|
||||||
"created_by",
|
"created_by",
|
||||||
|
"has_unread_updates",
|
||||||
"issue",
|
"issue",
|
||||||
"mirrored_activity",
|
"mirrored_activity",
|
||||||
"mirrored_attachments",
|
"mirrored_attachments",
|
||||||
|
|
@ -193,6 +195,20 @@ class ExternalContourRequestSerializer(BaseSerializer):
|
||||||
def get_source_project_id(self, obj):
|
def get_source_project_id(self, obj):
|
||||||
return obj.extra.get("source_project_id")
|
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):
|
def get_mirrored_activity(self, obj):
|
||||||
if not self.context.get("include_mirror_data") or not obj.issue_id:
|
if not self.context.get("include_mirror_data") or not obj.issue_id:
|
||||||
return []
|
return []
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ from plane.api.serializers import (
|
||||||
from plane.api.serializers.issue import IssueSerializer as IssueCreateSerializer
|
from plane.api.serializers.issue import IssueSerializer as IssueCreateSerializer
|
||||||
from plane.app.permissions import ProjectLitePermission
|
from plane.app.permissions import ProjectLitePermission
|
||||||
from plane.app.views.base import BaseAPIView
|
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.db.models.intake import IntakeIssueStatus, SourceType
|
||||||
from plane.settings.storage import S3Storage
|
from plane.settings.storage import S3Storage
|
||||||
from plane.utils.external_contours import create_external_contour_issue_comment
|
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):
|
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(
|
return Response(
|
||||||
{
|
{
|
||||||
"results": serializer.data,
|
"results": serializer.data,
|
||||||
|
|
@ -174,7 +178,8 @@ class ExternalContourListCreateEndpoint(BaseAPIView):
|
||||||
"issue__created_by",
|
"issue__created_by",
|
||||||
)
|
)
|
||||||
.prefetch_related("issue__issue_assignee__assignee", "issue__label_issue__label")
|
.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)
|
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")
|
.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):
|
def get(self, request, slug, project_id, request_id):
|
||||||
contour_request = get_object_or_404(self.get_queryset())
|
contour_request = get_object_or_404(self.get_queryset())
|
||||||
|
self.mark_request_notifications_read(request.user, contour_request)
|
||||||
serializer = ExternalContourRequestSerializer(
|
serializer = ExternalContourRequestSerializer(
|
||||||
contour_request,
|
contour_request,
|
||||||
context={
|
context={
|
||||||
"include_mirror_data": True,
|
"include_mirror_data": True,
|
||||||
"workspace_slug": slug,
|
"workspace_slug": slug,
|
||||||
"source_project_id": str(project_id),
|
"source_project_id": str(project_id),
|
||||||
|
"request": request,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
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.save(update_fields=["extra", "updated_at"])
|
||||||
|
|
||||||
contour_request.refresh_from_db()
|
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(
|
serializer = ExternalContourRequestSerializer(
|
||||||
IntakeIssue.objects.select_related(
|
IntakeIssue.objects.select_related(
|
||||||
"issue",
|
"issue",
|
||||||
|
|
@ -379,6 +404,7 @@ class ExternalContourDecisionEndpoint(BaseAPIView):
|
||||||
"include_mirror_data": True,
|
"include_mirror_data": True,
|
||||||
"workspace_slug": slug,
|
"workspace_slug": slug,
|
||||||
"source_project_id": str(project_id),
|
"source_project_id": str(project_id),
|
||||||
|
"request": request,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
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)
|
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()
|
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(
|
response_serializer = ExternalContourRequestSerializer(
|
||||||
contour_request,
|
contour_request,
|
||||||
context={
|
context={
|
||||||
"include_mirror_data": True,
|
"include_mirror_data": True,
|
||||||
"workspace_slug": slug,
|
"workspace_slug": slug,
|
||||||
"source_project_id": str(project_id),
|
"source_project_id": str(project_id),
|
||||||
|
"request": request,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
return Response(response_serializer.data, status=status.HTTP_200_OK)
|
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.api.serializers.issue import IssueSerializer as IssueCreateSerializer
|
||||||
from plane.app.permissions import ProjectLitePermission
|
from plane.app.permissions import ProjectLitePermission
|
||||||
from plane.app.views.base import BaseAPIView
|
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.db.models.intake import IntakeIssueStatus, SourceType
|
||||||
from plane.settings.storage import S3Storage
|
from plane.settings.storage import S3Storage
|
||||||
from plane.utils.external_contours import create_external_contour_issue_comment
|
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):
|
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(
|
return Response(
|
||||||
{
|
{
|
||||||
"results": serializer.data,
|
"results": serializer.data,
|
||||||
|
|
@ -174,7 +178,8 @@ class ExternalContourListCreateEndpoint(BaseAPIView):
|
||||||
"issue__created_by",
|
"issue__created_by",
|
||||||
)
|
)
|
||||||
.prefetch_related("issue__issue_assignee__assignee", "issue__label_issue__label")
|
.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)
|
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")
|
.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):
|
def get(self, request, slug, project_id, request_id):
|
||||||
contour_request = get_object_or_404(self.get_queryset())
|
contour_request = get_object_or_404(self.get_queryset())
|
||||||
|
self.mark_request_notifications_read(request.user, contour_request)
|
||||||
serializer = ExternalContourRequestSerializer(
|
serializer = ExternalContourRequestSerializer(
|
||||||
contour_request,
|
contour_request,
|
||||||
context={
|
context={
|
||||||
"include_mirror_data": True,
|
"include_mirror_data": True,
|
||||||
"workspace_slug": slug,
|
"workspace_slug": slug,
|
||||||
"source_project_id": str(project_id),
|
"source_project_id": str(project_id),
|
||||||
|
"request": request,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
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.save(update_fields=["extra", "updated_at"])
|
||||||
|
|
||||||
contour_request.refresh_from_db()
|
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(
|
serializer = ExternalContourRequestSerializer(
|
||||||
IntakeIssue.objects.select_related(
|
IntakeIssue.objects.select_related(
|
||||||
"issue",
|
"issue",
|
||||||
|
|
@ -379,6 +404,7 @@ class ExternalContourDecisionEndpoint(BaseAPIView):
|
||||||
"include_mirror_data": True,
|
"include_mirror_data": True,
|
||||||
"workspace_slug": slug,
|
"workspace_slug": slug,
|
||||||
"source_project_id": str(project_id),
|
"source_project_id": str(project_id),
|
||||||
|
"request": request,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
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)
|
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()
|
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(
|
response_serializer = ExternalContourRequestSerializer(
|
||||||
contour_request,
|
contour_request,
|
||||||
context={
|
context={
|
||||||
"include_mirror_data": True,
|
"include_mirror_data": True,
|
||||||
"workspace_slug": slug,
|
"workspace_slug": slug,
|
||||||
"source_project_id": str(project_id),
|
"source_project_id": str(project_id),
|
||||||
|
"request": request,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
return Response(response_serializer.data, status=status.HTTP_200_OK)
|
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}
|
{issue.project_detail?.identifier || "REQ"}-{issue.sequence_id}
|
||||||
</span>
|
</span>
|
||||||
{issue.project_detail?.name && <span className="truncate text-placeholder">{issue.project_detail.name}</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>
|
</div>
|
||||||
<ExternalContourStatePill request={request} />
|
<ExternalContourStatePill request={request} />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -299,6 +299,7 @@ export default {
|
||||||
list: {
|
list: {
|
||||||
last_updated: "Last updated",
|
last_updated: "Last updated",
|
||||||
unassigned: "Unassigned",
|
unassigned: "Unassigned",
|
||||||
|
unread_updates: "New updates available",
|
||||||
},
|
},
|
||||||
empty_state: {
|
empty_state: {
|
||||||
title: "External contours module is ready for the next stage",
|
title: "External contours module is ready for the next stage",
|
||||||
|
|
|
||||||
|
|
@ -456,6 +456,7 @@ export default {
|
||||||
list: {
|
list: {
|
||||||
last_updated: "Последнее изменение",
|
last_updated: "Последнее изменение",
|
||||||
unassigned: "Не назначено",
|
unassigned: "Не назначено",
|
||||||
|
unread_updates: "Есть новые изменения",
|
||||||
},
|
},
|
||||||
empty_state: {
|
empty_state: {
|
||||||
title: "Модуль внешних контуров подготовлен",
|
title: "Модуль внешних контуров подготовлен",
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,7 @@ export type TExternalContourMirroredActivity = {
|
||||||
export type TExternalContourRequest = {
|
export type TExternalContourRequest = {
|
||||||
created_at: string;
|
created_at: string;
|
||||||
created_by: string | null;
|
created_by: string | null;
|
||||||
|
has_unread_updates?: boolean;
|
||||||
id: string;
|
id: string;
|
||||||
issue: TExternalContourIssue;
|
issue: TExternalContourIssue;
|
||||||
mirrored_activity?: TExternalContourMirroredActivity[];
|
mirrored_activity?: TExternalContourMirroredActivity[];
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue