ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: причина отклонения, reply и in-app уведомления
This commit is contained in:
parent
61a8625a5c
commit
0a584abf26
|
|
@ -149,15 +149,16 @@
|
|||
- если у инициатора нет membership в target project, карточка переключается в source-side readonly режим без прямого открытия чужого проекта
|
||||
- для закрытого внешнего запроса доступны source-side действия `Принять` и `Отклонить`
|
||||
- `Принять` фиксирует решение источника в bridge metadata и помечает запрос как принятый во внутренний контур
|
||||
- `Отклонить` возвращает target issue в default-state целевого проекта и переносит source-side карточку обратно в список `Открытые`
|
||||
- `Отклонить` требует комментарий причины, возвращает target issue в default-state целевого проекта и переносит source-side карточку обратно в список `Открытые`
|
||||
- source-side readonly карточка зеркалит актуальные комментарии, вложения и activity целевой задачи
|
||||
- вложения доступны через proxy download endpoint без прямого membership в target project
|
||||
- detail карточка source-only пользователя обновляется polling-ом и подтягивает новые комментарии без ручной перезагрузки
|
||||
- инициатор может отправить комментарий обратно во внешний контур прямо из source-side карточки
|
||||
|
||||
Что остается:
|
||||
- зеркалирование inline-файлов из комментариев и описания, а не только issue attachments
|
||||
- двусторонняя работа с комментариями из source-side карточки
|
||||
- комментарий причины отклонения и ответ обратно во внешний контур
|
||||
- realtime вместо polling
|
||||
- отдельная сущность или шаг для переноса принятого результата во `Внутренний контур`
|
||||
|
||||
## Этап 4. Уведомления
|
||||
|
||||
|
|
@ -179,6 +180,27 @@
|
|||
|
||||
- инициатор получает уведомления по ключевым событиям жизненного цикла внешнего запроса
|
||||
|
||||
### Статус
|
||||
|
||||
Реализовано частично.
|
||||
|
||||
Что уже работает:
|
||||
- создаются in-app уведомления по изменениям целевой задачи внешнего контура
|
||||
- покрыты события:
|
||||
- смена статуса
|
||||
- новый комментарий
|
||||
- изменение описания
|
||||
- новое вложение
|
||||
- уведомление привязано к source project, а не требует membership в target project
|
||||
- notification payload несет `external contour request id` и `target issue id`
|
||||
- список уведомлений помечает такие записи как `is_external_contour = true`
|
||||
- notification preview может открыть source-side карточку внешнего контура, а не обычный issue preview
|
||||
|
||||
Что остается:
|
||||
- in-app уведомления на явные source-side решения `Принять / Отклонить`
|
||||
- отдельный индикатор новых изменений в списке `Открытые / Завершенные`
|
||||
- push/realtime канал вместо обычного цикла обновления UI
|
||||
|
||||
## Этап 5. Полировка и правила эксплуатации
|
||||
|
||||
### Что входит
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ from .intake import (
|
|||
from .external_contours import (
|
||||
ExternalContourRequestCreateSerializer,
|
||||
ExternalContourRequestDecisionSerializer,
|
||||
ExternalContourRequestReplySerializer,
|
||||
ExternalContourRequestSerializer,
|
||||
ExternalContourTargetOptionsSerializer,
|
||||
ExternalContourTargetProjectSerializer,
|
||||
|
|
|
|||
|
|
@ -29,6 +29,16 @@ class ExternalContourRequestCreateSerializer(serializers.Serializer):
|
|||
|
||||
class ExternalContourRequestDecisionSerializer(serializers.Serializer):
|
||||
action = serializers.ChoiceField(choices=["accept", "decline"])
|
||||
comment = serializers.CharField(required=False, allow_blank=True)
|
||||
|
||||
def validate(self, data):
|
||||
if data.get("action") == "decline" and not (data.get("comment") or "").strip():
|
||||
raise serializers.ValidationError({"comment": "Decline reason is required"})
|
||||
return data
|
||||
|
||||
|
||||
class ExternalContourRequestReplySerializer(serializers.Serializer):
|
||||
comment = serializers.CharField()
|
||||
|
||||
|
||||
class ExternalContourTargetProjectSerializer(BaseSerializer):
|
||||
|
|
|
|||
|
|
@ -4,44 +4,51 @@
|
|||
|
||||
from django.urls import path
|
||||
|
||||
from plane.api.views import (
|
||||
ExternalContourAttachmentDownloadAPIEndpoint,
|
||||
ExternalContourDetailAPIEndpoint,
|
||||
ExternalContourDecisionAPIEndpoint,
|
||||
ExternalContourListCreateAPIEndpoint,
|
||||
ExternalContourTargetOptionsAPIEndpoint,
|
||||
ExternalContourTargetProjectListAPIEndpoint,
|
||||
from plane.app.views import (
|
||||
ExternalContourAttachmentDownloadEndpoint,
|
||||
ExternalContourDetailEndpoint,
|
||||
ExternalContourDecisionEndpoint,
|
||||
ExternalContourListCreateEndpoint,
|
||||
ExternalContourReplyEndpoint,
|
||||
ExternalContourTargetOptionsEndpoint,
|
||||
ExternalContourTargetProjectListEndpoint,
|
||||
)
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/external-contours/",
|
||||
ExternalContourListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
|
||||
ExternalContourListCreateEndpoint.as_view(http_method_names=["get", "post"]),
|
||||
name="external-contours",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/external-contours/targets/",
|
||||
ExternalContourTargetProjectListAPIEndpoint.as_view(http_method_names=["get"]),
|
||||
ExternalContourTargetProjectListEndpoint.as_view(http_method_names=["get"]),
|
||||
name="external-contour-targets",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/external-contours/targets/<uuid:target_project_id>/options/",
|
||||
ExternalContourTargetOptionsAPIEndpoint.as_view(http_method_names=["get"]),
|
||||
ExternalContourTargetOptionsEndpoint.as_view(http_method_names=["get"]),
|
||||
name="external-contour-target-options",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/external-contours/<uuid:request_id>/",
|
||||
ExternalContourDetailAPIEndpoint.as_view(http_method_names=["get"]),
|
||||
ExternalContourDetailEndpoint.as_view(http_method_names=["get"]),
|
||||
name="external-contour-detail",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/external-contours/<uuid:request_id>/decision/",
|
||||
ExternalContourDecisionAPIEndpoint.as_view(http_method_names=["post"]),
|
||||
ExternalContourDecisionEndpoint.as_view(http_method_names=["post"]),
|
||||
name="external-contour-decision",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/external-contours/<uuid:request_id>/reply/",
|
||||
ExternalContourReplyEndpoint.as_view(http_method_names=["post"]),
|
||||
name="external-contour-reply",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/external-contours/<uuid:request_id>/attachments/<uuid:attachment_id>/",
|
||||
ExternalContourAttachmentDownloadAPIEndpoint.as_view(http_method_names=["get"]),
|
||||
ExternalContourAttachmentDownloadEndpoint.as_view(http_method_names=["get"]),
|
||||
name="external-contour-attachment-download",
|
||||
),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ from .external_contours import (
|
|||
ExternalContourListCreateEndpoint as ExternalContourListCreateAPIEndpoint,
|
||||
ExternalContourDetailEndpoint as ExternalContourDetailAPIEndpoint,
|
||||
ExternalContourDecisionEndpoint as ExternalContourDecisionAPIEndpoint,
|
||||
ExternalContourReplyEndpoint as ExternalContourReplyAPIEndpoint,
|
||||
ExternalContourTargetProjectListEndpoint as ExternalContourTargetProjectListAPIEndpoint,
|
||||
ExternalContourTargetOptionsEndpoint as ExternalContourTargetOptionsAPIEndpoint,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -5,22 +5,26 @@
|
|||
from django.http import HttpResponseRedirect
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils import timezone
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
from plane.utils.host import base_host
|
||||
|
||||
from plane.api.serializers import (
|
||||
ExternalContourRequestCreateSerializer,
|
||||
ExternalContourRequestDecisionSerializer,
|
||||
ExternalContourRequestReplySerializer,
|
||||
ExternalContourRequestSerializer,
|
||||
ExternalContourTargetOptionsSerializer,
|
||||
ExternalContourTargetProjectSerializer,
|
||||
)
|
||||
from plane.api.serializers.issue import IssueSerializer as IssueCreateSerializer
|
||||
from plane.app.permissions import ProjectLitePermission
|
||||
from .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.intake import IntakeIssueStatus, SourceType
|
||||
from plane.settings.storage import S3Storage
|
||||
from plane.utils.external_contours import create_external_contour_issue_comment
|
||||
|
||||
|
||||
class ExternalContourListCreateEndpoint(BaseAPIView):
|
||||
|
|
@ -309,6 +313,7 @@ class ExternalContourDecisionEndpoint(BaseAPIView):
|
|||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
action = serializer.validated_data["action"]
|
||||
comment = (serializer.validated_data.get("comment") or "").strip()
|
||||
issue = contour_request.issue
|
||||
|
||||
if not issue or not issue.state or issue.state.group not in [StateGroup.COMPLETED.value, StateGroup.CANCELLED.value]:
|
||||
|
|
@ -337,6 +342,16 @@ class ExternalContourDecisionEndpoint(BaseAPIView):
|
|||
if not target_default_state:
|
||||
return Response({"error": "Target project has no available workflow state"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
try:
|
||||
create_external_contour_issue_comment(
|
||||
issue=issue,
|
||||
actor=request.user,
|
||||
comment=comment,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
)
|
||||
except ValidationError as exc:
|
||||
return Response({"error": exc.message_dict if hasattr(exc, "message_dict") else str(exc)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
issue.state = target_default_state
|
||||
issue.save(update_fields=["state", "updated_at"])
|
||||
|
||||
|
|
@ -344,6 +359,7 @@ class ExternalContourDecisionEndpoint(BaseAPIView):
|
|||
extra.pop("source_decision", None)
|
||||
extra.pop("source_decision_at", None)
|
||||
extra.pop("source_decision_by_name", None)
|
||||
extra["last_decline_comment"] = comment
|
||||
extra["last_reopened_at"] = issue.updated_at.isoformat() if issue.updated_at else None
|
||||
extra["last_reopened_by_name"] = request.user.display_name
|
||||
contour_request.extra = extra
|
||||
|
|
@ -368,6 +384,58 @@ class ExternalContourDecisionEndpoint(BaseAPIView):
|
|||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class ExternalContourReplyEndpoint(BaseAPIView):
|
||||
permission_classes = [ProjectLitePermission]
|
||||
serializer_class = ExternalContourRequestSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
return (
|
||||
IntakeIssue.objects.filter(
|
||||
workspace__slug=self.kwargs.get("slug"),
|
||||
extra__bridge="external-contours",
|
||||
extra__source_project_id=str(self.kwargs.get("project_id")),
|
||||
pk=self.kwargs.get("request_id"),
|
||||
)
|
||||
.select_related(
|
||||
"issue",
|
||||
"issue__state",
|
||||
"issue__project",
|
||||
"issue__created_by",
|
||||
)
|
||||
.prefetch_related("issue__issue_assignee__assignee", "issue__label_issue__label")
|
||||
)
|
||||
|
||||
def post(self, request, slug, project_id, request_id):
|
||||
contour_request = get_object_or_404(self.get_queryset())
|
||||
serializer = ExternalContourRequestReplySerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
issue = contour_request.issue
|
||||
if not issue:
|
||||
return Response({"error": "Target issue was not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
try:
|
||||
create_external_contour_issue_comment(
|
||||
issue=issue,
|
||||
actor=request.user,
|
||||
comment=serializer.validated_data["comment"],
|
||||
origin=base_host(request=request, is_app=True),
|
||||
)
|
||||
except ValidationError as exc:
|
||||
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()
|
||||
response_serializer = ExternalContourRequestSerializer(
|
||||
contour_request,
|
||||
context={
|
||||
"include_mirror_data": True,
|
||||
"workspace_slug": slug,
|
||||
"source_project_id": str(project_id),
|
||||
},
|
||||
)
|
||||
return Response(response_serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class ExternalContourAttachmentDownloadEndpoint(BaseAPIView):
|
||||
permission_classes = [ProjectLitePermission]
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ class NotificationSerializer(BaseSerializer):
|
|||
triggered_by_details = UserLiteSerializer(read_only=True, source="triggered_by")
|
||||
is_inbox_issue = serializers.BooleanField(read_only=True)
|
||||
is_intake_issue = serializers.BooleanField(read_only=True)
|
||||
is_external_contour = serializers.BooleanField(read_only=True)
|
||||
is_mentioned_notification = serializers.BooleanField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ from plane.app.views import (
|
|||
ExternalContourDetailEndpoint,
|
||||
ExternalContourDecisionEndpoint,
|
||||
ExternalContourListCreateEndpoint,
|
||||
ExternalContourReplyEndpoint,
|
||||
ExternalContourTargetOptionsEndpoint,
|
||||
ExternalContourTargetProjectListEndpoint,
|
||||
)
|
||||
|
|
@ -40,6 +41,11 @@ urlpatterns = [
|
|||
ExternalContourDecisionEndpoint.as_view(http_method_names=["post"]),
|
||||
name="external-contour-decision",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/external-contours/<uuid:request_id>/reply/",
|
||||
ExternalContourReplyEndpoint.as_view(http_method_names=["post"]),
|
||||
name="external-contour-reply",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/external-contours/<uuid:request_id>/attachments/<uuid:attachment_id>/",
|
||||
ExternalContourAttachmentDownloadEndpoint.as_view(http_method_names=["get"]),
|
||||
|
|
|
|||
|
|
@ -229,6 +229,7 @@ from .external_contours import (
|
|||
ExternalContourListCreateEndpoint,
|
||||
ExternalContourDetailEndpoint,
|
||||
ExternalContourDecisionEndpoint,
|
||||
ExternalContourReplyEndpoint,
|
||||
ExternalContourTargetProjectListEndpoint,
|
||||
ExternalContourTargetOptionsEndpoint,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -5,12 +5,15 @@
|
|||
from django.http import HttpResponseRedirect
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils import timezone
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
from plane.utils.host import base_host
|
||||
|
||||
from plane.api.serializers import (
|
||||
ExternalContourRequestCreateSerializer,
|
||||
ExternalContourRequestDecisionSerializer,
|
||||
ExternalContourRequestReplySerializer,
|
||||
ExternalContourRequestSerializer,
|
||||
ExternalContourTargetOptionsSerializer,
|
||||
ExternalContourTargetProjectSerializer,
|
||||
|
|
@ -21,6 +24,7 @@ from plane.app.views.base import BaseAPIView
|
|||
from plane.db.models import FileAsset, Intake, IntakeIssue, Label, 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
|
||||
|
||||
|
||||
class ExternalContourListCreateEndpoint(BaseAPIView):
|
||||
|
|
@ -309,6 +313,7 @@ class ExternalContourDecisionEndpoint(BaseAPIView):
|
|||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
action = serializer.validated_data["action"]
|
||||
comment = (serializer.validated_data.get("comment") or "").strip()
|
||||
issue = contour_request.issue
|
||||
|
||||
if not issue or not issue.state or issue.state.group not in [StateGroup.COMPLETED.value, StateGroup.CANCELLED.value]:
|
||||
|
|
@ -337,6 +342,16 @@ class ExternalContourDecisionEndpoint(BaseAPIView):
|
|||
if not target_default_state:
|
||||
return Response({"error": "Target project has no available workflow state"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
try:
|
||||
create_external_contour_issue_comment(
|
||||
issue=issue,
|
||||
actor=request.user,
|
||||
comment=comment,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
)
|
||||
except ValidationError as exc:
|
||||
return Response({"error": exc.message_dict if hasattr(exc, "message_dict") else str(exc)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
issue.state = target_default_state
|
||||
issue.save(update_fields=["state", "updated_at"])
|
||||
|
||||
|
|
@ -344,6 +359,7 @@ class ExternalContourDecisionEndpoint(BaseAPIView):
|
|||
extra.pop("source_decision", None)
|
||||
extra.pop("source_decision_at", None)
|
||||
extra.pop("source_decision_by_name", None)
|
||||
extra["last_decline_comment"] = comment
|
||||
extra["last_reopened_at"] = issue.updated_at.isoformat() if issue.updated_at else None
|
||||
extra["last_reopened_by_name"] = request.user.display_name
|
||||
contour_request.extra = extra
|
||||
|
|
@ -368,6 +384,58 @@ class ExternalContourDecisionEndpoint(BaseAPIView):
|
|||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class ExternalContourReplyEndpoint(BaseAPIView):
|
||||
permission_classes = [ProjectLitePermission]
|
||||
serializer_class = ExternalContourRequestSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
return (
|
||||
IntakeIssue.objects.filter(
|
||||
workspace__slug=self.kwargs.get("slug"),
|
||||
extra__bridge="external-contours",
|
||||
extra__source_project_id=str(self.kwargs.get("project_id")),
|
||||
pk=self.kwargs.get("request_id"),
|
||||
)
|
||||
.select_related(
|
||||
"issue",
|
||||
"issue__state",
|
||||
"issue__project",
|
||||
"issue__created_by",
|
||||
)
|
||||
.prefetch_related("issue__issue_assignee__assignee", "issue__label_issue__label")
|
||||
)
|
||||
|
||||
def post(self, request, slug, project_id, request_id):
|
||||
contour_request = get_object_or_404(self.get_queryset())
|
||||
serializer = ExternalContourRequestReplySerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
issue = contour_request.issue
|
||||
if not issue:
|
||||
return Response({"error": "Target issue was not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
try:
|
||||
create_external_contour_issue_comment(
|
||||
issue=issue,
|
||||
actor=request.user,
|
||||
comment=serializer.validated_data["comment"],
|
||||
origin=base_host(request=request, is_app=True),
|
||||
)
|
||||
except ValidationError as exc:
|
||||
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()
|
||||
response_serializer = ExternalContourRequestSerializer(
|
||||
contour_request,
|
||||
context={
|
||||
"include_mirror_data": True,
|
||||
"workspace_slug": slug,
|
||||
"source_project_id": str(project_id),
|
||||
},
|
||||
)
|
||||
return Response(response_serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class ExternalContourAttachmentDownloadEndpoint(BaseAPIView):
|
||||
permission_classes = [ProjectLitePermission]
|
||||
|
||||
|
|
|
|||
|
|
@ -65,6 +65,13 @@ class NotificationViewSet(BaseViewSet, BasePaginator):
|
|||
.filter(entity_name="issue")
|
||||
.annotate(is_inbox_issue=Exists(intake_issue))
|
||||
.annotate(is_intake_issue=Exists(intake_issue))
|
||||
.annotate(
|
||||
is_external_contour=Case(
|
||||
When(sender__startswith="in_app:external_contours:", then=True),
|
||||
default=False,
|
||||
output_field=BooleanField(),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
is_mentioned_notification=Case(
|
||||
When(sender__icontains="mentioned", then=True),
|
||||
|
|
|
|||
|
|
@ -23,8 +23,10 @@ from plane.db.models import (
|
|||
IssueActivity,
|
||||
UserNotificationPreference,
|
||||
ProjectMember,
|
||||
IntakeIssue,
|
||||
)
|
||||
from django.db.models import Subquery
|
||||
from plane.utils.external_contours import create_external_contour_notification
|
||||
|
||||
# Third Party imports
|
||||
from celery import shared_task
|
||||
|
|
@ -99,6 +101,10 @@ def extract_mentions_as_subscribers(project_id, issue_id, mentions):
|
|||
and ProjectMember.objects.filter(project_id=project_id, member_id=mention_id, is_active=True).exists()
|
||||
):
|
||||
project = Project.objects.get(pk=project_id)
|
||||
external_contour_request = IntakeIssue.objects.filter(
|
||||
issue_id=issue_id,
|
||||
extra__bridge="external-contours",
|
||||
).first()
|
||||
|
||||
bulk_mention_subscribers.append(
|
||||
IssueSubscriber(
|
||||
|
|
@ -288,6 +294,10 @@ def notifications(
|
|||
)
|
||||
|
||||
issue = Issue.objects.filter(pk=issue_id).first()
|
||||
external_contour_request = IntakeIssue.objects.filter(
|
||||
issue_id=issue_id,
|
||||
extra__bridge="external-contours",
|
||||
).first()
|
||||
|
||||
if subscriber:
|
||||
# add the user to issue subscriber
|
||||
|
|
@ -517,7 +527,19 @@ def notifications(
|
|||
},
|
||||
)
|
||||
)
|
||||
bulk_notifications.append(notification)
|
||||
|
||||
if external_contour_request is not None:
|
||||
for issue_activity in issue_activities_created:
|
||||
if issue_activity.get("issue_detail").get("id") != issue_id:
|
||||
continue
|
||||
|
||||
external_contour_notification = create_external_contour_notification(
|
||||
contour_request=external_contour_request,
|
||||
issue=issue,
|
||||
issue_activity_data=issue_activity,
|
||||
)
|
||||
if external_contour_notification is not None:
|
||||
bulk_notifications.append(external_contour_notification)
|
||||
|
||||
for mention_id in new_mentions:
|
||||
if mention_id != actor_id:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,123 @@
|
|||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
import json
|
||||
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.utils import timezone
|
||||
from django.utils.html import escape
|
||||
|
||||
from plane.app.serializers import IssueCommentSerializer
|
||||
from plane.db.models import IssueComment, Notification, Project
|
||||
|
||||
|
||||
EXTERNAL_CONTOUR_NOTIFICATION_FIELDS = {"state", "comment", "attachment", "description"}
|
||||
|
||||
|
||||
def build_external_contour_comment_html(comment: str) -> str:
|
||||
escaped_comment = escape((comment or "").strip())
|
||||
escaped_comment = escaped_comment.replace("\n", "<br />")
|
||||
return f"<p>{escaped_comment}</p>"
|
||||
|
||||
|
||||
def create_external_contour_issue_comment(*, issue, actor, comment: str, origin: str):
|
||||
from plane.bgtasks.issue_activities_task import issue_activity
|
||||
|
||||
comment_html = build_external_contour_comment_html(comment)
|
||||
serializer = IssueCommentSerializer(data={"comment_html": comment_html})
|
||||
serializer.is_valid(raise_exception=True)
|
||||
serializer.save(project_id=issue.project_id, issue_id=issue.id, actor=actor)
|
||||
issue_activity.delay(
|
||||
type="comment.activity.created",
|
||||
requested_data=json.dumps(serializer.data, cls=DjangoJSONEncoder),
|
||||
actor_id=str(actor.id),
|
||||
issue_id=str(issue.id),
|
||||
project_id=str(issue.project_id),
|
||||
current_instance=None,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=origin,
|
||||
)
|
||||
return serializer.data
|
||||
|
||||
|
||||
def create_external_contour_notification(*, contour_request, issue, issue_activity_data):
|
||||
extra = contour_request.extra or {}
|
||||
receiver_id = extra.get("requested_by_id")
|
||||
source_project_id = extra.get("source_project_id")
|
||||
field = issue_activity_data.get("field")
|
||||
actor_id = issue_activity_data.get("actor_id") or (issue_activity_data.get("actor_detail") or {}).get("id")
|
||||
|
||||
if not receiver_id or not source_project_id or not field:
|
||||
return None
|
||||
|
||||
if field not in EXTERNAL_CONTOUR_NOTIFICATION_FIELDS:
|
||||
return None
|
||||
|
||||
if str(receiver_id) == str(actor_id):
|
||||
return None
|
||||
|
||||
source_project = Project.objects.filter(
|
||||
pk=source_project_id,
|
||||
workspace_id=issue.workspace_id,
|
||||
archived_at__isnull=True,
|
||||
).first()
|
||||
|
||||
if not source_project:
|
||||
return None
|
||||
|
||||
issue_comment = (
|
||||
IssueComment.objects.filter(
|
||||
id=issue_activity_data.get("issue_comment"),
|
||||
issue_id=issue.id,
|
||||
project_id=issue.project_id,
|
||||
workspace_id=issue.workspace_id,
|
||||
).first()
|
||||
if issue_activity_data.get("issue_comment")
|
||||
else None
|
||||
)
|
||||
|
||||
return Notification(
|
||||
workspace=source_project.workspace,
|
||||
project=source_project,
|
||||
sender=f"in_app:external_contours:{field}",
|
||||
triggered_by_id=actor_id,
|
||||
receiver_id=receiver_id,
|
||||
entity_identifier=issue.id,
|
||||
entity_name="issue",
|
||||
title=issue_activity_data.get("comment") or "External contour updated",
|
||||
data={
|
||||
"issue": {
|
||||
"id": str(contour_request.id),
|
||||
"name": str(issue.name),
|
||||
"identifier": str(issue.project.identifier),
|
||||
"sequence_id": issue.sequence_id,
|
||||
"state_name": issue.state.name if issue.state else None,
|
||||
"state_group": issue.state.group if issue.state else None,
|
||||
"project_id": str(source_project.id),
|
||||
"workspace_slug": str(source_project.workspace.slug),
|
||||
"external_contour_request_id": str(contour_request.id),
|
||||
"target_issue_id": str(issue.id),
|
||||
"target_project_id": str(issue.project_id),
|
||||
"target_project_name": str(issue.project.name),
|
||||
"source_project_id": str(source_project.id),
|
||||
"source_project_name": str(source_project.name),
|
||||
},
|
||||
"issue_activity": {
|
||||
"id": str(issue_activity_data.get("id")),
|
||||
"verb": str(issue_activity_data.get("verb")),
|
||||
"field": str(field),
|
||||
"actor": str(actor_id),
|
||||
"new_value": str(issue_activity_data.get("new_value")),
|
||||
"old_value": str(issue_activity_data.get("old_value")),
|
||||
"issue_comment": str(issue_comment.comment_stripped if issue_comment is not None else ""),
|
||||
"old_identifier": (
|
||||
str(issue_activity_data.get("old_identifier")) if issue_activity_data.get("old_identifier") else None
|
||||
),
|
||||
"new_identifier": (
|
||||
str(issue_activity_data.get("new_identifier")) if issue_activity_data.get("new_identifier") else None
|
||||
),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { EModalPosition, EModalWidth, ModalCore, TextArea } from "@plane/ui";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
isSubmitting?: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (comment: string) => Promise<void>;
|
||||
};
|
||||
|
||||
export function ExternalContourDeclineModal(props: Props) {
|
||||
const { isOpen, isSubmitting = false, onClose, onSubmit } = props;
|
||||
const { t } = useTranslation();
|
||||
const [comment, setComment] = useState("");
|
||||
|
||||
const handleClose = () => {
|
||||
if (isSubmitting) return;
|
||||
setComment("");
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!comment.trim() || isSubmitting) return;
|
||||
await onSubmit(comment.trim());
|
||||
setComment("");
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalCore
|
||||
isOpen={isOpen}
|
||||
handleClose={handleClose}
|
||||
position={EModalPosition.CENTER}
|
||||
width={EModalWidth.LG}
|
||||
className="rounded-lg"
|
||||
>
|
||||
<div className="space-y-4 p-6">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-18 font-semibold text-primary">{t("external_contours_page.decline_modal.title")}</h3>
|
||||
<p className="text-13 text-secondary">{t("external_contours_page.decline_modal.description")}</p>
|
||||
</div>
|
||||
|
||||
<TextArea
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
placeholder={t("external_contours_page.decline_modal.placeholder")}
|
||||
rows={5}
|
||||
disabled={isSubmitting}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="secondary" onClick={handleClose} disabled={isSubmitting}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button variant="primary" onClick={handleSubmit} disabled={isSubmitting || !comment.trim()}>
|
||||
{t("external_contours_page.decline_modal.submit")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalCore>
|
||||
);
|
||||
}
|
||||
|
|
@ -4,7 +4,7 @@
|
|||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { PanelLeft } from "lucide-react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
|
|
@ -22,6 +22,7 @@ import { useProject } from "@/hooks/store/use-project";
|
|||
import { useProjectExternalContours } from "@/hooks/store/use-project-external-contours";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
import { ExternalContourStatePill } from "./state-pill";
|
||||
import { ExternalContourDeclineModal } from "./decline-modal";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
|
|
@ -45,7 +46,8 @@ export const ExternalContoursIssueActionsHeader = observer(function ExternalCont
|
|||
} = props;
|
||||
const { t } = useTranslation();
|
||||
const router = useAppRouter();
|
||||
const { currentTab, decideRequest, filteredRequestIds, handleCurrentTab } = useProjectExternalContours();
|
||||
const [isDeclineModalOpen, setIsDeclineModalOpen] = useState(false);
|
||||
const { currentTab, decideRequest, filteredRequestIds, handleCurrentTab, loader } = useProjectExternalContours();
|
||||
const { getProjectById } = useProject();
|
||||
|
||||
const issue = contourRequest.issue;
|
||||
|
|
@ -96,10 +98,11 @@ export const ExternalContoursIssueActionsHeader = observer(function ExternalCont
|
|||
})
|
||||
);
|
||||
|
||||
const handleDecision = async (action: "accept" | "decline") => {
|
||||
const handleDecision = async (action: "accept" | "decline", comment?: string) => {
|
||||
try {
|
||||
await decideRequest(workspaceSlug, sourceProjectId, contourRequest.id, action);
|
||||
await decideRequest(workspaceSlug, sourceProjectId, contourRequest.id, action, comment);
|
||||
if (action === "decline") {
|
||||
setIsDeclineModalOpen(false);
|
||||
await handleCurrentTab(workspaceSlug, sourceProjectId, EInboxIssueCurrentTab.OPEN);
|
||||
router.push(
|
||||
`/${workspaceSlug}/projects/${sourceProjectId}/external-contours?currentTab=${EInboxIssueCurrentTab.OPEN}&inboxIssueId=${contourRequest.id}`
|
||||
|
|
@ -125,6 +128,13 @@ export const ExternalContoursIssueActionsHeader = observer(function ExternalCont
|
|||
|
||||
return (
|
||||
<>
|
||||
<ExternalContourDeclineModal
|
||||
isOpen={isDeclineModalOpen}
|
||||
isSubmitting={loader === "mutation-loading"}
|
||||
onClose={() => setIsDeclineModalOpen(false)}
|
||||
onSubmit={(comment) => handleDecision("decline", comment)}
|
||||
/>
|
||||
|
||||
<Row className="relative z-15 hidden h-full w-full items-center justify-between gap-2 border-b border-subtle bg-surface-1 lg:flex">
|
||||
<div className="flex items-center gap-4">
|
||||
{issue?.project_id && issue.sequence_id && (
|
||||
|
|
@ -151,7 +161,7 @@ export const ExternalContoursIssueActionsHeader = observer(function ExternalCont
|
|||
<CheckCircleFilledIcon className="size-4 shrink-0 text-success-secondary" />
|
||||
{t("external_contours_page.actions.accept")}
|
||||
</Button>
|
||||
<Button variant="secondary" size="lg" onClick={() => handleDecision("decline")}>
|
||||
<Button variant="secondary" size="lg" onClick={() => setIsDeclineModalOpen(true)}>
|
||||
<CloseCircleFilledIcon className="size-4 shrink-0 text-danger-secondary" />
|
||||
{t("external_contours_page.actions.decline")}
|
||||
</Button>
|
||||
|
|
@ -189,7 +199,7 @@ export const ExternalContoursIssueActionsHeader = observer(function ExternalCont
|
|||
<Button variant="secondary" size="sm" onClick={() => handleDecision("accept")}>
|
||||
{t("external_contours_page.actions.accept")}
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" onClick={() => handleDecision("decline")}>
|
||||
<Button variant="secondary" size="sm" onClick={() => setIsDeclineModalOpen(true)}>
|
||||
{t("external_contours_page.actions.decline")}
|
||||
</Button>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import { ExternalContoursMirroredAttachments } from "./mirrored-attachments";
|
|||
import { ExternalContoursMirroredComments } from "./mirrored-comments";
|
||||
import { ExternalContoursIssueContentProperties } from "./issue-properties";
|
||||
import { ExternalContoursRequestTraceability } from "./request-traceability";
|
||||
import { ExternalContoursSourceReplyBox } from "./source-reply-box";
|
||||
|
||||
const workItemVersionService = new WorkItemVersionService();
|
||||
const issueService = new IssueService();
|
||||
|
|
@ -172,6 +173,12 @@ export const ExternalContoursIssueMainContent = observer(function ExternalContou
|
|||
|
||||
<ExternalContoursMirroredAttachments attachments={mirroredAttachments} />
|
||||
|
||||
<ExternalContoursSourceReplyBox
|
||||
workspaceSlug={workspaceSlug}
|
||||
sourceProjectId={sourceProjectId}
|
||||
requestId={contourRequest.id}
|
||||
/>
|
||||
|
||||
<ExternalContoursMirroredComments comments={mirroredComments} />
|
||||
|
||||
<ExternalContoursMirroredActivity activity={mirroredActivity} />
|
||||
|
|
|
|||
|
|
@ -0,0 +1,66 @@
|
|||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import { TextArea } from "@plane/ui";
|
||||
import { useProjectExternalContours } from "@/hooks/store/use-project-external-contours";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
sourceProjectId: string;
|
||||
requestId: string;
|
||||
};
|
||||
|
||||
export const ExternalContoursSourceReplyBox = observer(function ExternalContoursSourceReplyBox(props: Props) {
|
||||
const { workspaceSlug, sourceProjectId, requestId } = props;
|
||||
const { t } = useTranslation();
|
||||
const { loader, replyToRequest } = useProjectExternalContours();
|
||||
const [comment, setComment] = useState("");
|
||||
|
||||
const isSubmitting = loader === "mutation-loading";
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!comment.trim() || isSubmitting) return;
|
||||
|
||||
try {
|
||||
await replyToRequest(workspaceSlug, sourceProjectId, requestId, comment.trim());
|
||||
setComment("");
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: t("success"),
|
||||
message: t("external_contours_page.reply.success"),
|
||||
});
|
||||
} catch (error: any) {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: t("error"),
|
||||
message: error?.error || t("external_contours_page.reply.error"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3 py-4">
|
||||
<div className="text-body-sm-medium">{t("external_contours_page.reply.title")}</div>
|
||||
<TextArea
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
rows={4}
|
||||
placeholder={t("external_contours_page.reply.placeholder")}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button variant="primary" onClick={handleSubmit} disabled={isSubmitting || !comment.trim()}>
|
||||
{t("external_contours_page.reply.submit")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
@ -20,6 +20,7 @@ import { useUserPermissions } from "@/hooks/store/user";
|
|||
import { useWorkspaceIssueProperties } from "@/hooks/use-workspace-issue-properties";
|
||||
// plane web imports
|
||||
import { useNotificationPreview } from "@/plane-web/hooks/use-notification-preview";
|
||||
import { ExternalContoursContentRoot } from "@/plane-web/components/projects/external-contours/content-root";
|
||||
// local imports
|
||||
import { InboxContentRoot } from "../inbox/content";
|
||||
|
||||
|
|
@ -40,7 +41,7 @@ export const NotificationsRoot = observer(function NotificationsRoot({ workspace
|
|||
const { fetchUserProjectInfo } = useUserPermissions();
|
||||
const { isWorkItem, PeekOverviewComponent, setPeekWorkItem } = useNotificationPreview();
|
||||
// derived values
|
||||
const { workspace_slug, project_id, issue_id, is_inbox_issue } =
|
||||
const { workspace_slug, project_id, issue_id, is_inbox_issue, is_external_contour } =
|
||||
notificationLiteByNotificationId(currentSelectedNotificationId);
|
||||
|
||||
// fetching workspace work item properties
|
||||
|
|
@ -64,10 +65,12 @@ export const NotificationsRoot = observer(function NotificationsRoot({ workspace
|
|||
|
||||
// fetching user project member info
|
||||
const { isLoading: projectMemberInfoLoader } = useSWR(
|
||||
workspace_slug && project_id && is_inbox_issue
|
||||
workspace_slug && project_id && (is_inbox_issue || is_external_contour)
|
||||
? `PROJECT_MEMBER_PERMISSION_INFO_${workspace_slug}_${project_id}`
|
||||
: null,
|
||||
workspace_slug && project_id && is_inbox_issue ? () => fetchUserProjectInfo(workspace_slug, project_id) : null
|
||||
workspace_slug && project_id && (is_inbox_issue || is_external_contour)
|
||||
? () => fetchUserProjectInfo(workspace_slug, project_id)
|
||||
: null
|
||||
);
|
||||
|
||||
const embedRemoveCurrentNotification = useCallback(
|
||||
|
|
@ -91,7 +94,23 @@ export const NotificationsRoot = observer(function NotificationsRoot({ workspace
|
|||
</div>
|
||||
) : (
|
||||
<>
|
||||
{is_inbox_issue === true && workspace_slug && project_id && issue_id ? (
|
||||
{is_external_contour === true && workspace_slug && project_id && issue_id ? (
|
||||
<>
|
||||
{projectMemberInfoLoader ? (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<LogoSpinner />
|
||||
</div>
|
||||
) : (
|
||||
<ExternalContoursContentRoot
|
||||
setIsMobileSidebar={() => {}}
|
||||
isMobileSidebar={false}
|
||||
workspaceSlug={workspace_slug}
|
||||
projectId={project_id}
|
||||
inboxIssueId={issue_id}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : is_inbox_issue === true && workspace_slug && project_id && issue_id ? (
|
||||
<>
|
||||
{projectMemberInfoLoader ? (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
|
|
|
|||
|
|
@ -81,10 +81,27 @@ export class ExternalContourService extends APIService {
|
|||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
requestId: string,
|
||||
action: "accept" | "decline"
|
||||
action: "accept" | "decline",
|
||||
comment?: string
|
||||
): Promise<TExternalContourRequest> {
|
||||
return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/external-contours/${requestId}/decision/`, {
|
||||
action,
|
||||
comment,
|
||||
})
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async reply(
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
requestId: string,
|
||||
comment: string
|
||||
): Promise<TExternalContourRequest> {
|
||||
return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/external-contours/${requestId}/reply/`, {
|
||||
comment,
|
||||
})
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
|
|
|
|||
|
|
@ -38,7 +38,14 @@ export interface IProjectExternalContoursStore {
|
|||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
requestId: string,
|
||||
action: "accept" | "decline"
|
||||
action: "accept" | "decline",
|
||||
comment?: string
|
||||
) => Promise<TExternalContourRequest | undefined>;
|
||||
replyToRequest: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
requestId: string,
|
||||
comment: string
|
||||
) => Promise<TExternalContourRequest | undefined>;
|
||||
fetchTargetProjects: (workspaceSlug: string, projectId: string) => Promise<void>;
|
||||
fetchTargetOptions: (workspaceSlug: string, projectId: string, targetProjectId: string) => Promise<TExternalContourTargetOptions | undefined>;
|
||||
|
|
@ -89,6 +96,7 @@ export class ProjectExternalContoursStore implements IProjectExternalContoursSto
|
|||
fetchRequestById: action,
|
||||
createRequest: action,
|
||||
decideRequest: action,
|
||||
replyToRequest: action,
|
||||
handleCurrentTab: action,
|
||||
upsertRequests: action,
|
||||
updateRequestIssue: action,
|
||||
|
|
@ -231,11 +239,29 @@ export class ProjectExternalContoursStore implements IProjectExternalContoursSto
|
|||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
requestId: string,
|
||||
action: "accept" | "decline"
|
||||
action: "accept" | "decline",
|
||||
comment?: string
|
||||
) => {
|
||||
this.loader = "mutation-loading";
|
||||
try {
|
||||
const request = await this.externalContourService.decide(workspaceSlug, projectId, requestId, action);
|
||||
const request = await this.externalContourService.decide(workspaceSlug, projectId, requestId, action, comment);
|
||||
runInAction(() => {
|
||||
this.upsertRequests([request]);
|
||||
this.loader = undefined;
|
||||
});
|
||||
return request;
|
||||
} catch (error) {
|
||||
runInAction(() => {
|
||||
this.loader = undefined;
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
replyToRequest = async (workspaceSlug: string, projectId: string, requestId: string, comment: string) => {
|
||||
this.loader = "mutation-loading";
|
||||
try {
|
||||
const request = await this.externalContourService.reply(workspaceSlug, projectId, requestId, comment);
|
||||
runInAction(() => {
|
||||
this.upsertRequests([request]);
|
||||
this.loader = undefined;
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ export class Notification implements INotification {
|
|||
archived_at: string | undefined = undefined;
|
||||
snoozed_till: string | undefined = undefined;
|
||||
is_inbox_issue: boolean | undefined = undefined;
|
||||
is_external_contour: boolean | undefined = undefined;
|
||||
is_mentioned_notification: boolean | undefined = undefined;
|
||||
workspace: string | undefined = undefined;
|
||||
project: string | undefined = undefined;
|
||||
|
|
@ -79,6 +80,7 @@ export class Notification implements INotification {
|
|||
archived_at: observable.ref,
|
||||
snoozed_till: observable.ref,
|
||||
is_inbox_issue: observable.ref,
|
||||
is_external_contour: observable.ref,
|
||||
is_mentioned_notification: observable.ref,
|
||||
workspace: observable.ref,
|
||||
project: observable.ref,
|
||||
|
|
@ -112,6 +114,7 @@ export class Notification implements INotification {
|
|||
this.archived_at = this.notification.archived_at;
|
||||
this.snoozed_till = this.notification.snoozed_till;
|
||||
this.is_inbox_issue = this.notification.is_inbox_issue;
|
||||
this.is_external_contour = this.notification.is_external_contour;
|
||||
this.is_mentioned_notification = this.notification.is_mentioned_notification;
|
||||
this.workspace = this.notification.workspace;
|
||||
this.project = this.notification.project;
|
||||
|
|
@ -143,6 +146,7 @@ export class Notification implements INotification {
|
|||
archived_at: this.archived_at,
|
||||
snoozed_till: this.snoozed_till,
|
||||
is_inbox_issue: this.is_inbox_issue,
|
||||
is_external_contour: this.is_external_contour,
|
||||
is_mentioned_notification: this.is_mentioned_notification,
|
||||
workspace: this.workspace,
|
||||
project: this.project,
|
||||
|
|
|
|||
|
|
@ -169,6 +169,7 @@ export class WorkspaceNotificationStore implements IWorkspaceNotificationStore {
|
|||
notification_id: notification.id,
|
||||
issue_id: notification.data?.issue?.id,
|
||||
is_inbox_issue: notification.is_inbox_issue || false,
|
||||
is_external_contour: notification.is_external_contour || false,
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -389,6 +389,19 @@ export default {
|
|||
unsupported_message:
|
||||
"The “{action}” button is already placed in the right UI slot. Real routing and the reverse flow will be connected next.",
|
||||
},
|
||||
decline_modal: {
|
||||
title: "Return the request for rework",
|
||||
description: "Provide the reason for returning the request. This comment will be sent to the external contour and added to the target issue.",
|
||||
placeholder: "Describe what needs to be revised or clarified",
|
||||
submit: "Decline and return",
|
||||
},
|
||||
reply: {
|
||||
title: "Reply to the external contour",
|
||||
placeholder: "Write a comment that will be added to the target issue",
|
||||
submit: "Send comment",
|
||||
success: "The comment has been sent to the external contour.",
|
||||
error: "The comment could not be sent to the external contour.",
|
||||
},
|
||||
},
|
||||
deactivate_your_account: "Deactivate your account",
|
||||
deactivate_your_account_description:
|
||||
|
|
|
|||
|
|
@ -545,6 +545,19 @@ export default {
|
|||
unsupported_message:
|
||||
"Кнопка «{action}» уже стоит на правильном месте в UI. Реальную маршрутизацию и обратный поток подключим следующим этапом.",
|
||||
},
|
||||
decline_modal: {
|
||||
title: "Вернуть запрос на доработку",
|
||||
description: "Укажите причину возврата. Этот комментарий уйдёт во внешний контур и появится в целевой задаче.",
|
||||
placeholder: "Напишите, что нужно доработать или уточнить",
|
||||
submit: "Отклонить и вернуть",
|
||||
},
|
||||
reply: {
|
||||
title: "Ответ во внешний контур",
|
||||
placeholder: "Напишите комментарий, который уйдёт в целевую задачу",
|
||||
submit: "Отправить комментарий",
|
||||
success: "Комментарий отправлен во внешний контур.",
|
||||
error: "Не удалось отправить комментарий во внешний контур.",
|
||||
},
|
||||
},
|
||||
deactivate_your_account: "Деактивировать ваш аккаунт",
|
||||
deactivate_your_account_description:
|
||||
|
|
|
|||
|
|
@ -25,6 +25,12 @@ export type TNotificationIssueLite = {
|
|||
name: string | undefined;
|
||||
state_name: string | undefined;
|
||||
state_group: string | undefined;
|
||||
external_contour_request_id?: string | undefined;
|
||||
target_issue_id?: string | undefined;
|
||||
source_project_id?: string | undefined;
|
||||
source_project_name?: string | undefined;
|
||||
target_project_id?: string | undefined;
|
||||
target_project_name?: string | undefined;
|
||||
};
|
||||
|
||||
export type TNotificationData = {
|
||||
|
|
@ -57,6 +63,7 @@ export type TNotification = {
|
|||
archived_at: string | undefined;
|
||||
snoozed_till: string | undefined;
|
||||
is_inbox_issue: boolean | undefined;
|
||||
is_external_contour: boolean | undefined;
|
||||
is_mentioned_notification: boolean | undefined;
|
||||
workspace: string | undefined;
|
||||
project: string | undefined;
|
||||
|
|
@ -103,4 +110,5 @@ export type TNotificationLite = {
|
|||
notification_id: string | undefined;
|
||||
issue_id: string | undefined;
|
||||
is_inbox_issue: boolean | undefined;
|
||||
is_external_contour: boolean | undefined;
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue