From 3fe3539614251f67162147bc44682faf1632a2d1 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Sun, 19 Apr 2026 00:07:10 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A4=D0=A3=D0=9D=D0=9A=D0=A6=D0=98=D0=98=20-?= =?UTF-8?q?=20=D0=9C=D0=95=D0=96=D0=9F=D0=A0=D0=9E=D0=95=D0=9A=D0=A2=D0=9D?= =?UTF-8?q?=D0=90=D0=AF=20=D0=9A=D0=9E=D0=9C=D0=9C=D0=A3=D0=9D=D0=98=D0=9A?= =?UTF-8?q?=D0=90=D0=A6=D0=98=D0=AF:=20web-=D0=B4=D0=BE=D1=81=D1=82=D1=83?= =?UTF-8?q?=D0=BF=20=D0=BA=20=D0=B2=D0=BD=D0=B5=D1=88=D0=BD=D0=B8=D0=BC=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=BD=D1=82=D1=83=D1=80=D0=B0=D0=BC=20=D0=B8=20?= =?UTF-8?q?=D0=B8=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20=D0=BC=D0=BE=D0=B4=D0=B0=D0=BB=D0=BA=D0=B8=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=BF=D1=80=D0=BE=D1=81=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../phase-roadmap.md | 7 + .../api/plane/api/serializers/__init__.py | 2 + .../api/serializers/external_contours.py | 18 +- .../api/plane/api/urls/external_contours.py | 17 +- .../apps/api/plane/api/views/__init__.py | 2 + .../api/plane/api/views/external_contours.py | 83 +++++- plane-src/apps/api/plane/app/urls/__init__.py | 2 + .../api/plane/app/urls/external_contours.py | 36 +++ .../apps/api/plane/app/views/__init__.py | 6 + .../api/plane/app/views/external_contours.py | 266 ++++++++++++++++++ .../external-contours/content-root.tsx | 10 +- .../external-contours/create-modal.tsx | 2 +- .../external-contours/create-properties.tsx | 60 ++-- .../external-contours/create-root.tsx | 27 +- .../external-contours/issue-header.tsx | 35 ++- .../projects/external-contours/issue-root.tsx | 73 ++++- .../external-contour.service.ts | 28 +- .../project-external-contours.store.ts | 60 +++- .../i18n/src/locales/en/translations.ts | 2 + .../i18n/src/locales/ru/translations.ts | 2 + .../packages/types/src/external-contours.ts | 11 + 21 files changed, 707 insertions(+), 42 deletions(-) create mode 100644 plane-src/apps/api/plane/app/urls/external_contours.py create mode 100644 plane-src/apps/api/plane/app/views/external_contours.py diff --git a/docs_prod/cross-project-task-routing/phase-roadmap.md b/docs_prod/cross-project-task-routing/phase-roadmap.md index 3be0fab..d0d7eac 100644 --- a/docs_prod/cross-project-task-routing/phase-roadmap.md +++ b/docs_prod/cross-project-task-routing/phase-roadmap.md @@ -65,6 +65,8 @@ - `POST /external-contours/` создает target issue в целевом проекте - target issue сразу попадает в обычный workflow целевого проекта - source-side `GET /external-contours/` возвращает отправленные запросы с metadata источника и цели +- target contour теперь выбирается по policy `same workspace + intake enabled`, а не только из joined projects +- прямое membership в target project для отправки не требуется ## Этап 2. Source-side список и статусная пришлепка @@ -144,6 +146,7 @@ - source-side detail использует отдельный экран `Внешних контуров` - в карточке отображается блок маршрутизации с ключевой source-target связью - текущий статус берется из фактического state целевой задачи +- если у инициатора нет membership в target project, карточка переключается в source-side readonly режим без прямого открытия чужого проекта Что остается: - зеркалирование комментариев @@ -235,6 +238,10 @@ - должен ли инициатор иметь прямую ссылку на target issue - или доступ к target issue должен быть скрыт, а source-side карточка должна быть единственной точкой просмотра +Текущее решение: +- при отсутствии membership в target project прямой переход в target issue скрывается +- карточка остается доступной из source project + ## Рекомендуемый порядок фактической разработки 1. Этап 0 diff --git a/plane-src/apps/api/plane/api/serializers/__init__.py b/plane-src/apps/api/plane/api/serializers/__init__.py index 9f530c0..0bb1b05 100644 --- a/plane-src/apps/api/plane/api/serializers/__init__.py +++ b/plane-src/apps/api/plane/api/serializers/__init__.py @@ -56,6 +56,8 @@ from .intake import ( from .external_contours import ( ExternalContourRequestCreateSerializer, ExternalContourRequestSerializer, + ExternalContourTargetOptionsSerializer, + ExternalContourTargetProjectSerializer, ) from .estimate import EstimateSerializer, EstimatePointSerializer from .asset import ( 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 9550cdf..56d9024 100644 --- a/plane-src/apps/api/plane/api/serializers/external_contours.py +++ b/plane-src/apps/api/plane/api/serializers/external_contours.py @@ -9,7 +9,8 @@ from .issue import IssueSerializer from .project import ProjectLiteSerializer from .state import StateLiteSerializer 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): @@ -26,6 +27,21 @@ class ExternalContourRequestCreateSerializer(serializers.Serializer): issue = ExternalContourIssuePayloadSerializer() +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): assignee_ids = serializers.SerializerMethodField() assignee_details = serializers.SerializerMethodField() 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 3eed993..d3a4e8a 100644 --- a/plane-src/apps/api/plane/api/urls/external_contours.py +++ b/plane-src/apps/api/plane/api/urls/external_contours.py @@ -4,7 +4,12 @@ from django.urls import path -from plane.api.views import ExternalContourDetailAPIEndpoint, ExternalContourListCreateAPIEndpoint +from plane.api.views import ( + ExternalContourDetailAPIEndpoint, + ExternalContourListCreateAPIEndpoint, + ExternalContourTargetOptionsAPIEndpoint, + ExternalContourTargetProjectListAPIEndpoint, +) urlpatterns = [ path( @@ -12,6 +17,16 @@ urlpatterns = [ ExternalContourListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]), name="external-contours", ), + path( + "workspaces//projects//external-contours/targets/", + ExternalContourTargetProjectListAPIEndpoint.as_view(http_method_names=["get"]), + name="external-contour-targets", + ), + path( + "workspaces//projects//external-contours/targets//options/", + ExternalContourTargetOptionsAPIEndpoint.as_view(http_method_names=["get"]), + name="external-contour-target-options", + ), path( "workspaces//projects//external-contours//", ExternalContourDetailAPIEndpoint.as_view(http_method_names=["get"]), diff --git a/plane-src/apps/api/plane/api/views/__init__.py b/plane-src/apps/api/plane/api/views/__init__.py index c53eabe..3881bdf 100644 --- a/plane-src/apps/api/plane/api/views/__init__.py +++ b/plane-src/apps/api/plane/api/views/__init__.py @@ -58,6 +58,8 @@ from .intake import ( from .external_contours import ( ExternalContourListCreateAPIEndpoint, ExternalContourDetailAPIEndpoint, + ExternalContourTargetProjectListAPIEndpoint, + ExternalContourTargetOptionsAPIEndpoint, ) from .asset import UserAssetEndpoint, UserServerAssetEndpoint, GenericAssetEndpoint 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 30d2cf0..bbc8fba 100644 --- a/plane-src/apps/api/plane/api/views/external_contours.py +++ b/plane-src/apps/api/plane/api/views/external_contours.py @@ -9,9 +9,11 @@ from rest_framework.response import Response from plane.api.serializers import ( ExternalContourRequestCreateSerializer, ExternalContourRequestSerializer, + ExternalContourTargetOptionsSerializer, + ExternalContourTargetProjectSerializer, ) from plane.app.permissions import ProjectLitePermission -from plane.db.models import Intake, IntakeIssue, Project, State, StateGroup +from plane.db.models import Intake, IntakeIssue, Label, Project, ProjectMember, State, StateGroup from plane.api.serializers.issue import IssueSerializer as IssueCreateSerializer from .base import BaseAPIView from plane.db.models.intake import IntakeIssueStatus, SourceType @@ -74,6 +76,12 @@ class ExternalContourListCreateAPIEndpoint(BaseAPIView): 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( @@ -158,6 +166,79 @@ class ExternalContourListCreateAPIEndpoint(BaseAPIView): return Response(response_serializer.data, status=status.HTTP_201_CREATED) +class ExternalContourTargetProjectListAPIEndpoint(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 ExternalContourTargetOptionsAPIEndpoint(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 ExternalContourDetailAPIEndpoint(BaseAPIView): permission_classes = [ProjectLitePermission] serializer_class = ExternalContourRequestSerializer diff --git a/plane-src/apps/api/plane/app/urls/__init__.py b/plane-src/apps/api/plane/app/urls/__init__.py index 3fa850b..cdd1954 100644 --- a/plane-src/apps/api/plane/app/urls/__init__.py +++ b/plane-src/apps/api/plane/app/urls/__init__.py @@ -8,6 +8,7 @@ from .asset import urlpatterns as asset_urls from .cycle import urlpatterns as cycle_urls from .estimate import urlpatterns as estimate_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 .issue import urlpatterns as issue_urls from .module import urlpatterns as module_urls @@ -29,6 +30,7 @@ urlpatterns = [ *cycle_urls, *estimate_urls, *external_urls, + *external_contour_urls, *intake_urls, *issue_urls, *module_urls, diff --git a/plane-src/apps/api/plane/app/urls/external_contours.py b/plane-src/apps/api/plane/app/urls/external_contours.py new file mode 100644 index 0000000..af90977 --- /dev/null +++ b/plane-src/apps/api/plane/app/urls/external_contours.py @@ -0,0 +1,36 @@ +# 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, + ExternalContourListCreateEndpoint, + ExternalContourTargetOptionsEndpoint, + ExternalContourTargetProjectListEndpoint, +) + + +urlpatterns = [ + path( + "workspaces//projects//external-contours/", + ExternalContourListCreateEndpoint.as_view(http_method_names=["get", "post"]), + name="external-contours", + ), + path( + "workspaces//projects//external-contours/targets/", + ExternalContourTargetProjectListEndpoint.as_view(http_method_names=["get"]), + name="external-contour-targets", + ), + path( + "workspaces//projects//external-contours/targets//options/", + ExternalContourTargetOptionsEndpoint.as_view(http_method_names=["get"]), + name="external-contour-target-options", + ), + path( + "workspaces//projects//external-contours//", + ExternalContourDetailEndpoint.as_view(http_method_names=["get"]), + name="external-contour-detail", + ), +] diff --git a/plane-src/apps/api/plane/app/views/__init__.py b/plane-src/apps/api/plane/app/views/__init__.py index 84f7872..4d14d57 100644 --- a/plane-src/apps/api/plane/app/views/__init__.py +++ b/plane-src/apps/api/plane/app/views/__init__.py @@ -224,6 +224,12 @@ from .notification.base import ( ) from .exporter.base import ExportIssuesEndpoint +from .external_contours import ( + ExternalContourListCreateEndpoint, + ExternalContourDetailEndpoint, + ExternalContourTargetProjectListEndpoint, + ExternalContourTargetOptionsEndpoint, +) from .webhook.base import ( diff --git a/plane-src/apps/api/plane/app/views/external_contours.py b/plane-src/apps/api/plane/app/views/external_contours.py new file mode 100644 index 0000000..31be8f9 --- /dev/null +++ b/plane-src/apps/api/plane/app/views/external_contours.py @@ -0,0 +1,266 @@ +# 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 rest_framework import status +from rest_framework.response import Response + +from plane.api.serializers import ( + ExternalContourRequestCreateSerializer, + 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_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 = ( + 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: + 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 "

", + "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) diff --git a/plane-src/apps/web/ce/components/projects/external-contours/content-root.tsx b/plane-src/apps/web/ce/components/projects/external-contours/content-root.tsx index 84b56e3..a71a2a8 100644 --- a/plane-src/apps/web/ce/components/projects/external-contours/content-root.tsx +++ b/plane-src/apps/web/ce/components/projects/external-contours/content-root.tsx @@ -34,6 +34,9 @@ export const ExternalContoursContentRoot = observer(function ExternalContoursCon const issue = contourRequest?.issue; const targetProjectId = issue?.project_id || projectId; const { allowPermissions, getProjectRoleByWorkspaceSlugAndProjectId } = useUserPermissions(); + const hasDirectTargetAccess = !!( + targetProjectId && getProjectRoleByWorkspaceSlugAndProjectId(workspaceSlug, targetProjectId) !== undefined + ); const isIssueAvailable = getIsRequestAvailable(inboxIssueId?.toString() || ""); @@ -53,11 +56,12 @@ export const ExternalContoursContentRoot = observer(function ExternalContoursCon ); const isEditable = - !!targetProjectId && + hasDirectTargetAccess && (allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT, workspaceSlug, targetProjectId) || 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 readOnly = !isOwner && isGuest; @@ -73,6 +77,7 @@ export const ExternalContoursContentRoot = observer(function ExternalContoursCon sourceProjectId={projectId} contourRequest={contourRequest} isSubmitting={isSubmitting} + hasDirectTargetAccess={hasDirectTargetAccess} /> @@ -80,6 +85,7 @@ export const ExternalContoursContentRoot = observer(function ExternalContoursCon workspaceSlug={workspaceSlug} sourceProjectId={projectId} contourRequest={contourRequest} + hasDirectTargetAccess={hasDirectTargetAccess} isEditable={!!isEditable && !readOnly} isSubmitting={isSubmitting} setIsSubmitting={setIsSubmitting} diff --git a/plane-src/apps/web/ce/components/projects/external-contours/create-modal.tsx b/plane-src/apps/web/ce/components/projects/external-contours/create-modal.tsx index a28c972..ff4333c 100644 --- a/plane-src/apps/web/ce/components/projects/external-contours/create-modal.tsx +++ b/plane-src/apps/web/ce/components/projects/external-contours/create-modal.tsx @@ -20,7 +20,7 @@ export function ExternalContourCreateModalRoot(props: Props) { return ( diff --git a/plane-src/apps/web/ce/components/projects/external-contours/create-properties.tsx b/plane-src/apps/web/ce/components/projects/external-contours/create-properties.tsx index 57bd902..9c19cef 100644 --- a/plane-src/apps/web/ce/components/projects/external-contours/create-properties.tsx +++ b/plane-src/apps/web/ce/components/projects/external-contours/create-properties.tsx @@ -8,31 +8,44 @@ import { useMemo } from "react"; import { observer } from "mobx-react"; import { useTranslation } from "@plane/i18n"; import { Badge } from "@plane/propel/badge"; +import { MembersPropertyIcon } from "@plane/propel/icons"; import type { TIssue, TIssuePriorities } from "@plane/types"; import { renderFormattedPayloadDate } from "@plane/utils"; 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 { ProjectDropdown } from "@/components/dropdowns/project/dropdown"; -import { IssueLabelSelect } from "@/components/issues/select"; -import { useProject } from "@/hooks/store/use-project"; +import { ProjectDropdownBase } from "@/components/dropdowns/project/base"; +import { WorkItemLabelSelectBase } from "@/components/issues/select/base"; +import { useMember } from "@/hooks/store/use-member"; +import { useProjectExternalContours } from "@/hooks/store/use-project-external-contours"; type Props = { - currentProjectId: string; currentProjectName?: string; data: Partial & { target_project_id?: string | null; priority?: TIssuePriorities }; handleData: (issueKey: keyof Props["data"], issueValue: Props["data"][keyof Props["data"]]) => void; }; export const ExternalContoursCreateProperties = observer(function ExternalContoursCreateProperties(props: Props) { - const { currentProjectId, currentProjectName, data, handleData } = props; + const { currentProjectName, data, handleData } = props; const { t } = useTranslation(); - const { getProjectById } = useProject(); + const { getUserDetails } = useMember(); + const { targetProjectIds, getTargetOptionsByProjectId, getTargetProjectById } = useProjectExternalContours(); - const selectedTargetProject = useMemo( - () => (data.target_project_id ? getProjectById(data.target_project_id) : undefined), - [data.target_project_id, getProjectById] + const selectedTargetProject = data.target_project_id ? getTargetProjectById(data.target_project_id) : undefined; + const selectedTargetOptions = getTargetOptionsByProjectId(data.target_project_id); + 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 (
@@ -42,7 +55,7 @@ export const ExternalContoursCreateProperties = observer(function ExternalContou
- { if (!Array.isArray(value)) { @@ -52,9 +65,11 @@ export const ExternalContoursCreateProperties = observer(function ExternalContou } }} multiple={false} + projectIds={targetProjectIds} + getProjectById={getTargetProjectById} buttonVariant="border-with-text" placeholder={t("external_contours_page.form.target_project")} - renderCondition={(projectId) => projectId !== currentProjectId} + disabled={targetProjectIds.length === 0} />
@@ -74,24 +89,33 @@ export const ExternalContoursCreateProperties = observer(function ExternalContou
- handleData("assignee_ids", assigneeIds)} + getUserDetails={getUserDetails} + memberIds={selectedTargetOptions?.member_ids ?? []} + button={ +
+ + {assigneeLabel} +
+ } buttonVariant={(data.assignee_ids || []).length > 0 ? "transparent-without-text" : "border-with-text"} buttonClassName={(data.assignee_ids || []).length > 0 ? "hover:bg-transparent" : ""} + optionsClassName="z-[60]" placeholder={t("external_contours_page.form.assignee")} - disabled={!data.target_project_id} + disabled={!data.target_project_id || !selectedTargetOptions} multiple />
- handleData("label_ids", labelIds)} - projectId={data.target_project_id ?? undefined} - disabled={!data.target_project_id} + getLabelById={getTargetLabelById} + labelIds={targetLabelIds} + disabled={!data.target_project_id || !selectedTargetOptions} />
diff --git a/plane-src/apps/web/ce/components/projects/external-contours/create-root.tsx b/plane-src/apps/web/ce/components/projects/external-contours/create-root.tsx index 46b2d3b..d940536 100644 --- a/plane-src/apps/web/ce/components/projects/external-contours/create-root.tsx +++ b/plane-src/apps/web/ce/components/projects/external-contours/create-root.tsx @@ -5,7 +5,7 @@ */ import type { FormEvent } from "react"; -import { useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { observer } from "mobx-react"; import type { EditorRefApi } from "@plane/editor"; import { useTranslation } from "@plane/i18n"; @@ -46,7 +46,7 @@ export const ExternalContoursCreateRoot = observer(function ExternalContoursCrea const { getWorkspaceBySlug } = useWorkspace(); const workspaceId = getWorkspaceBySlug(workspaceSlug)?.id; const { currentProjectDetails } = useProject(); - const { createRequest } = useProjectExternalContours(); + const { createRequest, fetchTargetOptions, fetchTargetProjects } = useProjectExternalContours(); const descriptionEditorRef = useRef(null); const [createMore, setCreateMore] = 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 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) => { event.preventDefault(); @@ -128,7 +150,6 @@ export const ExternalContoursCreateRoot = observer(function ExternalContoursCrea onAssetUpload={() => {}} /> void; }; 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 router = useAppRouter(); const { currentTab, filteredRequestIds } = useProjectExternalContours(); @@ -108,11 +117,13 @@ export const ExternalContoursIssueActionsHeader = observer(function ExternalCont - router.push(workItemLink)} target="_self"> - - + {hasDirectTargetAccess && ( + router.push(workItemLink)} target="_self"> + + + )} @@ -125,11 +136,13 @@ export const ExternalContoursIssueActionsHeader = observer(function ExternalCont
- router.push(workItemLink)} target="_self"> - - + {hasDirectTargetAccess && ( + router.push(workItemLink)} target="_self"> + + + )}
diff --git a/plane-src/apps/web/ce/components/projects/external-contours/issue-root.tsx b/plane-src/apps/web/ce/components/projects/external-contours/issue-root.tsx index f30a7db..f839dbf 100644 --- a/plane-src/apps/web/ce/components/projects/external-contours/issue-root.tsx +++ b/plane-src/apps/web/ce/components/projects/external-contours/issue-root.tsx @@ -9,10 +9,11 @@ import { useEffect, useMemo, useRef } from "react"; import { observer } from "mobx-react"; import type { EditorRefApi } from "@plane/editor"; import { useTranslation } from "@plane/i18n"; +import { Badge } from "@plane/propel/badge"; import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import type { TExternalContourRequest, TIssue, TNameDescriptionLoader } 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 { DescriptionInput } from "@/components/editor/rich-text/description-input"; import { DescriptionInputLoader } from "@/components/editor/rich-text/description-input/loader"; @@ -38,13 +39,14 @@ type Props = { workspaceSlug: string; sourceProjectId: string; contourRequest: TExternalContourRequest; + hasDirectTargetAccess: boolean; isEditable: boolean; isSubmitting: TNameDescriptionLoader; setIsSubmitting: Dispatch>; }; 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 editorRef = useRef(null); const { data: currentUser } = useUser(); @@ -100,6 +102,73 @@ export const ExternalContoursIssueMainContent = observer(function ExternalContou if (!issue || !issue.project_id || !issue.id) return <>; + if (!hasDirectTargetAccess) { + return ( + <> +
+

{issue.name}

+

" }} + /> +
+ +
+ +
+ +
+
{t("external_contours_page.properties.section_title")}
+
+
+ {t("external_contours_page.properties.target_contour")} + {issue.project_detail?.name || t("common.none")} +
+ +
+ {t("assignees")} + {issue.assignee_details?.length ? ( + issue.assignee_details.map((assignee) => ( + + {assignee.display_name} + + )) + ) : ( + {t("external_contours_page.list.unassigned")} + )} +
+ +
+ {t("priority")} + {issue.priority || t("none")} +
+ +
+ {t("due_date")} + {issue.target_date ? renderFormattedDate(issue.target_date) : t("common.none")} +
+ +
+ {t("labels")} + {issue.label_details?.length ? ( + issue.label_details.map((label) => ( +
+ + {label.name} +
+ )) + ) : ( + {t("common.none")} + )} +
+
+
+ +
{t("external_contours_page.readonly_source_view")}
+ + ); + } + return ( <>
diff --git a/plane-src/apps/web/core/services/external-contours/external-contour.service.ts b/plane-src/apps/web/core/services/external-contours/external-contour.service.ts index 55484f5..b7aabcc 100644 --- a/plane-src/apps/web/core/services/external-contours/external-contour.service.ts +++ b/plane-src/apps/web/core/services/external-contours/external-contour.service.ts @@ -5,7 +5,13 @@ */ 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"; export class ExternalContourService extends APIService { @@ -29,6 +35,26 @@ export class ExternalContourService extends APIService { }); } + async listTargetProjects(workspaceSlug: string, projectId: string): Promise { + 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 { + 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( workspaceSlug: string, projectId: string, diff --git a/plane-src/apps/web/core/store/external-contours/project-external-contours.store.ts b/plane-src/apps/web/core/store/external-contours/project-external-contours.store.ts index b363709..d0e1b34 100644 --- a/plane-src/apps/web/core/store/external-contours/project-external-contours.store.ts +++ b/plane-src/apps/web/core/store/external-contours/project-external-contours.store.ts @@ -6,7 +6,13 @@ import { set } from "lodash-es"; 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 { ExternalContourService } from "@/services/external-contours"; import type { CoreRootStore } from "../root.store"; @@ -20,15 +26,22 @@ export interface IProjectExternalContoursStore { loader: TLoader; requestIds: string[]; requests: Record; + targetProjectIds: string[]; + targetProjects: Record; + targetOptionsMap: Record; createRequest: ( workspaceSlug: string, projectId: string, data: Partial & { target_project_id?: string | null } ) => Promise; + fetchTargetProjects: (workspaceSlug: string, projectId: string) => Promise; + fetchTargetOptions: (workspaceSlug: string, projectId: string, targetProjectId: string) => Promise; fetchRequestById: (workspaceSlug: string, projectId: string, requestId: string) => Promise; fetchRequests: (workspaceSlug: string, projectId: string, tab?: TInboxIssueCurrentTab) => Promise; getIsRequestAvailable: (requestId: string) => boolean; 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; openRequestIds: string[]; closedRequestIds: string[]; @@ -44,6 +57,9 @@ export class ProjectExternalContoursStore implements IProjectExternalContoursSto loader: TLoader = "init-loading"; requestIds: string[] = []; requests: Record = {}; + targetProjectIds: string[] = []; + targetProjects: Record = {}; + targetOptionsMap: Record = {}; externalContourService; @@ -55,9 +71,14 @@ export class ProjectExternalContoursStore implements IProjectExternalContoursSto loader: observable.ref, requestIds: observable.shallow, requests: observable, + targetProjectIds: observable.shallow, + targetProjects: observable, + targetOptionsMap: observable, openRequestIds: computed, closedRequestIds: computed, filteredRequestIds: computed, + fetchTargetProjects: action, + fetchTargetOptions: action, fetchRequests: action, fetchRequestById: action, createRequest: action, @@ -82,6 +103,9 @@ export class ProjectExternalContoursStore implements IProjectExternalContoursSto } 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); @@ -97,6 +121,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) => { this.currentTab = tab; await this.fetchRequests(workspaceSlug, projectId, tab); diff --git a/plane-src/packages/i18n/src/locales/en/translations.ts b/plane-src/packages/i18n/src/locales/en/translations.ts index 2ce2e43..4d7e28e 100644 --- a/plane-src/packages/i18n/src/locales/en/translations.ts +++ b/plane-src/packages/i18n/src/locales/en/translations.ts @@ -335,6 +335,8 @@ export default { add_due_date: "Add due date", 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: { title: "Routing", description: diff --git a/plane-src/packages/i18n/src/locales/ru/translations.ts b/plane-src/packages/i18n/src/locales/ru/translations.ts index 5130933..3c33c44 100644 --- a/plane-src/packages/i18n/src/locales/ru/translations.ts +++ b/plane-src/packages/i18n/src/locales/ru/translations.ts @@ -492,6 +492,8 @@ export default { add_due_date: "Добавить срок выполнения", duplicate_of: "Дубликат", }, + readonly_source_view: + "Запрос показан в source-side режиме. Прямой доступ к целевому контуру не требуется, а подробная активность и вложения будут синхронизированы следующим этапом.", traceability: { title: "Маршрутизация", description: "Здесь отображается, из какого контура ушёл запрос, куда он направлен и с каким связанным элементом он сейчас работает.", diff --git a/plane-src/packages/types/src/external-contours.ts b/plane-src/packages/types/src/external-contours.ts index ed6a1fc..c5a5c48 100644 --- a/plane-src/packages/types/src/external-contours.ts +++ b/plane-src/packages/types/src/external-contours.ts @@ -6,6 +6,7 @@ import type { TPaginationInfo } from "./common"; import type { TIssue } from "./issues/issue"; +import type { IIssueLabel } from "./issues"; import type { IProjectLite } from "./project"; import type { IStateLite } from "./state"; import type { IUser } from "./users"; @@ -37,3 +38,13 @@ export type TExternalContourRequest = { export type TExternalContourRequestResponse = TPaginationInfo & { results: TExternalContourRequest[]; }; + +export type TExternalContourTargetProject = IProjectLite & { + inbox_view: boolean; +}; + +export type TExternalContourTargetOptions = { + project: TExternalContourTargetProject; + member_ids: string[]; + labels: Pick[]; +};