Compare commits

..

2 Commits

23 changed files with 1063 additions and 55 deletions

View File

@ -65,6 +65,8 @@
- `POST /external-contours/` создает target issue в целевом проекте - `POST /external-contours/` создает target issue в целевом проекте
- target issue сразу попадает в обычный workflow целевого проекта - target issue сразу попадает в обычный workflow целевого проекта
- source-side `GET /external-contours/` возвращает отправленные запросы с metadata источника и цели - source-side `GET /external-contours/` возвращает отправленные запросы с metadata источника и цели
- target contour теперь выбирается по policy `same workspace + intake enabled`, а не только из joined projects
- прямое membership в target project для отправки не требуется
## Этап 2. Source-side список и статусная пришлепка ## Этап 2. Source-side список и статусная пришлепка
@ -144,11 +146,16 @@
- source-side detail использует отдельный экран `Внешних контуров` - source-side detail использует отдельный экран `Внешних контуров`
- в карточке отображается блок маршрутизации с ключевой source-target связью - в карточке отображается блок маршрутизации с ключевой source-target связью
- текущий статус берется из фактического state целевой задачи - текущий статус берется из фактического state целевой задачи
- если у инициатора нет membership в target project, карточка переключается в source-side readonly режим без прямого открытия чужого проекта
- для закрытого внешнего запроса доступны source-side действия `Принять` и `Отклонить`
- `Принять` фиксирует решение источника в bridge metadata и помечает запрос как принятый во внутренний контур
- `Отклонить` возвращает target issue в default-state целевого проекта и переносит source-side карточку обратно в список `Открытые`
Что остается: Что остается:
- зеркалирование комментариев - зеркалирование комментариев
- зеркалирование файлов - зеркалирование файлов
- зеркалирование activity stream и обновлений описания - зеркалирование activity stream и обновлений описания
- комментарий причины отклонения и ответ обратно во внешний контур
## Этап 4. Уведомления ## Этап 4. Уведомления
@ -235,6 +242,10 @@
- должен ли инициатор иметь прямую ссылку на target issue - должен ли инициатор иметь прямую ссылку на target issue
- или доступ к target issue должен быть скрыт, а source-side карточка должна быть единственной точкой просмотра - или доступ к target issue должен быть скрыт, а source-side карточка должна быть единственной точкой просмотра
Текущее решение:
- при отсутствии membership в target project прямой переход в target issue скрывается
- карточка остается доступной из source project
## Рекомендуемый порядок фактической разработки ## Рекомендуемый порядок фактической разработки
1. Этап 0 1. Этап 0

View File

@ -55,7 +55,10 @@ from .intake import (
) )
from .external_contours import ( from .external_contours import (
ExternalContourRequestCreateSerializer, ExternalContourRequestCreateSerializer,
ExternalContourRequestDecisionSerializer,
ExternalContourRequestSerializer, ExternalContourRequestSerializer,
ExternalContourTargetOptionsSerializer,
ExternalContourTargetProjectSerializer,
) )
from .estimate import EstimateSerializer, EstimatePointSerializer from .estimate import EstimateSerializer, EstimatePointSerializer
from .asset import ( from .asset import (

View File

@ -9,7 +9,8 @@ from .issue import IssueSerializer
from .project import ProjectLiteSerializer from .project import ProjectLiteSerializer
from .state import StateLiteSerializer from .state import StateLiteSerializer
from .user import UserLiteSerializer from .user import UserLiteSerializer
from plane.db.models import IntakeIssue, Issue from plane.app.serializers.issue import LabelSerializer
from plane.db.models import IntakeIssue, Issue, Label, Project
class ExternalContourIssuePayloadSerializer(serializers.Serializer): class ExternalContourIssuePayloadSerializer(serializers.Serializer):
@ -26,6 +27,25 @@ class ExternalContourRequestCreateSerializer(serializers.Serializer):
issue = ExternalContourIssuePayloadSerializer() issue = ExternalContourIssuePayloadSerializer()
class ExternalContourRequestDecisionSerializer(serializers.Serializer):
action = serializers.ChoiceField(choices=["accept", "decline"])
class ExternalContourTargetProjectSerializer(BaseSerializer):
inbox_view = serializers.BooleanField(read_only=True, source="intake_view")
class Meta:
model = Project
fields = ["id", "identifier", "name", "logo_props", "inbox_view"]
read_only_fields = fields
class ExternalContourTargetOptionsSerializer(serializers.Serializer):
project = ExternalContourTargetProjectSerializer(read_only=True)
member_ids = serializers.ListField(child=serializers.UUIDField(), read_only=True)
labels = LabelSerializer(many=True, read_only=True)
class ExternalContourIssueSerializer(BaseSerializer): class ExternalContourIssueSerializer(BaseSerializer):
assignee_ids = serializers.SerializerMethodField() assignee_ids = serializers.SerializerMethodField()
assignee_details = serializers.SerializerMethodField() assignee_details = serializers.SerializerMethodField()
@ -79,6 +99,9 @@ class ExternalContourRequestSerializer(BaseSerializer):
issue = ExternalContourIssueSerializer(read_only=True) issue = ExternalContourIssueSerializer(read_only=True)
source_project_id = serializers.SerializerMethodField() source_project_id = serializers.SerializerMethodField()
source_project_name = serializers.SerializerMethodField() source_project_name = serializers.SerializerMethodField()
source_decision = serializers.SerializerMethodField()
source_decision_at = serializers.SerializerMethodField()
source_decision_by_name = serializers.SerializerMethodField()
target_project_id = serializers.SerializerMethodField() target_project_id = serializers.SerializerMethodField()
target_project_name = serializers.SerializerMethodField() target_project_name = serializers.SerializerMethodField()
requested_by_id = serializers.SerializerMethodField() requested_by_id = serializers.SerializerMethodField()
@ -96,6 +119,9 @@ class ExternalContourRequestSerializer(BaseSerializer):
"issue", "issue",
"source_project_id", "source_project_id",
"source_project_name", "source_project_name",
"source_decision",
"source_decision_at",
"source_decision_by_name",
"target_project_id", "target_project_id",
"target_project_name", "target_project_name",
"requested_by_id", "requested_by_id",
@ -111,6 +137,15 @@ class ExternalContourRequestSerializer(BaseSerializer):
def get_source_project_name(self, obj): def get_source_project_name(self, obj):
return obj.extra.get("source_project_name") return obj.extra.get("source_project_name")
def get_source_decision(self, obj):
return obj.extra.get("source_decision")
def get_source_decision_at(self, obj):
return obj.extra.get("source_decision_at")
def get_source_decision_by_name(self, obj):
return obj.extra.get("source_decision_by_name")
def get_target_project_id(self, obj): def get_target_project_id(self, obj):
target_project_id = obj.extra.get("target_project_id") target_project_id = obj.extra.get("target_project_id")
if target_project_id: if target_project_id:

View File

@ -4,7 +4,13 @@
from django.urls import path from django.urls import path
from plane.api.views import ExternalContourDetailAPIEndpoint, ExternalContourListCreateAPIEndpoint from plane.api.views import (
ExternalContourDetailAPIEndpoint,
ExternalContourDecisionAPIEndpoint,
ExternalContourListCreateAPIEndpoint,
ExternalContourTargetOptionsAPIEndpoint,
ExternalContourTargetProjectListAPIEndpoint,
)
urlpatterns = [ urlpatterns = [
path( path(
@ -12,9 +18,24 @@ urlpatterns = [
ExternalContourListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]), ExternalContourListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
name="external-contours", name="external-contours",
), ),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/external-contours/targets/",
ExternalContourTargetProjectListAPIEndpoint.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"]),
name="external-contour-target-options",
),
path( path(
"workspaces/<str:slug>/projects/<uuid:project_id>/external-contours/<uuid:request_id>/", "workspaces/<str:slug>/projects/<uuid:project_id>/external-contours/<uuid:request_id>/",
ExternalContourDetailAPIEndpoint.as_view(http_method_names=["get"]), ExternalContourDetailAPIEndpoint.as_view(http_method_names=["get"]),
name="external-contour-detail", 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"]),
name="external-contour-decision",
),
] ]

View File

@ -56,8 +56,11 @@ from .intake import (
IntakeIssueDetailAPIEndpoint, IntakeIssueDetailAPIEndpoint,
) )
from .external_contours import ( from .external_contours import (
ExternalContourListCreateAPIEndpoint, ExternalContourListCreateEndpoint as ExternalContourListCreateAPIEndpoint,
ExternalContourDetailAPIEndpoint, ExternalContourDetailEndpoint as ExternalContourDetailAPIEndpoint,
ExternalContourDecisionEndpoint as ExternalContourDecisionAPIEndpoint,
ExternalContourTargetProjectListEndpoint as ExternalContourTargetProjectListAPIEndpoint,
ExternalContourTargetOptionsEndpoint as ExternalContourTargetOptionsAPIEndpoint,
) )
from .asset import UserAssetEndpoint, UserServerAssetEndpoint, GenericAssetEndpoint from .asset import UserAssetEndpoint, UserServerAssetEndpoint, GenericAssetEndpoint

View File

@ -3,27 +3,40 @@
# See the LICENSE file for details. # See the LICENSE file for details.
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.utils import timezone
from rest_framework import status from rest_framework import status
from rest_framework.response import Response from rest_framework.response import Response
from plane.api.serializers import ( from plane.api.serializers import (
ExternalContourRequestCreateSerializer, ExternalContourRequestCreateSerializer,
ExternalContourRequestDecisionSerializer,
ExternalContourRequestSerializer, ExternalContourRequestSerializer,
ExternalContourTargetOptionsSerializer,
ExternalContourTargetProjectSerializer,
) )
from plane.app.permissions import ProjectLitePermission
from plane.db.models import Intake, IntakeIssue, Project, State, StateGroup
from plane.api.serializers.issue import IssueSerializer as IssueCreateSerializer from plane.api.serializers.issue import IssueSerializer as IssueCreateSerializer
from plane.app.permissions import ProjectLitePermission
from .base import BaseAPIView from .base import BaseAPIView
from plane.db.models import Intake, IntakeIssue, Label, Project, ProjectMember, State, StateGroup
from plane.db.models.intake import IntakeIssueStatus, SourceType from plane.db.models.intake import IntakeIssueStatus, SourceType
class ExternalContourListCreateAPIEndpoint(BaseAPIView): class ExternalContourListCreateEndpoint(BaseAPIView):
permission_classes = [ProjectLitePermission] permission_classes = [ProjectLitePermission]
serializer_class = ExternalContourRequestSerializer serializer_class = ExternalContourRequestSerializer
def get_source_project(self, slug, project_id): def get_source_project(self, slug, project_id):
return get_object_or_404(Project, workspace__slug=slug, pk=project_id) return get_object_or_404(Project, workspace__slug=slug, pk=project_id)
def get_default_target_state(self, target_project):
return (
State.objects.filter(project=target_project, default=True)
.exclude(group=StateGroup.TRIAGE.value)
.first()
) or State.objects.filter(project=target_project).exclude(group=StateGroup.TRIAGE.value).order_by(
"sequence", "created_at"
).first()
def get_queryset(self): def get_queryset(self):
return ( return (
IntakeIssue.objects.filter( IntakeIssue.objects.filter(
@ -74,6 +87,12 @@ class ExternalContourListCreateAPIEndpoint(BaseAPIView):
if str(target_project.id) == str(source_project.id): if str(target_project.id) == str(source_project.id):
return Response({"error": "Target project must differ from source project"}, status=status.HTTP_400_BAD_REQUEST) return Response({"error": "Target project must differ from source project"}, status=status.HTTP_400_BAD_REQUEST)
if not target_project.intake_view:
return Response(
{"error": "Target project is not enabled for external contour routing"},
status=status.HTTP_400_BAD_REQUEST,
)
triage_state = State.triage_objects.filter(project=target_project).first() triage_state = State.triage_objects.filter(project=target_project).first()
if not triage_state: if not triage_state:
triage_state = State.objects.create( triage_state = State.objects.create(
@ -85,11 +104,7 @@ class ExternalContourListCreateAPIEndpoint(BaseAPIView):
default=False, default=False,
) )
target_default_state = ( target_default_state = self.get_default_target_state(target_project)
State.objects.filter(project=target_project, default=True)
.exclude(group=StateGroup.TRIAGE.value)
.first()
) or State.objects.filter(project=target_project).exclude(group=StateGroup.TRIAGE.value).order_by("sequence", "created_at").first()
if not target_default_state: if not target_default_state:
return Response({"error": "Target project has no available workflow state"}, status=status.HTTP_400_BAD_REQUEST) return Response({"error": "Target project has no available workflow state"}, status=status.HTTP_400_BAD_REQUEST)
@ -158,7 +173,80 @@ class ExternalContourListCreateAPIEndpoint(BaseAPIView):
return Response(response_serializer.data, status=status.HTTP_201_CREATED) return Response(response_serializer.data, status=status.HTTP_201_CREATED)
class ExternalContourDetailAPIEndpoint(BaseAPIView): class ExternalContourTargetProjectListEndpoint(BaseAPIView):
permission_classes = [ProjectLitePermission]
serializer_class = ExternalContourTargetProjectSerializer
def get_source_project(self, slug, project_id):
return get_object_or_404(Project, workspace__slug=slug, pk=project_id)
def get_queryset(self):
source_project = self.get_source_project(self.kwargs.get("slug"), self.kwargs.get("project_id"))
return (
Project.objects.filter(
workspace_id=source_project.workspace_id,
archived_at__isnull=True,
intake_view=True,
)
.exclude(pk=source_project.id)
.order_by("name")
)
def get(self, request, slug, project_id):
serializer = ExternalContourTargetProjectSerializer(self.get_queryset(), many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
class ExternalContourTargetOptionsEndpoint(BaseAPIView):
permission_classes = [ProjectLitePermission]
serializer_class = ExternalContourTargetOptionsSerializer
def get_source_project(self, slug, project_id):
return get_object_or_404(Project, workspace__slug=slug, pk=project_id)
def get_target_project(self, source_project, target_project_id):
return get_object_or_404(
Project,
workspace_id=source_project.workspace_id,
pk=target_project_id,
archived_at__isnull=True,
intake_view=True,
)
def get(self, request, slug, project_id, target_project_id):
source_project = self.get_source_project(slug, project_id)
target_project = self.get_target_project(source_project, target_project_id)
if str(target_project.id) == str(source_project.id):
return Response({"error": "Target project must differ from source project"}, status=status.HTTP_400_BAD_REQUEST)
member_ids = list(
ProjectMember.objects.filter(
project=target_project,
workspace_id=target_project.workspace_id,
is_active=True,
member__is_bot=False,
member__member_workspace__workspace_id=target_project.workspace_id,
member__member_workspace__is_active=True,
)
.order_by("member__display_name", "member__email")
.values_list("member_id", flat=True)
.distinct()
)
labels = Label.objects.filter(project=target_project).order_by("sort_order", "name")
serializer = ExternalContourTargetOptionsSerializer(
{
"project": target_project,
"member_ids": member_ids,
"labels": labels,
}
)
return Response(serializer.data, status=status.HTTP_200_OK)
class ExternalContourDetailEndpoint(BaseAPIView):
permission_classes = [ProjectLitePermission] permission_classes = [ProjectLitePermission]
serializer_class = ExternalContourRequestSerializer serializer_class = ExternalContourRequestSerializer
@ -183,3 +271,84 @@ class ExternalContourDetailAPIEndpoint(BaseAPIView):
contour_request = get_object_or_404(self.get_queryset()) contour_request = get_object_or_404(self.get_queryset())
serializer = ExternalContourRequestSerializer(contour_request) serializer = ExternalContourRequestSerializer(contour_request)
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)
class ExternalContourDecisionEndpoint(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 = ExternalContourRequestDecisionSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
action = serializer.validated_data["action"]
issue = contour_request.issue
if not issue or not issue.state or issue.state.group not in [StateGroup.COMPLETED.value, StateGroup.CANCELLED.value]:
return Response(
{"error": "Source decision is available only after the target contour finishes processing"},
status=status.HTTP_400_BAD_REQUEST,
)
if action == "accept":
contour_request.extra = {
**contour_request.extra,
"source_decision": "accepted",
"source_decision_at": timezone.now().isoformat(),
"source_decision_by_name": request.user.display_name,
}
contour_request.save(update_fields=["extra", "updated_at"])
else:
target_default_state = (
State.objects.filter(project=issue.project, default=True)
.exclude(group=StateGroup.TRIAGE.value)
.first()
) or State.objects.filter(project=issue.project).exclude(group=StateGroup.TRIAGE.value).order_by(
"sequence", "created_at"
).first()
if not target_default_state:
return Response({"error": "Target project has no available workflow state"}, status=status.HTTP_400_BAD_REQUEST)
issue.state = target_default_state
issue.save(update_fields=["state", "updated_at"])
extra = dict(contour_request.extra or {})
extra.pop("source_decision", None)
extra.pop("source_decision_at", None)
extra.pop("source_decision_by_name", None)
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
contour_request.save(update_fields=["extra", "updated_at"])
contour_request.refresh_from_db()
serializer = ExternalContourRequestSerializer(
IntakeIssue.objects.select_related(
"issue",
"issue__state",
"issue__project",
"issue__created_by",
)
.prefetch_related("issue__issue_assignee__assignee", "issue__label_issue__label")
.get(pk=contour_request.id)
)
return Response(serializer.data, status=status.HTTP_200_OK)

View File

@ -8,6 +8,7 @@ from .asset import urlpatterns as asset_urls
from .cycle import urlpatterns as cycle_urls from .cycle import urlpatterns as cycle_urls
from .estimate import urlpatterns as estimate_urls from .estimate import urlpatterns as estimate_urls
from .external import urlpatterns as external_urls from .external import urlpatterns as external_urls
from .external_contours import urlpatterns as external_contour_urls
from .intake import urlpatterns as intake_urls from .intake import urlpatterns as intake_urls
from .issue import urlpatterns as issue_urls from .issue import urlpatterns as issue_urls
from .module import urlpatterns as module_urls from .module import urlpatterns as module_urls
@ -29,6 +30,7 @@ urlpatterns = [
*cycle_urls, *cycle_urls,
*estimate_urls, *estimate_urls,
*external_urls, *external_urls,
*external_contour_urls,
*intake_urls, *intake_urls,
*issue_urls, *issue_urls,
*module_urls, *module_urls,

View File

@ -0,0 +1,42 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
from django.urls import path
from plane.app.views import (
ExternalContourDetailEndpoint,
ExternalContourDecisionEndpoint,
ExternalContourListCreateEndpoint,
ExternalContourTargetOptionsEndpoint,
ExternalContourTargetProjectListEndpoint,
)
urlpatterns = [
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/external-contours/",
ExternalContourListCreateEndpoint.as_view(http_method_names=["get", "post"]),
name="external-contours",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/external-contours/targets/",
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/",
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>/",
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/",
ExternalContourDecisionEndpoint.as_view(http_method_names=["post"]),
name="external-contour-decision",
),
]

View File

@ -224,6 +224,13 @@ from .notification.base import (
) )
from .exporter.base import ExportIssuesEndpoint from .exporter.base import ExportIssuesEndpoint
from .external_contours import (
ExternalContourListCreateEndpoint,
ExternalContourDetailEndpoint,
ExternalContourDecisionEndpoint,
ExternalContourTargetProjectListEndpoint,
ExternalContourTargetOptionsEndpoint,
)
from .webhook.base import ( from .webhook.base import (

View File

@ -0,0 +1,354 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
from django.shortcuts import get_object_or_404
from django.utils import timezone
from rest_framework import status
from rest_framework.response import Response
from plane.api.serializers import (
ExternalContourRequestCreateSerializer,
ExternalContourRequestDecisionSerializer,
ExternalContourRequestSerializer,
ExternalContourTargetOptionsSerializer,
ExternalContourTargetProjectSerializer,
)
from plane.api.serializers.issue import IssueSerializer as IssueCreateSerializer
from plane.app.permissions import ProjectLitePermission
from plane.app.views.base import BaseAPIView
from plane.db.models import Intake, IntakeIssue, Label, Project, ProjectMember, State, StateGroup
from plane.db.models.intake import IntakeIssueStatus, SourceType
class ExternalContourListCreateEndpoint(BaseAPIView):
permission_classes = [ProjectLitePermission]
serializer_class = ExternalContourRequestSerializer
def get_source_project(self, slug, project_id):
return get_object_or_404(Project, workspace__slug=slug, pk=project_id)
def get_default_target_state(self, target_project):
return (
State.objects.filter(project=target_project, default=True)
.exclude(group=StateGroup.TRIAGE.value)
.first()
) or State.objects.filter(project=target_project).exclude(group=StateGroup.TRIAGE.value).order_by(
"sequence", "created_at"
).first()
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")),
)
.select_related(
"issue",
"issue__state",
"issue__project",
"issue__created_by",
)
.prefetch_related("issue__issue_assignee__assignee", "issue__label_issue__label")
.order_by("-updated_at")
)
def get(self, request, slug, project_id):
serializer = ExternalContourRequestSerializer(self.get_queryset(), many=True)
return Response(
{
"results": serializer.data,
"next_cursor": "",
"prev_cursor": "",
"next_page_results": False,
"prev_page_results": False,
"total_count": len(serializer.data),
"count": len(serializer.data),
"total_pages": 1,
"extra_stats": None,
"total_results": len(serializer.data),
},
status=status.HTTP_200_OK,
)
def post(self, request, slug, project_id):
source_project = self.get_source_project(slug, project_id)
serializer = ExternalContourRequestCreateSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
target_project = get_object_or_404(
Project,
workspace_id=source_project.workspace_id,
pk=serializer.validated_data["target_project_id"],
archived_at__isnull=True,
)
if str(target_project.id) == str(source_project.id):
return Response({"error": "Target project must differ from source project"}, status=status.HTTP_400_BAD_REQUEST)
if not target_project.intake_view:
return Response(
{"error": "Target project is not enabled for external contour routing"},
status=status.HTTP_400_BAD_REQUEST,
)
triage_state = State.triage_objects.filter(project=target_project).first()
if not triage_state:
triage_state = State.objects.create(
name="Triage",
group=StateGroup.TRIAGE.value,
project=target_project,
color="#4E5355",
sequence=65000,
default=False,
)
target_default_state = self.get_default_target_state(target_project)
if not target_default_state:
return Response({"error": "Target project has no available workflow state"}, status=status.HTTP_400_BAD_REQUEST)
intake = Intake.objects.filter(project=target_project, name="External Contours Bridge").first()
if not intake:
intake = Intake.objects.create(
name="External Contours Bridge",
description="System bridge intake used for cross-project routing.",
is_default=False,
project=target_project,
)
issue_payload = serializer.validated_data["issue"]
issue_serializer = IssueCreateSerializer(
data={
"name": issue_payload["name"],
"description_html": issue_payload.get("description_html") or "<p></p>",
"priority": issue_payload.get("priority", "none"),
"assignees": issue_payload.get("assignee_ids", []),
"labels": issue_payload.get("label_ids", []),
"target_date": issue_payload.get("target_date"),
"state_id": str(triage_state.id),
},
context={
"project_id": str(target_project.id),
"workspace_id": str(target_project.workspace_id),
"default_assignee_id": target_project.default_assignee_id,
},
)
issue_serializer.is_valid(raise_exception=True)
issue = issue_serializer.save(state=triage_state)
intake_issue = IntakeIssue.objects.create(
intake=intake,
project=target_project,
issue=issue,
source=SourceType.IN_APP,
status=IntakeIssueStatus.ACCEPTED.value,
extra={
"bridge": "external-contours",
"source_project_id": str(source_project.id),
"source_project_name": source_project.name,
"target_project_id": str(target_project.id),
"target_project_name": target_project.name,
"requested_by_id": str(request.user.id),
"requested_by_name": request.user.display_name,
"requested_at": issue.created_at.isoformat() if issue.created_at else None,
},
)
if issue.state_id != target_default_state.id:
issue.state = target_default_state
issue.save()
response_serializer = ExternalContourRequestSerializer(
IntakeIssue.objects.select_related(
"issue",
"issue__state",
"issue__project",
"issue__created_by",
)
.prefetch_related("issue__issue_assignee__assignee", "issue__label_issue__label")
.get(pk=intake_issue.id)
)
return Response(response_serializer.data, status=status.HTTP_201_CREATED)
class ExternalContourTargetProjectListEndpoint(BaseAPIView):
permission_classes = [ProjectLitePermission]
serializer_class = ExternalContourTargetProjectSerializer
def get_source_project(self, slug, project_id):
return get_object_or_404(Project, workspace__slug=slug, pk=project_id)
def get_queryset(self):
source_project = self.get_source_project(self.kwargs.get("slug"), self.kwargs.get("project_id"))
return (
Project.objects.filter(
workspace_id=source_project.workspace_id,
archived_at__isnull=True,
intake_view=True,
)
.exclude(pk=source_project.id)
.order_by("name")
)
def get(self, request, slug, project_id):
serializer = ExternalContourTargetProjectSerializer(self.get_queryset(), many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
class ExternalContourTargetOptionsEndpoint(BaseAPIView):
permission_classes = [ProjectLitePermission]
serializer_class = ExternalContourTargetOptionsSerializer
def get_source_project(self, slug, project_id):
return get_object_or_404(Project, workspace__slug=slug, pk=project_id)
def get_target_project(self, source_project, target_project_id):
return get_object_or_404(
Project,
workspace_id=source_project.workspace_id,
pk=target_project_id,
archived_at__isnull=True,
intake_view=True,
)
def get(self, request, slug, project_id, target_project_id):
source_project = self.get_source_project(slug, project_id)
target_project = self.get_target_project(source_project, target_project_id)
if str(target_project.id) == str(source_project.id):
return Response({"error": "Target project must differ from source project"}, status=status.HTTP_400_BAD_REQUEST)
member_ids = list(
ProjectMember.objects.filter(
project=target_project,
workspace_id=target_project.workspace_id,
is_active=True,
member__is_bot=False,
member__member_workspace__workspace_id=target_project.workspace_id,
member__member_workspace__is_active=True,
)
.order_by("member__display_name", "member__email")
.values_list("member_id", flat=True)
.distinct()
)
labels = Label.objects.filter(project=target_project).order_by("sort_order", "name")
serializer = ExternalContourTargetOptionsSerializer(
{
"project": target_project,
"member_ids": member_ids,
"labels": labels,
}
)
return Response(serializer.data, status=status.HTTP_200_OK)
class ExternalContourDetailEndpoint(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 get(self, request, slug, project_id, request_id):
contour_request = get_object_or_404(self.get_queryset())
serializer = ExternalContourRequestSerializer(contour_request)
return Response(serializer.data, status=status.HTTP_200_OK)
class ExternalContourDecisionEndpoint(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 = ExternalContourRequestDecisionSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
action = serializer.validated_data["action"]
issue = contour_request.issue
if not issue or not issue.state or issue.state.group not in [StateGroup.COMPLETED.value, StateGroup.CANCELLED.value]:
return Response(
{"error": "Source decision is available only after the target contour finishes processing"},
status=status.HTTP_400_BAD_REQUEST,
)
if action == "accept":
contour_request.extra = {
**contour_request.extra,
"source_decision": "accepted",
"source_decision_at": timezone.now().isoformat(),
"source_decision_by_name": request.user.display_name,
}
contour_request.save(update_fields=["extra", "updated_at"])
else:
target_default_state = (
State.objects.filter(project=issue.project, default=True)
.exclude(group=StateGroup.TRIAGE.value)
.first()
) or State.objects.filter(project=issue.project).exclude(group=StateGroup.TRIAGE.value).order_by(
"sequence", "created_at"
).first()
if not target_default_state:
return Response({"error": "Target project has no available workflow state"}, status=status.HTTP_400_BAD_REQUEST)
issue.state = target_default_state
issue.save(update_fields=["state", "updated_at"])
extra = dict(contour_request.extra or {})
extra.pop("source_decision", None)
extra.pop("source_decision_at", None)
extra.pop("source_decision_by_name", None)
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
contour_request.save(update_fields=["extra", "updated_at"])
contour_request.refresh_from_db()
serializer = ExternalContourRequestSerializer(
IntakeIssue.objects.select_related(
"issue",
"issue__state",
"issue__project",
"issue__created_by",
)
.prefetch_related("issue__issue_assignee__assignee", "issue__label_issue__label")
.get(pk=contour_request.id)
)
return Response(serializer.data, status=status.HTTP_200_OK)

View File

@ -34,6 +34,9 @@ export const ExternalContoursContentRoot = observer(function ExternalContoursCon
const issue = contourRequest?.issue; const issue = contourRequest?.issue;
const targetProjectId = issue?.project_id || projectId; const targetProjectId = issue?.project_id || projectId;
const { allowPermissions, getProjectRoleByWorkspaceSlugAndProjectId } = useUserPermissions(); const { allowPermissions, getProjectRoleByWorkspaceSlugAndProjectId } = useUserPermissions();
const hasDirectTargetAccess = !!(
targetProjectId && getProjectRoleByWorkspaceSlugAndProjectId(workspaceSlug, targetProjectId) !== undefined
);
const isIssueAvailable = getIsRequestAvailable(inboxIssueId?.toString() || ""); const isIssueAvailable = getIsRequestAvailable(inboxIssueId?.toString() || "");
@ -53,11 +56,12 @@ export const ExternalContoursContentRoot = observer(function ExternalContoursCon
); );
const isEditable = const isEditable =
!!targetProjectId && hasDirectTargetAccess &&
(allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT, workspaceSlug, targetProjectId) || (allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT, workspaceSlug, targetProjectId) ||
issue?.created_by === currentUser?.id); issue?.created_by === currentUser?.id);
const isGuest = !!targetProjectId && getProjectRoleByWorkspaceSlugAndProjectId(workspaceSlug, targetProjectId) === EUserPermissions.GUEST; const isGuest =
!!targetProjectId && getProjectRoleByWorkspaceSlugAndProjectId(workspaceSlug, targetProjectId) === EUserPermissions.GUEST;
const isOwner = issue?.created_by === currentUser?.id; const isOwner = issue?.created_by === currentUser?.id;
const readOnly = !isOwner && isGuest; const readOnly = !isOwner && isGuest;
@ -73,6 +77,7 @@ export const ExternalContoursContentRoot = observer(function ExternalContoursCon
sourceProjectId={projectId} sourceProjectId={projectId}
contourRequest={contourRequest} contourRequest={contourRequest}
isSubmitting={isSubmitting} isSubmitting={isSubmitting}
hasDirectTargetAccess={hasDirectTargetAccess}
/> />
</div> </div>
<ContentWrapper className="divide-y-2 divide-subtle-1"> <ContentWrapper className="divide-y-2 divide-subtle-1">
@ -80,6 +85,7 @@ export const ExternalContoursContentRoot = observer(function ExternalContoursCon
workspaceSlug={workspaceSlug} workspaceSlug={workspaceSlug}
sourceProjectId={projectId} sourceProjectId={projectId}
contourRequest={contourRequest} contourRequest={contourRequest}
hasDirectTargetAccess={hasDirectTargetAccess}
isEditable={!!isEditable && !readOnly} isEditable={!!isEditable && !readOnly}
isSubmitting={isSubmitting} isSubmitting={isSubmitting}
setIsSubmitting={setIsSubmitting} setIsSubmitting={setIsSubmitting}

View File

@ -20,7 +20,7 @@ export function ExternalContourCreateModalRoot(props: Props) {
return ( return (
<ModalCore <ModalCore
isOpen={modalState} isOpen={modalState}
position={EModalPosition.TOP} position={EModalPosition.CENTER}
width={EModalWidth.XXXXL} width={EModalWidth.XXXXL}
className="rounded-lg !bg-transparent shadow-none transition-[width] ease-linear" className="rounded-lg !bg-transparent shadow-none transition-[width] ease-linear"
> >

View File

@ -8,31 +8,44 @@ import { useMemo } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useTranslation } from "@plane/i18n"; import { useTranslation } from "@plane/i18n";
import { Badge } from "@plane/propel/badge"; import { Badge } from "@plane/propel/badge";
import { MembersPropertyIcon } from "@plane/propel/icons";
import type { TIssue, TIssuePriorities } from "@plane/types"; import type { TIssue, TIssuePriorities } from "@plane/types";
import { renderFormattedPayloadDate } from "@plane/utils"; import { renderFormattedPayloadDate } from "@plane/utils";
import { DateDropdown } from "@/components/dropdowns/date"; import { DateDropdown } from "@/components/dropdowns/date";
import { MemberDropdown } from "@/components/dropdowns/member/dropdown"; import { ButtonAvatars } from "@/components/dropdowns/member/avatar";
import { MemberDropdownBase } from "@/components/dropdowns/member/base";
import { PriorityDropdown } from "@/components/dropdowns/priority"; import { PriorityDropdown } from "@/components/dropdowns/priority";
import { ProjectDropdown } from "@/components/dropdowns/project/dropdown"; import { ProjectDropdownBase } from "@/components/dropdowns/project/base";
import { IssueLabelSelect } from "@/components/issues/select"; import { WorkItemLabelSelectBase } from "@/components/issues/select/base";
import { useProject } from "@/hooks/store/use-project"; import { useMember } from "@/hooks/store/use-member";
import { useProjectExternalContours } from "@/hooks/store/use-project-external-contours";
type Props = { type Props = {
currentProjectId: string;
currentProjectName?: string; currentProjectName?: string;
data: Partial<TIssue> & { target_project_id?: string | null; priority?: TIssuePriorities }; data: Partial<TIssue> & { target_project_id?: string | null; priority?: TIssuePriorities };
handleData: (issueKey: keyof Props["data"], issueValue: Props["data"][keyof Props["data"]]) => void; handleData: (issueKey: keyof Props["data"], issueValue: Props["data"][keyof Props["data"]]) => void;
}; };
export const ExternalContoursCreateProperties = observer(function ExternalContoursCreateProperties(props: Props) { export const ExternalContoursCreateProperties = observer(function ExternalContoursCreateProperties(props: Props) {
const { currentProjectId, currentProjectName, data, handleData } = props; const { currentProjectName, data, handleData } = props;
const { t } = useTranslation(); const { t } = useTranslation();
const { getProjectById } = useProject(); const { getUserDetails } = useMember();
const { targetProjectIds, getTargetOptionsByProjectId, getTargetProjectById } = useProjectExternalContours();
const selectedTargetProject = useMemo( const selectedTargetProject = data.target_project_id ? getTargetProjectById(data.target_project_id) : undefined;
() => (data.target_project_id ? getProjectById(data.target_project_id) : undefined), const selectedTargetOptions = getTargetOptionsByProjectId(data.target_project_id);
[data.target_project_id, getProjectById] const targetLabelIds = useMemo(
() => selectedTargetOptions?.labels?.map((label) => label.id) ?? [],
[selectedTargetOptions?.labels]
); );
const assigneeLabel = useMemo(() => {
const assigneeIds = data.assignee_ids || [];
if (!assigneeIds.length) return t("external_contours_page.form.assignee");
if (assigneeIds.length === 1) return getUserDetails(assigneeIds[0])?.display_name || t("external_contours_page.form.assignee");
return `${assigneeIds.length} ${t("assignees").toLocaleLowerCase()}`;
}, [data.assignee_ids, getUserDetails, t]);
const getTargetLabelById = (labelId: string) =>
selectedTargetOptions?.labels?.find((label) => label.id === labelId) ?? null;
return ( return (
<div className="relative flex flex-wrap items-center gap-2"> <div className="relative flex flex-wrap items-center gap-2">
@ -42,7 +55,7 @@ export const ExternalContoursCreateProperties = observer(function ExternalContou
</div> </div>
<div className="h-7"> <div className="h-7">
<ProjectDropdown <ProjectDropdownBase
value={data.target_project_id ?? null} value={data.target_project_id ?? null}
onChange={(value) => { onChange={(value) => {
if (!Array.isArray(value)) { if (!Array.isArray(value)) {
@ -52,9 +65,11 @@ export const ExternalContoursCreateProperties = observer(function ExternalContou
} }
}} }}
multiple={false} multiple={false}
projectIds={targetProjectIds}
getProjectById={getTargetProjectById}
buttonVariant="border-with-text" buttonVariant="border-with-text"
placeholder={t("external_contours_page.form.target_project")} placeholder={t("external_contours_page.form.target_project")}
renderCondition={(projectId) => projectId !== currentProjectId} disabled={targetProjectIds.length === 0}
/> />
</div> </div>
@ -74,24 +89,33 @@ export const ExternalContoursCreateProperties = observer(function ExternalContou
</div> </div>
<div className="h-7"> <div className="h-7">
<MemberDropdown <MemberDropdownBase
projectId={data.target_project_id ?? undefined}
value={data.assignee_ids || []} value={data.assignee_ids || []}
onChange={(assigneeIds) => handleData("assignee_ids", assigneeIds)} onChange={(assigneeIds) => handleData("assignee_ids", assigneeIds)}
getUserDetails={getUserDetails}
memberIds={selectedTargetOptions?.member_ids ?? []}
button={
<div className="flex h-full items-center justify-start gap-1.5 rounded-sm border-[0.5px] border-strong px-1.5 text-11 text-secondary">
<ButtonAvatars showTooltip={false} userIds={data.assignee_ids || []} icon={MembersPropertyIcon} />
<span className="flex-grow truncate text-left text-body-xs-medium leading-5">{assigneeLabel}</span>
</div>
}
buttonVariant={(data.assignee_ids || []).length > 0 ? "transparent-without-text" : "border-with-text"} buttonVariant={(data.assignee_ids || []).length > 0 ? "transparent-without-text" : "border-with-text"}
buttonClassName={(data.assignee_ids || []).length > 0 ? "hover:bg-transparent" : ""} buttonClassName={(data.assignee_ids || []).length > 0 ? "hover:bg-transparent" : ""}
optionsClassName="z-[60]"
placeholder={t("external_contours_page.form.assignee")} placeholder={t("external_contours_page.form.assignee")}
disabled={!data.target_project_id} disabled={!data.target_project_id || !selectedTargetOptions}
multiple multiple
/> />
</div> </div>
<div className="h-7"> <div className="h-7">
<IssueLabelSelect <WorkItemLabelSelectBase
value={data.label_ids || []} value={data.label_ids || []}
onChange={(labelIds) => handleData("label_ids", labelIds)} onChange={(labelIds) => handleData("label_ids", labelIds)}
projectId={data.target_project_id ?? undefined} getLabelById={getTargetLabelById}
disabled={!data.target_project_id} labelIds={targetLabelIds}
disabled={!data.target_project_id || !selectedTargetOptions}
/> />
</div> </div>

View File

@ -5,7 +5,7 @@
*/ */
import type { FormEvent } from "react"; import type { FormEvent } from "react";
import { useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import type { EditorRefApi } from "@plane/editor"; import type { EditorRefApi } from "@plane/editor";
import { useTranslation } from "@plane/i18n"; import { useTranslation } from "@plane/i18n";
@ -46,7 +46,7 @@ export const ExternalContoursCreateRoot = observer(function ExternalContoursCrea
const { getWorkspaceBySlug } = useWorkspace(); const { getWorkspaceBySlug } = useWorkspace();
const workspaceId = getWorkspaceBySlug(workspaceSlug)?.id; const workspaceId = getWorkspaceBySlug(workspaceSlug)?.id;
const { currentProjectDetails } = useProject(); const { currentProjectDetails } = useProject();
const { createRequest } = useProjectExternalContours(); const { createRequest, fetchTargetOptions, fetchTargetProjects } = useProjectExternalContours();
const descriptionEditorRef = useRef<EditorRefApi>(null); const descriptionEditorRef = useRef<EditorRefApi>(null);
const [createMore, setCreateMore] = useState(false); const [createMore, setCreateMore] = useState(false);
const [formSubmitting, setFormSubmitting] = useState(false); const [formSubmitting, setFormSubmitting] = useState(false);
@ -59,6 +59,28 @@ export const ExternalContoursCreateRoot = observer(function ExternalContoursCrea
const isTitleLengthMoreThan255Character = formData?.name ? formData.name.length > 255 : false; const isTitleLengthMoreThan255Character = formData?.name ? formData.name.length > 255 : false;
const canSubmit = !!formData.name?.trim() && !!formData.target_project_id; const canSubmit = !!formData.name?.trim() && !!formData.target_project_id;
useEffect(() => {
if (!workspaceSlug || !projectId) return;
fetchTargetProjects(workspaceSlug, projectId).catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: t("error"),
message: t("external_contours_page.modal.error_message"),
});
});
}, [fetchTargetProjects, projectId, t, workspaceSlug]);
useEffect(() => {
if (!workspaceSlug || !projectId || !formData.target_project_id) return;
fetchTargetOptions(workspaceSlug, projectId, formData.target_project_id).catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: t("error"),
message: t("external_contours_page.modal.error_message"),
});
});
}, [fetchTargetOptions, formData.target_project_id, projectId, t, workspaceSlug]);
const handleFormSubmit = async (event: FormEvent<HTMLFormElement>) => { const handleFormSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault(); event.preventDefault();
@ -128,7 +150,6 @@ export const ExternalContoursCreateRoot = observer(function ExternalContoursCrea
onAssetUpload={() => {}} onAssetUpload={() => {}}
/> />
<ExternalContoursCreateProperties <ExternalContoursCreateProperties
currentProjectId={projectId}
currentProjectName={currentProjectDetails?.name} currentProjectName={currentProjectDetails?.name}
data={formData} data={formData}
handleData={handleFormData as any} handleData={handleFormData as any}

View File

@ -8,11 +8,13 @@ import { useCallback, useEffect } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { PanelLeft } from "lucide-react"; import { PanelLeft } from "lucide-react";
import { useTranslation } from "@plane/i18n"; import { useTranslation } from "@plane/i18n";
import { Badge } from "@plane/propel/badge";
import { Button } from "@plane/propel/button"; import { Button } from "@plane/propel/button";
import { IconButton } from "@plane/propel/icon-button"; import { IconButton } from "@plane/propel/icon-button";
import { ChevronDownIcon, ChevronUpIcon, LinkIcon, NewTabIcon } from "@plane/propel/icons"; import { CheckCircleFilledIcon, ChevronDownIcon, ChevronUpIcon, CloseCircleFilledIcon, LinkIcon, NewTabIcon } from "@plane/propel/icons";
import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { TExternalContourRequest, TNameDescriptionLoader } from "@plane/types"; import type { TExternalContourRequest, TNameDescriptionLoader } from "@plane/types";
import { EInboxIssueCurrentTab } from "@plane/types";
import { ControlLink, Header, Row } from "@plane/ui"; import { ControlLink, Header, Row } from "@plane/ui";
import { copyUrlToClipboard, generateWorkItemLink } from "@plane/utils"; import { copyUrlToClipboard, generateWorkItemLink } from "@plane/utils";
import { NameDescriptionUpdateStatus } from "@/components/issues/issue-update-status"; import { NameDescriptionUpdateStatus } from "@/components/issues/issue-update-status";
@ -25,20 +27,31 @@ type Props = {
workspaceSlug: string; workspaceSlug: string;
sourceProjectId: string; sourceProjectId: string;
contourRequest: TExternalContourRequest; contourRequest: TExternalContourRequest;
hasDirectTargetAccess: boolean;
isSubmitting: TNameDescriptionLoader; isSubmitting: TNameDescriptionLoader;
isMobileSidebar: boolean; isMobileSidebar: boolean;
setIsMobileSidebar: (value: boolean) => void; setIsMobileSidebar: (value: boolean) => void;
}; };
export const ExternalContoursIssueActionsHeader = observer(function ExternalContoursIssueActionsHeader(props: Props) { export const ExternalContoursIssueActionsHeader = observer(function ExternalContoursIssueActionsHeader(props: Props) {
const { workspaceSlug, sourceProjectId, contourRequest, isSubmitting, isMobileSidebar, setIsMobileSidebar } = props; const {
workspaceSlug,
sourceProjectId,
contourRequest,
hasDirectTargetAccess,
isSubmitting,
isMobileSidebar,
setIsMobileSidebar,
} = props;
const { t } = useTranslation(); const { t } = useTranslation();
const router = useAppRouter(); const router = useAppRouter();
const { currentTab, filteredRequestIds } = useProjectExternalContours(); const { currentTab, decideRequest, filteredRequestIds, handleCurrentTab } = useProjectExternalContours();
const { getProjectById } = useProject(); const { getProjectById } = useProject();
const issue = contourRequest.issue; const issue = contourRequest.issue;
const currentRequestId = contourRequest.id; const currentRequestId = contourRequest.id;
const canReviewClosedRequest = contourRequest.status === "closed" && contourRequest.source_decision !== "accepted";
const isSourceAccepted = contourRequest.source_decision === "accepted";
const redirectToRelativeIssue = useCallback( const redirectToRelativeIssue = useCallback(
(direction: "next" | "prev") => { (direction: "next" | "prev") => {
@ -83,6 +96,33 @@ export const ExternalContoursIssueActionsHeader = observer(function ExternalCont
}) })
); );
const handleDecision = async (action: "accept" | "decline") => {
try {
await decideRequest(workspaceSlug, sourceProjectId, contourRequest.id, action);
if (action === "decline") {
await handleCurrentTab(workspaceSlug, sourceProjectId, EInboxIssueCurrentTab.OPEN);
router.push(
`/${workspaceSlug}/projects/${sourceProjectId}/external-contours?currentTab=${EInboxIssueCurrentTab.OPEN}&inboxIssueId=${contourRequest.id}`
);
}
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("success"),
message: t(
action === "accept"
? "external_contours_page.actions.accept_success"
: "external_contours_page.actions.decline_success"
),
});
} catch (error: any) {
setToast({
type: TOAST_TYPE.ERROR,
title: t("error"),
message: error?.error || t("external_contours_page.actions.decision_error"),
});
}
};
return ( return (
<> <>
<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"> <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">
@ -105,14 +145,33 @@ export const ExternalContoursIssueActionsHeader = observer(function ExternalCont
</div> </div>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
{canReviewClosedRequest && (
<>
<Button variant="secondary" size="lg" onClick={() => handleDecision("accept")}>
<CheckCircleFilledIcon className="size-4 shrink-0 text-success-secondary" />
{t("external_contours_page.actions.accept")}
</Button>
<Button variant="secondary" size="lg" onClick={() => handleDecision("decline")}>
<CloseCircleFilledIcon className="size-4 shrink-0 text-danger-secondary" />
{t("external_contours_page.actions.decline")}
</Button>
</>
)}
{isSourceAccepted && (
<Badge variant="neutral">{t("external_contours_page.traceability.source_decision_accepted")}</Badge>
)}
<Button variant="secondary" size="lg" prependIcon={<LinkIcon className="h-2.5 w-2.5" />} onClick={handleCopyLink}> <Button variant="secondary" size="lg" prependIcon={<LinkIcon className="h-2.5 w-2.5" />} onClick={handleCopyLink}>
{t("external_contours_page.actions.copy")} {t("external_contours_page.actions.copy")}
</Button> </Button>
{hasDirectTargetAccess && (
<ControlLink href={workItemLink} onClick={() => router.push(workItemLink)} target="_self"> <ControlLink href={workItemLink} onClick={() => router.push(workItemLink)} target="_self">
<Button variant="secondary" size="lg" prependIcon={<NewTabIcon className="h-2.5 w-2.5" />}> <Button variant="secondary" size="lg" prependIcon={<NewTabIcon className="h-2.5 w-2.5" />}>
{t("external_contours_page.actions.open")} {t("external_contours_page.actions.open")}
</Button> </Button>
</ControlLink> </ControlLink>
)}
</div> </div>
</div> </div>
</Row> </Row>
@ -125,11 +184,26 @@ export const ExternalContoursIssueActionsHeader = observer(function ExternalCont
<div className="flex w-full items-center gap-2"> <div className="flex w-full items-center gap-2">
<ExternalContourStatePill request={contourRequest} /> <ExternalContourStatePill request={contourRequest} />
<div className="ml-auto flex items-center gap-2"> <div className="ml-auto flex items-center gap-2">
{canReviewClosedRequest && (
<>
<Button variant="secondary" size="sm" onClick={() => handleDecision("accept")}>
{t("external_contours_page.actions.accept")}
</Button>
<Button variant="secondary" size="sm" onClick={() => handleDecision("decline")}>
{t("external_contours_page.actions.decline")}
</Button>
</>
)}
{isSourceAccepted && (
<Badge variant="neutral">{t("external_contours_page.traceability.source_decision_accepted")}</Badge>
)}
{hasDirectTargetAccess && (
<ControlLink href={workItemLink} onClick={() => router.push(workItemLink)} target="_self"> <ControlLink href={workItemLink} onClick={() => router.push(workItemLink)} target="_self">
<Button variant="secondary" size="sm"> <Button variant="secondary" size="sm">
{t("external_contours_page.actions.open")} {t("external_contours_page.actions.open")}
</Button> </Button>
</ControlLink> </ControlLink>
)}
</div> </div>
</div> </div>
</Header> </Header>

View File

@ -9,10 +9,11 @@ import { useEffect, useMemo, useRef } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import type { EditorRefApi } from "@plane/editor"; import type { EditorRefApi } from "@plane/editor";
import { useTranslation } from "@plane/i18n"; import { useTranslation } from "@plane/i18n";
import { Badge } from "@plane/propel/badge";
import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { TExternalContourRequest, TIssue, TNameDescriptionLoader } from "@plane/types"; import type { TExternalContourRequest, TIssue, TNameDescriptionLoader } from "@plane/types";
import { EFileAssetType } from "@plane/types"; import { EFileAssetType } from "@plane/types";
import { getTextContent } from "@plane/utils"; import { getTextContent, renderFormattedDate } from "@plane/utils";
import { DescriptionVersionsRoot } from "@/components/core/description-versions"; import { DescriptionVersionsRoot } from "@/components/core/description-versions";
import { DescriptionInput } from "@/components/editor/rich-text/description-input"; import { DescriptionInput } from "@/components/editor/rich-text/description-input";
import { DescriptionInputLoader } from "@/components/editor/rich-text/description-input/loader"; import { DescriptionInputLoader } from "@/components/editor/rich-text/description-input/loader";
@ -38,13 +39,14 @@ type Props = {
workspaceSlug: string; workspaceSlug: string;
sourceProjectId: string; sourceProjectId: string;
contourRequest: TExternalContourRequest; contourRequest: TExternalContourRequest;
hasDirectTargetAccess: boolean;
isEditable: boolean; isEditable: boolean;
isSubmitting: TNameDescriptionLoader; isSubmitting: TNameDescriptionLoader;
setIsSubmitting: Dispatch<SetStateAction<TNameDescriptionLoader>>; setIsSubmitting: Dispatch<SetStateAction<TNameDescriptionLoader>>;
}; };
export const ExternalContoursIssueMainContent = observer(function ExternalContoursIssueMainContent(props: Props) { export const ExternalContoursIssueMainContent = observer(function ExternalContoursIssueMainContent(props: Props) {
const { workspaceSlug, sourceProjectId, contourRequest, isEditable, isSubmitting, setIsSubmitting } = props; const { workspaceSlug, sourceProjectId, contourRequest, hasDirectTargetAccess, isEditable, isSubmitting, setIsSubmitting } = props;
const { t } = useTranslation(); const { t } = useTranslation();
const editorRef = useRef<EditorRefApi>(null); const editorRef = useRef<EditorRefApi>(null);
const { data: currentUser } = useUser(); const { data: currentUser } = useUser();
@ -100,6 +102,73 @@ export const ExternalContoursIssueMainContent = observer(function ExternalContou
if (!issue || !issue.project_id || !issue.id) return <></>; if (!issue || !issue.project_id || !issue.id) return <></>;
if (!hasDirectTargetAccess) {
return (
<>
<div className="space-y-4 pb-4">
<h1 className="text-xl font-semibold text-primary">{issue.name}</h1>
<div
className="prose prose-invert max-w-none text-sm text-secondary [&_p]:mb-3"
dangerouslySetInnerHTML={{ __html: issue.description_html || "<p></p>" }}
/>
</div>
<div className="py-4">
<ExternalContoursRequestTraceability contourRequest={contourRequest} />
</div>
<div className="py-4">
<div className="mb-2 text-body-sm-medium">{t("external_contours_page.properties.section_title")}</div>
<div className="flex flex-col gap-3 text-13 text-secondary">
<div className="flex flex-wrap items-center gap-2">
<span className="text-tertiary">{t("external_contours_page.properties.target_contour")}</span>
<Badge variant="neutral">{issue.project_detail?.name || t("common.none")}</Badge>
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-tertiary">{t("assignees")}</span>
{issue.assignee_details?.length ? (
issue.assignee_details.map((assignee) => (
<Badge key={assignee.id} variant="neutral">
{assignee.display_name}
</Badge>
))
) : (
<span>{t("external_contours_page.list.unassigned")}</span>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-tertiary">{t("priority")}</span>
<Badge variant="neutral">{issue.priority || t("none")}</Badge>
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-tertiary">{t("due_date")}</span>
<span>{issue.target_date ? renderFormattedDate(issue.target_date) : t("common.none")}</span>
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-tertiary">{t("labels")}</span>
{issue.label_details?.length ? (
issue.label_details.map((label) => (
<div key={label.id} className="flex items-center gap-1 rounded-sm border border-strong px-2 py-1 text-11">
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: label.color }} />
<span>{label.name}</span>
</div>
))
) : (
<span>{t("common.none")}</span>
)}
</div>
</div>
</div>
<div className="py-4 text-13 text-secondary">{t("external_contours_page.readonly_source_view")}</div>
</>
);
}
return ( return (
<> <>
<div className="space-y-4 pb-4"> <div className="space-y-4 pb-4">

View File

@ -25,6 +25,10 @@ export const ExternalContoursRequestTraceability = observer(function ExternalCon
const requestedAt = contourRequest.requested_at || contourRequest.created_at; const requestedAt = contourRequest.requested_at || contourRequest.created_at;
const lastUpdatedAt = issue.updated_at || contourRequest.updated_at; const lastUpdatedAt = issue.updated_at || contourRequest.updated_at;
const targetProjectName = contourRequest.target_project_name || issue.project_detail?.name || t("common.none"); const targetProjectName = contourRequest.target_project_name || issue.project_detail?.name || t("common.none");
const sourceDecision =
contourRequest.source_decision === "accepted"
? t("external_contours_page.traceability.source_decision_accepted")
: t("external_contours_page.traceability.source_decision_pending");
const targetIssueKey = const targetIssueKey =
issue.project_detail?.identifier && issue.sequence_id issue.project_detail?.identifier && issue.sequence_id
? `${issue.project_detail.identifier}-${issue.sequence_id}` ? `${issue.project_detail.identifier}-${issue.sequence_id}`
@ -61,6 +65,11 @@ export const ExternalContoursRequestTraceability = observer(function ExternalCon
</div> </div>
</div> </div>
<div className="rounded-md border border-subtle bg-surface-1 p-3">
<div className="text-11 font-medium text-tertiary">{t("external_contours_page.traceability.source_decision")}</div>
<div className="mt-1 text-13 font-medium text-secondary">{sourceDecision}</div>
</div>
<div className="rounded-md border border-subtle bg-surface-1 p-3"> <div className="rounded-md border border-subtle bg-surface-1 p-3">
<div className="text-11 font-medium text-tertiary">{t("external_contours_page.traceability.requested_by")}</div> <div className="text-11 font-medium text-tertiary">{t("external_contours_page.traceability.requested_by")}</div>
<div className="mt-1 flex items-center gap-2"> <div className="mt-1 flex items-center gap-2">

View File

@ -44,7 +44,7 @@ export const ExternalContoursRoot = observer(function ExternalContoursRoot(props
fetchRequests(workspaceSlug.toString(), projectId.toString(), navigationTab || EInboxIssueCurrentTab.OPEN); fetchRequests(workspaceSlug.toString(), projectId.toString(), navigationTab || EInboxIssueCurrentTab.OPEN);
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [workspaceSlug, projectId]); }, [workspaceSlug, projectId, navigationTab]);
if (loader === "init-loading") { if (loader === "init-loading") {
return ( return (

View File

@ -5,7 +5,13 @@
*/ */
import { API_BASE_URL } from "@plane/constants"; import { API_BASE_URL } from "@plane/constants";
import type { TExternalContourRequest, TExternalContourRequestResponse, TIssue } from "@plane/types"; import type {
TExternalContourRequest,
TExternalContourRequestResponse,
TExternalContourTargetOptions,
TExternalContourTargetProject,
TIssue,
} from "@plane/types";
import { APIService } from "@/services/api.service"; import { APIService } from "@/services/api.service";
export class ExternalContourService extends APIService { export class ExternalContourService extends APIService {
@ -29,6 +35,26 @@ export class ExternalContourService extends APIService {
}); });
} }
async listTargetProjects(workspaceSlug: string, projectId: string): Promise<TExternalContourTargetProject[]> {
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/external-contours/targets/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async retrieveTargetOptions(
workspaceSlug: string,
projectId: string,
targetProjectId: string
): Promise<TExternalContourTargetOptions> {
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/external-contours/targets/${targetProjectId}/options/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async create( async create(
workspaceSlug: string, workspaceSlug: string,
projectId: string, projectId: string,
@ -50,4 +76,19 @@ export class ExternalContourService extends APIService {
throw error?.response?.data; throw error?.response?.data;
}); });
} }
async decide(
workspaceSlug: string,
projectId: string,
requestId: string,
action: "accept" | "decline"
): Promise<TExternalContourRequest> {
return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/external-contours/${requestId}/decision/`, {
action,
})
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
} }

View File

@ -6,7 +6,13 @@
import { set } from "lodash-es"; import { set } from "lodash-es";
import { action, computed, makeObservable, observable, runInAction } from "mobx"; import { action, computed, makeObservable, observable, runInAction } from "mobx";
import type { TExternalContourRequest, TInboxIssueCurrentTab, TIssue } from "@plane/types"; import type {
TExternalContourRequest,
TExternalContourTargetOptions,
TExternalContourTargetProject,
TInboxIssueCurrentTab,
TIssue,
} from "@plane/types";
import { EInboxIssueCurrentTab } from "@plane/types"; import { EInboxIssueCurrentTab } from "@plane/types";
import { ExternalContourService } from "@/services/external-contours"; import { ExternalContourService } from "@/services/external-contours";
import type { CoreRootStore } from "../root.store"; import type { CoreRootStore } from "../root.store";
@ -20,15 +26,28 @@ export interface IProjectExternalContoursStore {
loader: TLoader; loader: TLoader;
requestIds: string[]; requestIds: string[];
requests: Record<string, TExternalContourRequest>; requests: Record<string, TExternalContourRequest>;
targetProjectIds: string[];
targetProjects: Record<string, TExternalContourTargetProject>;
targetOptionsMap: Record<string, TExternalContourTargetOptions>;
createRequest: ( createRequest: (
workspaceSlug: string, workspaceSlug: string,
projectId: string, projectId: string,
data: Partial<TIssue> & { target_project_id?: string | null } data: Partial<TIssue> & { target_project_id?: string | null }
) => Promise<TExternalContourRequest | undefined>; ) => Promise<TExternalContourRequest | undefined>;
decideRequest: (
workspaceSlug: string,
projectId: string,
requestId: string,
action: "accept" | "decline"
) => Promise<TExternalContourRequest | undefined>;
fetchTargetProjects: (workspaceSlug: string, projectId: string) => Promise<void>;
fetchTargetOptions: (workspaceSlug: string, projectId: string, targetProjectId: string) => Promise<TExternalContourTargetOptions | undefined>;
fetchRequestById: (workspaceSlug: string, projectId: string, requestId: string) => Promise<TExternalContourRequest | undefined>; fetchRequestById: (workspaceSlug: string, projectId: string, requestId: string) => Promise<TExternalContourRequest | undefined>;
fetchRequests: (workspaceSlug: string, projectId: string, tab?: TInboxIssueCurrentTab) => Promise<void>; fetchRequests: (workspaceSlug: string, projectId: string, tab?: TInboxIssueCurrentTab) => Promise<void>;
getIsRequestAvailable: (requestId: string) => boolean; getIsRequestAvailable: (requestId: string) => boolean;
getRequestById: (requestId: string) => TExternalContourRequest | undefined; getRequestById: (requestId: string) => TExternalContourRequest | undefined;
getTargetProjectById: (projectId: string | null | undefined) => TExternalContourTargetProject | undefined;
getTargetOptionsByProjectId: (projectId: string | null | undefined) => TExternalContourTargetOptions | undefined;
handleCurrentTab: (workspaceSlug: string, projectId: string, tab: TInboxIssueCurrentTab) => Promise<void>; handleCurrentTab: (workspaceSlug: string, projectId: string, tab: TInboxIssueCurrentTab) => Promise<void>;
openRequestIds: string[]; openRequestIds: string[];
closedRequestIds: string[]; closedRequestIds: string[];
@ -44,6 +63,9 @@ export class ProjectExternalContoursStore implements IProjectExternalContoursSto
loader: TLoader = "init-loading"; loader: TLoader = "init-loading";
requestIds: string[] = []; requestIds: string[] = [];
requests: Record<string, TExternalContourRequest> = {}; requests: Record<string, TExternalContourRequest> = {};
targetProjectIds: string[] = [];
targetProjects: Record<string, TExternalContourTargetProject> = {};
targetOptionsMap: Record<string, TExternalContourTargetOptions> = {};
externalContourService; externalContourService;
@ -55,12 +77,18 @@ export class ProjectExternalContoursStore implements IProjectExternalContoursSto
loader: observable.ref, loader: observable.ref,
requestIds: observable.shallow, requestIds: observable.shallow,
requests: observable, requests: observable,
targetProjectIds: observable.shallow,
targetProjects: observable,
targetOptionsMap: observable,
openRequestIds: computed, openRequestIds: computed,
closedRequestIds: computed, closedRequestIds: computed,
filteredRequestIds: computed, filteredRequestIds: computed,
fetchTargetProjects: action,
fetchTargetOptions: action,
fetchRequests: action, fetchRequests: action,
fetchRequestById: action, fetchRequestById: action,
createRequest: action, createRequest: action,
decideRequest: action,
handleCurrentTab: action, handleCurrentTab: action,
upsertRequests: action, upsertRequests: action,
updateRequestIssue: action, updateRequestIssue: action,
@ -82,6 +110,9 @@ export class ProjectExternalContoursStore implements IProjectExternalContoursSto
} }
getRequestById = (requestId: string) => this.requests[requestId]; getRequestById = (requestId: string) => this.requests[requestId];
getTargetProjectById = (projectId: string | null | undefined) => (projectId ? this.targetProjects[projectId] : undefined);
getTargetOptionsByProjectId = (projectId: string | null | undefined) =>
projectId ? this.targetOptionsMap[projectId] : undefined;
getIsRequestAvailable = (requestId: string) => this.requestIds.includes(requestId); getIsRequestAvailable = (requestId: string) => this.requestIds.includes(requestId);
@ -97,6 +128,40 @@ export class ProjectExternalContoursStore implements IProjectExternalContoursSto
}); });
}; };
fetchTargetProjects = async (workspaceSlug: string, projectId: string) => {
try {
const projects = await this.externalContourService.listTargetProjects(workspaceSlug, projectId);
runInAction(() => {
this.targetProjectIds = [];
this.targetProjects = {};
projects.forEach((project) => {
set(this.targetProjects, project.id, project);
this.targetProjectIds.push(project.id);
});
});
} catch (error) {
runInAction(() => {
this.targetProjectIds = [];
this.targetProjects = {};
});
throw error;
}
};
fetchTargetOptions = async (workspaceSlug: string, projectId: string, targetProjectId: string) => {
try {
const options = await this.externalContourService.retrieveTargetOptions(workspaceSlug, projectId, targetProjectId);
runInAction(() => {
set(this.targetOptionsMap, targetProjectId, options);
set(this.targetProjects, targetProjectId, options.project);
if (!this.targetProjectIds.includes(targetProjectId)) this.targetProjectIds.push(targetProjectId);
});
return options;
} catch (error) {
throw error;
}
};
handleCurrentTab = async (workspaceSlug: string, projectId: string, tab: TInboxIssueCurrentTab) => { handleCurrentTab = async (workspaceSlug: string, projectId: string, tab: TInboxIssueCurrentTab) => {
this.currentTab = tab; this.currentTab = tab;
await this.fetchRequests(workspaceSlug, projectId, tab); await this.fetchRequests(workspaceSlug, projectId, tab);
@ -162,6 +227,28 @@ export class ProjectExternalContoursStore implements IProjectExternalContoursSto
} }
}; };
decideRequest = async (
workspaceSlug: string,
projectId: string,
requestId: string,
action: "accept" | "decline"
) => {
this.loader = "mutation-loading";
try {
const request = await this.externalContourService.decide(workspaceSlug, projectId, requestId, action);
runInAction(() => {
this.upsertRequests([request]);
this.loader = undefined;
});
return request;
} catch (error) {
runInAction(() => {
this.loader = undefined;
});
throw error;
}
};
updateRequestIssue = (requestId: string, issueData: Partial<TExternalContourRequest["issue"]>) => { updateRequestIssue = (requestId: string, issueData: Partial<TExternalContourRequest["issue"]>) => {
if (!this.requests[requestId]) return; if (!this.requests[requestId]) return;
const nextStatus = const nextStatus =

View File

@ -335,11 +335,16 @@ export default {
add_due_date: "Add due date", add_due_date: "Add due date",
duplicate_of: "Duplicate of", duplicate_of: "Duplicate of",
}, },
readonly_source_view:
"This request is shown in source-side mode. Direct access to the target contour is not required, and detailed activity and attachments will be synchronized in the next stage.",
traceability: { traceability: {
title: "Routing", title: "Routing",
description: description:
"This block shows which contour sent the request, where it was routed, and which linked work item now carries the execution.", "This block shows which contour sent the request, where it was routed, and which linked work item now carries the execution.",
source_contour: "Source internal contour", source_contour: "Source internal contour",
source_decision: "Source-side decision",
source_decision_pending: "Awaiting decision",
source_decision_accepted: "Accepted into the internal contour",
target_contour: "Target external contour", target_contour: "Target external contour",
status: "Current status", status: "Current status",
requested_by: "Requested by", requested_by: "Requested by",
@ -351,6 +356,9 @@ export default {
send: "Send", send: "Send",
accept: "Accept", accept: "Accept",
decline: "Decline", decline: "Decline",
accept_success: "The external contour result has been accepted.",
decline_success: "The request has been returned to the external contour for rework.",
decision_error: "The external contour action could not be completed.",
copy: "Copy link", copy: "Copy link",
open: "Open request", open: "Open request",
unsupported_title: "This action will be connected in the next stage", unsupported_title: "This action will be connected in the next stage",

View File

@ -492,10 +492,15 @@ export default {
add_due_date: "Добавить срок выполнения", add_due_date: "Добавить срок выполнения",
duplicate_of: "Дубликат", duplicate_of: "Дубликат",
}, },
readonly_source_view:
"Запрос показан в source-side режиме. Прямой доступ к целевому контуру не требуется, а подробная активность и вложения будут синхронизированы следующим этапом.",
traceability: { traceability: {
title: "Маршрутизация", title: "Маршрутизация",
description: "Здесь отображается, из какого контура ушёл запрос, куда он направлен и с каким связанным элементом он сейчас работает.", description: "Здесь отображается, из какого контура ушёл запрос, куда он направлен и с каким связанным элементом он сейчас работает.",
source_contour: "Исходный внутренний контур", source_contour: "Исходный внутренний контур",
source_decision: "Решение источника",
source_decision_pending: "Ожидает решения",
source_decision_accepted: "Принято во внутренний контур",
target_contour: "Целевой внешний контур", target_contour: "Целевой внешний контур",
status: "Текущий статус", status: "Текущий статус",
requested_by: "Отправитель", requested_by: "Отправитель",
@ -507,6 +512,9 @@ export default {
send: "Отправить", send: "Отправить",
accept: "Принять", accept: "Принять",
decline: "Отклонить", decline: "Отклонить",
accept_success: "Результат внешнего контура принят.",
decline_success: "Запрос возвращён во внешний контур на доработку.",
decision_error: "Не удалось выполнить действие по внешнему запросу.",
copy: "Копировать ссылку", copy: "Копировать ссылку",
open: "Открыть запрос", open: "Открыть запрос",
unsupported_title: "Действие будет подключено следующим этапом", unsupported_title: "Действие будет подключено следующим этапом",

View File

@ -6,6 +6,7 @@
import type { TPaginationInfo } from "./common"; import type { TPaginationInfo } from "./common";
import type { TIssue } from "./issues/issue"; import type { TIssue } from "./issues/issue";
import type { IIssueLabel } from "./issues";
import type { IProjectLite } from "./project"; import type { IProjectLite } from "./project";
import type { IStateLite } from "./state"; import type { IStateLite } from "./state";
import type { IUser } from "./users"; import type { IUser } from "./users";
@ -23,6 +24,9 @@ export type TExternalContourRequest = {
created_by: string | null; created_by: string | null;
id: string; id: string;
issue: TExternalContourIssue; issue: TExternalContourIssue;
source_decision?: "accepted" | null;
source_decision_at?: string | null;
source_decision_by_name?: string | null;
source_project_id: string; source_project_id: string;
source_project_name?: string | null; source_project_name?: string | null;
target_project_id?: string | null; target_project_id?: string | null;
@ -37,3 +41,13 @@ export type TExternalContourRequest = {
export type TExternalContourRequestResponse = TPaginationInfo & { export type TExternalContourRequestResponse = TPaginationInfo & {
results: TExternalContourRequest[]; results: TExternalContourRequest[];
}; };
export type TExternalContourTargetProject = IProjectLite & {
inbox_view: boolean;
};
export type TExternalContourTargetOptions = {
project: TExternalContourTargetProject;
member_ids: string[];
labels: Pick<IIssueLabel, "id" | "name" | "color" | "parent" | "project_id" | "workspace_id" | "sort_order">[];
};