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

This commit is contained in:
DCCONSTRUCTIONS 2026-04-19 10:30:04 +03:00
parent 4dff521f7f
commit a4eaf4b247
15 changed files with 318 additions and 171 deletions

View File

@ -145,8 +145,13 @@
Что уже работает:
- source-side detail использует отдельный экран `Внешних контуров`
- в карточке отображается блок маршрутизации с ключевой source-target связью
- у отправителя открытый внешний запрос редактируется прямо из source-side карточки по полям `заголовок` и `описание`, даже без membership в target project
- текущий статус берется из фактического state целевой задачи
- если у инициатора нет membership в target project, карточка переключается в source-side readonly режим без прямого открытия чужого проекта
- блок `Маршрутизация` перестроен в фиксированный формат `3 x 3`
- в `Маршрутизацию` перенесены `Назначенный` и `Срок выполнения`
- в блоке `Свойства` убраны дубли `Внешний контур`, `Назначенный` и `Срок выполнения`
- название исходного внутреннего контура в карточке маршрутизации берется из живого проекта, а не из застывшего metadata snapshot
- для закрытого внешнего запроса доступны source-side действия `Принять` и `Отклонить`
- `Принять` фиксирует решение источника в bridge metadata и помечает запрос как принятый во внутренний контур
- `Отклонить` требует комментарий причины, возвращает target issue в default-state целевого проекта и переносит source-side карточку обратно в список `Открытые`

View File

@ -57,6 +57,7 @@ from .external_contours import (
ExternalContourRequestCreateSerializer,
ExternalContourRequestDecisionSerializer,
ExternalContourRequestReplySerializer,
ExternalContourRequestUpdateSerializer,
ExternalContourRequestSerializer,
ExternalContourTargetOptionsSerializer,
ExternalContourTargetProjectSerializer,

View File

@ -41,6 +41,22 @@ class ExternalContourRequestReplySerializer(serializers.Serializer):
comment = serializers.CharField()
class ExternalContourRequestUpdateSerializer(serializers.Serializer):
name = serializers.CharField(max_length=255, required=False)
description_html = serializers.CharField(required=False, allow_blank=True, allow_null=True)
def validate(self, data):
if not data:
raise serializers.ValidationError("At least one field must be provided")
if "name" in data:
data["name"] = data["name"].strip()
if not data["name"]:
raise serializers.ValidationError({"name": "Title is required"})
return data
class ExternalContourTargetProjectSerializer(BaseSerializer):
inbox_view = serializers.BooleanField(read_only=True, source="intake_view")
@ -257,6 +273,11 @@ class ExternalContourRequestSerializer(BaseSerializer):
return ExternalContourMirroredCommentSerializer(comments, many=True).data
def get_source_project_name(self, obj):
source_project_id = obj.extra.get("source_project_id")
if source_project_id:
live_name = Project.objects.filter(pk=source_project_id).values_list("name", flat=True).first()
if live_name:
return live_name
return obj.extra.get("source_project_name")
def get_source_decision(self, obj):

View File

@ -33,7 +33,7 @@ urlpatterns = [
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/external-contours/<uuid:request_id>/",
ExternalContourDetailEndpoint.as_view(http_method_names=["get"]),
ExternalContourDetailEndpoint.as_view(http_method_names=["get", "patch"]),
name="external-contour-detail",
),
path(

View File

@ -14,6 +14,7 @@ from plane.api.serializers import (
ExternalContourRequestCreateSerializer,
ExternalContourRequestDecisionSerializer,
ExternalContourRequestReplySerializer,
ExternalContourRequestUpdateSerializer,
ExternalContourRequestSerializer,
ExternalContourTargetOptionsSerializer,
ExternalContourTargetProjectSerializer,
@ -304,6 +305,55 @@ class ExternalContourDetailEndpoint(BaseAPIView):
)
return Response(serializer.data, status=status.HTTP_200_OK)
def patch(self, request, slug, project_id, request_id):
contour_request = get_object_or_404(self.get_queryset())
issue = contour_request.issue
if not issue:
return Response({"error": "Target issue was not found"}, status=status.HTTP_404_NOT_FOUND)
requested_by_id = contour_request.extra.get("requested_by_id") or (
str(issue.created_by_id) if issue.created_by_id else None
)
if str(request.user.id) != str(requested_by_id):
return Response({"error": "Only the sender can edit this request"}, status=status.HTTP_403_FORBIDDEN)
if issue.state and issue.state.group in [StateGroup.COMPLETED.value, StateGroup.CANCELLED.value]:
return Response(
{"error": "Only open external contour requests can be edited"},
status=status.HTTP_400_BAD_REQUEST,
)
serializer = ExternalContourRequestUpdateSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
issue_serializer = IssueCreateSerializer(
issue,
data=serializer.validated_data,
partial=True,
context={
"project_id": str(issue.project_id),
"workspace_id": str(issue.workspace_id),
},
)
issue_serializer.is_valid(raise_exception=True)
issue_serializer.save()
contour_request.updated_at = timezone.now()
contour_request.save(update_fields=["updated_at"])
contour_request.refresh_from_db()
response_serializer = ExternalContourRequestSerializer(
contour_request,
context={
"include_mirror_data": True,
"workspace_slug": slug,
"source_project_id": str(project_id),
"request": request,
},
)
return Response(response_serializer.data, status=status.HTTP_200_OK)
class ExternalContourDecisionEndpoint(BaseAPIView):
permission_classes = [ProjectLitePermission]

View File

@ -33,7 +33,7 @@ urlpatterns = [
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/external-contours/<uuid:request_id>/",
ExternalContourDetailEndpoint.as_view(http_method_names=["get"]),
ExternalContourDetailEndpoint.as_view(http_method_names=["get", "patch"]),
name="external-contour-detail",
),
path(

View File

@ -14,6 +14,7 @@ from plane.api.serializers import (
ExternalContourRequestCreateSerializer,
ExternalContourRequestDecisionSerializer,
ExternalContourRequestReplySerializer,
ExternalContourRequestUpdateSerializer,
ExternalContourRequestSerializer,
ExternalContourTargetOptionsSerializer,
ExternalContourTargetProjectSerializer,
@ -304,6 +305,55 @@ class ExternalContourDetailEndpoint(BaseAPIView):
)
return Response(serializer.data, status=status.HTTP_200_OK)
def patch(self, request, slug, project_id, request_id):
contour_request = get_object_or_404(self.get_queryset())
issue = contour_request.issue
if not issue:
return Response({"error": "Target issue was not found"}, status=status.HTTP_404_NOT_FOUND)
requested_by_id = contour_request.extra.get("requested_by_id") or (
str(issue.created_by_id) if issue.created_by_id else None
)
if str(request.user.id) != str(requested_by_id):
return Response({"error": "Only the sender can edit this request"}, status=status.HTTP_403_FORBIDDEN)
if issue.state and issue.state.group in [StateGroup.COMPLETED.value, StateGroup.CANCELLED.value]:
return Response(
{"error": "Only open external contour requests can be edited"},
status=status.HTTP_400_BAD_REQUEST,
)
serializer = ExternalContourRequestUpdateSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
issue_serializer = IssueCreateSerializer(
issue,
data=serializer.validated_data,
partial=True,
context={
"project_id": str(issue.project_id),
"workspace_id": str(issue.workspace_id),
},
)
issue_serializer.is_valid(raise_exception=True)
issue_serializer.save()
contour_request.updated_at = timezone.now()
contour_request.save(update_fields=["updated_at"])
contour_request.refresh_from_db()
response_serializer = ExternalContourRequestSerializer(
contour_request,
context={
"include_mirror_data": True,
"workspace_slug": slug,
"source_project_id": str(project_id),
"request": request,
},
)
return Response(response_serializer.data, status=status.HTTP_200_OK)
class ExternalContourDecisionEndpoint(BaseAPIView):
permission_classes = [ProjectLitePermission]

View File

@ -63,6 +63,12 @@ export const ExternalContoursContentRoot = observer(function ExternalContoursCon
hasDirectTargetAccess &&
(allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT, workspaceSlug, targetProjectId) ||
issue?.created_by === currentUser?.id);
const sourceRequesterId = contourRequest?.requested_by_id || issue?.created_by || contourRequest?.created_by;
const isSourceEditable =
!hasDirectTargetAccess &&
contourRequest?.status === "open" &&
!!currentUser?.id &&
String(sourceRequesterId) === String(currentUser.id);
const isGuest =
!!targetProjectId && getProjectRoleByWorkspaceSlugAndProjectId(workspaceSlug, targetProjectId) === EUserPermissions.GUEST;
@ -91,6 +97,7 @@ export const ExternalContoursContentRoot = observer(function ExternalContoursCon
contourRequest={contourRequest}
hasDirectTargetAccess={hasDirectTargetAccess}
isEditable={!!isEditable && !readOnly}
isSourceEditable={isSourceEditable}
isSubmitting={isSubmitting}
setIsSubmitting={setIsSubmitting}
/>

View File

@ -6,18 +6,8 @@
import { observer } from "mobx-react";
import { useTranslation } from "@plane/i18n";
import {
DueDatePropertyIcon,
LabelPropertyIcon,
MembersPropertyIcon,
PriorityPropertyIcon,
StatePropertyIcon,
} from "@plane/propel/icons";
import { LabelPropertyIcon, PriorityPropertyIcon } from "@plane/propel/icons";
import type { TIssue } from "@plane/types";
import { Badge } from "@plane/propel/badge";
import { getDate, renderFormattedPayloadDate } from "@plane/utils";
import { DateDropdown } from "@/components/dropdowns/date";
import { MemberDropdown } from "@/components/dropdowns/member/dropdown";
import { PriorityDropdown } from "@/components/dropdowns/priority";
import { IssueLabelSelect } from "@/components/issues/select";
import type { TIssueOperations } from "@/components/issues/issue-detail";
@ -36,8 +26,6 @@ export const ExternalContoursIssueContentProperties = observer(function External
const { workspaceSlug, targetProjectId, issue, issueOperations, isEditable } = props;
const { t } = useTranslation();
const minDate = issue.start_date ? getDate(issue.start_date) : null;
minDate?.setDate(minDate.getDate());
if (!issue || !issue?.id) return <></>;
return (
@ -46,38 +34,6 @@ export const ExternalContoursIssueContentProperties = observer(function External
<h5 className="mb-2 text-body-sm-medium">{t("external_contours_page.properties.section_title")}</h5>
<div className={`divide-y-2 divide-subtle-1 ${!isEditable ? "opacity-60" : ""}`}>
<div className="flex flex-col gap-3">
<div className="flex h-8 items-center gap-2">
<div className="flex w-2/5 flex-shrink-0 items-center gap-1 text-13 text-tertiary">
<StatePropertyIcon className="h-4 w-4 flex-shrink-0" />
<span>{t("external_contours_page.properties.target_contour")}</span>
</div>
<div className="w-3/5 flex-grow text-13">
<Badge variant="neutral">{issue.project_detail?.name || t("common.none")}</Badge>
</div>
</div>
<div className="flex h-8 items-center gap-2">
<div className="flex w-2/5 flex-shrink-0 items-center gap-1 text-13 text-tertiary">
<MembersPropertyIcon className="h-4 w-4 flex-shrink-0" />
<span>{t("assignees")}</span>
</div>
<MemberDropdown
value={issue?.assignee_ids ?? []}
onChange={(val) => issue?.id && issueOperations.update(workspaceSlug, targetProjectId, issue.id, { assignee_ids: val })}
disabled={!isEditable}
projectId={targetProjectId}
placeholder={t("assignee")}
multiple
buttonVariant={(issue?.assignee_ids || []).length > 0 ? "transparent-without-text" : "transparent-with-text"}
className="group w-3/5 flex-grow"
buttonContainerClassName="w-full text-left"
buttonClassName={`text-13 justify-between ${(issue?.assignee_ids || []).length > 0 ? "" : "text-placeholder"}`}
hideIcon={issue.assignee_ids?.length === 0}
dropdownArrow
dropdownArrowClassName="hidden h-3.5 w-3.5 group-hover:inline"
/>
</div>
<div className="flex h-8 items-center gap-2">
<div className="flex w-2/5 flex-shrink-0 items-center gap-1 text-13 text-tertiary">
<PriorityPropertyIcon className="h-4 w-4 flex-shrink-0" />
@ -93,35 +49,6 @@ export const ExternalContoursIssueContentProperties = observer(function External
buttonClassName="h-auto w-min whitespace-nowrap"
/>
</div>
</div>
</div>
<div className={`mt-3 divide-y-2 divide-subtle-1 ${!isEditable ? "opacity-60" : ""}`}>
<div className="flex flex-col gap-3">
<div className="flex h-8 items-center gap-2">
<div className="flex w-2/5 flex-shrink-0 items-center gap-1 text-13 text-tertiary">
<DueDatePropertyIcon className="h-4 w-4 flex-shrink-0" />
<span>{t("due_date")}</span>
</div>
<DateDropdown
placeholder={t("external_contours_page.properties.add_due_date")}
value={issue.target_date || null}
onChange={(val) =>
issue?.id &&
issueOperations.update(workspaceSlug, targetProjectId, issue.id, {
target_date: val ? renderFormattedPayloadDate(val) : null,
})
}
minDate={minDate ?? undefined}
disabled={!isEditable}
buttonVariant="transparent-with-text"
className="group w-3/5 flex-grow"
buttonContainerClassName="w-full text-left"
buttonClassName={`text-13 ${issue?.target_date ? "" : "text-placeholder"}`}
hideIcon
clearIconClassName="hidden h-3 w-3 group-hover:inline"
/>
</div>
<div className="flex min-h-8 items-center gap-2">
<div className="flex w-2/5 flex-shrink-0 items-center gap-1 text-13 text-tertiary">

View File

@ -13,7 +13,7 @@ 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, renderFormattedDate } from "@plane/utils";
import { getTextContent } 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";
@ -45,16 +45,26 @@ type Props = {
contourRequest: TExternalContourRequest;
hasDirectTargetAccess: boolean;
isEditable: boolean;
isSourceEditable: boolean;
isSubmitting: TNameDescriptionLoader;
setIsSubmitting: Dispatch<SetStateAction<TNameDescriptionLoader>>;
};
export const ExternalContoursIssueMainContent = observer(function ExternalContoursIssueMainContent(props: Props) {
const { workspaceSlug, sourceProjectId, contourRequest, hasDirectTargetAccess, isEditable, isSubmitting, setIsSubmitting } = props;
const {
workspaceSlug,
sourceProjectId,
contourRequest,
hasDirectTargetAccess,
isEditable,
isSourceEditable,
isSubmitting,
setIsSubmitting,
} = props;
const { t } = useTranslation();
const editorRef = useRef<EditorRefApi>(null);
const { data: currentUser } = useUser();
const { loader, updateRequestIssue } = useProjectExternalContours();
const { loader, updateRequest, updateRequestIssue } = useProjectExternalContours();
const { setShowAlert } = useReloadConfirmations(isSubmitting === "submitting");
useEffect(() => {
@ -107,17 +117,77 @@ export const ExternalContoursIssueMainContent = observer(function ExternalContou
[contourRequest.id, targetProjectId, t, updateRequestIssue, workspaceSlug]
);
const sourceIssueOperations: TIssueOperations = useMemo(
() => ({
fetch: async () => undefined,
remove: async () => undefined,
update: async (_workspaceSlug: string, _projectId: string, requestId: string, data: Partial<TIssue>) => {
try {
await updateRequest(workspaceSlug, sourceProjectId, requestId, {
name: data.name,
description_html: data.description_html,
});
} catch {
setToast({ title: t("error"), type: TOAST_TYPE.ERROR, message: t("issue_could_not_be_updated") });
}
},
}),
[sourceProjectId, t, updateRequest, workspaceSlug]
);
if (!issue || !issue.project_id || !issue.id) return <></>;
if (!hasDirectTargetAccess) {
return (
<>
<div className="space-y-4 pb-4">
{isSourceEditable ? (
<>
<IssueTitleInput
workspaceSlug={workspaceSlug}
projectId={sourceProjectId}
issueId={contourRequest.id}
isSubmitting={isSubmitting}
setIsSubmitting={(value) => setIsSubmitting(value)}
issueOperations={sourceIssueOperations}
disabled={false}
value={issue.name}
containerClassName="-ml-3"
/>
{loader === "issue-loading" || issue.description_html === undefined ? (
<DescriptionInputLoader />
) : (
<DescriptionInput
issueSequenceId={issue.sequence_id}
containerClassName="-ml-3 border-none"
disabled={false}
disabledExtensions={["image"]}
editorRef={editorRef}
entityId={issue.id}
fileAssetType={EFileAssetType.ISSUE_DESCRIPTION}
initialValue={!issue.description_html || issue.description_html === "" ? "<p></p>" : issue.description_html}
key={`${issue.id}-source`}
onSubmit={async (value) => {
await sourceIssueOperations.update(workspaceSlug, sourceProjectId, contourRequest.id, {
description_html: value.description_html,
});
}}
projectId={sourceProjectId}
setIsSubmitting={(value) => setIsSubmitting(value)}
workspaceSlug={workspaceSlug}
/>
)}
</>
) : (
<>
<h1 className="text-xl font-semibold text-primary">{issue.name}</h1>
<div
className="prose prose-invert max-w-none text-sm text-secondary [&_p]:mb-3"
dangerouslySetInnerHTML={{ __html: issue.description_html || "<p></p>" }}
/>
</>
)}
</div>
<div className="py-4">
@ -127,34 +197,11 @@ export const ExternalContoursIssueMainContent = observer(function ExternalContou
<div className="py-4">
<div className="mb-2 text-body-sm-medium">{t("external_contours_page.properties.section_title")}</div>
<div className="flex flex-col gap-3 text-13 text-secondary">
<div className="flex flex-wrap items-center gap-2">
<span className="text-tertiary">{t("external_contours_page.properties.target_contour")}</span>
<Badge variant="neutral">{issue.project_detail?.name || t("common.none")}</Badge>
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-tertiary">{t("assignees")}</span>
{issue.assignee_details?.length ? (
issue.assignee_details.map((assignee) => (
<Badge key={assignee.id} variant="neutral">
{assignee.display_name}
</Badge>
))
) : (
<span>{t("external_contours_page.list.unassigned")}</span>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-tertiary">{t("priority")}</span>
<Badge variant="neutral">{issue.priority || t("none")}</Badge>
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-tertiary">{t("due_date")}</span>
<span>{issue.target_date ? renderFormattedDate(issue.target_date) : t("common.none")}</span>
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-tertiary">{t("labels")}</span>
{issue.label_details?.length ? (
@ -183,7 +230,7 @@ export const ExternalContoursIssueMainContent = observer(function ExternalContou
<ExternalContoursMirroredActivity activity={mirroredActivity} />
<div className="py-4 text-13 text-secondary">{t("external_contours_page.readonly_source_view")}</div>
{!isSourceEditable && <div className="py-4 text-13 text-secondary">{t("external_contours_page.readonly_source_view")}</div>}
</>
);
}

View File

@ -4,6 +4,7 @@
* See the LICENSE file for details.
*/
import type { ReactNode } from "react";
import { observer } from "mobx-react";
import { useTranslation } from "@plane/i18n";
import { Badge } from "@plane/propel/badge";
@ -16,6 +17,13 @@ type Props = {
contourRequest: TExternalContourRequest;
};
const TraceabilityCell = ({ label, children }: { label: string; children: ReactNode }) => (
<div className="rounded-md border border-subtle bg-surface-1 p-3">
<div className="text-11 font-medium text-tertiary">{label}</div>
<div className="mt-1 min-h-6 text-13 font-medium text-secondary">{children}</div>
</div>
);
export const ExternalContoursRequestTraceability = observer(function ExternalContoursRequestTraceability(props: Props) {
const { contourRequest } = props;
const { t } = useTranslation();
@ -25,16 +33,13 @@ 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 sourceProjectName = contourRequest.source_project_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}`
: issue.sequence_id
? `#${issue.sequence_id}`
: t("common.none");
const dueDate = issue.target_date ? renderFormattedDate(issue.target_date) : t("common.none");
const assigneeDetails = issue.assignee_details ?? [];
return (
<div className="rounded-lg border border-subtle bg-surface-2 p-4">
@ -43,66 +48,56 @@ export const ExternalContoursRequestTraceability = observer(function ExternalCon
<p className="mt-1 text-12 text-tertiary">{t("external_contours_page.traceability.description")}</p>
</div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
<div className="rounded-md border border-subtle bg-surface-1 p-3">
<div className="text-11 font-medium text-tertiary">{t("external_contours_page.traceability.source_contour")}</div>
<div className="mt-1 text-13 font-medium text-secondary">
{contourRequest.source_project_name || t("common.none")}
</div>
</div>
<div className="grid gap-3 md:grid-cols-3">
<TraceabilityCell label={t("external_contours_page.traceability.source_contour")}>
{sourceProjectName}
</TraceabilityCell>
<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.target_contour")}</div>
<div className="mt-1 flex items-center gap-2">
<TraceabilityCell label={t("external_contours_page.traceability.target_contour")}>
<Badge variant="neutral">{targetProjectName}</Badge>
</div>
</div>
</TraceabilityCell>
<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.status")}</div>
<div className="mt-1">
<TraceabilityCell label={t("external_contours_page.traceability.status")}>
<ExternalContourStatePill request={contourRequest} />
</div>
</div>
</TraceabilityCell>
<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>
<TraceabilityCell label={t("external_contours_page.traceability.requested_by")}>
<div className="flex items-center gap-2">
<Avatar src={issue.created_by_detail?.avatar_url || ""} name={requestedByName} size="md" showTooltip />
<span>{requestedByName}</span>
</div>
</TraceabilityCell>
<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">
<Avatar
src={issue.created_by_detail?.avatar_url || ""}
name={requestedByName}
size="md"
showTooltip
/>
<span className="text-13 font-medium text-secondary">{requestedByName}</span>
<TraceabilityCell label={t("external_contours_page.traceability.assignee")}>
{assigneeDetails.length > 0 ? (
<div className="flex flex-wrap items-center gap-2">
{assigneeDetails.map((assignee) => (
<div key={assignee.id} className="flex items-center gap-2 rounded-sm border border-strong px-2 py-1">
<Avatar src={assignee.avatar_url || ""} name={assignee.display_name || t("common.none")} size="sm" showTooltip />
<span className="text-12 font-medium text-secondary">{assignee.display_name}</span>
</div>
))}
</div>
) : (
t("external_contours_page.list.unassigned")
)}
</TraceabilityCell>
<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_at")}</div>
<div className="mt-1 text-13 font-medium text-secondary">
<TraceabilityCell label={t("external_contours_page.traceability.source_decision")}>
{sourceDecision}
</TraceabilityCell>
<TraceabilityCell label={t("external_contours_page.traceability.requested_at")}>
{requestedAt ? renderFormattedDate(requestedAt) : t("common.none")}
</div>
</div>
</TraceabilityCell>
<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.last_updated")}</div>
<div className="mt-1 text-13 font-medium text-secondary">
<TraceabilityCell label={t("external_contours_page.traceability.due_date")}>
{dueDate}
</TraceabilityCell>
<TraceabilityCell label={t("external_contours_page.traceability.last_updated")}>
{lastUpdatedAt ? renderFormattedDate(lastUpdatedAt) : t("common.none")}
</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.linked_item")}</div>
<div className="mt-1 flex items-center gap-2">
<Badge variant="neutral">{targetIssueKey}</Badge>
</div>
</div>
</TraceabilityCell>
</div>
</div>
);

View File

@ -35,6 +35,19 @@ export class ExternalContourService extends APIService {
});
}
async updateRequest(
workspaceSlug: string,
projectId: string,
requestId: string,
data: Pick<Partial<TIssue>, "name" | "description_html">
): Promise<TExternalContourRequest> {
return this.patch(`/api/workspaces/${workspaceSlug}/projects/${projectId}/external-contours/${requestId}/`, data)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async listTargetProjects(workspaceSlug: string, projectId: string): Promise<TExternalContourTargetProject[]> {
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/external-contours/targets/`)
.then((response) => response?.data)

View File

@ -34,6 +34,12 @@ export interface IProjectExternalContoursStore {
projectId: string,
data: Partial<TIssue> & { target_project_id?: string | null }
) => Promise<TExternalContourRequest | undefined>;
updateRequest: (
workspaceSlug: string,
projectId: string,
requestId: string,
data: Pick<Partial<TIssue>, "name" | "description_html">
) => Promise<TExternalContourRequest | undefined>;
decideRequest: (
workspaceSlug: string,
projectId: string,
@ -95,6 +101,7 @@ export class ProjectExternalContoursStore implements IProjectExternalContoursSto
fetchRequests: action,
fetchRequestById: action,
createRequest: action,
updateRequest: action,
decideRequest: action,
replyToRequest: action,
handleCurrentTab: action,
@ -235,6 +242,28 @@ export class ProjectExternalContoursStore implements IProjectExternalContoursSto
}
};
updateRequest = async (
workspaceSlug: string,
projectId: string,
requestId: string,
data: Pick<Partial<TIssue>, "name" | "description_html">
) => {
this.loader = "mutation-loading";
try {
const request = await this.externalContourService.updateRequest(workspaceSlug, projectId, requestId, data);
runInAction(() => {
this.upsertRequests([request]);
this.loader = undefined;
});
return request;
} catch (error) {
runInAction(() => {
this.loader = undefined;
});
throw error;
}
};
decideRequest = async (
workspaceSlug: string,
projectId: string,

View File

@ -365,7 +365,7 @@ export default {
traceability: {
title: "Routing",
description:
"This block shows which contour sent the request, where it was routed, and which linked work item now carries the execution.",
"This block shows which contour sent the request, where it was routed, and what state the work is currently in.",
source_contour: "Source internal contour",
source_decision: "Source-side decision",
source_decision_pending: "Awaiting decision",
@ -373,9 +373,10 @@ export default {
target_contour: "Target external contour",
status: "Current status",
requested_by: "Requested by",
assignee: "Assignee",
requested_at: "Sent at",
due_date: "Due date",
last_updated: "Last updated",
linked_item: "Linked work item",
},
actions: {
send: "Send",

View File

@ -521,7 +521,7 @@ export default {
},
traceability: {
title: "Маршрутизация",
description: "Здесь отображается, из какого контура ушёл запрос, куда он направлен и с каким связанным элементом он сейчас работает.",
description: "Здесь отображается, из какого контура ушёл запрос, куда он направлен и в каком состоянии находится работа по нему.",
source_contour: "Исходный внутренний контур",
source_decision: "Решение источника",
source_decision_pending: "Ожидает решения",
@ -529,9 +529,10 @@ export default {
target_contour: "Целевой внешний контур",
status: "Текущий статус",
requested_by: "Отправитель",
assignee: "Назначенный",
requested_at: "Отправлено",
due_date: "Срок выполнения",
last_updated: "Последнее изменение",
linked_item: "Связанная задача",
},
actions: {
send: "Отправить",