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

This commit is contained in:
DCCONSTRUCTIONS 2026-04-18 21:47:29 +03:00
parent 390bcdbf38
commit fb33f093de
28 changed files with 911 additions and 386 deletions

View File

@ -71,6 +71,23 @@
- если в целевом проекте меняют описание, прикладывают файлы, комментируют или закрывают задачу, это отражается в источнике
- инициатор получает уведомления о существенных изменениях
## Текущий статус реализации
На текущем этапе уже реализовано:
- отдельный модуль `Внешние контуры`
- отдельный backend endpoint маршрутизации
- создание target issue в целевом проекте через intake bridge
- немедленный перевод target issue в обычный workflow целевого проекта
- source-side список отправленных запросов
- source-side детальный экран на базе shell `Предложений`
- status pill по фактическому состоянию target issue
- чтение и редактирование title/description/priority/due date/assignees/labels через target issue API
Текущее ограничение MVP:
- выбор `Внешнего контура` сейчас доступен только среди проектов, в которых отправитель уже состоит
- это осознанное упрощение первого рабочего вертикального среза
- за счет этого source-side карточка может безопасно использовать обычные target issue API без отдельного proxy-слоя для комментариев и файлов
## Обязательные требования
### 1. Отдельный модуль

View File

@ -57,6 +57,15 @@
- задача не зависает в triage
- source-side список уже видит факт отправки
### Статус
Реализовано.
Что работает фактически:
- `POST /external-contours/` создает target issue в целевом проекте
- target issue сразу попадает в обычный workflow целевого проекта
- source-side `GET /external-contours/` возвращает отправленные запросы с metadata источника и цели
## Этап 2. Source-side список и статусная пришлепка
### Цель
@ -78,6 +87,21 @@
- в проекте-источнике виден список отправленных запросов
- у каждой записи отображается актуальный статус целевой задачи
### Статус
Реализовано частично в рамках текущего вертикального среза.
Что уже работает:
- source-side список `Открытые / Завершенные`
- status pill по фактическому state целевой задачи
- отображение целевого проекта
- открытие source-side detail экрана
Что еще остается на следующие этапы:
- индикатор новых изменений
- полноценная зеркальная activity/history
- уведомления
## Этап 3. Source-side детальный экран и зеркалирование изменений
### Цель

View File

@ -53,6 +53,10 @@ from .intake import (
IntakeIssueCreateSerializer,
IntakeIssueUpdateSerializer,
)
from .external_contours import (
ExternalContourRequestCreateSerializer,
ExternalContourRequestSerializer,
)
from .estimate import EstimateSerializer, EstimatePointSerializer
from .asset import (
UserAssetUploadSerializer,

View File

@ -0,0 +1,103 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
from rest_framework import serializers
from .base import BaseSerializer
from .issue import IssueSerializer
from .project import ProjectLiteSerializer
from .state import StateLiteSerializer
from .user import UserLiteSerializer
from plane.db.models import IntakeIssue, Issue
class ExternalContourIssuePayloadSerializer(serializers.Serializer):
name = serializers.CharField(max_length=255)
description_html = serializers.CharField(required=False, allow_blank=True, allow_null=True)
priority = serializers.ChoiceField(choices=Issue.PRIORITY_CHOICES, default="none", required=False)
assignee_ids = serializers.ListField(child=serializers.UUIDField(), required=False)
label_ids = serializers.ListField(child=serializers.UUIDField(), required=False)
target_date = serializers.DateField(required=False, allow_null=True)
class ExternalContourRequestCreateSerializer(serializers.Serializer):
target_project_id = serializers.UUIDField()
issue = ExternalContourIssuePayloadSerializer()
class ExternalContourIssueSerializer(BaseSerializer):
assignee_ids = serializers.SerializerMethodField()
assignee_details = serializers.SerializerMethodField()
created_by_detail = UserLiteSerializer(source="created_by", read_only=True)
label_details = serializers.SerializerMethodField()
label_ids = serializers.SerializerMethodField()
project_detail = ProjectLiteSerializer(source="project", read_only=True)
state_detail = StateLiteSerializer(source="state", read_only=True)
class Meta:
model = Issue
fields = [
"id",
"name",
"description_html",
"priority",
"sequence_id",
"project_id",
"created_at",
"updated_at",
"created_by",
"state_id",
"target_date",
"label_ids",
"label_details",
"assignee_ids",
"assignee_details",
"state_detail",
"project_detail",
"created_by_detail",
]
read_only_fields = fields
def get_assignee_ids(self, obj):
return [assignee.assignee_id for assignee in obj.issue_assignee.all()]
def get_assignee_details(self, obj):
return UserLiteSerializer([assignee.assignee for assignee in obj.issue_assignee.all()], many=True).data
def get_label_ids(self, obj):
return [label.label_id for label in obj.label_issue.all()]
def get_label_details(self, obj):
return [
{"id": str(label_bridge.label.id), "name": label_bridge.label.name, "color": label_bridge.label.color}
for label_bridge in obj.label_issue.all()
]
class ExternalContourRequestSerializer(BaseSerializer):
issue = ExternalContourIssueSerializer(read_only=True)
source_project_id = serializers.SerializerMethodField()
status = serializers.SerializerMethodField()
class Meta:
model = IntakeIssue
fields = [
"id",
"created_at",
"updated_at",
"created_by",
"issue",
"source_project_id",
"status",
]
read_only_fields = fields
def get_source_project_id(self, obj):
return obj.extra.get("source_project_id")
def get_status(self, obj):
issue = obj.issue
if issue and issue.state and issue.state.group in ["completed", "cancelled"]:
return "closed"
return "open"

View File

@ -4,6 +4,7 @@
from .asset import urlpatterns as asset_patterns
from .cycle import urlpatterns as cycle_patterns
from .external_contours import urlpatterns as external_contour_patterns
from .intake import urlpatterns as intake_patterns
from .label import urlpatterns as label_patterns
from .member import urlpatterns as member_patterns
@ -18,6 +19,7 @@ from .sticky import urlpatterns as sticky_patterns
urlpatterns = [
*asset_patterns,
*cycle_patterns,
*external_contour_patterns,
*intake_patterns,
*label_patterns,
*member_patterns,

View File

@ -0,0 +1,20 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
from django.urls import path
from plane.api.views import ExternalContourDetailAPIEndpoint, ExternalContourListCreateAPIEndpoint
urlpatterns = [
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/external-contours/",
ExternalContourListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
name="external-contours",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/external-contours/<uuid:request_id>/",
ExternalContourDetailAPIEndpoint.as_view(http_method_names=["get"]),
name="external-contour-detail",
),
]

View File

@ -55,6 +55,10 @@ from .intake import (
IntakeIssueListCreateAPIEndpoint,
IntakeIssueDetailAPIEndpoint,
)
from .external_contours import (
ExternalContourListCreateAPIEndpoint,
ExternalContourDetailAPIEndpoint,
)
from .asset import UserAssetEndpoint, UserServerAssetEndpoint, GenericAssetEndpoint

View File

@ -0,0 +1,185 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
from django.shortcuts import get_object_or_404
from rest_framework import status
from rest_framework.response import Response
from plane.api.serializers import (
ExternalContourRequestCreateSerializer,
ExternalContourRequestSerializer,
)
from plane.app.permissions import ProjectLitePermission
from plane.db.models import Intake, IntakeIssue, Project, State, StateGroup
from plane.api.serializers.issue import IssueSerializer as IssueCreateSerializer
from .base import BaseAPIView
from plane.db.models.intake import IntakeIssueStatus, SourceType
class ExternalContourListCreateAPIEndpoint(BaseAPIView):
permission_classes = [ProjectLitePermission]
serializer_class = ExternalContourRequestSerializer
def get_source_project(self, slug, project_id):
return get_object_or_404(Project, workspace__slug=slug, pk=project_id)
def get_queryset(self):
return (
IntakeIssue.objects.filter(
workspace__slug=self.kwargs.get("slug"),
extra__bridge="external-contours",
extra__source_project_id=str(self.kwargs.get("project_id")),
)
.select_related(
"issue",
"issue__state",
"issue__project",
"issue__created_by",
)
.prefetch_related("issue__issue_assignee__assignee", "issue__label_issue__label")
.order_by("-updated_at")
)
def get(self, request, slug, project_id):
serializer = ExternalContourRequestSerializer(self.get_queryset(), many=True)
return Response(
{
"results": serializer.data,
"next_cursor": "",
"prev_cursor": "",
"next_page_results": False,
"prev_page_results": False,
"total_count": len(serializer.data),
"count": len(serializer.data),
"total_pages": 1,
"extra_stats": None,
"total_results": len(serializer.data),
},
status=status.HTTP_200_OK,
)
def post(self, request, slug, project_id):
source_project = self.get_source_project(slug, project_id)
serializer = ExternalContourRequestCreateSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
target_project = get_object_or_404(
Project,
workspace_id=source_project.workspace_id,
pk=serializer.validated_data["target_project_id"],
archived_at__isnull=True,
)
if str(target_project.id) == str(source_project.id):
return Response({"error": "Target project must differ from source project"}, status=status.HTTP_400_BAD_REQUEST)
triage_state = State.triage_objects.filter(project=target_project).first()
if not triage_state:
triage_state = State.objects.create(
name="Triage",
group=StateGroup.TRIAGE.value,
project=target_project,
color="#4E5355",
sequence=65000,
default=False,
)
target_default_state = (
State.objects.filter(project=target_project, default=True)
.exclude(group=StateGroup.TRIAGE.value)
.first()
) or State.objects.filter(project=target_project).exclude(group=StateGroup.TRIAGE.value).order_by("sequence", "created_at").first()
if not target_default_state:
return Response({"error": "Target project has no available workflow state"}, status=status.HTTP_400_BAD_REQUEST)
intake = Intake.objects.filter(project=target_project, name="External Contours Bridge").first()
if not intake:
intake = Intake.objects.create(
name="External Contours Bridge",
description="System bridge intake used for cross-project routing.",
is_default=False,
project=target_project,
)
issue_payload = serializer.validated_data["issue"]
issue_serializer = IssueCreateSerializer(
data={
"name": issue_payload["name"],
"description_html": issue_payload.get("description_html") or "<p></p>",
"priority": issue_payload.get("priority", "none"),
"assignees": issue_payload.get("assignee_ids", []),
"labels": issue_payload.get("label_ids", []),
"target_date": issue_payload.get("target_date"),
"state_id": str(triage_state.id),
},
context={
"project_id": str(target_project.id),
"workspace_id": str(target_project.workspace_id),
"default_assignee_id": target_project.default_assignee_id,
},
)
issue_serializer.is_valid(raise_exception=True)
issue = issue_serializer.save(state=triage_state)
intake_issue = IntakeIssue.objects.create(
intake=intake,
project=target_project,
issue=issue,
source=SourceType.IN_APP,
status=IntakeIssueStatus.ACCEPTED.value,
extra={
"bridge": "external-contours",
"source_project_id": str(source_project.id),
"source_project_name": source_project.name,
"target_project_id": str(target_project.id),
"target_project_name": target_project.name,
"requested_by_id": str(request.user.id),
"requested_by_name": request.user.display_name,
"requested_at": issue.created_at.isoformat() if issue.created_at else None,
},
)
if issue.state_id != target_default_state.id:
issue.state = target_default_state
issue.save()
response_serializer = ExternalContourRequestSerializer(
IntakeIssue.objects.select_related(
"issue",
"issue__state",
"issue__project",
"issue__created_by",
)
.prefetch_related("issue__issue_assignee__assignee", "issue__label_issue__label")
.get(pk=intake_issue.id)
)
return Response(response_serializer.data, status=status.HTTP_201_CREATED)
class ExternalContourDetailAPIEndpoint(BaseAPIView):
permission_classes = [ProjectLitePermission]
serializer_class = ExternalContourRequestSerializer
def get_queryset(self):
return (
IntakeIssue.objects.filter(
workspace__slug=self.kwargs.get("slug"),
extra__bridge="external-contours",
extra__source_project_id=str(self.kwargs.get("project_id")),
pk=self.kwargs.get("request_id"),
)
.select_related(
"issue",
"issue__state",
"issue__project",
"issue__created_by",
)
.prefetch_related("issue__issue_assignee__assignee", "issue__label_issue__label")
)
def get(self, request, slug, project_id, request_id):
contour_request = get_object_or_404(self.get_queryset())
serializer = ExternalContourRequestSerializer(contour_request)
return Response(serializer.data, status=status.HTTP_200_OK)

View File

@ -6,51 +6,20 @@
import { observer } from "mobx-react";
import { useSearchParams } from "next/navigation";
import { useTheme } from "next-themes";
import { EInboxIssueCurrentTab } from "@plane/types";
import { useTranslation } from "@plane/i18n";
import darkIntakeAsset from "@/app/assets/empty-state/disabled-feature/intake-dark.webp?url";
import lightIntakeAsset from "@/app/assets/empty-state/disabled-feature/intake-light.webp?url";
import { PageHead } from "@/components/core/page-title";
import { DetailedEmptyState } from "@/components/empty-state/detailed-empty-state-root";
import { useProject } from "@/hooks/store/use-project";
import { useUserPermissions } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router";
import { EUserPermissionsLevel } from "@plane/constants";
import { EUserProjectRoles } from "@plane/types";
import { ExternalContoursRoot } from "@/plane-web/components/projects/external-contours/root";
import type { Route } from "./+types/page";
function ProjectExternalContoursPage({ params }: Route.ComponentProps) {
const router = useAppRouter();
const { workspaceSlug, projectId } = params;
const searchParams = useSearchParams();
const navigationTab = searchParams.get("currentTab");
const inboxIssueId = searchParams.get("inboxIssueId");
const { resolvedTheme } = useTheme();
const { t } = useTranslation();
const { currentProjectDetails } = useProject();
const { allowPermissions } = useUserPermissions();
const canPerformEmptyStateActions = allowPermissions([EUserProjectRoles.ADMIN], EUserPermissionsLevel.PROJECT);
const resolvedPath = resolvedTheme === "light" ? lightIntakeAsset : darkIntakeAsset;
if (currentProjectDetails?.inbox_view === false)
return (
<div className="flex h-full w-full items-center justify-center">
<DetailedEmptyState
title={t("external_contours_page.disabled.title")}
description={t("external_contours_page.disabled.description")}
assetPath={resolvedPath}
primaryButton={{
text: t("external_contours_page.disabled.button"),
onClick: () => {
router.push(`/${workspaceSlug}/settings/projects/${projectId}/features`);
},
disabled: !canPerformEmptyStateActions,
}}
/>
</div>
);
const pageTitle = currentProjectDetails?.name
? t("external_contours_page.page_label", { workspace: currentProjectDetails.name })
@ -70,7 +39,6 @@ function ProjectExternalContoursPage({ params }: Route.ComponentProps) {
workspaceSlug={workspaceSlug}
projectId={projectId}
inboxIssueId={inboxIssueId || undefined}
inboxAccessible={currentProjectDetails?.inbox_view || false}
navigationTab={currentNavigationTab}
/>
</div>

View File

@ -10,7 +10,7 @@ import useSWR from "swr";
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import type { TNameDescriptionLoader } from "@plane/types";
import { ContentWrapper } from "@plane/ui";
import { useProjectInbox } from "@/hooks/store/use-project-inbox";
import { useProjectExternalContours } from "@/hooks/store/use-project-external-contours";
import { useUser, useUserPermissions } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router";
import { ExternalContoursIssueActionsHeader } from "./issue-header";
@ -29,11 +29,13 @@ export const ExternalContoursContentRoot = observer(function ExternalContoursCon
const router = useAppRouter();
const [isSubmitting, setIsSubmitting] = useState<TNameDescriptionLoader>("saved");
const { data: currentUser } = useUser();
const { currentTab, fetchInboxIssueById, getIssueInboxByIssueId, getIsIssueAvailable } = useProjectInbox();
const inboxIssue = getIssueInboxByIssueId(inboxIssueId);
const { currentTab, fetchRequestById, getRequestById, getIsRequestAvailable } = useProjectExternalContours();
const contourRequest = getRequestById(inboxIssueId);
const issue = contourRequest?.issue;
const targetProjectId = issue?.project_id || projectId;
const { allowPermissions, getProjectRoleByWorkspaceSlugAndProjectId } = useUserPermissions();
const isIssueAvailable = getIsIssueAvailable(inboxIssueId?.toString() || "");
const isIssueAvailable = getIsRequestAvailable(inboxIssueId?.toString() || "");
useEffect(() => {
if (!isIssueAvailable && inboxIssueId) {
@ -43,20 +45,23 @@ export const ExternalContoursContentRoot = observer(function ExternalContoursCon
}, [isIssueAvailable]);
useSWR(
workspaceSlug && projectId && inboxIssueId ? `PROJECT_EXTERNAL_CONTOUR_ISSUE_DETAIL_${workspaceSlug}_${projectId}_${inboxIssueId}` : null,
workspaceSlug && projectId && inboxIssueId ? () => fetchInboxIssueById(workspaceSlug, projectId, inboxIssueId) : null,
workspaceSlug && projectId && inboxIssueId
? `PROJECT_EXTERNAL_CONTOUR_DETAIL_${workspaceSlug}_${projectId}_${inboxIssueId}`
: null,
workspaceSlug && projectId && inboxIssueId ? () => fetchRequestById(workspaceSlug, projectId, inboxIssueId) : null,
{ revalidateOnFocus: false, revalidateIfStale: false }
);
const isEditable =
allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT, workspaceSlug, projectId) ||
inboxIssue?.issue.created_by === currentUser?.id;
!!targetProjectId &&
(allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT, workspaceSlug, targetProjectId) ||
issue?.created_by === currentUser?.id);
const isGuest = getProjectRoleByWorkspaceSlugAndProjectId(workspaceSlug, projectId) === EUserPermissions.GUEST;
const isOwner = inboxIssue?.issue.created_by === currentUser?.id;
const isGuest = !!targetProjectId && getProjectRoleByWorkspaceSlugAndProjectId(workspaceSlug, targetProjectId) === EUserPermissions.GUEST;
const isOwner = issue?.created_by === currentUser?.id;
const readOnly = !isOwner && isGuest;
if (!inboxIssue) return <></>;
if (!contourRequest || !issue) return <></>;
return (
<div className="relative flex h-full w-full flex-col overflow-hidden">
@ -65,17 +70,17 @@ export const ExternalContoursContentRoot = observer(function ExternalContoursCon
setIsMobileSidebar={setIsMobileSidebar}
isMobileSidebar={isMobileSidebar}
workspaceSlug={workspaceSlug}
projectId={projectId}
inboxIssue={inboxIssue}
sourceProjectId={projectId}
contourRequest={contourRequest}
isSubmitting={isSubmitting}
/>
</div>
<ContentWrapper className="divide-y-2 divide-subtle-1">
<ExternalContoursIssueMainContent
workspaceSlug={workspaceSlug}
projectId={projectId}
inboxIssue={inboxIssue}
isEditable={isEditable && !readOnly}
sourceProjectId={projectId}
contourRequest={contourRequest}
isEditable={!!isEditable && !readOnly}
isSubmitting={isSubmitting}
setIsSubmitting={setIsSubmitting}
/>

View File

@ -12,9 +12,12 @@ import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { TIssue } from "@plane/types";
import { EInboxIssueCurrentTab } from "@plane/types";
import { ToggleSwitch } from "@plane/ui";
import { useProject } from "@/hooks/store/use-project";
import { useProjectExternalContours } from "@/hooks/store/use-project-external-contours";
import { useWorkspace } from "@/hooks/store/use-workspace";
import { useAppRouter } from "@/hooks/use-app-router";
import { InboxIssueDescription } from "@/components/inbox/modals/create-modal/issue-description";
import { InboxIssueTitle } from "@/components/inbox/modals/create-modal/issue-title";
import { ExternalContoursCreateProperties } from "./create-properties";
@ -39,9 +42,11 @@ type Props = {
export const ExternalContoursCreateRoot = observer(function ExternalContoursCreateRoot(props: Props) {
const { workspaceSlug, projectId, handleModalClose } = props;
const { t } = useTranslation();
const router = useAppRouter();
const { getWorkspaceBySlug } = useWorkspace();
const workspaceId = getWorkspaceBySlug(workspaceSlug)?.id;
const { currentProjectDetails } = useProject();
const { createRequest } = useProjectExternalContours();
const descriptionEditorRef = useRef<EditorRefApi>(null);
const [createMore, setCreateMore] = useState(false);
const [formSubmitting, setFormSubmitting] = useState(false);
@ -67,15 +72,32 @@ export const ExternalContoursCreateRoot = observer(function ExternalContoursCrea
}
setFormSubmitting(true);
setToast({
type: TOAST_TYPE.INFO,
title: t("external_contours_page.modal.toast_title"),
message: t("external_contours_page.modal.toast_message"),
});
setFormSubmitting(false);
if (!createMore) {
handleModalClose();
try {
const createdRequest = await createRequest(workspaceSlug, projectId, formData);
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("success"),
message: t("external_contours_page.modal.success_message"),
});
if (createMore) {
setFormData(defaultIssueData);
} else {
handleModalClose();
}
if (createdRequest?.id) {
router.push(`/${workspaceSlug}/projects/${projectId}/external-contours?currentTab=${EInboxIssueCurrentTab.OPEN}&inboxIssueId=${createdRequest.id}`);
}
} catch (error: any) {
setToast({
type: TOAST_TYPE.ERROR,
title: t("error"),
message: error?.error || t("external_contours_page.modal.error_message"),
});
} finally {
setFormSubmitting(false);
}
};

View File

@ -15,7 +15,7 @@ import { TransferIcon } from "@plane/propel/icons";
import { Breadcrumbs, Header } from "@plane/ui";
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
import { useProject } from "@/hooks/store/use-project";
import { useProjectInbox } from "@/hooks/store/use-project-inbox";
import { useProjectExternalContours } from "@/hooks/store/use-project-external-contours";
import { useUserPermissions } from "@/hooks/store/user";
import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common";
import { ExternalContourCreateModalRoot } from "./create-modal";
@ -25,8 +25,8 @@ export const ProjectExternalContoursHeader = observer(function ProjectExternalCo
const { workspaceSlug, projectId } = useParams();
const { t } = useTranslation();
const { allowPermissions } = useUserPermissions();
const { currentProjectDetails, loader: currentProjectDetailsLoader } = useProject();
const { loader } = useProjectInbox();
const { loader: currentProjectDetailsLoader } = useProject();
const { loader } = useProjectExternalContours();
const isAuthorized = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST],
@ -52,7 +52,7 @@ export const ProjectExternalContoursHeader = observer(function ProjectExternalCo
/>
</Breadcrumbs>
{loader === "pagination-loading" && (
{(loader === "mutation-loading" || loader === "issue-loading") && (
<div className="flex items-center gap-1.5 text-tertiary">
<RefreshCcw className="h-3.5 w-3.5 animate-spin" />
<p className="text-13">{t("syncing")}...</p>
@ -61,7 +61,7 @@ export const ProjectExternalContoursHeader = observer(function ProjectExternalCo
</div>
</Header.LeftItem>
<Header.RightItem>
{currentProjectDetails?.inbox_view && workspaceSlug && projectId && isAuthorized ? (
{workspaceSlug && projectId && isAuthorized ? (
<div className="flex items-center gap-2">
<ExternalContourCreateModalRoot
workspaceSlug={workspaceSlug.toString()}

View File

@ -10,51 +10,49 @@ import { PanelLeft } from "lucide-react";
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
import { IconButton } from "@plane/propel/icon-button";
import { CheckCircleFilledIcon, ChevronDownIcon, ChevronUpIcon, CloseCircleFilledIcon, LinkIcon, NewTabIcon } from "@plane/propel/icons";
import { ChevronDownIcon, ChevronUpIcon, LinkIcon, NewTabIcon } from "@plane/propel/icons";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { TNameDescriptionLoader } from "@plane/types";
import { EInboxIssueCurrentTab } from "@plane/types";
import type { TExternalContourRequest, TNameDescriptionLoader } from "@plane/types";
import { ControlLink, Header, Row } from "@plane/ui";
import { copyUrlToClipboard, generateWorkItemLink } from "@plane/utils";
import { NameDescriptionUpdateStatus } from "@/components/issues/issue-update-status";
import { useProject } from "@/hooks/store/use-project";
import { useProjectInbox } from "@/hooks/store/use-project-inbox";
import { useProjectExternalContours } from "@/hooks/store/use-project-external-contours";
import { useAppRouter } from "@/hooks/use-app-router";
import type { IInboxIssueStore } from "@/store/inbox/inbox-issue.store";
import { InboxIssueStatus } from "@/components/inbox/inbox-issue-status";
import { ExternalContourStatePill } from "./state-pill";
type Props = {
workspaceSlug: string;
projectId: string;
inboxIssue: IInboxIssueStore | undefined;
sourceProjectId: string;
contourRequest: TExternalContourRequest;
isSubmitting: TNameDescriptionLoader;
isMobileSidebar: boolean;
setIsMobileSidebar: (value: boolean) => void;
};
export const ExternalContoursIssueActionsHeader = observer(function ExternalContoursIssueActionsHeader(props: Props) {
const { workspaceSlug, projectId, inboxIssue, isSubmitting, isMobileSidebar, setIsMobileSidebar } = props;
const { workspaceSlug, sourceProjectId, contourRequest, isSubmitting, isMobileSidebar, setIsMobileSidebar } = props;
const { t } = useTranslation();
const router = useAppRouter();
const { currentTab, filteredInboxIssueIds } = useProjectInbox();
const { currentTab, filteredRequestIds } = useProjectExternalContours();
const { getProjectById } = useProject();
const issue = inboxIssue?.issue;
const currentInboxIssueId = issue?.id;
const issue = contourRequest.issue;
const currentRequestId = contourRequest.id;
const redirectToRelativeIssue = useCallback(
(direction: "next" | "prev") => {
if (!filteredInboxIssueIds || !currentInboxIssueId) return;
const currentIssueIndex = filteredInboxIssueIds.findIndex((issueId) => issueId === currentInboxIssueId);
if (!filteredRequestIds || !currentRequestId) return;
const currentIssueIndex = filteredRequestIds.findIndex((requestId) => requestId === currentRequestId);
const nextIssueIndex =
direction === "next"
? (currentIssueIndex + 1) % filteredInboxIssueIds.length
: (currentIssueIndex - 1 + filteredInboxIssueIds.length) % filteredInboxIssueIds.length;
const nextIssueId = filteredInboxIssueIds[nextIssueIndex];
? (currentIssueIndex + 1) % filteredRequestIds.length
: (currentIssueIndex - 1 + filteredRequestIds.length) % filteredRequestIds.length;
const nextIssueId = filteredRequestIds[nextIssueIndex];
if (!nextIssueId) return;
router.push(`/${workspaceSlug}/projects/${projectId}/external-contours?currentTab=${currentTab}&inboxIssueId=${nextIssueId}`);
router.push(`/${workspaceSlug}/projects/${sourceProjectId}/external-contours?currentTab=${currentTab}&inboxIssueId=${nextIssueId}`);
},
[currentInboxIssueId, currentTab, filteredInboxIssueIds, projectId, router, workspaceSlug]
[currentRequestId, currentTab, filteredRequestIds, router, sourceProjectId, workspaceSlug]
);
useEffect(() => {
@ -67,23 +65,15 @@ export const ExternalContoursIssueActionsHeader = observer(function ExternalCont
return () => document.removeEventListener("keydown", onKeyDown);
}, [redirectToRelativeIssue]);
if (!issue || !inboxIssue) return null;
const targetProjectIdentifier = issue.project_detail?.identifier || getProjectById(issue.project_id || "")?.identifier;
const workItemLink = generateWorkItemLink({
workspaceSlug: workspaceSlug?.toString(),
projectId: issue.project_id,
issueId: currentInboxIssueId,
projectIdentifier: getProjectById(issue.project_id)?.identifier,
issueId: issue.id,
projectIdentifier: targetProjectIdentifier,
sequenceId: issue.sequence_id,
});
const showWorkflowToast = (actionLabel: string) =>
setToast({
type: TOAST_TYPE.INFO,
title: t("external_contours_page.actions.unsupported_title"),
message: t("external_contours_page.actions.unsupported_message", { action: actionLabel.toLowerCase() }),
});
const handleCopyLink = () =>
copyUrlToClipboard(workItemLink).then(() =>
setToast({
@ -93,18 +83,16 @@ export const ExternalContoursIssueActionsHeader = observer(function ExternalCont
})
);
const isOpenTab = currentTab === EInboxIssueCurrentTab.OPEN;
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">
<div className="flex items-center gap-4">
{issue?.project_id && issue.sequence_id && (
<h3 className="flex-shrink-0 text-14 font-medium text-tertiary">
{getProjectById(issue.project_id)?.identifier}-{issue.sequence_id}
{targetProjectIdentifier}-{issue.sequence_id}
</h3>
)}
<InboxIssueStatus inboxIssue={inboxIssue} iconSize={12} />
<ExternalContourStatePill request={contourRequest} />
<div className="flex w-full items-center justify-end">
<NameDescriptionUpdateStatus isSubmitting={isSubmitting} />
</div>
@ -117,30 +105,6 @@ export const ExternalContoursIssueActionsHeader = observer(function ExternalCont
</div>
<div className="flex flex-wrap items-center gap-2">
{isOpenTab ? (
<>
<Button variant="secondary" size="lg" onClick={() => showWorkflowToast(t("external_contours_page.actions.send"))}>
<CheckCircleFilledIcon className="size-4 shrink-0 text-success-secondary" />
{t("external_contours_page.actions.send")}
</Button>
<Button variant="secondary" size="lg" onClick={() => showWorkflowToast(t("external_contours_page.actions.decline"))}>
<CloseCircleFilledIcon className="size-4 shrink-0 text-danger-secondary" />
{t("external_contours_page.actions.decline")}
</Button>
</>
) : (
<>
<Button variant="secondary" size="lg" onClick={() => showWorkflowToast(t("external_contours_page.actions.accept"))}>
<CheckCircleFilledIcon className="size-4 shrink-0 text-success-secondary" />
{t("external_contours_page.actions.accept")}
</Button>
<Button variant="secondary" size="lg" onClick={() => showWorkflowToast(t("external_contours_page.actions.decline"))}>
<CloseCircleFilledIcon className="size-4 shrink-0 text-danger-secondary" />
{t("external_contours_page.actions.decline")}
</Button>
</>
)}
<Button variant="secondary" size="lg" prependIcon={<LinkIcon className="h-2.5 w-2.5" />} onClick={handleCopyLink}>
{t("external_contours_page.actions.copy")}
</Button>
@ -159,14 +123,13 @@ export const ExternalContoursIssueActionsHeader = observer(function ExternalCont
className={`my-auto mr-2 h-4 w-4 flex-shrink-0 ${isMobileSidebar ? "text-accent-primary" : "text-secondary"}`}
/>
<div className="flex w-full items-center gap-2">
<InboxIssueStatus inboxIssue={inboxIssue} iconSize={12} />
<ExternalContourStatePill request={contourRequest} />
<div className="ml-auto flex items-center gap-2">
<Button variant="secondary" size="sm" onClick={() => showWorkflowToast(isOpenTab ? t("external_contours_page.actions.send") : t("external_contours_page.actions.accept"))}>
{isOpenTab ? t("external_contours_page.actions.send") : t("external_contours_page.actions.accept")}
</Button>
<Button variant="secondary" size="sm" onClick={() => showWorkflowToast(t("external_contours_page.actions.decline"))}>
{t("external_contours_page.actions.decline")}
</Button>
<ControlLink href={workItemLink} onClick={() => router.push(workItemLink)} target="_self">
<Button variant="secondary" size="sm">
{t("external_contours_page.actions.open")}
</Button>
</ControlLink>
</div>
</div>
</Header>

View File

@ -7,52 +7,39 @@
import { observer } from "mobx-react";
import { useTranslation } from "@plane/i18n";
import {
DuplicatePropertyIcon,
DueDatePropertyIcon,
LabelPropertyIcon,
MembersPropertyIcon,
PriorityPropertyIcon,
StatePropertyIcon,
} from "@plane/propel/icons";
import { Tooltip } from "@plane/propel/tooltip";
import type { TInboxDuplicateIssueDetails, TIssue } from "@plane/types";
import { ControlLink } from "@plane/ui";
import { generateWorkItemLink, getDate, renderFormattedPayloadDate } from "@plane/utils";
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";
import { IssueLabel } from "@/components/issues/issue-detail/label";
import { useProject } from "@/hooks/store/use-project";
import { useAppRouter } from "@/hooks/use-app-router";
type Props = {
workspaceSlug: string;
projectId: string;
issue: Partial<TIssue>;
targetProjectId: string;
issue: Partial<TIssue> & {
project_detail?: { name?: string } | null;
};
issueOperations: TIssueOperations;
isEditable: boolean;
duplicateIssueDetails: TInboxDuplicateIssueDetails | undefined;
};
export const ExternalContoursIssueContentProperties = observer(function ExternalContoursIssueContentProperties(props: Props) {
const { workspaceSlug, projectId, issue, issueOperations, isEditable, duplicateIssueDetails } = props;
const { workspaceSlug, targetProjectId, issue, issueOperations, isEditable } = props;
const { t } = useTranslation();
const router = useAppRouter();
const { currentProjectDetails } = useProject();
const minDate = issue.start_date ? getDate(issue.start_date) : null;
minDate?.setDate(minDate.getDate());
if (!issue || !issue?.id) return <></>;
const duplicateWorkItemLink = generateWorkItemLink({
workspaceSlug: workspaceSlug?.toString(),
projectId,
issueId: duplicateIssueDetails?.id,
projectIdentifier: currentProjectDetails?.identifier,
sequenceId: duplicateIssueDetails?.sequence_id,
});
return (
<div className="flex w-full flex-col divide-y-2 divide-subtle-1">
<div className="w-full overflow-y-auto">
@ -64,8 +51,8 @@ export const ExternalContoursIssueContentProperties = observer(function External
<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 text-placeholder">
{t("external_contours_page.properties.target_contour_placeholder")}
<div className="w-3/5 flex-grow text-13">
<Badge variant="neutral">{issue.project_detail?.name || t("common.none")}</Badge>
</div>
</div>
@ -76,9 +63,9 @@ export const ExternalContoursIssueContentProperties = observer(function External
</div>
<MemberDropdown
value={issue?.assignee_ids ?? []}
onChange={(val) => issue?.id && issueOperations.update(workspaceSlug, projectId, issue.id, { assignee_ids: val })}
onChange={(val) => issue?.id && issueOperations.update(workspaceSlug, targetProjectId, issue.id, { assignee_ids: val })}
disabled={!isEditable}
projectId={projectId?.toString() ?? ""}
projectId={targetProjectId}
placeholder={t("assignee")}
multiple
buttonVariant={(issue?.assignee_ids || []).length > 0 ? "transparent-without-text" : "transparent-with-text"}
@ -98,7 +85,7 @@ export const ExternalContoursIssueContentProperties = observer(function External
</div>
<PriorityDropdown
value={issue?.priority}
onChange={(val) => issue?.id && issueOperations.update(workspaceSlug, projectId, issue.id, { priority: val })}
onChange={(val) => issue?.id && issueOperations.update(workspaceSlug, targetProjectId, issue.id, { priority: val })}
disabled={!isEditable}
buttonVariant="border-with-text"
className="w-3/5 flex-grow rounded-sm px-2 hover:bg-layer-1"
@ -121,7 +108,7 @@ export const ExternalContoursIssueContentProperties = observer(function External
value={issue.target_date || null}
onChange={(val) =>
issue?.id &&
issueOperations.update(workspaceSlug, projectId, issue.id, {
issueOperations.update(workspaceSlug, targetProjectId, issue.id, {
target_date: val ? renderFormattedPayloadDate(val) : null,
})
}
@ -142,34 +129,14 @@ export const ExternalContoursIssueContentProperties = observer(function External
<span>{t("labels")}</span>
</div>
<div className="h-full min-h-8 w-3/5 flex-grow pt-1">
{issue?.id && (
<IssueLabel
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issue.id}
disabled={!isEditable}
isInboxIssue
onLabelUpdate={(val: string[]) => issue?.id && issueOperations.update(workspaceSlug, projectId, issue.id, { label_ids: val })}
/>
)}
<IssueLabelSelect
value={issue.label_ids || []}
onChange={(labelIds) => issue?.id && issueOperations.update(workspaceSlug, targetProjectId, issue.id, { label_ids: labelIds })}
projectId={targetProjectId}
disabled={!isEditable}
/>
</div>
</div>
{duplicateIssueDetails && (
<div className="flex min-h-8 gap-2">
<div className="flex w-2/5 flex-shrink-0 gap-1 pt-2 text-13 text-tertiary">
<DuplicatePropertyIcon className="h-4 w-4 flex-shrink-0" />
<span>{t("external_contours_page.properties.duplicate_of")}</span>
</div>
<ControlLink href={duplicateWorkItemLink} onClick={() => router.push(duplicateWorkItemLink)} target="_self">
<Tooltip tooltipContent={`${duplicateIssueDetails?.name}`}>
<span className="flex cursor-pointer items-center gap-1 rounded-sm bg-layer-1 px-1.5 py-1 pb-0.5 text-11 text-secondary">
{`${currentProjectDetails?.identifier}-${duplicateIssueDetails?.sequence_id}`}
</span>
</Tooltip>
</ControlLink>
</div>
)}
</div>
</div>
</div>

View File

@ -10,8 +10,8 @@ import { observer } from "mobx-react";
import type { EditorRefApi } from "@plane/editor";
import { useTranslation } from "@plane/i18n";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { TIssue, TNameDescriptionLoader } from "@plane/types";
import { EFileAssetType, EInboxIssueSource } from "@plane/types";
import type { TExternalContourRequest, TIssue, TNameDescriptionLoader } from "@plane/types";
import { EFileAssetType } from "@plane/types";
import { getTextContent } from "@plane/utils";
import { DescriptionVersionsRoot } from "@/components/core/description-versions";
import { DescriptionInput } from "@/components/editor/rich-text/description-input";
@ -21,38 +21,33 @@ import type { TIssueOperations } from "@/components/issues/issue-detail";
import { IssueActivity } from "@/components/issues/issue-detail/issue-activity";
import { IssueReaction } from "@/components/issues/issue-detail/reactions";
import { IssueTitleInput } from "@/components/issues/title-input";
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useMember } from "@/hooks/store/use-member";
import { useProject } from "@/hooks/store/use-project";
import { useProjectInbox } from "@/hooks/store/use-project-inbox";
import { useProjectExternalContours } from "@/hooks/store/use-project-external-contours";
import { useUser } from "@/hooks/store/user";
import useReloadConfirmations from "@/hooks/use-reload-confirmation";
import { DeDupeIssuePopoverRoot } from "@/plane-web/components/de-dupe/duplicate-popover";
import { useDebouncedDuplicateIssues } from "@/plane-web/hooks/use-debounced-duplicate-issues";
import { IntakeWorkItemVersionService } from "@/services/inbox";
import type { IInboxIssueStore } from "@/store/inbox/inbox-issue.store";
import { IssueService } from "@/services/issue/issue.service";
import { WorkItemVersionService } from "@/services/issue/work_item_version.service";
import { ExternalContoursIssueContentProperties } from "./issue-properties";
const intakeWorkItemVersionService = new IntakeWorkItemVersionService();
const workItemVersionService = new WorkItemVersionService();
const issueService = new IssueService();
type Props = {
workspaceSlug: string;
projectId: string;
inboxIssue: IInboxIssueStore;
sourceProjectId: string;
contourRequest: TExternalContourRequest;
isEditable: boolean;
isSubmitting: TNameDescriptionLoader;
setIsSubmitting: Dispatch<SetStateAction<TNameDescriptionLoader>>;
};
export const ExternalContoursIssueMainContent = observer(function ExternalContoursIssueMainContent(props: Props) {
const { workspaceSlug, projectId, inboxIssue, isEditable, isSubmitting, setIsSubmitting } = props;
const { workspaceSlug, sourceProjectId, contourRequest, isEditable, isSubmitting, setIsSubmitting } = props;
const { t } = useTranslation();
const editorRef = useRef<EditorRefApi>(null);
const { data: currentUser } = useUser();
const { getUserDetails } = useMember();
const { loader } = useProjectInbox();
const { getProjectById } = useProject();
const { removeIssue, archiveIssue } = useIssueDetail();
const { loader, updateRequestIssue } = useProjectExternalContours();
const { setShowAlert } = useReloadConfirmations(isSubmitting === "submitting");
useEffect(() => {
@ -64,43 +59,42 @@ export const ExternalContoursIssueMainContent = observer(function ExternalContou
}
}, [isSubmitting, setIsSubmitting, setShowAlert]);
const issue = inboxIssue.issue;
const projectDetails = issue?.project_id ? getProjectById(issue.project_id) : undefined;
const issue = contourRequest.issue;
const targetProjectId = issue.project_id || sourceProjectId;
const { duplicateIssues } = useDebouncedDuplicateIssues(workspaceSlug, projectDetails?.workspace.toString(), projectId, {
name: issue?.name,
description_html: getTextContent(issue?.description_html),
issueId: issue?.id,
});
const { duplicateIssues } = useDebouncedDuplicateIssues(
workspaceSlug,
targetProjectId,
targetProjectId,
{
name: issue?.name,
description_html: getTextContent(issue?.description_html),
issueId: issue?.id,
}
);
const issueOperations: TIssueOperations = useMemo(
() => ({
fetch: async () => undefined,
remove: async (_workspaceSlug: string, _projectId: string, issueId: string) => {
try {
await removeIssue(workspaceSlug, projectId, issueId);
await issueService.deleteIssue(workspaceSlug, targetProjectId, issueId);
setToast({ title: t("success"), type: TOAST_TYPE.SUCCESS, message: t("inbox_issue.modals.delete.success") });
} catch (error) {
console.log("Error in deleting work item:", error);
} catch {
setToast({ title: t("error"), type: TOAST_TYPE.ERROR, message: t("something_went_wrong_please_try_again") });
}
},
update: async (_workspaceSlug: string, _projectId: string, _issueId: string, data: Partial<TIssue>) => {
update: async (_workspaceSlug: string, _projectId: string, issueId: string, data: Partial<TIssue>) => {
try {
await inboxIssue.updateIssue(data);
const updatedIssue = await issueService.patchIssue(workspaceSlug, targetProjectId, issueId, data);
updateRequestIssue(contourRequest.id, { ...data, ...updatedIssue });
} catch {
setToast({ title: t("error"), type: TOAST_TYPE.ERROR, message: t("issue_could_not_be_updated") });
}
},
archive: async (_workspaceSlug: string, _projectId: string, issueId: string) => {
try {
await archiveIssue(workspaceSlug, projectId, issueId);
} catch (error) {
console.error("Error in archiving issue:", error);
}
},
archive: async () => undefined,
}),
[archiveIssue, inboxIssue, projectId, removeIssue, workspaceSlug]
[contourRequest.id, targetProjectId, t, updateRequestIssue, workspaceSlug]
);
if (!issue || !issue.project_id || !issue.id) return <></>;
@ -111,16 +105,15 @@ export const ExternalContoursIssueMainContent = observer(function ExternalContou
{duplicateIssues.length > 0 && (
<DeDupeIssuePopoverRoot
workspaceSlug={workspaceSlug}
projectId={issue.project_id}
projectId={targetProjectId}
rootIssueId={issue.id}
issues={duplicateIssues}
issueOperations={issueOperations}
isIntakeIssue
/>
)}
<IssueTitleInput
workspaceSlug={workspaceSlug}
projectId={issue.project_id}
projectId={targetProjectId}
issueId={issue.id}
isSubmitting={isSubmitting}
setIsSubmitting={(value) => setIsSubmitting(value)}
@ -143,12 +136,12 @@ export const ExternalContoursIssueMainContent = observer(function ExternalContou
initialValue={!issue.description_html || issue.description_html === "" ? "<p></p>" : issue.description_html}
key={issue.id}
onSubmit={async (value, isMigrationUpdate) => {
await issueOperations.update(workspaceSlug, issue.project_id, issue.id, {
await issueOperations.update(workspaceSlug, targetProjectId, issue.id, {
description_html: value.description_html,
...(isMigrationUpdate ? { skip_activity: "true" } : {}),
});
}}
projectId={issue.project_id}
projectId={targetProjectId}
setIsSubmitting={(value) => setIsSubmitting(value)}
workspaceSlug={workspaceSlug}
/>
@ -156,27 +149,25 @@ export const ExternalContoursIssueMainContent = observer(function ExternalContou
<div className="flex items-center justify-between gap-2">
{currentUser && (
<IssueReaction workspaceSlug={workspaceSlug} projectId={projectId} issueId={issue.id} currentUser={currentUser} />
<IssueReaction workspaceSlug={workspaceSlug} projectId={targetProjectId} issueId={issue.id} currentUser={currentUser} />
)}
{isEditable && (
<DescriptionVersionsRoot
className="flex-shrink-0"
entityInformation={{
createdAt: issue.created_at ? new Date(issue.created_at) : new Date(),
createdByDisplayName:
inboxIssue.source === EInboxIssueSource.FORMS
? t("inbox_issue.source.form_user")
: (getUserDetails(issue.created_by ?? "")?.display_name ?? ""),
createdByDisplayName: issue.created_by_detail?.display_name ?? "",
id: issue.id,
isRestoreDisabled: !isEditable,
}}
fetchHandlers={{
listDescriptionVersions: (issueId) => intakeWorkItemVersionService.listDescriptionVersions(workspaceSlug, projectId, issueId),
listDescriptionVersions: (issueId) =>
workItemVersionService.listDescriptionVersions(workspaceSlug, targetProjectId, issueId),
retrieveDescriptionVersion: (issueId, versionId) =>
intakeWorkItemVersionService.retrieveDescriptionVersion(workspaceSlug, projectId, issueId, versionId),
workItemVersionService.retrieveDescriptionVersion(workspaceSlug, targetProjectId, issueId, versionId),
}}
handleRestore={(descriptionHTML) => editorRef.current?.setEditorValue(descriptionHTML, true)}
projectId={projectId}
projectId={targetProjectId}
workspaceSlug={workspaceSlug}
/>
)}
@ -184,22 +175,21 @@ export const ExternalContoursIssueMainContent = observer(function ExternalContou
</div>
<div className="py-4">
<IssueAttachmentRoot workspaceSlug={workspaceSlug} projectId={projectId} issueId={issue.id} disabled={!isEditable} />
<IssueAttachmentRoot workspaceSlug={workspaceSlug} projectId={targetProjectId} issueId={issue.id} disabled={!isEditable} />
</div>
<div className="py-4">
<ExternalContoursIssueContentProperties
workspaceSlug={workspaceSlug}
projectId={projectId}
targetProjectId={targetProjectId}
issue={issue}
issueOperations={issueOperations}
isEditable={isEditable}
duplicateIssueDetails={inboxIssue.duplicate_issue_detail}
/>
</div>
<div className="pt-4">
<IssueActivity workspaceSlug={workspaceSlug} projectId={projectId} issueId={issue.id} isIntakeIssue />
<IssueActivity workspaceSlug={workspaceSlug} projectId={targetProjectId} issueId={issue.id} />
</div>
</>
);

View File

@ -12,71 +12,65 @@ import { useTranslation } from "@plane/i18n";
import { PriorityIcon } from "@plane/propel/icons";
import { Tooltip } from "@plane/propel/tooltip";
import { Avatar, Row } from "@plane/ui";
import { cn, getFileURL, renderFormattedDate } from "@plane/utils";
import { ButtonAvatars } from "@/components/dropdowns/member/avatar";
import { useLabel } from "@/hooks/store/use-label";
import { useMember } from "@/hooks/store/use-member";
import { useProjectInbox } from "@/hooks/store/use-project-inbox";
import { cn, renderFormattedDate } from "@plane/utils";
import { usePlatformOS } from "@/hooks/use-platform-os";
import { InboxSourcePill } from "@/plane-web/components/inbox/source-pill";
import { InboxIssueStatus } from "@/components/inbox/inbox-issue-status";
import { useProjectExternalContours } from "@/hooks/store/use-project-external-contours";
import { ExternalContourStatePill } from "./state-pill";
type Props = {
workspaceSlug: string;
projectId: string;
projectIdentifier?: string;
inboxIssueId: string;
requestId: string;
setIsMobileSidebar: (value: boolean) => void;
};
export const ExternalContoursListItem = observer(function ExternalContoursListItem(props: Props) {
const { workspaceSlug, projectId, inboxIssueId, projectIdentifier, setIsMobileSidebar } = props;
const { workspaceSlug, projectId, requestId, setIsMobileSidebar } = props;
const searchParams = useSearchParams();
const selectedInboxIssueId = searchParams.get("inboxIssueId");
const { t } = useTranslation();
const { currentTab, getIssueInboxByIssueId } = useProjectInbox();
const { projectLabels } = useLabel();
const { currentTab, getRequestById } = useProjectExternalContours();
const { isMobile } = usePlatformOS();
const { getUserDetails } = useMember();
const inboxIssue = getIssueInboxByIssueId(inboxIssueId);
const issue = inboxIssue?.issue;
const request = getRequestById(requestId);
const issue = request?.issue;
const handleIssueRedirection = (event: MouseEvent, currentIssueId: string | undefined) => {
if (selectedInboxIssueId === currentIssueId) event.preventDefault();
setIsMobileSidebar(false);
};
if (!issue) return <></>;
if (!request || !issue) return <></>;
const createdByDetails = issue?.created_by ? getUserDetails(issue?.created_by) : undefined;
const createdByDetails = issue.created_by_detail;
const visibleLabels = issue.label_details?.slice(0, 3) ?? [];
return (
<Link
id={`external-contour-list-item-${issue.id}`}
key={`${projectId}_${issue.id}`}
href={`/${workspaceSlug}/projects/${projectId}/external-contours?currentTab=${currentTab}&inboxIssueId=${issue.id}`}
onClick={(e) => handleIssueRedirection(e, issue.id)}
id={`external-contour-list-item-${request.id}`}
key={`${projectId}_${request.id}`}
href={`/${workspaceSlug}/projects/${projectId}/external-contours?currentTab=${currentTab}&inboxIssueId=${request.id}`}
onClick={(e) => handleIssueRedirection(e, request.id)}
>
<Row
className={cn(
"relative flex cursor-pointer flex-col gap-2 border border-t-transparent border-r-transparent border-b-subtle-1 border-l-transparent py-4 transition-all hover:bg-accent-primary/5",
{ "border border-accent-strong": selectedInboxIssueId === issue.id }
{ "border border-accent-strong": selectedInboxIssueId === request.id }
)}
>
<div className="space-y-1">
<div className="relative flex items-center justify-between gap-2">
<div className="flex-shrink-0 text-11 font-medium text-tertiary">
{projectIdentifier}-{issue.sequence_id}
</div>
<div className="flex items-center gap-2">
{inboxIssue.source && <InboxSourcePill source={inboxIssue.source} />}
{inboxIssue.status !== -2 && <InboxIssueStatus inboxIssue={inboxIssue} iconSize={12} />}
<div className="flex items-center gap-2 text-11 font-medium text-tertiary">
<span>
{issue.project_detail?.identifier || "REQ"}-{issue.sequence_id}
</span>
{issue.project_detail?.name && <span className="truncate text-placeholder">{issue.project_detail.name}</span>}
</div>
<ExternalContourStatePill request={request} />
</div>
<h3 className="w-full truncate text-13">{issue.name}</h3>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center justify-between gap-2">
<div className="flex flex-wrap items-center gap-2">
<Tooltip
tooltipHeading={t("issues.properties.created_on")}
@ -86,41 +80,21 @@ export const ExternalContoursListItem = observer(function ExternalContoursListIt
<div className="text-11 text-secondary">{renderFormattedDate(issue.created_at ?? "")}</div>
</Tooltip>
<div className="rounded-full border-2 border-strong-1" />
{issue.priority && (
{issue.priority && issue.priority !== "none" && (
<Tooltip tooltipHeading={t("priority")} tooltipContent={`${issue.priority ?? t("none")}`}>
<PriorityIcon priority={issue.priority} withContainer className="h-3 w-3" />
</Tooltip>
)}
{issue.label_ids && issue.label_ids.length > 3 ? (
<div className="relative flex !h-[17.5px] items-center gap-1 rounded-sm border border-strong px-1 text-11">
<span className="h-2 w-2 rounded-full bg-orange-400" />
<span className="max-w-28 truncate normal-case">{`${issue.label_ids.length} ${t("labels").toLowerCase()}`}</span>
{visibleLabels.map((label) => (
<div key={label.id} className="relative flex !h-[17.5px] items-center gap-1 rounded-sm border border-strong px-1 text-11">
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: label.color }} />
<span className="max-w-28 truncate normal-case">{label.name}</span>
</div>
) : (
<>
{(issue.label_ids ?? []).map((labelId) => {
const labelDetails = projectLabels?.find((l) => l.id === labelId);
if (!labelDetails) return null;
return (
<div
key={labelId}
className="relative flex !h-[17.5px] items-center gap-1 rounded-sm border border-strong px-1 text-11"
>
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: labelDetails.color }} />
<span className="max-w-28 truncate normal-case">{labelDetails.name}</span>
</div>
);
})}
</>
)}
))}
</div>
{createdByDetails && createdByDetails.email?.includes("intake@plane.so") ? (
<Avatar src={getFileURL("")} name={"NODE.DC"} size="md" showTooltip />
) : createdByDetails ? (
<ButtonAvatars showTooltip={false} userIds={createdByDetails?.id} />
{createdByDetails ? (
<Avatar src={createdByDetails.avatar_url || ""} name={createdByDetails.display_name || "NODE.DC"} size="md" showTooltip />
) : null}
</div>
</Row>

View File

@ -14,7 +14,7 @@ import type { TInboxIssueCurrentTab } from "@plane/types";
import { EInboxIssueCurrentTab } from "@plane/types";
import { cn } from "@plane/utils";
import { InboxLayoutLoader } from "@/components/ui/loader/layouts/project-inbox/inbox-layout-loader";
import { useProjectInbox } from "@/hooks/store/use-project-inbox";
import { useProjectExternalContours } from "@/hooks/store/use-project-external-contours";
import { ExternalContoursContentRoot } from "./content-root";
import { ExternalContoursSidebar } from "./sidebar";
@ -22,30 +22,29 @@ type TExternalContoursRoot = {
workspaceSlug: string;
projectId: string;
inboxIssueId: string | undefined;
inboxAccessible: boolean;
navigationTab?: TInboxIssueCurrentTab | undefined;
};
export const ExternalContoursRoot = observer(function ExternalContoursRoot(props: TExternalContoursRoot) {
const { workspaceSlug, projectId, inboxIssueId, inboxAccessible, navigationTab } = props;
const { workspaceSlug, projectId, inboxIssueId, navigationTab } = props;
const [isMobileSidebar, setIsMobileSidebar] = useState(true);
const { t } = useTranslation();
const { loader, error, currentTab, currentInboxProjectId, handleCurrentTab, fetchInboxIssues } = useProjectInbox();
const { loader, error, currentTab, currentProjectId, handleCurrentTab, fetchRequests } = useProjectExternalContours();
useEffect(() => {
if (!inboxAccessible || !workspaceSlug || !projectId) return;
if (!workspaceSlug || !projectId) return;
const hasProjectChanged = currentInboxProjectId && currentInboxProjectId !== projectId;
const hasProjectChanged = currentProjectId && currentProjectId !== projectId;
if (navigationTab && navigationTab !== currentTab) {
handleCurrentTab(workspaceSlug, projectId, navigationTab);
} else if (hasProjectChanged) {
handleCurrentTab(workspaceSlug, projectId, EInboxIssueCurrentTab.OPEN);
} else {
fetchInboxIssues(workspaceSlug.toString(), projectId.toString(), undefined, navigationTab || EInboxIssueCurrentTab.OPEN);
fetchRequests(workspaceSlug.toString(), projectId.toString(), navigationTab || EInboxIssueCurrentTab.OPEN);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [inboxAccessible, workspaceSlug, projectId]);
}, [workspaceSlug, projectId]);
if (loader === "init-loading") {
return (

View File

@ -4,21 +4,16 @@
* See the LICENSE file for details.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { useEffect } from "react";
import { observer } from "mobx-react";
import { useTranslation } from "@plane/i18n";
import { EmptyStateDetailed } from "@plane/propel/empty-state";
import type { TInboxIssueCurrentTab } from "@plane/types";
import { EInboxIssueCurrentTab } from "@plane/types";
import { EHeaderVariant, Header, Loader } from "@plane/ui";
import { EHeaderVariant, Header } from "@plane/ui";
import { cn } from "@plane/utils";
import { InboxSidebarLoader } from "@/components/ui/loader/layouts/project-inbox/inbox-sidebar-loader";
import { useProject } from "@/hooks/store/use-project";
import { useProjectInbox } from "@/hooks/store/use-project-inbox";
import { useProjectExternalContours } from "@/hooks/store/use-project-external-contours";
import { useAppRouter } from "@/hooks/use-app-router";
import { useIntersectionObserver } from "@/hooks/use-intersection-observer";
import { FiltersRoot } from "@/components/inbox/inbox-filter";
import { InboxIssueAppliedFilters } from "@/components/inbox/inbox-filter/applied-filters/root";
import { ExternalContoursListItem } from "./list-item";
type Props = {
@ -36,34 +31,18 @@ const tabNavigationOptions: { key: TInboxIssueCurrentTab; i18n_label: string }[]
export const ExternalContoursSidebar = observer(function ExternalContoursSidebar(props: Props) {
const { workspaceSlug, projectId, inboxIssueId, setIsMobileSidebar } = props;
const router = useAppRouter();
const containerRef = useRef<HTMLDivElement>(null);
const [elementRef, setElementRef] = useState<HTMLDivElement | null>(null);
const { t } = useTranslation();
const { currentProjectDetails } = useProject();
const {
currentTab,
handleCurrentTab,
loader,
filteredInboxIssueIds,
inboxIssuePaginationInfo,
fetchInboxPaginationIssues,
getAppliedFiltersCount,
} = useProjectInbox();
const fetchNextPages = useCallback(() => {
if (!workspaceSlug || !projectId) return;
fetchInboxPaginationIssues(workspaceSlug.toString(), projectId.toString());
}, [workspaceSlug, projectId, fetchInboxPaginationIssues]);
useIntersectionObserver(containerRef, elementRef, fetchNextPages, "20%");
const { currentTab, handleCurrentTab, filteredRequestIds, openRequestIds, closedRequestIds } = useProjectExternalContours();
useEffect(() => {
if (workspaceSlug && projectId && currentTab && filteredInboxIssueIds.length > 0 && inboxIssueId === undefined) {
if (workspaceSlug && projectId && filteredRequestIds.length > 0 && inboxIssueId === undefined) {
router.push(
`/${workspaceSlug}/projects/${projectId}/external-contours?currentTab=${currentTab}&inboxIssueId=${filteredInboxIssueIds[0]}`
`/${workspaceSlug}/projects/${projectId}/external-contours?currentTab=${currentTab}&inboxIssueId=${filteredRequestIds[0]}`
);
}
}, [currentTab, filteredInboxIssueIds, inboxIssueId, projectId, router, workspaceSlug]);
}, [currentTab, filteredRequestIds, inboxIssueId, projectId, router, workspaceSlug]);
const currentCount = currentTab === EInboxIssueCurrentTab.CLOSED ? closedRequestIds.length : openRequestIds.length;
return (
<div className="h-full w-full flex-shrink-0 border-r border-strong bg-surface-1">
@ -84,9 +63,9 @@ export const ExternalContoursSidebar = observer(function ExternalContoursSidebar
}}
>
<div>{t(option.i18n_label)}</div>
{option.key === EInboxIssueCurrentTab.OPEN && currentTab === option.key && (
{currentTab === option.key && (
<div className="rounded-full bg-accent-primary/20 px-1.5 py-0.5 text-11 font-semibold text-accent-primary">
{inboxIssuePaginationInfo?.total_results || 0}
{currentCount}
</div>
)}
<div
@ -97,67 +76,41 @@ export const ExternalContoursSidebar = observer(function ExternalContoursSidebar
/>
</div>
))}
<div className="m-auto mr-0">
<FiltersRoot />
</div>
</Header>
<InboxIssueAppliedFilters />
{loader === "filter-loading" && !inboxIssuePaginationInfo?.next_page_results ? (
<InboxSidebarLoader />
) : (
<div className="vertical-scrollbar scrollbar-md h-full w-full overflow-hidden overflow-y-auto" ref={containerRef}>
{filteredInboxIssueIds.length > 0 ? (
filteredInboxIssueIds.map((inboxId) => (
<ExternalContoursListItem
key={inboxId}
setIsMobileSidebar={setIsMobileSidebar}
workspaceSlug={workspaceSlug}
projectId={projectId}
projectIdentifier={currentProjectDetails?.identifier}
inboxIssueId={inboxId}
<div className="vertical-scrollbar scrollbar-md h-full w-full overflow-hidden overflow-y-auto">
{filteredRequestIds.length > 0 ? (
filteredRequestIds.map((requestId) => (
<ExternalContoursListItem
key={requestId}
setIsMobileSidebar={setIsMobileSidebar}
workspaceSlug={workspaceSlug}
projectId={projectId}
requestId={requestId}
/>
))
) : (
<div className="flex h-full w-full items-center justify-center">
{currentTab === EInboxIssueCurrentTab.OPEN ? (
<EmptyStateDetailed
assetKey="inbox"
title={t("external_contours_page.empty_state.open_title")}
description={t("external_contours_page.empty_state.open_description")}
assetClassName="size-20"
rootClassName="px-page-x"
/>
) : (
<EmptyStateDetailed
assetKey="inbox"
title={t("external_contours_page.empty_state.closed_title")}
description={t("external_contours_page.empty_state.closed_description")}
assetClassName="size-20"
className="px-10"
/>
))
) : (
<div className="flex h-full w-full items-center justify-center">
{getAppliedFiltersCount > 0 ? (
<EmptyStateDetailed
assetKey="search"
title={t("external_contours_page.empty_state.filtered_title")}
description={t("external_contours_page.empty_state.filtered_description")}
assetClassName="size-20"
rootClassName="px-page-x"
/>
) : currentTab === EInboxIssueCurrentTab.OPEN ? (
<EmptyStateDetailed
assetKey="inbox"
title={t("external_contours_page.empty_state.open_title")}
description={t("external_contours_page.empty_state.open_description")}
assetClassName="size-20"
rootClassName="px-page-x"
/>
) : (
<EmptyStateDetailed
assetKey="inbox"
title={t("external_contours_page.empty_state.closed_title")}
description={t("external_contours_page.empty_state.closed_description")}
assetClassName="size-20"
className="px-10"
/>
)}
</div>
)}
<div ref={setElementRef}>
{inboxIssuePaginationInfo?.next_page_results && (
<Loader className="mx-auto w-full space-y-4 px-2 py-4">
<Loader.Item height="64px" width="w-100" />
<Loader.Item height="64px" width="w-100" />
</Loader>
)}
</div>
</div>
)}
)}
</div>
</div>
</div>
);

View File

@ -0,0 +1,31 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useTranslation } from "@plane/i18n";
import { StateGroupIcon } from "@plane/propel/icons";
import type { TExternalContourRequest } from "@plane/types";
type Props = {
request: TExternalContourRequest;
iconSize?: string;
};
export function ExternalContourStatePill(props: Props) {
const { request, iconSize = "size-3.5" } = props;
const { t } = useTranslation();
const state = request.issue.state_detail;
return (
<div className="inline-flex items-center gap-1 rounded-sm border border-subtle bg-layer-2 px-1.5 py-0.5 text-11 font-medium text-secondary">
<StateGroupIcon
stateGroup={state?.group ?? "backlog"}
color={state?.color}
className={`${iconSize} shrink-0`}
/>
<span className="whitespace-nowrap">{state?.name || t("state")}</span>
</div>
);
}

View File

@ -0,0 +1,15 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useContext } from "react";
import { StoreContext } from "@/lib/store-context";
import type { IProjectExternalContoursStore } from "@/store/external-contours/project-external-contours.store";
export const useProjectExternalContours = (): IProjectExternalContoursStore => {
const context = useContext(StoreContext);
if (context === undefined) throw new Error("useProjectExternalContours must be used within StoreProvider");
return context.projectExternalContours;
};

View File

@ -0,0 +1,53 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { API_BASE_URL } from "@plane/constants";
import type { TExternalContourRequest, TExternalContourRequestResponse, TIssue } from "@plane/types";
import { APIService } from "@/services/api.service";
export class ExternalContourService extends APIService {
constructor() {
super(API_BASE_URL);
}
async list(workspaceSlug: string, projectId: string): Promise<TExternalContourRequestResponse> {
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/external-contours/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async retrieve(workspaceSlug: string, projectId: string, requestId: string): Promise<TExternalContourRequest> {
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/external-contours/${requestId}/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async create(
workspaceSlug: string,
projectId: string,
data: Partial<TIssue> & { target_project_id?: string | null }
): Promise<TExternalContourRequest> {
return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/external-contours/`, {
target_project_id: data.target_project_id,
issue: {
name: data.name,
description_html: data.description_html,
priority: data.priority,
assignee_ids: data.assignee_ids,
label_ids: data.label_ids,
target_date: data.target_date || null,
},
})
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
}

View File

@ -0,0 +1 @@
export * from "./external-contour.service";

View File

@ -0,0 +1,182 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { set } from "lodash-es";
import { action, computed, makeObservable, observable, runInAction } from "mobx";
import type { TExternalContourRequest, TInboxIssueCurrentTab, TIssue } from "@plane/types";
import { EInboxIssueCurrentTab } from "@plane/types";
import { ExternalContourService } from "@/services/external-contours";
import type { CoreRootStore } from "../root.store";
type TLoader = "init-loading" | "mutation-loading" | "issue-loading" | undefined;
export interface IProjectExternalContoursStore {
currentProjectId: string;
currentTab: TInboxIssueCurrentTab;
error: { message: string; status: "init-error" } | undefined;
loader: TLoader;
requestIds: string[];
requests: Record<string, TExternalContourRequest>;
createRequest: (
workspaceSlug: string,
projectId: string,
data: Partial<TIssue> & { target_project_id?: string | null }
) => Promise<TExternalContourRequest | undefined>;
fetchRequestById: (workspaceSlug: string, projectId: string, requestId: string) => Promise<TExternalContourRequest | undefined>;
fetchRequests: (workspaceSlug: string, projectId: string, tab?: TInboxIssueCurrentTab) => Promise<void>;
getIsRequestAvailable: (requestId: string) => boolean;
getRequestById: (requestId: string) => TExternalContourRequest | undefined;
handleCurrentTab: (workspaceSlug: string, projectId: string, tab: TInboxIssueCurrentTab) => Promise<void>;
openRequestIds: string[];
closedRequestIds: string[];
filteredRequestIds: string[];
upsertRequests: (requests: TExternalContourRequest[]) => void;
updateRequestIssue: (requestId: string, issueData: Partial<TExternalContourRequest["issue"]>) => void;
}
export class ProjectExternalContoursStore implements IProjectExternalContoursStore {
currentProjectId = "";
currentTab: TInboxIssueCurrentTab = EInboxIssueCurrentTab.OPEN;
error: { message: string; status: "init-error" } | undefined = undefined;
loader: TLoader = "init-loading";
requestIds: string[] = [];
requests: Record<string, TExternalContourRequest> = {};
externalContourService;
constructor(_store: CoreRootStore) {
makeObservable(this, {
currentProjectId: observable.ref,
currentTab: observable.ref,
error: observable.ref,
loader: observable.ref,
requestIds: observable.shallow,
requests: observable,
openRequestIds: computed,
closedRequestIds: computed,
filteredRequestIds: computed,
fetchRequests: action,
fetchRequestById: action,
createRequest: action,
handleCurrentTab: action,
upsertRequests: action,
updateRequestIssue: action,
});
this.externalContourService = new ExternalContourService();
}
get openRequestIds() {
return this.requestIds.filter((requestId) => this.requests[requestId]?.status === "open");
}
get closedRequestIds() {
return this.requestIds.filter((requestId) => this.requests[requestId]?.status === "closed");
}
get filteredRequestIds() {
return this.currentTab === EInboxIssueCurrentTab.CLOSED ? this.closedRequestIds : this.openRequestIds;
}
getRequestById = (requestId: string) => this.requests[requestId];
getIsRequestAvailable = (requestId: string) => this.requestIds.includes(requestId);
upsertRequests = (requests: TExternalContourRequest[]) => {
requests.forEach((request) => {
set(this.requests, request.id, request);
if (!this.requestIds.includes(request.id)) this.requestIds.push(request.id);
});
this.requestIds = this.requestIds.sort((left, right) => {
const leftUpdatedAt = this.requests[left]?.updated_at || "";
const rightUpdatedAt = this.requests[right]?.updated_at || "";
return rightUpdatedAt.localeCompare(leftUpdatedAt);
});
};
handleCurrentTab = async (workspaceSlug: string, projectId: string, tab: TInboxIssueCurrentTab) => {
this.currentTab = tab;
await this.fetchRequests(workspaceSlug, projectId, tab);
};
fetchRequests = async (workspaceSlug: string, projectId: string, tab = this.currentTab) => {
this.loader = "init-loading";
this.error = undefined;
this.currentProjectId = projectId;
this.currentTab = tab;
try {
const response = await this.externalContourService.list(workspaceSlug, projectId);
runInAction(() => {
this.requestIds = [];
this.requests = {};
this.upsertRequests(response.results || []);
this.loader = undefined;
});
} catch (error: any) {
runInAction(() => {
this.loader = undefined;
this.error = {
message: error?.error || "Не удалось загрузить внешние контуры",
status: "init-error",
};
});
}
};
fetchRequestById = async (workspaceSlug: string, projectId: string, requestId: string) => {
this.loader = "issue-loading";
try {
const request = await this.externalContourService.retrieve(workspaceSlug, projectId, requestId);
runInAction(() => {
this.upsertRequests([request]);
this.loader = undefined;
});
return request;
} catch (error) {
runInAction(() => {
this.loader = undefined;
});
return undefined;
}
};
createRequest = async (workspaceSlug: string, projectId: string, data: Partial<TIssue> & { target_project_id?: string | null }) => {
this.loader = "mutation-loading";
try {
const request = await this.externalContourService.create(workspaceSlug, projectId, data);
runInAction(() => {
this.upsertRequests([request]);
this.currentTab = EInboxIssueCurrentTab.OPEN;
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 =
issueData.state_detail?.group !== undefined
? ["completed", "cancelled"].includes(issueData.state_detail.group)
? "closed"
: "open"
: this.requests[requestId].status;
this.requests[requestId] = {
...this.requests[requestId],
issue: {
...this.requests[requestId].issue,
...issueData,
},
status: nextStatus,
};
};
}

View File

@ -31,6 +31,8 @@ import type { IEditorAssetStore } from "./editor/asset.store";
import { EditorAssetStore } from "./editor/asset.store";
import type { IProjectEstimateStore } from "./estimates/project-estimate.store";
import { ProjectEstimateStore } from "./estimates/project-estimate.store";
import type { IProjectExternalContoursStore } from "./external-contours/project-external-contours.store";
import { ProjectExternalContoursStore } from "./external-contours/project-external-contours.store";
import type { IFavoriteStore } from "./favorite.store";
import { FavoriteStore } from "./favorite.store";
import type { IGlobalViewStore } from "./global-view.store";
@ -93,6 +95,7 @@ export class CoreRootStore {
instance: IInstanceStore;
user: IUserStore;
projectInbox: IProjectInboxStore;
projectExternalContours: IProjectExternalContoursStore;
projectEstimate: IProjectEstimateStore;
multipleSelect: IMultipleSelectStore;
workspaceNotification: IWorkspaceNotificationStore;
@ -123,6 +126,7 @@ export class CoreRootStore {
this.dashboard = new DashboardStore(this);
this.multipleSelect = new MultipleSelectStore();
this.projectInbox = new ProjectInboxStore(this);
this.projectExternalContours = new ProjectExternalContoursStore(this);
this.projectPages = new ProjectPageStore(this as unknown as RootStore);
this.projectEstimate = new ProjectEstimateStore(this);
this.workspaceNotification = new WorkspaceNotificationStore(this);
@ -156,6 +160,7 @@ export class CoreRootStore {
this.label = new LabelStore(this);
this.dashboard = new DashboardStore(this);
this.projectInbox = new ProjectInboxStore(this);
this.projectExternalContours = new ProjectExternalContoursStore(this);
this.projectPages = new ProjectPageStore(this as unknown as RootStore);
this.multipleSelect = new MultipleSelectStore();
this.projectEstimate = new ProjectEstimateStore(this);

View File

@ -318,6 +318,8 @@ export default {
modal: {
title: "Add request",
submit: "Send",
success_message: "Request sent to the target contour.",
error_message: "Failed to send the request to the target contour.",
toast_title: "Routing is not connected yet",
toast_message:
"The UI now uses the intake shell. The next stage will connect backend routing and real delivery to the target contour.",

View File

@ -475,6 +475,8 @@ export default {
modal: {
title: "Добавить запрос",
submit: "Отправить",
success_message: "Запрос отправлен во внешний контур.",
error_message: "Не удалось отправить запрос во внешний контур.",
toast_title: "Маршрутизация ещё не подключена",
toast_message:
"UI уже переведён на shell предложений. На следующем этапе подключим backend-маршрутизацию и реальную отправку в целевой контур.",

View File

@ -0,0 +1,33 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { TPaginationInfo } from "./common";
import type { TIssue } from "./issues/issue";
import type { IProjectLite } from "./project";
import type { IStateLite } from "./state";
import type { IUser } from "./users";
export type TExternalContourIssue = TIssue & {
assignee_details?: Pick<IUser, "id" | "display_name" | "avatar_url">[];
created_by_detail?: Pick<IUser, "id" | "display_name" | "avatar_url"> | null;
label_details?: { id: string; name: string; color: string }[];
project_detail?: IProjectLite | null;
state_detail?: IStateLite | null;
};
export type TExternalContourRequest = {
created_at: string;
created_by: string | null;
id: string;
issue: TExternalContourIssue;
source_project_id: string;
status: "open" | "closed";
updated_at: string;
};
export type TExternalContourRequestResponse = TPaginationInfo & {
results: TExternalContourRequest[];
};

View File

@ -21,6 +21,7 @@ export * from "./editor";
export * from "./enums";
export * from "./epics";
export * from "./estimate";
export * from "./external-contours";
export * from "./favorite";
export * from "./file";
export * from "./home";