ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: причина отклонения, reply и in-app уведомления

This commit is contained in:
DCCONSTRUCTIONS 2026-04-19 09:22:15 +03:00
parent 61a8625a5c
commit 0a584abf26
25 changed files with 623 additions and 32 deletions

View File

@ -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. Полировка и правила эксплуатации
### Что входит

View File

@ -56,6 +56,7 @@ from .intake import (
from .external_contours import (
ExternalContourRequestCreateSerializer,
ExternalContourRequestDecisionSerializer,
ExternalContourRequestReplySerializer,
ExternalContourRequestSerializer,
ExternalContourTargetOptionsSerializer,
ExternalContourTargetProjectSerializer,

View File

@ -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):

View File

@ -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",
),
]

View File

@ -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,
)

View File

@ -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]

View File

@ -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:

View File

@ -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"]),

View File

@ -229,6 +229,7 @@ from .external_contours import (
ExternalContourListCreateEndpoint,
ExternalContourDetailEndpoint,
ExternalContourDecisionEndpoint,
ExternalContourReplyEndpoint,
ExternalContourTargetProjectListEndpoint,
ExternalContourTargetOptionsEndpoint,
)

View File

@ -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]

View File

@ -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),

View File

@ -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:

View File

@ -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
),
},
},
)

View File

@ -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>
);
}

View File

@ -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>
</>

View File

@ -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} />

View File

@ -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>
);
});

View File

@ -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">

View File

@ -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) => {

View File

@ -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;

View File

@ -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,

View File

@ -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,
};
});

View File

@ -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:

View File

@ -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:

View File

@ -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;
};