ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: отправка во внешний контур и 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. Отдельный модуль ### 1. Отдельный модуль

View File

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

View File

@ -53,6 +53,10 @@ from .intake import (
IntakeIssueCreateSerializer, IntakeIssueCreateSerializer,
IntakeIssueUpdateSerializer, IntakeIssueUpdateSerializer,
) )
from .external_contours import (
ExternalContourRequestCreateSerializer,
ExternalContourRequestSerializer,
)
from .estimate import EstimateSerializer, EstimatePointSerializer from .estimate import EstimateSerializer, EstimatePointSerializer
from .asset import ( from .asset import (
UserAssetUploadSerializer, 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 .asset import urlpatterns as asset_patterns
from .cycle import urlpatterns as cycle_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 .intake import urlpatterns as intake_patterns
from .label import urlpatterns as label_patterns from .label import urlpatterns as label_patterns
from .member import urlpatterns as member_patterns from .member import urlpatterns as member_patterns
@ -18,6 +19,7 @@ from .sticky import urlpatterns as sticky_patterns
urlpatterns = [ urlpatterns = [
*asset_patterns, *asset_patterns,
*cycle_patterns, *cycle_patterns,
*external_contour_patterns,
*intake_patterns, *intake_patterns,
*label_patterns, *label_patterns,
*member_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, IntakeIssueListCreateAPIEndpoint,
IntakeIssueDetailAPIEndpoint, IntakeIssueDetailAPIEndpoint,
) )
from .external_contours import (
ExternalContourListCreateAPIEndpoint,
ExternalContourDetailAPIEndpoint,
)
from .asset import UserAssetEndpoint, UserServerAssetEndpoint, GenericAssetEndpoint 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 { observer } from "mobx-react";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import { useTheme } from "next-themes";
import { EInboxIssueCurrentTab } from "@plane/types"; import { EInboxIssueCurrentTab } from "@plane/types";
import { useTranslation } from "@plane/i18n"; 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 { PageHead } from "@/components/core/page-title";
import { DetailedEmptyState } from "@/components/empty-state/detailed-empty-state-root";
import { useProject } from "@/hooks/store/use-project"; 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 { ExternalContoursRoot } from "@/plane-web/components/projects/external-contours/root";
import type { Route } from "./+types/page"; import type { Route } from "./+types/page";
function ProjectExternalContoursPage({ params }: Route.ComponentProps) { function ProjectExternalContoursPage({ params }: Route.ComponentProps) {
const router = useAppRouter();
const { workspaceSlug, projectId } = params; const { workspaceSlug, projectId } = params;
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const navigationTab = searchParams.get("currentTab"); const navigationTab = searchParams.get("currentTab");
const inboxIssueId = searchParams.get("inboxIssueId"); const inboxIssueId = searchParams.get("inboxIssueId");
const { resolvedTheme } = useTheme();
const { t } = useTranslation(); const { t } = useTranslation();
const { currentProjectDetails } = useProject(); 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 const pageTitle = currentProjectDetails?.name
? t("external_contours_page.page_label", { workspace: currentProjectDetails.name }) ? t("external_contours_page.page_label", { workspace: currentProjectDetails.name })
@ -70,7 +39,6 @@ function ProjectExternalContoursPage({ params }: Route.ComponentProps) {
workspaceSlug={workspaceSlug} workspaceSlug={workspaceSlug}
projectId={projectId} projectId={projectId}
inboxIssueId={inboxIssueId || undefined} inboxIssueId={inboxIssueId || undefined}
inboxAccessible={currentProjectDetails?.inbox_view || false}
navigationTab={currentNavigationTab} navigationTab={currentNavigationTab}
/> />
</div> </div>

View File

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

View File

@ -12,9 +12,12 @@ import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button"; import { Button } from "@plane/propel/button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { TIssue } from "@plane/types"; import type { TIssue } from "@plane/types";
import { EInboxIssueCurrentTab } from "@plane/types";
import { ToggleSwitch } from "@plane/ui"; import { ToggleSwitch } from "@plane/ui";
import { useProject } from "@/hooks/store/use-project"; import { useProject } from "@/hooks/store/use-project";
import { useProjectExternalContours } from "@/hooks/store/use-project-external-contours";
import { useWorkspace } from "@/hooks/store/use-workspace"; 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 { InboxIssueDescription } from "@/components/inbox/modals/create-modal/issue-description";
import { InboxIssueTitle } from "@/components/inbox/modals/create-modal/issue-title"; import { InboxIssueTitle } from "@/components/inbox/modals/create-modal/issue-title";
import { ExternalContoursCreateProperties } from "./create-properties"; import { ExternalContoursCreateProperties } from "./create-properties";
@ -39,9 +42,11 @@ type Props = {
export const ExternalContoursCreateRoot = observer(function ExternalContoursCreateRoot(props: Props) { export const ExternalContoursCreateRoot = observer(function ExternalContoursCreateRoot(props: Props) {
const { workspaceSlug, projectId, handleModalClose } = props; const { workspaceSlug, projectId, handleModalClose } = props;
const { t } = useTranslation(); const { t } = useTranslation();
const router = useAppRouter();
const { getWorkspaceBySlug } = useWorkspace(); const { getWorkspaceBySlug } = useWorkspace();
const workspaceId = getWorkspaceBySlug(workspaceSlug)?.id; const workspaceId = getWorkspaceBySlug(workspaceSlug)?.id;
const { currentProjectDetails } = useProject(); const { currentProjectDetails } = useProject();
const { createRequest } = useProjectExternalContours();
const descriptionEditorRef = useRef<EditorRefApi>(null); const descriptionEditorRef = useRef<EditorRefApi>(null);
const [createMore, setCreateMore] = useState(false); const [createMore, setCreateMore] = useState(false);
const [formSubmitting, setFormSubmitting] = useState(false); const [formSubmitting, setFormSubmitting] = useState(false);
@ -67,16 +72,33 @@ export const ExternalContoursCreateRoot = observer(function ExternalContoursCrea
} }
setFormSubmitting(true); 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) { 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(); 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);
}
}; };
if (!workspaceSlug || !projectId || !workspaceId) return <></>; if (!workspaceSlug || !projectId || !workspaceId) return <></>;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -4,21 +4,16 @@
* See the LICENSE file for details. * See the LICENSE file for details.
*/ */
import { useCallback, useEffect, useRef, useState } from "react"; import { useEffect } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useTranslation } from "@plane/i18n"; import { useTranslation } from "@plane/i18n";
import { EmptyStateDetailed } from "@plane/propel/empty-state"; import { EmptyStateDetailed } from "@plane/propel/empty-state";
import type { TInboxIssueCurrentTab } from "@plane/types"; import type { TInboxIssueCurrentTab } from "@plane/types";
import { EInboxIssueCurrentTab } 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 { cn } from "@plane/utils";
import { InboxSidebarLoader } from "@/components/ui/loader/layouts/project-inbox/inbox-sidebar-loader"; import { useProjectExternalContours } from "@/hooks/store/use-project-external-contours";
import { useProject } from "@/hooks/store/use-project";
import { useProjectInbox } from "@/hooks/store/use-project-inbox";
import { useAppRouter } from "@/hooks/use-app-router"; 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"; import { ExternalContoursListItem } from "./list-item";
type Props = { type Props = {
@ -36,34 +31,18 @@ const tabNavigationOptions: { key: TInboxIssueCurrentTab; i18n_label: string }[]
export const ExternalContoursSidebar = observer(function ExternalContoursSidebar(props: Props) { export const ExternalContoursSidebar = observer(function ExternalContoursSidebar(props: Props) {
const { workspaceSlug, projectId, inboxIssueId, setIsMobileSidebar } = props; const { workspaceSlug, projectId, inboxIssueId, setIsMobileSidebar } = props;
const router = useAppRouter(); const router = useAppRouter();
const containerRef = useRef<HTMLDivElement>(null);
const [elementRef, setElementRef] = useState<HTMLDivElement | null>(null);
const { t } = useTranslation(); const { t } = useTranslation();
const { currentProjectDetails } = useProject(); const { currentTab, handleCurrentTab, filteredRequestIds, openRequestIds, closedRequestIds } = useProjectExternalContours();
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%");
useEffect(() => { useEffect(() => {
if (workspaceSlug && projectId && currentTab && filteredInboxIssueIds.length > 0 && inboxIssueId === undefined) { if (workspaceSlug && projectId && filteredRequestIds.length > 0 && inboxIssueId === undefined) {
router.push( 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 ( return (
<div className="h-full w-full flex-shrink-0 border-r border-strong bg-surface-1"> <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> <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"> <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>
)} )}
<div <div
@ -97,39 +76,22 @@ export const ExternalContoursSidebar = observer(function ExternalContoursSidebar
/> />
</div> </div>
))} ))}
<div className="m-auto mr-0">
<FiltersRoot />
</div>
</Header> </Header>
<InboxIssueAppliedFilters /> <div className="vertical-scrollbar scrollbar-md h-full w-full overflow-hidden overflow-y-auto">
{filteredRequestIds.length > 0 ? (
{loader === "filter-loading" && !inboxIssuePaginationInfo?.next_page_results ? ( filteredRequestIds.map((requestId) => (
<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 <ExternalContoursListItem
key={inboxId} key={requestId}
setIsMobileSidebar={setIsMobileSidebar} setIsMobileSidebar={setIsMobileSidebar}
workspaceSlug={workspaceSlug} workspaceSlug={workspaceSlug}
projectId={projectId} projectId={projectId}
projectIdentifier={currentProjectDetails?.identifier} requestId={requestId}
inboxIssueId={inboxId}
/> />
)) ))
) : ( ) : (
<div className="flex h-full w-full items-center justify-center"> <div className="flex h-full w-full items-center justify-center">
{getAppliedFiltersCount > 0 ? ( {currentTab === EInboxIssueCurrentTab.OPEN ? (
<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 <EmptyStateDetailed
assetKey="inbox" assetKey="inbox"
title={t("external_contours_page.empty_state.open_title")} title={t("external_contours_page.empty_state.open_title")}
@ -148,17 +110,8 @@ export const ExternalContoursSidebar = observer(function ExternalContoursSidebar
)} )}
</div> </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>
</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 { EditorAssetStore } from "./editor/asset.store";
import type { IProjectEstimateStore } from "./estimates/project-estimate.store"; import type { IProjectEstimateStore } from "./estimates/project-estimate.store";
import { ProjectEstimateStore } 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 type { IFavoriteStore } from "./favorite.store";
import { FavoriteStore } from "./favorite.store"; import { FavoriteStore } from "./favorite.store";
import type { IGlobalViewStore } from "./global-view.store"; import type { IGlobalViewStore } from "./global-view.store";
@ -93,6 +95,7 @@ export class CoreRootStore {
instance: IInstanceStore; instance: IInstanceStore;
user: IUserStore; user: IUserStore;
projectInbox: IProjectInboxStore; projectInbox: IProjectInboxStore;
projectExternalContours: IProjectExternalContoursStore;
projectEstimate: IProjectEstimateStore; projectEstimate: IProjectEstimateStore;
multipleSelect: IMultipleSelectStore; multipleSelect: IMultipleSelectStore;
workspaceNotification: IWorkspaceNotificationStore; workspaceNotification: IWorkspaceNotificationStore;
@ -123,6 +126,7 @@ export class CoreRootStore {
this.dashboard = new DashboardStore(this); this.dashboard = new DashboardStore(this);
this.multipleSelect = new MultipleSelectStore(); this.multipleSelect = new MultipleSelectStore();
this.projectInbox = new ProjectInboxStore(this); this.projectInbox = new ProjectInboxStore(this);
this.projectExternalContours = new ProjectExternalContoursStore(this);
this.projectPages = new ProjectPageStore(this as unknown as RootStore); this.projectPages = new ProjectPageStore(this as unknown as RootStore);
this.projectEstimate = new ProjectEstimateStore(this); this.projectEstimate = new ProjectEstimateStore(this);
this.workspaceNotification = new WorkspaceNotificationStore(this); this.workspaceNotification = new WorkspaceNotificationStore(this);
@ -156,6 +160,7 @@ export class CoreRootStore {
this.label = new LabelStore(this); this.label = new LabelStore(this);
this.dashboard = new DashboardStore(this); this.dashboard = new DashboardStore(this);
this.projectInbox = new ProjectInboxStore(this); this.projectInbox = new ProjectInboxStore(this);
this.projectExternalContours = new ProjectExternalContoursStore(this);
this.projectPages = new ProjectPageStore(this as unknown as RootStore); this.projectPages = new ProjectPageStore(this as unknown as RootStore);
this.multipleSelect = new MultipleSelectStore(); this.multipleSelect = new MultipleSelectStore();
this.projectEstimate = new ProjectEstimateStore(this); this.projectEstimate = new ProjectEstimateStore(this);

View File

@ -318,6 +318,8 @@ export default {
modal: { modal: {
title: "Add request", title: "Add request",
submit: "Send", 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_title: "Routing is not connected yet",
toast_message: toast_message:
"The UI now uses the intake shell. The next stage will connect backend routing and real delivery to the target contour.", "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: { modal: {
title: "Добавить запрос", title: "Добавить запрос",
submit: "Отправить", submit: "Отправить",
success_message: "Запрос отправлен во внешний контур.",
error_message: "Не удалось отправить запрос во внешний контур.",
toast_title: "Маршрутизация ещё не подключена", toast_title: "Маршрутизация ещё не подключена",
toast_message: toast_message:
"UI уже переведён на shell предложений. На следующем этапе подключим backend-маршрутизацию и реальную отправку в целевой контур.", "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 "./enums";
export * from "./epics"; export * from "./epics";
export * from "./estimate"; export * from "./estimate";
export * from "./external-contours";
export * from "./favorite"; export * from "./favorite";
export * from "./file"; export * from "./file";
export * from "./home"; export * from "./home";