ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: отправка во внешний контур и source-side список
This commit is contained in:
parent
390bcdbf38
commit
fb33f093de
|
|
@ -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. Отдельный модуль
|
||||
|
|
|
|||
|
|
@ -57,6 +57,15 @@
|
|||
- задача не зависает в triage
|
||||
- source-side список уже видит факт отправки
|
||||
|
||||
### Статус
|
||||
|
||||
Реализовано.
|
||||
|
||||
Что работает фактически:
|
||||
- `POST /external-contours/` создает target issue в целевом проекте
|
||||
- target issue сразу попадает в обычный workflow целевого проекта
|
||||
- source-side `GET /external-contours/` возвращает отправленные запросы с metadata источника и цели
|
||||
|
||||
## Этап 2. Source-side список и статусная пришлепка
|
||||
|
||||
### Цель
|
||||
|
|
@ -78,6 +87,21 @@
|
|||
- в проекте-источнике виден список отправленных запросов
|
||||
- у каждой записи отображается актуальный статус целевой задачи
|
||||
|
||||
### Статус
|
||||
|
||||
Реализовано частично в рамках текущего вертикального среза.
|
||||
|
||||
Что уже работает:
|
||||
- source-side список `Открытые / Завершенные`
|
||||
- status pill по фактическому state целевой задачи
|
||||
- отображение целевого проекта
|
||||
- открытие source-side detail экрана
|
||||
|
||||
Что еще остается на следующие этапы:
|
||||
- индикатор новых изменений
|
||||
- полноценная зеркальная activity/history
|
||||
- уведомления
|
||||
|
||||
## Этап 3. Source-side детальный экран и зеркалирование изменений
|
||||
|
||||
### Цель
|
||||
|
|
|
|||
|
|
@ -53,6 +53,10 @@ from .intake import (
|
|||
IntakeIssueCreateSerializer,
|
||||
IntakeIssueUpdateSerializer,
|
||||
)
|
||||
from .external_contours import (
|
||||
ExternalContourRequestCreateSerializer,
|
||||
ExternalContourRequestSerializer,
|
||||
)
|
||||
from .estimate import EstimateSerializer, EstimatePointSerializer
|
||||
from .asset import (
|
||||
UserAssetUploadSerializer,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
from .asset import urlpatterns as asset_patterns
|
||||
from .cycle import urlpatterns as cycle_patterns
|
||||
from .external_contours import urlpatterns as external_contour_patterns
|
||||
from .intake import urlpatterns as intake_patterns
|
||||
from .label import urlpatterns as label_patterns
|
||||
from .member import urlpatterns as member_patterns
|
||||
|
|
@ -18,6 +19,7 @@ from .sticky import urlpatterns as sticky_patterns
|
|||
urlpatterns = [
|
||||
*asset_patterns,
|
||||
*cycle_patterns,
|
||||
*external_contour_patterns,
|
||||
*intake_patterns,
|
||||
*label_patterns,
|
||||
*member_patterns,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
),
|
||||
]
|
||||
|
|
@ -55,6 +55,10 @@ from .intake import (
|
|||
IntakeIssueListCreateAPIEndpoint,
|
||||
IntakeIssueDetailAPIEndpoint,
|
||||
)
|
||||
from .external_contours import (
|
||||
ExternalContourListCreateAPIEndpoint,
|
||||
ExternalContourDetailAPIEndpoint,
|
||||
)
|
||||
|
||||
from .asset import UserAssetEndpoint, UserServerAssetEndpoint, GenericAssetEndpoint
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -6,51 +6,20 @@
|
|||
|
||||
import { observer } from "mobx-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useTheme } from "next-themes";
|
||||
import { EInboxIssueCurrentTab } from "@plane/types";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import darkIntakeAsset from "@/app/assets/empty-state/disabled-feature/intake-dark.webp?url";
|
||||
import lightIntakeAsset from "@/app/assets/empty-state/disabled-feature/intake-light.webp?url";
|
||||
import { PageHead } from "@/components/core/page-title";
|
||||
import { DetailedEmptyState } from "@/components/empty-state/detailed-empty-state-root";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
import { EUserPermissionsLevel } from "@plane/constants";
|
||||
import { EUserProjectRoles } from "@plane/types";
|
||||
import { ExternalContoursRoot } from "@/plane-web/components/projects/external-contours/root";
|
||||
import type { Route } from "./+types/page";
|
||||
|
||||
function ProjectExternalContoursPage({ params }: Route.ComponentProps) {
|
||||
const router = useAppRouter();
|
||||
const { workspaceSlug, projectId } = params;
|
||||
const searchParams = useSearchParams();
|
||||
const navigationTab = searchParams.get("currentTab");
|
||||
const inboxIssueId = searchParams.get("inboxIssueId");
|
||||
const { resolvedTheme } = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const { currentProjectDetails } = useProject();
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
const canPerformEmptyStateActions = allowPermissions([EUserProjectRoles.ADMIN], EUserPermissionsLevel.PROJECT);
|
||||
const resolvedPath = resolvedTheme === "light" ? lightIntakeAsset : darkIntakeAsset;
|
||||
|
||||
if (currentProjectDetails?.inbox_view === false)
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<DetailedEmptyState
|
||||
title={t("external_contours_page.disabled.title")}
|
||||
description={t("external_contours_page.disabled.description")}
|
||||
assetPath={resolvedPath}
|
||||
primaryButton={{
|
||||
text: t("external_contours_page.disabled.button"),
|
||||
onClick: () => {
|
||||
router.push(`/${workspaceSlug}/settings/projects/${projectId}/features`);
|
||||
},
|
||||
disabled: !canPerformEmptyStateActions,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const pageTitle = currentProjectDetails?.name
|
||||
? t("external_contours_page.page_label", { workspace: currentProjectDetails.name })
|
||||
|
|
@ -70,7 +39,6 @@ function ProjectExternalContoursPage({ params }: Route.ComponentProps) {
|
|||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
inboxIssueId={inboxIssueId || undefined}
|
||||
inboxAccessible={currentProjectDetails?.inbox_view || false}
|
||||
navigationTab={currentNavigationTab}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import useSWR from "swr";
|
|||
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import type { TNameDescriptionLoader } from "@plane/types";
|
||||
import { ContentWrapper } from "@plane/ui";
|
||||
import { useProjectInbox } from "@/hooks/store/use-project-inbox";
|
||||
import { useProjectExternalContours } from "@/hooks/store/use-project-external-contours";
|
||||
import { useUser, useUserPermissions } from "@/hooks/store/user";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
import { ExternalContoursIssueActionsHeader } from "./issue-header";
|
||||
|
|
@ -29,11 +29,13 @@ export const ExternalContoursContentRoot = observer(function ExternalContoursCon
|
|||
const router = useAppRouter();
|
||||
const [isSubmitting, setIsSubmitting] = useState<TNameDescriptionLoader>("saved");
|
||||
const { data: currentUser } = useUser();
|
||||
const { currentTab, fetchInboxIssueById, getIssueInboxByIssueId, getIsIssueAvailable } = useProjectInbox();
|
||||
const inboxIssue = getIssueInboxByIssueId(inboxIssueId);
|
||||
const { currentTab, fetchRequestById, getRequestById, getIsRequestAvailable } = useProjectExternalContours();
|
||||
const contourRequest = getRequestById(inboxIssueId);
|
||||
const issue = contourRequest?.issue;
|
||||
const targetProjectId = issue?.project_id || projectId;
|
||||
const { allowPermissions, getProjectRoleByWorkspaceSlugAndProjectId } = useUserPermissions();
|
||||
|
||||
const isIssueAvailable = getIsIssueAvailable(inboxIssueId?.toString() || "");
|
||||
const isIssueAvailable = getIsRequestAvailable(inboxIssueId?.toString() || "");
|
||||
|
||||
useEffect(() => {
|
||||
if (!isIssueAvailable && inboxIssueId) {
|
||||
|
|
@ -43,20 +45,23 @@ export const ExternalContoursContentRoot = observer(function ExternalContoursCon
|
|||
}, [isIssueAvailable]);
|
||||
|
||||
useSWR(
|
||||
workspaceSlug && projectId && inboxIssueId ? `PROJECT_EXTERNAL_CONTOUR_ISSUE_DETAIL_${workspaceSlug}_${projectId}_${inboxIssueId}` : null,
|
||||
workspaceSlug && projectId && inboxIssueId ? () => fetchInboxIssueById(workspaceSlug, projectId, inboxIssueId) : null,
|
||||
workspaceSlug && projectId && inboxIssueId
|
||||
? `PROJECT_EXTERNAL_CONTOUR_DETAIL_${workspaceSlug}_${projectId}_${inboxIssueId}`
|
||||
: null,
|
||||
workspaceSlug && projectId && inboxIssueId ? () => fetchRequestById(workspaceSlug, projectId, inboxIssueId) : null,
|
||||
{ revalidateOnFocus: false, revalidateIfStale: false }
|
||||
);
|
||||
|
||||
const isEditable =
|
||||
allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT, workspaceSlug, projectId) ||
|
||||
inboxIssue?.issue.created_by === currentUser?.id;
|
||||
!!targetProjectId &&
|
||||
(allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT, workspaceSlug, targetProjectId) ||
|
||||
issue?.created_by === currentUser?.id);
|
||||
|
||||
const isGuest = getProjectRoleByWorkspaceSlugAndProjectId(workspaceSlug, projectId) === EUserPermissions.GUEST;
|
||||
const isOwner = inboxIssue?.issue.created_by === currentUser?.id;
|
||||
const isGuest = !!targetProjectId && getProjectRoleByWorkspaceSlugAndProjectId(workspaceSlug, targetProjectId) === EUserPermissions.GUEST;
|
||||
const isOwner = issue?.created_by === currentUser?.id;
|
||||
const readOnly = !isOwner && isGuest;
|
||||
|
||||
if (!inboxIssue) return <></>;
|
||||
if (!contourRequest || !issue) return <></>;
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full w-full flex-col overflow-hidden">
|
||||
|
|
@ -65,17 +70,17 @@ export const ExternalContoursContentRoot = observer(function ExternalContoursCon
|
|||
setIsMobileSidebar={setIsMobileSidebar}
|
||||
isMobileSidebar={isMobileSidebar}
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
inboxIssue={inboxIssue}
|
||||
sourceProjectId={projectId}
|
||||
contourRequest={contourRequest}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
<ContentWrapper className="divide-y-2 divide-subtle-1">
|
||||
<ExternalContoursIssueMainContent
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
inboxIssue={inboxIssue}
|
||||
isEditable={isEditable && !readOnly}
|
||||
sourceProjectId={projectId}
|
||||
contourRequest={contourRequest}
|
||||
isEditable={!!isEditable && !readOnly}
|
||||
isSubmitting={isSubmitting}
|
||||
setIsSubmitting={setIsSubmitting}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -12,9 +12,12 @@ import { useTranslation } from "@plane/i18n";
|
|||
import { Button } from "@plane/propel/button";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { TIssue } from "@plane/types";
|
||||
import { EInboxIssueCurrentTab } from "@plane/types";
|
||||
import { ToggleSwitch } from "@plane/ui";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useProjectExternalContours } from "@/hooks/store/use-project-external-contours";
|
||||
import { useWorkspace } from "@/hooks/store/use-workspace";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
import { InboxIssueDescription } from "@/components/inbox/modals/create-modal/issue-description";
|
||||
import { InboxIssueTitle } from "@/components/inbox/modals/create-modal/issue-title";
|
||||
import { ExternalContoursCreateProperties } from "./create-properties";
|
||||
|
|
@ -39,9 +42,11 @@ type Props = {
|
|||
export const ExternalContoursCreateRoot = observer(function ExternalContoursCreateRoot(props: Props) {
|
||||
const { workspaceSlug, projectId, handleModalClose } = props;
|
||||
const { t } = useTranslation();
|
||||
const router = useAppRouter();
|
||||
const { getWorkspaceBySlug } = useWorkspace();
|
||||
const workspaceId = getWorkspaceBySlug(workspaceSlug)?.id;
|
||||
const { currentProjectDetails } = useProject();
|
||||
const { createRequest } = useProjectExternalContours();
|
||||
const descriptionEditorRef = useRef<EditorRefApi>(null);
|
||||
const [createMore, setCreateMore] = useState(false);
|
||||
const [formSubmitting, setFormSubmitting] = useState(false);
|
||||
|
|
@ -67,15 +72,32 @@ export const ExternalContoursCreateRoot = observer(function ExternalContoursCrea
|
|||
}
|
||||
|
||||
setFormSubmitting(true);
|
||||
setToast({
|
||||
type: TOAST_TYPE.INFO,
|
||||
title: t("external_contours_page.modal.toast_title"),
|
||||
message: t("external_contours_page.modal.toast_message"),
|
||||
});
|
||||
setFormSubmitting(false);
|
||||
|
||||
if (!createMore) {
|
||||
handleModalClose();
|
||||
try {
|
||||
const createdRequest = await createRequest(workspaceSlug, projectId, formData);
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: t("success"),
|
||||
message: t("external_contours_page.modal.success_message"),
|
||||
});
|
||||
|
||||
if (createMore) {
|
||||
setFormData(defaultIssueData);
|
||||
} else {
|
||||
handleModalClose();
|
||||
}
|
||||
|
||||
if (createdRequest?.id) {
|
||||
router.push(`/${workspaceSlug}/projects/${projectId}/external-contours?currentTab=${EInboxIssueCurrentTab.OPEN}&inboxIssueId=${createdRequest.id}`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: t("error"),
|
||||
message: error?.error || t("external_contours_page.modal.error_message"),
|
||||
});
|
||||
} finally {
|
||||
setFormSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import { TransferIcon } from "@plane/propel/icons";
|
|||
import { Breadcrumbs, Header } from "@plane/ui";
|
||||
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useProjectInbox } from "@/hooks/store/use-project-inbox";
|
||||
import { useProjectExternalContours } from "@/hooks/store/use-project-external-contours";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common";
|
||||
import { ExternalContourCreateModalRoot } from "./create-modal";
|
||||
|
|
@ -25,8 +25,8 @@ export const ProjectExternalContoursHeader = observer(function ProjectExternalCo
|
|||
const { workspaceSlug, projectId } = useParams();
|
||||
const { t } = useTranslation();
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
const { currentProjectDetails, loader: currentProjectDetailsLoader } = useProject();
|
||||
const { loader } = useProjectInbox();
|
||||
const { loader: currentProjectDetailsLoader } = useProject();
|
||||
const { loader } = useProjectExternalContours();
|
||||
|
||||
const isAuthorized = allowPermissions(
|
||||
[EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST],
|
||||
|
|
@ -52,7 +52,7 @@ export const ProjectExternalContoursHeader = observer(function ProjectExternalCo
|
|||
/>
|
||||
</Breadcrumbs>
|
||||
|
||||
{loader === "pagination-loading" && (
|
||||
{(loader === "mutation-loading" || loader === "issue-loading") && (
|
||||
<div className="flex items-center gap-1.5 text-tertiary">
|
||||
<RefreshCcw className="h-3.5 w-3.5 animate-spin" />
|
||||
<p className="text-13">{t("syncing")}...</p>
|
||||
|
|
@ -61,7 +61,7 @@ export const ProjectExternalContoursHeader = observer(function ProjectExternalCo
|
|||
</div>
|
||||
</Header.LeftItem>
|
||||
<Header.RightItem>
|
||||
{currentProjectDetails?.inbox_view && workspaceSlug && projectId && isAuthorized ? (
|
||||
{workspaceSlug && projectId && isAuthorized ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<ExternalContourCreateModalRoot
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
|
|
|
|||
|
|
@ -10,51 +10,49 @@ import { PanelLeft } from "lucide-react";
|
|||
import { useTranslation } from "@plane/i18n";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { IconButton } from "@plane/propel/icon-button";
|
||||
import { CheckCircleFilledIcon, ChevronDownIcon, ChevronUpIcon, CloseCircleFilledIcon, LinkIcon, NewTabIcon } from "@plane/propel/icons";
|
||||
import { ChevronDownIcon, ChevronUpIcon, LinkIcon, NewTabIcon } from "@plane/propel/icons";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { TNameDescriptionLoader } from "@plane/types";
|
||||
import { EInboxIssueCurrentTab } from "@plane/types";
|
||||
import type { TExternalContourRequest, TNameDescriptionLoader } from "@plane/types";
|
||||
import { ControlLink, Header, Row } from "@plane/ui";
|
||||
import { copyUrlToClipboard, generateWorkItemLink } from "@plane/utils";
|
||||
import { NameDescriptionUpdateStatus } from "@/components/issues/issue-update-status";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useProjectInbox } from "@/hooks/store/use-project-inbox";
|
||||
import { useProjectExternalContours } from "@/hooks/store/use-project-external-contours";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
import type { IInboxIssueStore } from "@/store/inbox/inbox-issue.store";
|
||||
import { InboxIssueStatus } from "@/components/inbox/inbox-issue-status";
|
||||
import { ExternalContourStatePill } from "./state-pill";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
inboxIssue: IInboxIssueStore | undefined;
|
||||
sourceProjectId: string;
|
||||
contourRequest: TExternalContourRequest;
|
||||
isSubmitting: TNameDescriptionLoader;
|
||||
isMobileSidebar: boolean;
|
||||
setIsMobileSidebar: (value: boolean) => void;
|
||||
};
|
||||
|
||||
export const ExternalContoursIssueActionsHeader = observer(function ExternalContoursIssueActionsHeader(props: Props) {
|
||||
const { workspaceSlug, projectId, inboxIssue, isSubmitting, isMobileSidebar, setIsMobileSidebar } = props;
|
||||
const { workspaceSlug, sourceProjectId, contourRequest, isSubmitting, isMobileSidebar, setIsMobileSidebar } = props;
|
||||
const { t } = useTranslation();
|
||||
const router = useAppRouter();
|
||||
const { currentTab, filteredInboxIssueIds } = useProjectInbox();
|
||||
const { currentTab, filteredRequestIds } = useProjectExternalContours();
|
||||
const { getProjectById } = useProject();
|
||||
|
||||
const issue = inboxIssue?.issue;
|
||||
const currentInboxIssueId = issue?.id;
|
||||
const issue = contourRequest.issue;
|
||||
const currentRequestId = contourRequest.id;
|
||||
|
||||
const redirectToRelativeIssue = useCallback(
|
||||
(direction: "next" | "prev") => {
|
||||
if (!filteredInboxIssueIds || !currentInboxIssueId) return;
|
||||
const currentIssueIndex = filteredInboxIssueIds.findIndex((issueId) => issueId === currentInboxIssueId);
|
||||
if (!filteredRequestIds || !currentRequestId) return;
|
||||
const currentIssueIndex = filteredRequestIds.findIndex((requestId) => requestId === currentRequestId);
|
||||
const nextIssueIndex =
|
||||
direction === "next"
|
||||
? (currentIssueIndex + 1) % filteredInboxIssueIds.length
|
||||
: (currentIssueIndex - 1 + filteredInboxIssueIds.length) % filteredInboxIssueIds.length;
|
||||
const nextIssueId = filteredInboxIssueIds[nextIssueIndex];
|
||||
? (currentIssueIndex + 1) % filteredRequestIds.length
|
||||
: (currentIssueIndex - 1 + filteredRequestIds.length) % filteredRequestIds.length;
|
||||
const nextIssueId = filteredRequestIds[nextIssueIndex];
|
||||
if (!nextIssueId) return;
|
||||
router.push(`/${workspaceSlug}/projects/${projectId}/external-contours?currentTab=${currentTab}&inboxIssueId=${nextIssueId}`);
|
||||
router.push(`/${workspaceSlug}/projects/${sourceProjectId}/external-contours?currentTab=${currentTab}&inboxIssueId=${nextIssueId}`);
|
||||
},
|
||||
[currentInboxIssueId, currentTab, filteredInboxIssueIds, projectId, router, workspaceSlug]
|
||||
[currentRequestId, currentTab, filteredRequestIds, router, sourceProjectId, workspaceSlug]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -67,23 +65,15 @@ export const ExternalContoursIssueActionsHeader = observer(function ExternalCont
|
|||
return () => document.removeEventListener("keydown", onKeyDown);
|
||||
}, [redirectToRelativeIssue]);
|
||||
|
||||
if (!issue || !inboxIssue) return null;
|
||||
|
||||
const targetProjectIdentifier = issue.project_detail?.identifier || getProjectById(issue.project_id || "")?.identifier;
|
||||
const workItemLink = generateWorkItemLink({
|
||||
workspaceSlug: workspaceSlug?.toString(),
|
||||
projectId: issue.project_id,
|
||||
issueId: currentInboxIssueId,
|
||||
projectIdentifier: getProjectById(issue.project_id)?.identifier,
|
||||
issueId: issue.id,
|
||||
projectIdentifier: targetProjectIdentifier,
|
||||
sequenceId: issue.sequence_id,
|
||||
});
|
||||
|
||||
const showWorkflowToast = (actionLabel: string) =>
|
||||
setToast({
|
||||
type: TOAST_TYPE.INFO,
|
||||
title: t("external_contours_page.actions.unsupported_title"),
|
||||
message: t("external_contours_page.actions.unsupported_message", { action: actionLabel.toLowerCase() }),
|
||||
});
|
||||
|
||||
const handleCopyLink = () =>
|
||||
copyUrlToClipboard(workItemLink).then(() =>
|
||||
setToast({
|
||||
|
|
@ -93,18 +83,16 @@ export const ExternalContoursIssueActionsHeader = observer(function ExternalCont
|
|||
})
|
||||
);
|
||||
|
||||
const isOpenTab = currentTab === EInboxIssueCurrentTab.OPEN;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Row className="relative z-15 hidden h-full w-full items-center justify-between gap-2 border-b border-subtle bg-surface-1 lg:flex">
|
||||
<div className="flex items-center gap-4">
|
||||
{issue?.project_id && issue.sequence_id && (
|
||||
<h3 className="flex-shrink-0 text-14 font-medium text-tertiary">
|
||||
{getProjectById(issue.project_id)?.identifier}-{issue.sequence_id}
|
||||
{targetProjectIdentifier}-{issue.sequence_id}
|
||||
</h3>
|
||||
)}
|
||||
<InboxIssueStatus inboxIssue={inboxIssue} iconSize={12} />
|
||||
<ExternalContourStatePill request={contourRequest} />
|
||||
<div className="flex w-full items-center justify-end">
|
||||
<NameDescriptionUpdateStatus isSubmitting={isSubmitting} />
|
||||
</div>
|
||||
|
|
@ -117,30 +105,6 @@ export const ExternalContoursIssueActionsHeader = observer(function ExternalCont
|
|||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{isOpenTab ? (
|
||||
<>
|
||||
<Button variant="secondary" size="lg" onClick={() => showWorkflowToast(t("external_contours_page.actions.send"))}>
|
||||
<CheckCircleFilledIcon className="size-4 shrink-0 text-success-secondary" />
|
||||
{t("external_contours_page.actions.send")}
|
||||
</Button>
|
||||
<Button variant="secondary" size="lg" onClick={() => showWorkflowToast(t("external_contours_page.actions.decline"))}>
|
||||
<CloseCircleFilledIcon className="size-4 shrink-0 text-danger-secondary" />
|
||||
{t("external_contours_page.actions.decline")}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="secondary" size="lg" onClick={() => showWorkflowToast(t("external_contours_page.actions.accept"))}>
|
||||
<CheckCircleFilledIcon className="size-4 shrink-0 text-success-secondary" />
|
||||
{t("external_contours_page.actions.accept")}
|
||||
</Button>
|
||||
<Button variant="secondary" size="lg" onClick={() => showWorkflowToast(t("external_contours_page.actions.decline"))}>
|
||||
<CloseCircleFilledIcon className="size-4 shrink-0 text-danger-secondary" />
|
||||
{t("external_contours_page.actions.decline")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button variant="secondary" size="lg" prependIcon={<LinkIcon className="h-2.5 w-2.5" />} onClick={handleCopyLink}>
|
||||
{t("external_contours_page.actions.copy")}
|
||||
</Button>
|
||||
|
|
@ -159,14 +123,13 @@ export const ExternalContoursIssueActionsHeader = observer(function ExternalCont
|
|||
className={`my-auto mr-2 h-4 w-4 flex-shrink-0 ${isMobileSidebar ? "text-accent-primary" : "text-secondary"}`}
|
||||
/>
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<InboxIssueStatus inboxIssue={inboxIssue} iconSize={12} />
|
||||
<ExternalContourStatePill request={contourRequest} />
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<Button variant="secondary" size="sm" onClick={() => showWorkflowToast(isOpenTab ? t("external_contours_page.actions.send") : t("external_contours_page.actions.accept"))}>
|
||||
{isOpenTab ? t("external_contours_page.actions.send") : t("external_contours_page.actions.accept")}
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" onClick={() => showWorkflowToast(t("external_contours_page.actions.decline"))}>
|
||||
{t("external_contours_page.actions.decline")}
|
||||
</Button>
|
||||
<ControlLink href={workItemLink} onClick={() => router.push(workItemLink)} target="_self">
|
||||
<Button variant="secondary" size="sm">
|
||||
{t("external_contours_page.actions.open")}
|
||||
</Button>
|
||||
</ControlLink>
|
||||
</div>
|
||||
</div>
|
||||
</Header>
|
||||
|
|
|
|||
|
|
@ -7,52 +7,39 @@
|
|||
import { observer } from "mobx-react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import {
|
||||
DuplicatePropertyIcon,
|
||||
DueDatePropertyIcon,
|
||||
LabelPropertyIcon,
|
||||
MembersPropertyIcon,
|
||||
PriorityPropertyIcon,
|
||||
StatePropertyIcon,
|
||||
} from "@plane/propel/icons";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import type { TInboxDuplicateIssueDetails, TIssue } from "@plane/types";
|
||||
import { ControlLink } from "@plane/ui";
|
||||
import { generateWorkItemLink, getDate, renderFormattedPayloadDate } from "@plane/utils";
|
||||
import type { TIssue } from "@plane/types";
|
||||
import { Badge } from "@plane/propel/badge";
|
||||
import { getDate, renderFormattedPayloadDate } from "@plane/utils";
|
||||
import { DateDropdown } from "@/components/dropdowns/date";
|
||||
import { MemberDropdown } from "@/components/dropdowns/member/dropdown";
|
||||
import { PriorityDropdown } from "@/components/dropdowns/priority";
|
||||
import { IssueLabelSelect } from "@/components/issues/select";
|
||||
import type { TIssueOperations } from "@/components/issues/issue-detail";
|
||||
import { IssueLabel } from "@/components/issues/issue-detail/label";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issue: Partial<TIssue>;
|
||||
targetProjectId: string;
|
||||
issue: Partial<TIssue> & {
|
||||
project_detail?: { name?: string } | null;
|
||||
};
|
||||
issueOperations: TIssueOperations;
|
||||
isEditable: boolean;
|
||||
duplicateIssueDetails: TInboxDuplicateIssueDetails | undefined;
|
||||
};
|
||||
|
||||
export const ExternalContoursIssueContentProperties = observer(function ExternalContoursIssueContentProperties(props: Props) {
|
||||
const { workspaceSlug, projectId, issue, issueOperations, isEditable, duplicateIssueDetails } = props;
|
||||
const { workspaceSlug, targetProjectId, issue, issueOperations, isEditable } = props;
|
||||
const { t } = useTranslation();
|
||||
const router = useAppRouter();
|
||||
const { currentProjectDetails } = useProject();
|
||||
|
||||
const minDate = issue.start_date ? getDate(issue.start_date) : null;
|
||||
minDate?.setDate(minDate.getDate());
|
||||
if (!issue || !issue?.id) return <></>;
|
||||
|
||||
const duplicateWorkItemLink = generateWorkItemLink({
|
||||
workspaceSlug: workspaceSlug?.toString(),
|
||||
projectId,
|
||||
issueId: duplicateIssueDetails?.id,
|
||||
projectIdentifier: currentProjectDetails?.identifier,
|
||||
sequenceId: duplicateIssueDetails?.sequence_id,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col divide-y-2 divide-subtle-1">
|
||||
<div className="w-full overflow-y-auto">
|
||||
|
|
@ -64,8 +51,8 @@ export const ExternalContoursIssueContentProperties = observer(function External
|
|||
<StatePropertyIcon className="h-4 w-4 flex-shrink-0" />
|
||||
<span>{t("external_contours_page.properties.target_contour")}</span>
|
||||
</div>
|
||||
<div className="w-3/5 flex-grow text-13 text-placeholder">
|
||||
{t("external_contours_page.properties.target_contour_placeholder")}
|
||||
<div className="w-3/5 flex-grow text-13">
|
||||
<Badge variant="neutral">{issue.project_detail?.name || t("common.none")}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -76,9 +63,9 @@ export const ExternalContoursIssueContentProperties = observer(function External
|
|||
</div>
|
||||
<MemberDropdown
|
||||
value={issue?.assignee_ids ?? []}
|
||||
onChange={(val) => issue?.id && issueOperations.update(workspaceSlug, projectId, issue.id, { assignee_ids: val })}
|
||||
onChange={(val) => issue?.id && issueOperations.update(workspaceSlug, targetProjectId, issue.id, { assignee_ids: val })}
|
||||
disabled={!isEditable}
|
||||
projectId={projectId?.toString() ?? ""}
|
||||
projectId={targetProjectId}
|
||||
placeholder={t("assignee")}
|
||||
multiple
|
||||
buttonVariant={(issue?.assignee_ids || []).length > 0 ? "transparent-without-text" : "transparent-with-text"}
|
||||
|
|
@ -98,7 +85,7 @@ export const ExternalContoursIssueContentProperties = observer(function External
|
|||
</div>
|
||||
<PriorityDropdown
|
||||
value={issue?.priority}
|
||||
onChange={(val) => issue?.id && issueOperations.update(workspaceSlug, projectId, issue.id, { priority: val })}
|
||||
onChange={(val) => issue?.id && issueOperations.update(workspaceSlug, targetProjectId, issue.id, { priority: val })}
|
||||
disabled={!isEditable}
|
||||
buttonVariant="border-with-text"
|
||||
className="w-3/5 flex-grow rounded-sm px-2 hover:bg-layer-1"
|
||||
|
|
@ -121,7 +108,7 @@ export const ExternalContoursIssueContentProperties = observer(function External
|
|||
value={issue.target_date || null}
|
||||
onChange={(val) =>
|
||||
issue?.id &&
|
||||
issueOperations.update(workspaceSlug, projectId, issue.id, {
|
||||
issueOperations.update(workspaceSlug, targetProjectId, issue.id, {
|
||||
target_date: val ? renderFormattedPayloadDate(val) : null,
|
||||
})
|
||||
}
|
||||
|
|
@ -142,34 +129,14 @@ export const ExternalContoursIssueContentProperties = observer(function External
|
|||
<span>{t("labels")}</span>
|
||||
</div>
|
||||
<div className="h-full min-h-8 w-3/5 flex-grow pt-1">
|
||||
{issue?.id && (
|
||||
<IssueLabel
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issue.id}
|
||||
disabled={!isEditable}
|
||||
isInboxIssue
|
||||
onLabelUpdate={(val: string[]) => issue?.id && issueOperations.update(workspaceSlug, projectId, issue.id, { label_ids: val })}
|
||||
/>
|
||||
)}
|
||||
<IssueLabelSelect
|
||||
value={issue.label_ids || []}
|
||||
onChange={(labelIds) => issue?.id && issueOperations.update(workspaceSlug, targetProjectId, issue.id, { label_ids: labelIds })}
|
||||
projectId={targetProjectId}
|
||||
disabled={!isEditable}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{duplicateIssueDetails && (
|
||||
<div className="flex min-h-8 gap-2">
|
||||
<div className="flex w-2/5 flex-shrink-0 gap-1 pt-2 text-13 text-tertiary">
|
||||
<DuplicatePropertyIcon className="h-4 w-4 flex-shrink-0" />
|
||||
<span>{t("external_contours_page.properties.duplicate_of")}</span>
|
||||
</div>
|
||||
<ControlLink href={duplicateWorkItemLink} onClick={() => router.push(duplicateWorkItemLink)} target="_self">
|
||||
<Tooltip tooltipContent={`${duplicateIssueDetails?.name}`}>
|
||||
<span className="flex cursor-pointer items-center gap-1 rounded-sm bg-layer-1 px-1.5 py-1 pb-0.5 text-11 text-secondary">
|
||||
{`${currentProjectDetails?.identifier}-${duplicateIssueDetails?.sequence_id}`}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</ControlLink>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -10,8 +10,8 @@ import { observer } from "mobx-react";
|
|||
import type { EditorRefApi } from "@plane/editor";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { TIssue, TNameDescriptionLoader } from "@plane/types";
|
||||
import { EFileAssetType, EInboxIssueSource } from "@plane/types";
|
||||
import type { TExternalContourRequest, TIssue, TNameDescriptionLoader } from "@plane/types";
|
||||
import { EFileAssetType } from "@plane/types";
|
||||
import { getTextContent } from "@plane/utils";
|
||||
import { DescriptionVersionsRoot } from "@/components/core/description-versions";
|
||||
import { DescriptionInput } from "@/components/editor/rich-text/description-input";
|
||||
|
|
@ -21,38 +21,33 @@ import type { TIssueOperations } from "@/components/issues/issue-detail";
|
|||
import { IssueActivity } from "@/components/issues/issue-detail/issue-activity";
|
||||
import { IssueReaction } from "@/components/issues/issue-detail/reactions";
|
||||
import { IssueTitleInput } from "@/components/issues/title-input";
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
import { useMember } from "@/hooks/store/use-member";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useProjectInbox } from "@/hooks/store/use-project-inbox";
|
||||
import { useProjectExternalContours } from "@/hooks/store/use-project-external-contours";
|
||||
import { useUser } from "@/hooks/store/user";
|
||||
import useReloadConfirmations from "@/hooks/use-reload-confirmation";
|
||||
import { DeDupeIssuePopoverRoot } from "@/plane-web/components/de-dupe/duplicate-popover";
|
||||
import { useDebouncedDuplicateIssues } from "@/plane-web/hooks/use-debounced-duplicate-issues";
|
||||
import { IntakeWorkItemVersionService } from "@/services/inbox";
|
||||
import type { IInboxIssueStore } from "@/store/inbox/inbox-issue.store";
|
||||
import { IssueService } from "@/services/issue/issue.service";
|
||||
import { WorkItemVersionService } from "@/services/issue/work_item_version.service";
|
||||
import { ExternalContoursIssueContentProperties } from "./issue-properties";
|
||||
|
||||
const intakeWorkItemVersionService = new IntakeWorkItemVersionService();
|
||||
const workItemVersionService = new WorkItemVersionService();
|
||||
const issueService = new IssueService();
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
inboxIssue: IInboxIssueStore;
|
||||
sourceProjectId: string;
|
||||
contourRequest: TExternalContourRequest;
|
||||
isEditable: boolean;
|
||||
isSubmitting: TNameDescriptionLoader;
|
||||
setIsSubmitting: Dispatch<SetStateAction<TNameDescriptionLoader>>;
|
||||
};
|
||||
|
||||
export const ExternalContoursIssueMainContent = observer(function ExternalContoursIssueMainContent(props: Props) {
|
||||
const { workspaceSlug, projectId, inboxIssue, isEditable, isSubmitting, setIsSubmitting } = props;
|
||||
const { workspaceSlug, sourceProjectId, contourRequest, isEditable, isSubmitting, setIsSubmitting } = props;
|
||||
const { t } = useTranslation();
|
||||
const editorRef = useRef<EditorRefApi>(null);
|
||||
const { data: currentUser } = useUser();
|
||||
const { getUserDetails } = useMember();
|
||||
const { loader } = useProjectInbox();
|
||||
const { getProjectById } = useProject();
|
||||
const { removeIssue, archiveIssue } = useIssueDetail();
|
||||
const { loader, updateRequestIssue } = useProjectExternalContours();
|
||||
const { setShowAlert } = useReloadConfirmations(isSubmitting === "submitting");
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -64,43 +59,42 @@ export const ExternalContoursIssueMainContent = observer(function ExternalContou
|
|||
}
|
||||
}, [isSubmitting, setIsSubmitting, setShowAlert]);
|
||||
|
||||
const issue = inboxIssue.issue;
|
||||
const projectDetails = issue?.project_id ? getProjectById(issue.project_id) : undefined;
|
||||
const issue = contourRequest.issue;
|
||||
const targetProjectId = issue.project_id || sourceProjectId;
|
||||
|
||||
const { duplicateIssues } = useDebouncedDuplicateIssues(workspaceSlug, projectDetails?.workspace.toString(), projectId, {
|
||||
name: issue?.name,
|
||||
description_html: getTextContent(issue?.description_html),
|
||||
issueId: issue?.id,
|
||||
});
|
||||
const { duplicateIssues } = useDebouncedDuplicateIssues(
|
||||
workspaceSlug,
|
||||
targetProjectId,
|
||||
targetProjectId,
|
||||
{
|
||||
name: issue?.name,
|
||||
description_html: getTextContent(issue?.description_html),
|
||||
issueId: issue?.id,
|
||||
}
|
||||
);
|
||||
|
||||
const issueOperations: TIssueOperations = useMemo(
|
||||
() => ({
|
||||
fetch: async () => undefined,
|
||||
remove: async (_workspaceSlug: string, _projectId: string, issueId: string) => {
|
||||
try {
|
||||
await removeIssue(workspaceSlug, projectId, issueId);
|
||||
await issueService.deleteIssue(workspaceSlug, targetProjectId, issueId);
|
||||
setToast({ title: t("success"), type: TOAST_TYPE.SUCCESS, message: t("inbox_issue.modals.delete.success") });
|
||||
} catch (error) {
|
||||
console.log("Error in deleting work item:", error);
|
||||
} catch {
|
||||
setToast({ title: t("error"), type: TOAST_TYPE.ERROR, message: t("something_went_wrong_please_try_again") });
|
||||
}
|
||||
},
|
||||
update: async (_workspaceSlug: string, _projectId: string, _issueId: string, data: Partial<TIssue>) => {
|
||||
update: async (_workspaceSlug: string, _projectId: string, issueId: string, data: Partial<TIssue>) => {
|
||||
try {
|
||||
await inboxIssue.updateIssue(data);
|
||||
const updatedIssue = await issueService.patchIssue(workspaceSlug, targetProjectId, issueId, data);
|
||||
updateRequestIssue(contourRequest.id, { ...data, ...updatedIssue });
|
||||
} catch {
|
||||
setToast({ title: t("error"), type: TOAST_TYPE.ERROR, message: t("issue_could_not_be_updated") });
|
||||
}
|
||||
},
|
||||
archive: async (_workspaceSlug: string, _projectId: string, issueId: string) => {
|
||||
try {
|
||||
await archiveIssue(workspaceSlug, projectId, issueId);
|
||||
} catch (error) {
|
||||
console.error("Error in archiving issue:", error);
|
||||
}
|
||||
},
|
||||
archive: async () => undefined,
|
||||
}),
|
||||
[archiveIssue, inboxIssue, projectId, removeIssue, workspaceSlug]
|
||||
[contourRequest.id, targetProjectId, t, updateRequestIssue, workspaceSlug]
|
||||
);
|
||||
|
||||
if (!issue || !issue.project_id || !issue.id) return <></>;
|
||||
|
|
@ -111,16 +105,15 @@ export const ExternalContoursIssueMainContent = observer(function ExternalContou
|
|||
{duplicateIssues.length > 0 && (
|
||||
<DeDupeIssuePopoverRoot
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={issue.project_id}
|
||||
projectId={targetProjectId}
|
||||
rootIssueId={issue.id}
|
||||
issues={duplicateIssues}
|
||||
issueOperations={issueOperations}
|
||||
isIntakeIssue
|
||||
/>
|
||||
)}
|
||||
<IssueTitleInput
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={issue.project_id}
|
||||
projectId={targetProjectId}
|
||||
issueId={issue.id}
|
||||
isSubmitting={isSubmitting}
|
||||
setIsSubmitting={(value) => setIsSubmitting(value)}
|
||||
|
|
@ -143,12 +136,12 @@ export const ExternalContoursIssueMainContent = observer(function ExternalContou
|
|||
initialValue={!issue.description_html || issue.description_html === "" ? "<p></p>" : issue.description_html}
|
||||
key={issue.id}
|
||||
onSubmit={async (value, isMigrationUpdate) => {
|
||||
await issueOperations.update(workspaceSlug, issue.project_id, issue.id, {
|
||||
await issueOperations.update(workspaceSlug, targetProjectId, issue.id, {
|
||||
description_html: value.description_html,
|
||||
...(isMigrationUpdate ? { skip_activity: "true" } : {}),
|
||||
});
|
||||
}}
|
||||
projectId={issue.project_id}
|
||||
projectId={targetProjectId}
|
||||
setIsSubmitting={(value) => setIsSubmitting(value)}
|
||||
workspaceSlug={workspaceSlug}
|
||||
/>
|
||||
|
|
@ -156,27 +149,25 @@ export const ExternalContoursIssueMainContent = observer(function ExternalContou
|
|||
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
{currentUser && (
|
||||
<IssueReaction workspaceSlug={workspaceSlug} projectId={projectId} issueId={issue.id} currentUser={currentUser} />
|
||||
<IssueReaction workspaceSlug={workspaceSlug} projectId={targetProjectId} issueId={issue.id} currentUser={currentUser} />
|
||||
)}
|
||||
{isEditable && (
|
||||
<DescriptionVersionsRoot
|
||||
className="flex-shrink-0"
|
||||
entityInformation={{
|
||||
createdAt: issue.created_at ? new Date(issue.created_at) : new Date(),
|
||||
createdByDisplayName:
|
||||
inboxIssue.source === EInboxIssueSource.FORMS
|
||||
? t("inbox_issue.source.form_user")
|
||||
: (getUserDetails(issue.created_by ?? "")?.display_name ?? ""),
|
||||
createdByDisplayName: issue.created_by_detail?.display_name ?? "",
|
||||
id: issue.id,
|
||||
isRestoreDisabled: !isEditable,
|
||||
}}
|
||||
fetchHandlers={{
|
||||
listDescriptionVersions: (issueId) => intakeWorkItemVersionService.listDescriptionVersions(workspaceSlug, projectId, issueId),
|
||||
listDescriptionVersions: (issueId) =>
|
||||
workItemVersionService.listDescriptionVersions(workspaceSlug, targetProjectId, issueId),
|
||||
retrieveDescriptionVersion: (issueId, versionId) =>
|
||||
intakeWorkItemVersionService.retrieveDescriptionVersion(workspaceSlug, projectId, issueId, versionId),
|
||||
workItemVersionService.retrieveDescriptionVersion(workspaceSlug, targetProjectId, issueId, versionId),
|
||||
}}
|
||||
handleRestore={(descriptionHTML) => editorRef.current?.setEditorValue(descriptionHTML, true)}
|
||||
projectId={projectId}
|
||||
projectId={targetProjectId}
|
||||
workspaceSlug={workspaceSlug}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -184,22 +175,21 @@ export const ExternalContoursIssueMainContent = observer(function ExternalContou
|
|||
</div>
|
||||
|
||||
<div className="py-4">
|
||||
<IssueAttachmentRoot workspaceSlug={workspaceSlug} projectId={projectId} issueId={issue.id} disabled={!isEditable} />
|
||||
<IssueAttachmentRoot workspaceSlug={workspaceSlug} projectId={targetProjectId} issueId={issue.id} disabled={!isEditable} />
|
||||
</div>
|
||||
|
||||
<div className="py-4">
|
||||
<ExternalContoursIssueContentProperties
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
targetProjectId={targetProjectId}
|
||||
issue={issue}
|
||||
issueOperations={issueOperations}
|
||||
isEditable={isEditable}
|
||||
duplicateIssueDetails={inboxIssue.duplicate_issue_detail}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pt-4">
|
||||
<IssueActivity workspaceSlug={workspaceSlug} projectId={projectId} issueId={issue.id} isIntakeIssue />
|
||||
<IssueActivity workspaceSlug={workspaceSlug} projectId={targetProjectId} issueId={issue.id} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -12,71 +12,65 @@ import { useTranslation } from "@plane/i18n";
|
|||
import { PriorityIcon } from "@plane/propel/icons";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import { Avatar, Row } from "@plane/ui";
|
||||
import { cn, getFileURL, renderFormattedDate } from "@plane/utils";
|
||||
import { ButtonAvatars } from "@/components/dropdowns/member/avatar";
|
||||
import { useLabel } from "@/hooks/store/use-label";
|
||||
import { useMember } from "@/hooks/store/use-member";
|
||||
import { useProjectInbox } from "@/hooks/store/use-project-inbox";
|
||||
import { cn, renderFormattedDate } from "@plane/utils";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
import { InboxSourcePill } from "@/plane-web/components/inbox/source-pill";
|
||||
import { InboxIssueStatus } from "@/components/inbox/inbox-issue-status";
|
||||
import { useProjectExternalContours } from "@/hooks/store/use-project-external-contours";
|
||||
import { ExternalContourStatePill } from "./state-pill";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
projectIdentifier?: string;
|
||||
inboxIssueId: string;
|
||||
requestId: string;
|
||||
setIsMobileSidebar: (value: boolean) => void;
|
||||
};
|
||||
|
||||
export const ExternalContoursListItem = observer(function ExternalContoursListItem(props: Props) {
|
||||
const { workspaceSlug, projectId, inboxIssueId, projectIdentifier, setIsMobileSidebar } = props;
|
||||
const { workspaceSlug, projectId, requestId, setIsMobileSidebar } = props;
|
||||
const searchParams = useSearchParams();
|
||||
const selectedInboxIssueId = searchParams.get("inboxIssueId");
|
||||
const { t } = useTranslation();
|
||||
const { currentTab, getIssueInboxByIssueId } = useProjectInbox();
|
||||
const { projectLabels } = useLabel();
|
||||
const { currentTab, getRequestById } = useProjectExternalContours();
|
||||
const { isMobile } = usePlatformOS();
|
||||
const { getUserDetails } = useMember();
|
||||
const inboxIssue = getIssueInboxByIssueId(inboxIssueId);
|
||||
const issue = inboxIssue?.issue;
|
||||
const request = getRequestById(requestId);
|
||||
const issue = request?.issue;
|
||||
|
||||
const handleIssueRedirection = (event: MouseEvent, currentIssueId: string | undefined) => {
|
||||
if (selectedInboxIssueId === currentIssueId) event.preventDefault();
|
||||
setIsMobileSidebar(false);
|
||||
};
|
||||
|
||||
if (!issue) return <></>;
|
||||
if (!request || !issue) return <></>;
|
||||
|
||||
const createdByDetails = issue?.created_by ? getUserDetails(issue?.created_by) : undefined;
|
||||
const createdByDetails = issue.created_by_detail;
|
||||
const visibleLabels = issue.label_details?.slice(0, 3) ?? [];
|
||||
|
||||
return (
|
||||
<Link
|
||||
id={`external-contour-list-item-${issue.id}`}
|
||||
key={`${projectId}_${issue.id}`}
|
||||
href={`/${workspaceSlug}/projects/${projectId}/external-contours?currentTab=${currentTab}&inboxIssueId=${issue.id}`}
|
||||
onClick={(e) => handleIssueRedirection(e, issue.id)}
|
||||
id={`external-contour-list-item-${request.id}`}
|
||||
key={`${projectId}_${request.id}`}
|
||||
href={`/${workspaceSlug}/projects/${projectId}/external-contours?currentTab=${currentTab}&inboxIssueId=${request.id}`}
|
||||
onClick={(e) => handleIssueRedirection(e, request.id)}
|
||||
>
|
||||
<Row
|
||||
className={cn(
|
||||
"relative flex cursor-pointer flex-col gap-2 border border-t-transparent border-r-transparent border-b-subtle-1 border-l-transparent py-4 transition-all hover:bg-accent-primary/5",
|
||||
{ "border border-accent-strong": selectedInboxIssueId === issue.id }
|
||||
{ "border border-accent-strong": selectedInboxIssueId === request.id }
|
||||
)}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<div className="relative flex items-center justify-between gap-2">
|
||||
<div className="flex-shrink-0 text-11 font-medium text-tertiary">
|
||||
{projectIdentifier}-{issue.sequence_id}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{inboxIssue.source && <InboxSourcePill source={inboxIssue.source} />}
|
||||
{inboxIssue.status !== -2 && <InboxIssueStatus inboxIssue={inboxIssue} iconSize={12} />}
|
||||
<div className="flex items-center gap-2 text-11 font-medium text-tertiary">
|
||||
<span>
|
||||
{issue.project_detail?.identifier || "REQ"}-{issue.sequence_id}
|
||||
</span>
|
||||
{issue.project_detail?.name && <span className="truncate text-placeholder">{issue.project_detail.name}</span>}
|
||||
</div>
|
||||
<ExternalContourStatePill request={request} />
|
||||
</div>
|
||||
<h3 className="w-full truncate text-13">{issue.name}</h3>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Tooltip
|
||||
tooltipHeading={t("issues.properties.created_on")}
|
||||
|
|
@ -86,41 +80,21 @@ export const ExternalContoursListItem = observer(function ExternalContoursListIt
|
|||
<div className="text-11 text-secondary">{renderFormattedDate(issue.created_at ?? "")}</div>
|
||||
</Tooltip>
|
||||
|
||||
<div className="rounded-full border-2 border-strong-1" />
|
||||
|
||||
{issue.priority && (
|
||||
{issue.priority && issue.priority !== "none" && (
|
||||
<Tooltip tooltipHeading={t("priority")} tooltipContent={`${issue.priority ?? t("none")}`}>
|
||||
<PriorityIcon priority={issue.priority} withContainer className="h-3 w-3" />
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{issue.label_ids && issue.label_ids.length > 3 ? (
|
||||
<div className="relative flex !h-[17.5px] items-center gap-1 rounded-sm border border-strong px-1 text-11">
|
||||
<span className="h-2 w-2 rounded-full bg-orange-400" />
|
||||
<span className="max-w-28 truncate normal-case">{`${issue.label_ids.length} ${t("labels").toLowerCase()}`}</span>
|
||||
{visibleLabels.map((label) => (
|
||||
<div key={label.id} className="relative flex !h-[17.5px] items-center gap-1 rounded-sm border border-strong px-1 text-11">
|
||||
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: label.color }} />
|
||||
<span className="max-w-28 truncate normal-case">{label.name}</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{(issue.label_ids ?? []).map((labelId) => {
|
||||
const labelDetails = projectLabels?.find((l) => l.id === labelId);
|
||||
if (!labelDetails) return null;
|
||||
return (
|
||||
<div
|
||||
key={labelId}
|
||||
className="relative flex !h-[17.5px] items-center gap-1 rounded-sm border border-strong px-1 text-11"
|
||||
>
|
||||
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: labelDetails.color }} />
|
||||
<span className="max-w-28 truncate normal-case">{labelDetails.name}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
{createdByDetails && createdByDetails.email?.includes("intake@plane.so") ? (
|
||||
<Avatar src={getFileURL("")} name={"NODE.DC"} size="md" showTooltip />
|
||||
) : createdByDetails ? (
|
||||
<ButtonAvatars showTooltip={false} userIds={createdByDetails?.id} />
|
||||
{createdByDetails ? (
|
||||
<Avatar src={createdByDetails.avatar_url || ""} name={createdByDetails.display_name || "NODE.DC"} size="md" showTooltip />
|
||||
) : null}
|
||||
</div>
|
||||
</Row>
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import type { TInboxIssueCurrentTab } from "@plane/types";
|
|||
import { EInboxIssueCurrentTab } from "@plane/types";
|
||||
import { cn } from "@plane/utils";
|
||||
import { InboxLayoutLoader } from "@/components/ui/loader/layouts/project-inbox/inbox-layout-loader";
|
||||
import { useProjectInbox } from "@/hooks/store/use-project-inbox";
|
||||
import { useProjectExternalContours } from "@/hooks/store/use-project-external-contours";
|
||||
import { ExternalContoursContentRoot } from "./content-root";
|
||||
import { ExternalContoursSidebar } from "./sidebar";
|
||||
|
||||
|
|
@ -22,30 +22,29 @@ type TExternalContoursRoot = {
|
|||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
inboxIssueId: string | undefined;
|
||||
inboxAccessible: boolean;
|
||||
navigationTab?: TInboxIssueCurrentTab | undefined;
|
||||
};
|
||||
|
||||
export const ExternalContoursRoot = observer(function ExternalContoursRoot(props: TExternalContoursRoot) {
|
||||
const { workspaceSlug, projectId, inboxIssueId, inboxAccessible, navigationTab } = props;
|
||||
const { workspaceSlug, projectId, inboxIssueId, navigationTab } = props;
|
||||
const [isMobileSidebar, setIsMobileSidebar] = useState(true);
|
||||
const { t } = useTranslation();
|
||||
const { loader, error, currentTab, currentInboxProjectId, handleCurrentTab, fetchInboxIssues } = useProjectInbox();
|
||||
const { loader, error, currentTab, currentProjectId, handleCurrentTab, fetchRequests } = useProjectExternalContours();
|
||||
|
||||
useEffect(() => {
|
||||
if (!inboxAccessible || !workspaceSlug || !projectId) return;
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
const hasProjectChanged = currentInboxProjectId && currentInboxProjectId !== projectId;
|
||||
const hasProjectChanged = currentProjectId && currentProjectId !== projectId;
|
||||
|
||||
if (navigationTab && navigationTab !== currentTab) {
|
||||
handleCurrentTab(workspaceSlug, projectId, navigationTab);
|
||||
} else if (hasProjectChanged) {
|
||||
handleCurrentTab(workspaceSlug, projectId, EInboxIssueCurrentTab.OPEN);
|
||||
} else {
|
||||
fetchInboxIssues(workspaceSlug.toString(), projectId.toString(), undefined, navigationTab || EInboxIssueCurrentTab.OPEN);
|
||||
fetchRequests(workspaceSlug.toString(), projectId.toString(), navigationTab || EInboxIssueCurrentTab.OPEN);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [inboxAccessible, workspaceSlug, projectId]);
|
||||
}, [workspaceSlug, projectId]);
|
||||
|
||||
if (loader === "init-loading") {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -4,21 +4,16 @@
|
|||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { EmptyStateDetailed } from "@plane/propel/empty-state";
|
||||
import type { TInboxIssueCurrentTab } from "@plane/types";
|
||||
import { EInboxIssueCurrentTab } from "@plane/types";
|
||||
import { EHeaderVariant, Header, Loader } from "@plane/ui";
|
||||
import { EHeaderVariant, Header } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
import { InboxSidebarLoader } from "@/components/ui/loader/layouts/project-inbox/inbox-sidebar-loader";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useProjectInbox } from "@/hooks/store/use-project-inbox";
|
||||
import { useProjectExternalContours } from "@/hooks/store/use-project-external-contours";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
import { useIntersectionObserver } from "@/hooks/use-intersection-observer";
|
||||
import { FiltersRoot } from "@/components/inbox/inbox-filter";
|
||||
import { InboxIssueAppliedFilters } from "@/components/inbox/inbox-filter/applied-filters/root";
|
||||
import { ExternalContoursListItem } from "./list-item";
|
||||
|
||||
type Props = {
|
||||
|
|
@ -36,34 +31,18 @@ const tabNavigationOptions: { key: TInboxIssueCurrentTab; i18n_label: string }[]
|
|||
export const ExternalContoursSidebar = observer(function ExternalContoursSidebar(props: Props) {
|
||||
const { workspaceSlug, projectId, inboxIssueId, setIsMobileSidebar } = props;
|
||||
const router = useAppRouter();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [elementRef, setElementRef] = useState<HTMLDivElement | null>(null);
|
||||
const { t } = useTranslation();
|
||||
const { currentProjectDetails } = useProject();
|
||||
const {
|
||||
currentTab,
|
||||
handleCurrentTab,
|
||||
loader,
|
||||
filteredInboxIssueIds,
|
||||
inboxIssuePaginationInfo,
|
||||
fetchInboxPaginationIssues,
|
||||
getAppliedFiltersCount,
|
||||
} = useProjectInbox();
|
||||
|
||||
const fetchNextPages = useCallback(() => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
fetchInboxPaginationIssues(workspaceSlug.toString(), projectId.toString());
|
||||
}, [workspaceSlug, projectId, fetchInboxPaginationIssues]);
|
||||
|
||||
useIntersectionObserver(containerRef, elementRef, fetchNextPages, "20%");
|
||||
const { currentTab, handleCurrentTab, filteredRequestIds, openRequestIds, closedRequestIds } = useProjectExternalContours();
|
||||
|
||||
useEffect(() => {
|
||||
if (workspaceSlug && projectId && currentTab && filteredInboxIssueIds.length > 0 && inboxIssueId === undefined) {
|
||||
if (workspaceSlug && projectId && filteredRequestIds.length > 0 && inboxIssueId === undefined) {
|
||||
router.push(
|
||||
`/${workspaceSlug}/projects/${projectId}/external-contours?currentTab=${currentTab}&inboxIssueId=${filteredInboxIssueIds[0]}`
|
||||
`/${workspaceSlug}/projects/${projectId}/external-contours?currentTab=${currentTab}&inboxIssueId=${filteredRequestIds[0]}`
|
||||
);
|
||||
}
|
||||
}, [currentTab, filteredInboxIssueIds, inboxIssueId, projectId, router, workspaceSlug]);
|
||||
}, [currentTab, filteredRequestIds, inboxIssueId, projectId, router, workspaceSlug]);
|
||||
|
||||
const currentCount = currentTab === EInboxIssueCurrentTab.CLOSED ? closedRequestIds.length : openRequestIds.length;
|
||||
|
||||
return (
|
||||
<div className="h-full w-full flex-shrink-0 border-r border-strong bg-surface-1">
|
||||
|
|
@ -84,9 +63,9 @@ export const ExternalContoursSidebar = observer(function ExternalContoursSidebar
|
|||
}}
|
||||
>
|
||||
<div>{t(option.i18n_label)}</div>
|
||||
{option.key === EInboxIssueCurrentTab.OPEN && currentTab === option.key && (
|
||||
{currentTab === option.key && (
|
||||
<div className="rounded-full bg-accent-primary/20 px-1.5 py-0.5 text-11 font-semibold text-accent-primary">
|
||||
{inboxIssuePaginationInfo?.total_results || 0}
|
||||
{currentCount}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
|
|
@ -97,67 +76,41 @@ export const ExternalContoursSidebar = observer(function ExternalContoursSidebar
|
|||
/>
|
||||
</div>
|
||||
))}
|
||||
<div className="m-auto mr-0">
|
||||
<FiltersRoot />
|
||||
</div>
|
||||
</Header>
|
||||
|
||||
<InboxIssueAppliedFilters />
|
||||
|
||||
{loader === "filter-loading" && !inboxIssuePaginationInfo?.next_page_results ? (
|
||||
<InboxSidebarLoader />
|
||||
) : (
|
||||
<div className="vertical-scrollbar scrollbar-md h-full w-full overflow-hidden overflow-y-auto" ref={containerRef}>
|
||||
{filteredInboxIssueIds.length > 0 ? (
|
||||
filteredInboxIssueIds.map((inboxId) => (
|
||||
<ExternalContoursListItem
|
||||
key={inboxId}
|
||||
setIsMobileSidebar={setIsMobileSidebar}
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
projectIdentifier={currentProjectDetails?.identifier}
|
||||
inboxIssueId={inboxId}
|
||||
<div className="vertical-scrollbar scrollbar-md h-full w-full overflow-hidden overflow-y-auto">
|
||||
{filteredRequestIds.length > 0 ? (
|
||||
filteredRequestIds.map((requestId) => (
|
||||
<ExternalContoursListItem
|
||||
key={requestId}
|
||||
setIsMobileSidebar={setIsMobileSidebar}
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
requestId={requestId}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
{currentTab === EInboxIssueCurrentTab.OPEN ? (
|
||||
<EmptyStateDetailed
|
||||
assetKey="inbox"
|
||||
title={t("external_contours_page.empty_state.open_title")}
|
||||
description={t("external_contours_page.empty_state.open_description")}
|
||||
assetClassName="size-20"
|
||||
rootClassName="px-page-x"
|
||||
/>
|
||||
) : (
|
||||
<EmptyStateDetailed
|
||||
assetKey="inbox"
|
||||
title={t("external_contours_page.empty_state.closed_title")}
|
||||
description={t("external_contours_page.empty_state.closed_description")}
|
||||
assetClassName="size-20"
|
||||
className="px-10"
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
{getAppliedFiltersCount > 0 ? (
|
||||
<EmptyStateDetailed
|
||||
assetKey="search"
|
||||
title={t("external_contours_page.empty_state.filtered_title")}
|
||||
description={t("external_contours_page.empty_state.filtered_description")}
|
||||
assetClassName="size-20"
|
||||
rootClassName="px-page-x"
|
||||
/>
|
||||
) : currentTab === EInboxIssueCurrentTab.OPEN ? (
|
||||
<EmptyStateDetailed
|
||||
assetKey="inbox"
|
||||
title={t("external_contours_page.empty_state.open_title")}
|
||||
description={t("external_contours_page.empty_state.open_description")}
|
||||
assetClassName="size-20"
|
||||
rootClassName="px-page-x"
|
||||
/>
|
||||
) : (
|
||||
<EmptyStateDetailed
|
||||
assetKey="inbox"
|
||||
title={t("external_contours_page.empty_state.closed_title")}
|
||||
description={t("external_contours_page.empty_state.closed_description")}
|
||||
assetClassName="size-20"
|
||||
className="px-10"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div ref={setElementRef}>
|
||||
{inboxIssuePaginationInfo?.next_page_results && (
|
||||
<Loader className="mx-auto w-full space-y-4 px-2 py-4">
|
||||
<Loader.Item height="64px" width="w-100" />
|
||||
<Loader.Item height="64px" width="w-100" />
|
||||
</Loader>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export * from "./external-contour.service";
|
||||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
@ -31,6 +31,8 @@ import type { IEditorAssetStore } from "./editor/asset.store";
|
|||
import { EditorAssetStore } from "./editor/asset.store";
|
||||
import type { IProjectEstimateStore } from "./estimates/project-estimate.store";
|
||||
import { ProjectEstimateStore } from "./estimates/project-estimate.store";
|
||||
import type { IProjectExternalContoursStore } from "./external-contours/project-external-contours.store";
|
||||
import { ProjectExternalContoursStore } from "./external-contours/project-external-contours.store";
|
||||
import type { IFavoriteStore } from "./favorite.store";
|
||||
import { FavoriteStore } from "./favorite.store";
|
||||
import type { IGlobalViewStore } from "./global-view.store";
|
||||
|
|
@ -93,6 +95,7 @@ export class CoreRootStore {
|
|||
instance: IInstanceStore;
|
||||
user: IUserStore;
|
||||
projectInbox: IProjectInboxStore;
|
||||
projectExternalContours: IProjectExternalContoursStore;
|
||||
projectEstimate: IProjectEstimateStore;
|
||||
multipleSelect: IMultipleSelectStore;
|
||||
workspaceNotification: IWorkspaceNotificationStore;
|
||||
|
|
@ -123,6 +126,7 @@ export class CoreRootStore {
|
|||
this.dashboard = new DashboardStore(this);
|
||||
this.multipleSelect = new MultipleSelectStore();
|
||||
this.projectInbox = new ProjectInboxStore(this);
|
||||
this.projectExternalContours = new ProjectExternalContoursStore(this);
|
||||
this.projectPages = new ProjectPageStore(this as unknown as RootStore);
|
||||
this.projectEstimate = new ProjectEstimateStore(this);
|
||||
this.workspaceNotification = new WorkspaceNotificationStore(this);
|
||||
|
|
@ -156,6 +160,7 @@ export class CoreRootStore {
|
|||
this.label = new LabelStore(this);
|
||||
this.dashboard = new DashboardStore(this);
|
||||
this.projectInbox = new ProjectInboxStore(this);
|
||||
this.projectExternalContours = new ProjectExternalContoursStore(this);
|
||||
this.projectPages = new ProjectPageStore(this as unknown as RootStore);
|
||||
this.multipleSelect = new MultipleSelectStore();
|
||||
this.projectEstimate = new ProjectEstimateStore(this);
|
||||
|
|
|
|||
|
|
@ -318,6 +318,8 @@ export default {
|
|||
modal: {
|
||||
title: "Add request",
|
||||
submit: "Send",
|
||||
success_message: "Request sent to the target contour.",
|
||||
error_message: "Failed to send the request to the target contour.",
|
||||
toast_title: "Routing is not connected yet",
|
||||
toast_message:
|
||||
"The UI now uses the intake shell. The next stage will connect backend routing and real delivery to the target contour.",
|
||||
|
|
|
|||
|
|
@ -475,6 +475,8 @@ export default {
|
|||
modal: {
|
||||
title: "Добавить запрос",
|
||||
submit: "Отправить",
|
||||
success_message: "Запрос отправлен во внешний контур.",
|
||||
error_message: "Не удалось отправить запрос во внешний контур.",
|
||||
toast_title: "Маршрутизация ещё не подключена",
|
||||
toast_message:
|
||||
"UI уже переведён на shell предложений. На следующем этапе подключим backend-маршрутизацию и реальную отправку в целевой контур.",
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
};
|
||||
|
|
@ -21,6 +21,7 @@ export * from "./editor";
|
|||
export * from "./enums";
|
||||
export * from "./epics";
|
||||
export * from "./estimate";
|
||||
export * from "./external-contours";
|
||||
export * from "./favorite";
|
||||
export * from "./file";
|
||||
export * from "./home";
|
||||
|
|
|
|||
Loading…
Reference in New Issue