ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: source-side принятие и возврат внешнего запроса

This commit is contained in:
DCCONSTRUCTIONS 2026-04-19 00:29:23 +03:00
parent 3fe3539614
commit 8195c3fc80
17 changed files with 366 additions and 23 deletions

View File

@ -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. Уведомления

View File

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

View File

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

View File

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

View File

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

View File

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

@ -227,6 +227,7 @@ from .exporter.base import ExportIssuesEndpoint
from .external_contours import (
ExternalContourListCreateEndpoint,
ExternalContourDetailEndpoint,
ExternalContourDecisionEndpoint,
ExternalContourTargetProjectListEndpoint,
ExternalContourTargetOptionsEndpoint,
)

View File

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

View File

@ -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 (
<>
<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">
@ -114,6 +145,23 @@ export const ExternalContoursIssueActionsHeader = observer(function ExternalCont
</div>
<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}>
{t("external_contours_page.actions.copy")}
</Button>
@ -136,6 +184,19 @@ export const ExternalContoursIssueActionsHeader = observer(function ExternalCont
<div className="flex w-full items-center gap-2">
<ExternalContourStatePill request={contourRequest} />
<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">
<Button variant="secondary" size="sm">

View File

@ -25,6 +25,10 @@ export const ExternalContoursRequestTraceability = observer(function ExternalCon
const requestedAt = contourRequest.requested_at || contourRequest.created_at;
const lastUpdatedAt = issue.updated_at || contourRequest.updated_at;
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 =
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 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="text-11 font-medium text-tertiary">{t("external_contours_page.traceability.requested_by")}</div>
<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);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [workspaceSlug, projectId]);
}, [workspaceSlug, projectId, navigationTab]);
if (loader === "init-loading") {
return (

View File

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

@ -34,6 +34,12 @@ export interface IProjectExternalContoursStore {
projectId: string,
data: Partial<TIssue> & { target_project_id?: string | null }
) => 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>;
@ -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<TExternalContourRequest["issue"]>) => {
if (!this.requests[requestId]) return;
const nextStatus =

View File

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

View File

@ -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: "Действие будет подключено следующим этапом",

View File

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