diff --git a/docs_prod/cross-project-task-routing/phase-roadmap.md b/docs_prod/cross-project-task-routing/phase-roadmap.md index d0d7eac..67faabf 100644 --- a/docs_prod/cross-project-task-routing/phase-roadmap.md +++ b/docs_prod/cross-project-task-routing/phase-roadmap.md @@ -147,11 +147,15 @@ - в карточке отображается блок маршрутизации с ключевой source-target связью - текущий статус берется из фактического state целевой задачи - если у инициатора нет membership в target project, карточка переключается в source-side readonly режим без прямого открытия чужого проекта +- для закрытого внешнего запроса доступны source-side действия `Принять` и `Отклонить` +- `Принять` фиксирует решение источника в bridge metadata и помечает запрос как принятый во внутренний контур +- `Отклонить` возвращает target issue в default-state целевого проекта и переносит source-side карточку обратно в список `Открытые` Что остается: - зеркалирование комментариев - зеркалирование файлов - зеркалирование activity stream и обновлений описания +- комментарий причины отклонения и ответ обратно во внешний контур ## Этап 4. Уведомления diff --git a/plane-src/apps/api/plane/api/serializers/__init__.py b/plane-src/apps/api/plane/api/serializers/__init__.py index 0bb1b05..1495596 100644 --- a/plane-src/apps/api/plane/api/serializers/__init__.py +++ b/plane-src/apps/api/plane/api/serializers/__init__.py @@ -55,6 +55,7 @@ from .intake import ( ) from .external_contours import ( ExternalContourRequestCreateSerializer, + ExternalContourRequestDecisionSerializer, ExternalContourRequestSerializer, ExternalContourTargetOptionsSerializer, ExternalContourTargetProjectSerializer, diff --git a/plane-src/apps/api/plane/api/serializers/external_contours.py b/plane-src/apps/api/plane/api/serializers/external_contours.py index 56d9024..1d6a13e 100644 --- a/plane-src/apps/api/plane/api/serializers/external_contours.py +++ b/plane-src/apps/api/plane/api/serializers/external_contours.py @@ -27,6 +27,10 @@ class ExternalContourRequestCreateSerializer(serializers.Serializer): 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") @@ -95,6 +99,9 @@ class ExternalContourRequestSerializer(BaseSerializer): issue = ExternalContourIssueSerializer(read_only=True) source_project_id = 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_name = serializers.SerializerMethodField() requested_by_id = serializers.SerializerMethodField() @@ -112,6 +119,9 @@ class ExternalContourRequestSerializer(BaseSerializer): "issue", "source_project_id", "source_project_name", + "source_decision", + "source_decision_at", + "source_decision_by_name", "target_project_id", "target_project_name", "requested_by_id", @@ -127,6 +137,15 @@ class ExternalContourRequestSerializer(BaseSerializer): def get_source_project_name(self, obj): 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): target_project_id = obj.extra.get("target_project_id") if target_project_id: 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 d3a4e8a..9c43849 100644 --- a/plane-src/apps/api/plane/api/urls/external_contours.py +++ b/plane-src/apps/api/plane/api/urls/external_contours.py @@ -6,6 +6,7 @@ from django.urls import path from plane.api.views import ( ExternalContourDetailAPIEndpoint, + ExternalContourDecisionAPIEndpoint, ExternalContourListCreateAPIEndpoint, ExternalContourTargetOptionsAPIEndpoint, ExternalContourTargetProjectListAPIEndpoint, @@ -32,4 +33,9 @@ urlpatterns = [ ExternalContourDetailAPIEndpoint.as_view(http_method_names=["get"]), name="external-contour-detail", ), + path( + "workspaces//projects//external-contours//decision/", + ExternalContourDecisionAPIEndpoint.as_view(http_method_names=["post"]), + name="external-contour-decision", + ), ] diff --git a/plane-src/apps/api/plane/api/views/__init__.py b/plane-src/apps/api/plane/api/views/__init__.py index 3881bdf..54d0d8f 100644 --- a/plane-src/apps/api/plane/api/views/__init__.py +++ b/plane-src/apps/api/plane/api/views/__init__.py @@ -56,10 +56,11 @@ from .intake import ( IntakeIssueDetailAPIEndpoint, ) from .external_contours import ( - ExternalContourListCreateAPIEndpoint, - ExternalContourDetailAPIEndpoint, - ExternalContourTargetProjectListAPIEndpoint, - ExternalContourTargetOptionsAPIEndpoint, + ExternalContourListCreateEndpoint as ExternalContourListCreateAPIEndpoint, + ExternalContourDetailEndpoint as ExternalContourDetailAPIEndpoint, + ExternalContourDecisionEndpoint as ExternalContourDecisionAPIEndpoint, + ExternalContourTargetProjectListEndpoint as ExternalContourTargetProjectListAPIEndpoint, + ExternalContourTargetOptionsEndpoint as 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 bbc8fba..50f010d 100644 --- a/plane-src/apps/api/plane/api/views/external_contours.py +++ b/plane-src/apps/api/plane/api/views/external_contours.py @@ -3,29 +3,40 @@ # 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.app.permissions import ProjectLitePermission -from plane.db.models import Intake, IntakeIssue, Label, Project, ProjectMember, State, StateGroup from plane.api.serializers.issue import IssueSerializer as IssueCreateSerializer +from plane.app.permissions import ProjectLitePermission from .base import BaseAPIView +from plane.db.models import Intake, IntakeIssue, Label, Project, ProjectMember, State, StateGroup from plane.db.models.intake import IntakeIssueStatus, SourceType -class ExternalContourListCreateAPIEndpoint(BaseAPIView): +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( @@ -93,11 +104,7 @@ class ExternalContourListCreateAPIEndpoint(BaseAPIView): 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() + 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) @@ -166,7 +173,7 @@ class ExternalContourListCreateAPIEndpoint(BaseAPIView): return Response(response_serializer.data, status=status.HTTP_201_CREATED) -class ExternalContourTargetProjectListAPIEndpoint(BaseAPIView): +class ExternalContourTargetProjectListEndpoint(BaseAPIView): permission_classes = [ProjectLitePermission] serializer_class = ExternalContourTargetProjectSerializer @@ -190,7 +197,7 @@ class ExternalContourTargetProjectListAPIEndpoint(BaseAPIView): return Response(serializer.data, status=status.HTTP_200_OK) -class ExternalContourTargetOptionsAPIEndpoint(BaseAPIView): +class ExternalContourTargetOptionsEndpoint(BaseAPIView): permission_classes = [ProjectLitePermission] serializer_class = ExternalContourTargetOptionsSerializer @@ -239,7 +246,7 @@ class ExternalContourTargetOptionsAPIEndpoint(BaseAPIView): return Response(serializer.data, status=status.HTTP_200_OK) -class ExternalContourDetailAPIEndpoint(BaseAPIView): +class ExternalContourDetailEndpoint(BaseAPIView): permission_classes = [ProjectLitePermission] serializer_class = ExternalContourRequestSerializer @@ -264,3 +271,84 @@ class ExternalContourDetailAPIEndpoint(BaseAPIView): 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) diff --git a/plane-src/apps/api/plane/app/urls/external_contours.py b/plane-src/apps/api/plane/app/urls/external_contours.py index af90977..9791177 100644 --- a/plane-src/apps/api/plane/app/urls/external_contours.py +++ b/plane-src/apps/api/plane/app/urls/external_contours.py @@ -6,6 +6,7 @@ from django.urls import path from plane.app.views import ( ExternalContourDetailEndpoint, + ExternalContourDecisionEndpoint, ExternalContourListCreateEndpoint, ExternalContourTargetOptionsEndpoint, ExternalContourTargetProjectListEndpoint, @@ -33,4 +34,9 @@ urlpatterns = [ ExternalContourDetailEndpoint.as_view(http_method_names=["get"]), name="external-contour-detail", ), + path( + "workspaces//projects//external-contours//decision/", + ExternalContourDecisionEndpoint.as_view(http_method_names=["post"]), + name="external-contour-decision", + ), ] diff --git a/plane-src/apps/api/plane/app/views/__init__.py b/plane-src/apps/api/plane/app/views/__init__.py index 4d14d57..f82ea8b 100644 --- a/plane-src/apps/api/plane/app/views/__init__.py +++ b/plane-src/apps/api/plane/app/views/__init__.py @@ -227,6 +227,7 @@ from .exporter.base import ExportIssuesEndpoint from .external_contours import ( ExternalContourListCreateEndpoint, ExternalContourDetailEndpoint, + ExternalContourDecisionEndpoint, ExternalContourTargetProjectListEndpoint, ExternalContourTargetOptionsEndpoint, ) diff --git a/plane-src/apps/api/plane/app/views/external_contours.py b/plane-src/apps/api/plane/app/views/external_contours.py index 31be8f9..721634d 100644 --- a/plane-src/apps/api/plane/app/views/external_contours.py +++ b/plane-src/apps/api/plane/app/views/external_contours.py @@ -3,11 +3,13 @@ # 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, @@ -26,6 +28,15 @@ class ExternalContourListCreateEndpoint(BaseAPIView): 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( @@ -93,11 +104,7 @@ class ExternalContourListCreateEndpoint(BaseAPIView): 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() + 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) @@ -264,3 +271,84 @@ class ExternalContourDetailEndpoint(BaseAPIView): 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) diff --git a/plane-src/apps/web/ce/components/projects/external-contours/issue-header.tsx b/plane-src/apps/web/ce/components/projects/external-contours/issue-header.tsx index 24fa602..3067ff0 100644 --- a/plane-src/apps/web/ce/components/projects/external-contours/issue-header.tsx +++ b/plane-src/apps/web/ce/components/projects/external-contours/issue-header.tsx @@ -8,11 +8,13 @@ import { useCallback, useEffect } from "react"; import { observer } from "mobx-react"; import { PanelLeft } from "lucide-react"; import { useTranslation } from "@plane/i18n"; +import { Badge } from "@plane/propel/badge"; import { Button } from "@plane/propel/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 type { TExternalContourRequest, TNameDescriptionLoader } from "@plane/types"; +import { EInboxIssueCurrentTab } from "@plane/types"; import { ControlLink, Header, Row } from "@plane/ui"; import { copyUrlToClipboard, generateWorkItemLink } from "@plane/utils"; import { NameDescriptionUpdateStatus } from "@/components/issues/issue-update-status"; @@ -43,11 +45,13 @@ export const ExternalContoursIssueActionsHeader = observer(function ExternalCont } = props; const { t } = useTranslation(); const router = useAppRouter(); - const { currentTab, filteredRequestIds } = useProjectExternalContours(); + const { currentTab, decideRequest, filteredRequestIds, handleCurrentTab } = useProjectExternalContours(); const { getProjectById } = useProject(); const issue = contourRequest.issue; const currentRequestId = contourRequest.id; + const canReviewClosedRequest = contourRequest.status === "closed" && contourRequest.source_decision !== "accepted"; + const isSourceAccepted = contourRequest.source_decision === "accepted"; const redirectToRelativeIssue = useCallback( (direction: "next" | "prev") => { @@ -92,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 ( <> @@ -114,6 +145,23 @@ export const ExternalContoursIssueActionsHeader = observer(function ExternalCont
+ {canReviewClosedRequest && ( + <> + + + + )} + + {isSourceAccepted && ( + {t("external_contours_page.traceability.source_decision_accepted")} + )} + @@ -136,6 +184,19 @@ export const ExternalContoursIssueActionsHeader = observer(function ExternalCont
+ {canReviewClosedRequest && ( + <> + + + + )} + {isSourceAccepted && ( + {t("external_contours_page.traceability.source_decision_accepted")} + )} {hasDirectTargetAccess && ( router.push(workItemLink)} target="_self">
+
+
{t("external_contours_page.traceability.source_decision")}
+
{sourceDecision}
+
+
{t("external_contours_page.traceability.requested_by")}
diff --git a/plane-src/apps/web/ce/components/projects/external-contours/root.tsx b/plane-src/apps/web/ce/components/projects/external-contours/root.tsx index 0638609..87e8818 100644 --- a/plane-src/apps/web/ce/components/projects/external-contours/root.tsx +++ b/plane-src/apps/web/ce/components/projects/external-contours/root.tsx @@ -44,7 +44,7 @@ export const ExternalContoursRoot = observer(function ExternalContoursRoot(props fetchRequests(workspaceSlug.toString(), projectId.toString(), navigationTab || EInboxIssueCurrentTab.OPEN); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [workspaceSlug, projectId]); + }, [workspaceSlug, projectId, navigationTab]); if (loader === "init-loading") { 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 b7aabcc..165f68d 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 @@ -76,4 +76,19 @@ export class ExternalContourService extends APIService { throw error?.response?.data; }); } + + async decide( + workspaceSlug: string, + projectId: string, + requestId: string, + action: "accept" | "decline" + ): Promise { + return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/external-contours/${requestId}/decision/`, { + action, + }) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } } 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 d0e1b34..b9db38a 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 @@ -34,6 +34,12 @@ export interface IProjectExternalContoursStore { projectId: string, data: Partial & { target_project_id?: string | null } ) => Promise; + decideRequest: ( + workspaceSlug: string, + projectId: string, + requestId: string, + action: "accept" | "decline" + ) => Promise; fetchTargetProjects: (workspaceSlug: string, projectId: string) => Promise; fetchTargetOptions: (workspaceSlug: string, projectId: string, targetProjectId: string) => Promise; fetchRequestById: (workspaceSlug: string, projectId: string, requestId: string) => Promise; @@ -82,6 +88,7 @@ export class ProjectExternalContoursStore implements IProjectExternalContoursSto fetchRequests: action, fetchRequestById: action, createRequest: action, + decideRequest: action, handleCurrentTab: action, upsertRequests: action, updateRequestIssue: action, @@ -220,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) => { if (!this.requests[requestId]) return; const nextStatus = diff --git a/plane-src/packages/i18n/src/locales/en/translations.ts b/plane-src/packages/i18n/src/locales/en/translations.ts index 4d7e28e..150f0d4 100644 --- a/plane-src/packages/i18n/src/locales/en/translations.ts +++ b/plane-src/packages/i18n/src/locales/en/translations.ts @@ -342,6 +342,9 @@ export default { description: "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_decision: "Source-side decision", + source_decision_pending: "Awaiting decision", + source_decision_accepted: "Accepted into the internal contour", target_contour: "Target external contour", status: "Current status", requested_by: "Requested by", @@ -353,6 +356,9 @@ export default { send: "Send", accept: "Accept", 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", open: "Open request", unsupported_title: "This action will be connected in the next stage", diff --git a/plane-src/packages/i18n/src/locales/ru/translations.ts b/plane-src/packages/i18n/src/locales/ru/translations.ts index 3c33c44..629047c 100644 --- a/plane-src/packages/i18n/src/locales/ru/translations.ts +++ b/plane-src/packages/i18n/src/locales/ru/translations.ts @@ -498,6 +498,9 @@ export default { title: "Маршрутизация", description: "Здесь отображается, из какого контура ушёл запрос, куда он направлен и с каким связанным элементом он сейчас работает.", source_contour: "Исходный внутренний контур", + source_decision: "Решение источника", + source_decision_pending: "Ожидает решения", + source_decision_accepted: "Принято во внутренний контур", target_contour: "Целевой внешний контур", status: "Текущий статус", requested_by: "Отправитель", @@ -509,6 +512,9 @@ export default { send: "Отправить", accept: "Принять", decline: "Отклонить", + accept_success: "Результат внешнего контура принят.", + decline_success: "Запрос возвращён во внешний контур на доработку.", + decision_error: "Не удалось выполнить действие по внешнему запросу.", copy: "Копировать ссылку", open: "Открыть запрос", unsupported_title: "Действие будет подключено следующим этапом", diff --git a/plane-src/packages/types/src/external-contours.ts b/plane-src/packages/types/src/external-contours.ts index c5a5c48..6e54b09 100644 --- a/plane-src/packages/types/src/external-contours.ts +++ b/plane-src/packages/types/src/external-contours.ts @@ -24,6 +24,9 @@ export type TExternalContourRequest = { created_by: string | null; id: string; issue: TExternalContourIssue; + source_decision?: "accepted" | null; + source_decision_at?: string | null; + source_decision_by_name?: string | null; source_project_id: string; source_project_name?: string | null; target_project_id?: string | null;