ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: source-side принятие и возврат внешнего запроса
This commit is contained in:
parent
3fe3539614
commit
8195c3fc80
|
|
@ -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. Уведомления
|
||||
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ from .intake import (
|
|||
)
|
||||
from .external_contours import (
|
||||
ExternalContourRequestCreateSerializer,
|
||||
ExternalContourRequestDecisionSerializer,
|
||||
ExternalContourRequestSerializer,
|
||||
ExternalContourTargetOptionsSerializer,
|
||||
ExternalContourTargetProjectSerializer,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -227,6 +227,7 @@ from .exporter.base import ExportIssuesEndpoint
|
|||
from .external_contours import (
|
||||
ExternalContourListCreateEndpoint,
|
||||
ExternalContourDetailEndpoint,
|
||||
ExternalContourDecisionEndpoint,
|
||||
ExternalContourTargetProjectListEndpoint,
|
||||
ExternalContourTargetOptionsEndpoint,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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: "Действие будет подключено следующим этапом",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue