diff --git a/docs_prod/cross-project-task-routing/phase-roadmap.md b/docs_prod/cross-project-task-routing/phase-roadmap.md index d96f2d6..57f7565 100644 --- a/docs_prod/cross-project-task-routing/phase-roadmap.md +++ b/docs_prod/cross-project-task-routing/phase-roadmap.md @@ -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. Полировка и правила эксплуатации ### Что входит diff --git a/plane-src/apps/api/plane/api/serializers/__init__.py b/plane-src/apps/api/plane/api/serializers/__init__.py index 1495596..0908272 100644 --- a/plane-src/apps/api/plane/api/serializers/__init__.py +++ b/plane-src/apps/api/plane/api/serializers/__init__.py @@ -56,6 +56,7 @@ from .intake import ( from .external_contours import ( ExternalContourRequestCreateSerializer, ExternalContourRequestDecisionSerializer, + ExternalContourRequestReplySerializer, ExternalContourRequestSerializer, ExternalContourTargetOptionsSerializer, ExternalContourTargetProjectSerializer, diff --git a/plane-src/apps/api/plane/api/serializers/external_contours.py b/plane-src/apps/api/plane/api/serializers/external_contours.py index 3cf42df..2e0094f 100644 --- a/plane-src/apps/api/plane/api/serializers/external_contours.py +++ b/plane-src/apps/api/plane/api/serializers/external_contours.py @@ -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): diff --git a/plane-src/apps/api/plane/api/urls/external_contours.py b/plane-src/apps/api/plane/api/urls/external_contours.py index 4c95cd6..653d7e0 100644 --- a/plane-src/apps/api/plane/api/urls/external_contours.py +++ b/plane-src/apps/api/plane/api/urls/external_contours.py @@ -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//projects//external-contours/", - ExternalContourListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]), + ExternalContourListCreateEndpoint.as_view(http_method_names=["get", "post"]), name="external-contours", ), path( "workspaces//projects//external-contours/targets/", - ExternalContourTargetProjectListAPIEndpoint.as_view(http_method_names=["get"]), + ExternalContourTargetProjectListEndpoint.as_view(http_method_names=["get"]), name="external-contour-targets", ), path( "workspaces//projects//external-contours/targets//options/", - ExternalContourTargetOptionsAPIEndpoint.as_view(http_method_names=["get"]), + ExternalContourTargetOptionsEndpoint.as_view(http_method_names=["get"]), name="external-contour-target-options", ), path( "workspaces//projects//external-contours//", - ExternalContourDetailAPIEndpoint.as_view(http_method_names=["get"]), + ExternalContourDetailEndpoint.as_view(http_method_names=["get"]), name="external-contour-detail", ), path( "workspaces//projects//external-contours//decision/", - ExternalContourDecisionAPIEndpoint.as_view(http_method_names=["post"]), + ExternalContourDecisionEndpoint.as_view(http_method_names=["post"]), name="external-contour-decision", ), + path( + "workspaces//projects//external-contours//reply/", + ExternalContourReplyEndpoint.as_view(http_method_names=["post"]), + name="external-contour-reply", + ), path( "workspaces//projects//external-contours//attachments//", - ExternalContourAttachmentDownloadAPIEndpoint.as_view(http_method_names=["get"]), + ExternalContourAttachmentDownloadEndpoint.as_view(http_method_names=["get"]), name="external-contour-attachment-download", ), ] diff --git a/plane-src/apps/api/plane/api/views/__init__.py b/plane-src/apps/api/plane/api/views/__init__.py index ee4d72e..230d4d0 100644 --- a/plane-src/apps/api/plane/api/views/__init__.py +++ b/plane-src/apps/api/plane/api/views/__init__.py @@ -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, ) diff --git a/plane-src/apps/api/plane/api/views/external_contours.py b/plane-src/apps/api/plane/api/views/external_contours.py index df00978..300c1d9 100644 --- a/plane-src/apps/api/plane/api/views/external_contours.py +++ b/plane-src/apps/api/plane/api/views/external_contours.py @@ -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] diff --git a/plane-src/apps/api/plane/app/serializers/notification.py b/plane-src/apps/api/plane/app/serializers/notification.py index b4eb4ea..83c3431 100644 --- a/plane-src/apps/api/plane/app/serializers/notification.py +++ b/plane-src/apps/api/plane/app/serializers/notification.py @@ -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: diff --git a/plane-src/apps/api/plane/app/urls/external_contours.py b/plane-src/apps/api/plane/app/urls/external_contours.py index af8a28e..653d7e0 100644 --- a/plane-src/apps/api/plane/app/urls/external_contours.py +++ b/plane-src/apps/api/plane/app/urls/external_contours.py @@ -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//projects//external-contours//reply/", + ExternalContourReplyEndpoint.as_view(http_method_names=["post"]), + name="external-contour-reply", + ), path( "workspaces//projects//external-contours//attachments//", ExternalContourAttachmentDownloadEndpoint.as_view(http_method_names=["get"]), diff --git a/plane-src/apps/api/plane/app/views/__init__.py b/plane-src/apps/api/plane/app/views/__init__.py index 3b70432..35205bb 100644 --- a/plane-src/apps/api/plane/app/views/__init__.py +++ b/plane-src/apps/api/plane/app/views/__init__.py @@ -229,6 +229,7 @@ from .external_contours import ( ExternalContourListCreateEndpoint, ExternalContourDetailEndpoint, ExternalContourDecisionEndpoint, + ExternalContourReplyEndpoint, ExternalContourTargetProjectListEndpoint, ExternalContourTargetOptionsEndpoint, ) diff --git a/plane-src/apps/api/plane/app/views/external_contours.py b/plane-src/apps/api/plane/app/views/external_contours.py index 9117906..300c1d9 100644 --- a/plane-src/apps/api/plane/app/views/external_contours.py +++ b/plane-src/apps/api/plane/app/views/external_contours.py @@ -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] diff --git a/plane-src/apps/api/plane/app/views/notification/base.py b/plane-src/apps/api/plane/app/views/notification/base.py index 0b7dc27..63fd577 100644 --- a/plane-src/apps/api/plane/app/views/notification/base.py +++ b/plane-src/apps/api/plane/app/views/notification/base.py @@ -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), diff --git a/plane-src/apps/api/plane/bgtasks/notification_task.py b/plane-src/apps/api/plane/bgtasks/notification_task.py index bfb72af..10ada06 100644 --- a/plane-src/apps/api/plane/bgtasks/notification_task.py +++ b/plane-src/apps/api/plane/bgtasks/notification_task.py @@ -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: diff --git a/plane-src/apps/api/plane/utils/external_contours.py b/plane-src/apps/api/plane/utils/external_contours.py new file mode 100644 index 0000000..67f81bb --- /dev/null +++ b/plane-src/apps/api/plane/utils/external_contours.py @@ -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", "
") + return f"

{escaped_comment}

" + + +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 + ), + }, + }, + ) diff --git a/plane-src/apps/web/ce/components/projects/external-contours/decline-modal.tsx b/plane-src/apps/web/ce/components/projects/external-contours/decline-modal.tsx new file mode 100644 index 0000000..4ee8d84 --- /dev/null +++ b/plane-src/apps/web/ce/components/projects/external-contours/decline-modal.tsx @@ -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; +}; + +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 ( + +
+
+

{t("external_contours_page.decline_modal.title")}

+

{t("external_contours_page.decline_modal.description")}

+
+ +