diff --git a/docs_prod/cross-project-task-routing/README.md b/docs_prod/cross-project-task-routing/README.md index 1b2dbcd..447a085 100644 --- a/docs_prod/cross-project-task-routing/README.md +++ b/docs_prod/cross-project-task-routing/README.md @@ -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. Отдельный модуль diff --git a/docs_prod/cross-project-task-routing/phase-roadmap.md b/docs_prod/cross-project-task-routing/phase-roadmap.md index 896f1b5..f78b8e3 100644 --- a/docs_prod/cross-project-task-routing/phase-roadmap.md +++ b/docs_prod/cross-project-task-routing/phase-roadmap.md @@ -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 детальный экран и зеркалирование изменений ### Цель diff --git a/plane-src/apps/api/plane/api/serializers/__init__.py b/plane-src/apps/api/plane/api/serializers/__init__.py index 2ab639d..9f530c0 100644 --- a/plane-src/apps/api/plane/api/serializers/__init__.py +++ b/plane-src/apps/api/plane/api/serializers/__init__.py @@ -53,6 +53,10 @@ from .intake import ( IntakeIssueCreateSerializer, IntakeIssueUpdateSerializer, ) +from .external_contours import ( + ExternalContourRequestCreateSerializer, + ExternalContourRequestSerializer, +) from .estimate import EstimateSerializer, EstimatePointSerializer from .asset import ( UserAssetUploadSerializer, diff --git a/plane-src/apps/api/plane/api/serializers/external_contours.py b/plane-src/apps/api/plane/api/serializers/external_contours.py new file mode 100644 index 0000000..7bb131b --- /dev/null +++ b/plane-src/apps/api/plane/api/serializers/external_contours.py @@ -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" diff --git a/plane-src/apps/api/plane/api/urls/__init__.py b/plane-src/apps/api/plane/api/urls/__init__.py index 4a20243..e735870 100644 --- a/plane-src/apps/api/plane/api/urls/__init__.py +++ b/plane-src/apps/api/plane/api/urls/__init__.py @@ -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, diff --git a/plane-src/apps/api/plane/api/urls/external_contours.py b/plane-src/apps/api/plane/api/urls/external_contours.py new file mode 100644 index 0000000..3eed993 --- /dev/null +++ b/plane-src/apps/api/plane/api/urls/external_contours.py @@ -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//projects//external-contours/", + ExternalContourListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]), + name="external-contours", + ), + path( + "workspaces//projects//external-contours//", + ExternalContourDetailAPIEndpoint.as_view(http_method_names=["get"]), + name="external-contour-detail", + ), +] diff --git a/plane-src/apps/api/plane/api/views/__init__.py b/plane-src/apps/api/plane/api/views/__init__.py index e8549af..c53eabe 100644 --- a/plane-src/apps/api/plane/api/views/__init__.py +++ b/plane-src/apps/api/plane/api/views/__init__.py @@ -55,6 +55,10 @@ from .intake import ( IntakeIssueListCreateAPIEndpoint, IntakeIssueDetailAPIEndpoint, ) +from .external_contours import ( + ExternalContourListCreateAPIEndpoint, + ExternalContourDetailAPIEndpoint, +) from .asset import UserAssetEndpoint, UserServerAssetEndpoint, GenericAssetEndpoint diff --git a/plane-src/apps/api/plane/api/views/external_contours.py b/plane-src/apps/api/plane/api/views/external_contours.py new file mode 100644 index 0000000..30d2cf0 --- /dev/null +++ b/plane-src/apps/api/plane/api/views/external_contours.py @@ -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 "

", + "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) diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/external-contours/page.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/external-contours/page.tsx index 4e3b39b..d2e25e3 100644 --- a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/external-contours/page.tsx +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/external-contours/page.tsx @@ -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 ( -
- { - router.push(`/${workspaceSlug}/settings/projects/${projectId}/features`); - }, - disabled: !canPerformEmptyStateActions, - }} - /> -
- ); 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} /> diff --git a/plane-src/apps/web/ce/components/projects/external-contours/content-root.tsx b/plane-src/apps/web/ce/components/projects/external-contours/content-root.tsx index dfc0da9..84b56e3 100644 --- a/plane-src/apps/web/ce/components/projects/external-contours/content-root.tsx +++ b/plane-src/apps/web/ce/components/projects/external-contours/content-root.tsx @@ -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("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 (
@@ -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} />
diff --git a/plane-src/apps/web/ce/components/projects/external-contours/create-root.tsx b/plane-src/apps/web/ce/components/projects/external-contours/create-root.tsx index 1cafe3a..46b2d3b 100644 --- a/plane-src/apps/web/ce/components/projects/external-contours/create-root.tsx +++ b/plane-src/apps/web/ce/components/projects/external-contours/create-root.tsx @@ -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(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); } }; diff --git a/plane-src/apps/web/ce/components/projects/external-contours/header.tsx b/plane-src/apps/web/ce/components/projects/external-contours/header.tsx index 8f11831..5afc342 100644 --- a/plane-src/apps/web/ce/components/projects/external-contours/header.tsx +++ b/plane-src/apps/web/ce/components/projects/external-contours/header.tsx @@ -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 /> - {loader === "pagination-loading" && ( + {(loader === "mutation-loading" || loader === "issue-loading") && (

{t("syncing")}...

@@ -61,7 +61,7 @@ export const ProjectExternalContoursHeader = observer(function ProjectExternalCo
- {currentProjectDetails?.inbox_view && workspaceSlug && projectId && isAuthorized ? ( + {workspaceSlug && projectId && isAuthorized ? (
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 ( <>
{issue?.project_id && issue.sequence_id && (

- {getProjectById(issue.project_id)?.identifier}-{issue.sequence_id} + {targetProjectIdentifier}-{issue.sequence_id}

)} - +
@@ -117,30 +105,6 @@ export const ExternalContoursIssueActionsHeader = observer(function ExternalCont
- {isOpenTab ? ( - <> - - - - ) : ( - <> - - - - )} - @@ -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"}`} />
- +
- - + router.push(workItemLink)} target="_self"> + +
diff --git a/plane-src/apps/web/ce/components/projects/external-contours/issue-properties.tsx b/plane-src/apps/web/ce/components/projects/external-contours/issue-properties.tsx index 0ce071f..c2c33b0 100644 --- a/plane-src/apps/web/ce/components/projects/external-contours/issue-properties.tsx +++ b/plane-src/apps/web/ce/components/projects/external-contours/issue-properties.tsx @@ -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; + targetProjectId: string; + issue: Partial & { + 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 (
@@ -64,8 +51,8 @@ export const ExternalContoursIssueContentProperties = observer(function External {t("external_contours_page.properties.target_contour")}
-
- {t("external_contours_page.properties.target_contour_placeholder")} +
+ {issue.project_detail?.name || t("common.none")}
@@ -76,9 +63,9 @@ export const ExternalContoursIssueContentProperties = observer(function External
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
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 {t("labels")}
- {issue?.id && ( - issue?.id && issueOperations.update(workspaceSlug, projectId, issue.id, { label_ids: val })} - /> - )} + issue?.id && issueOperations.update(workspaceSlug, targetProjectId, issue.id, { label_ids: labelIds })} + projectId={targetProjectId} + disabled={!isEditable} + />
- - {duplicateIssueDetails && ( -
-
- - {t("external_contours_page.properties.duplicate_of")} -
- router.push(duplicateWorkItemLink)} target="_self"> - - - {`${currentProjectDetails?.identifier}-${duplicateIssueDetails?.sequence_id}`} - - - -
- )} diff --git a/plane-src/apps/web/ce/components/projects/external-contours/issue-root.tsx b/plane-src/apps/web/ce/components/projects/external-contours/issue-root.tsx index b2e2c01..aa2137c 100644 --- a/plane-src/apps/web/ce/components/projects/external-contours/issue-root.tsx +++ b/plane-src/apps/web/ce/components/projects/external-contours/issue-root.tsx @@ -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>; }; 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(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) => { + update: async (_workspaceSlug: string, _projectId: string, issueId: string, data: Partial) => { 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 && ( )} setIsSubmitting(value)} @@ -143,12 +136,12 @@ export const ExternalContoursIssueMainContent = observer(function ExternalContou initialValue={!issue.description_html || issue.description_html === "" ? "

" : 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
{currentUser && ( - + )} {isEditable && ( 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
- +
- +
); diff --git a/plane-src/apps/web/ce/components/projects/external-contours/list-item.tsx b/plane-src/apps/web/ce/components/projects/external-contours/list-item.tsx index 31c9f28..4b5c1b4 100644 --- a/plane-src/apps/web/ce/components/projects/external-contours/list-item.tsx +++ b/plane-src/apps/web/ce/components/projects/external-contours/list-item.tsx @@ -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 ( 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)} >
-
- {projectIdentifier}-{issue.sequence_id} -
-
- {inboxIssue.source && } - {inboxIssue.status !== -2 && } +
+ + {issue.project_detail?.identifier || "REQ"}-{issue.sequence_id} + + {issue.project_detail?.name && {issue.project_detail.name}}
+

{issue.name}

-
+
{renderFormattedDate(issue.created_at ?? "")}
-
- - {issue.priority && ( + {issue.priority && issue.priority !== "none" && ( )} - {issue.label_ids && issue.label_ids.length > 3 ? ( -
- - {`${issue.label_ids.length} ${t("labels").toLowerCase()}`} + {visibleLabels.map((label) => ( +
+ + {label.name}
- ) : ( - <> - {(issue.label_ids ?? []).map((labelId) => { - const labelDetails = projectLabels?.find((l) => l.id === labelId); - if (!labelDetails) return null; - return ( -
- - {labelDetails.name} -
- ); - })} - - )} + ))}
- {createdByDetails && createdByDetails.email?.includes("intake@plane.so") ? ( - - ) : createdByDetails ? ( - + {createdByDetails ? ( + ) : null}
diff --git a/plane-src/apps/web/ce/components/projects/external-contours/root.tsx b/plane-src/apps/web/ce/components/projects/external-contours/root.tsx index bcb6114..0638609 100644 --- a/plane-src/apps/web/ce/components/projects/external-contours/root.tsx +++ b/plane-src/apps/web/ce/components/projects/external-contours/root.tsx @@ -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 ( diff --git a/plane-src/apps/web/ce/components/projects/external-contours/sidebar.tsx b/plane-src/apps/web/ce/components/projects/external-contours/sidebar.tsx index 30d2feb..1d98ba5 100644 --- a/plane-src/apps/web/ce/components/projects/external-contours/sidebar.tsx +++ b/plane-src/apps/web/ce/components/projects/external-contours/sidebar.tsx @@ -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(null); - const [elementRef, setElementRef] = useState(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 (
@@ -84,9 +63,9 @@ export const ExternalContoursSidebar = observer(function ExternalContoursSidebar }} >
{t(option.i18n_label)}
- {option.key === EInboxIssueCurrentTab.OPEN && currentTab === option.key && ( + {currentTab === option.key && (
- {inboxIssuePaginationInfo?.total_results || 0} + {currentCount}
)}
))} -
- -
- - - {loader === "filter-loading" && !inboxIssuePaginationInfo?.next_page_results ? ( - - ) : ( -
- {filteredInboxIssueIds.length > 0 ? ( - filteredInboxIssueIds.map((inboxId) => ( - + {filteredRequestIds.length > 0 ? ( + filteredRequestIds.map((requestId) => ( + + )) + ) : ( +
+ {currentTab === EInboxIssueCurrentTab.OPEN ? ( + + ) : ( + - )) - ) : ( -
- {getAppliedFiltersCount > 0 ? ( - - ) : currentTab === EInboxIssueCurrentTab.OPEN ? ( - - ) : ( - - )} -
- )} -
- {inboxIssuePaginationInfo?.next_page_results && ( - - - - )}
-
- )} + )} +
); diff --git a/plane-src/apps/web/ce/components/projects/external-contours/state-pill.tsx b/plane-src/apps/web/ce/components/projects/external-contours/state-pill.tsx new file mode 100644 index 0000000..d28eb89 --- /dev/null +++ b/plane-src/apps/web/ce/components/projects/external-contours/state-pill.tsx @@ -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 ( +
+ + {state?.name || t("state")} +
+ ); +} diff --git a/plane-src/apps/web/core/hooks/store/use-project-external-contours.ts b/plane-src/apps/web/core/hooks/store/use-project-external-contours.ts new file mode 100644 index 0000000..4025a5c --- /dev/null +++ b/plane-src/apps/web/core/hooks/store/use-project-external-contours.ts @@ -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; +}; diff --git a/plane-src/apps/web/core/services/external-contours/external-contour.service.ts b/plane-src/apps/web/core/services/external-contours/external-contour.service.ts new file mode 100644 index 0000000..55484f5 --- /dev/null +++ b/plane-src/apps/web/core/services/external-contours/external-contour.service.ts @@ -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 { + 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 { + 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 & { target_project_id?: string | null } + ): Promise { + 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; + }); + } +} diff --git a/plane-src/apps/web/core/services/external-contours/index.ts b/plane-src/apps/web/core/services/external-contours/index.ts new file mode 100644 index 0000000..4816de4 --- /dev/null +++ b/plane-src/apps/web/core/services/external-contours/index.ts @@ -0,0 +1 @@ +export * from "./external-contour.service"; diff --git a/plane-src/apps/web/core/store/external-contours/project-external-contours.store.ts b/plane-src/apps/web/core/store/external-contours/project-external-contours.store.ts new file mode 100644 index 0000000..b363709 --- /dev/null +++ b/plane-src/apps/web/core/store/external-contours/project-external-contours.store.ts @@ -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; + createRequest: ( + workspaceSlug: string, + projectId: string, + data: Partial & { target_project_id?: string | null } + ) => Promise; + fetchRequestById: (workspaceSlug: string, projectId: string, requestId: string) => Promise; + fetchRequests: (workspaceSlug: string, projectId: string, tab?: TInboxIssueCurrentTab) => Promise; + getIsRequestAvailable: (requestId: string) => boolean; + getRequestById: (requestId: string) => TExternalContourRequest | undefined; + handleCurrentTab: (workspaceSlug: string, projectId: string, tab: TInboxIssueCurrentTab) => Promise; + openRequestIds: string[]; + closedRequestIds: string[]; + filteredRequestIds: string[]; + upsertRequests: (requests: TExternalContourRequest[]) => void; + updateRequestIssue: (requestId: string, issueData: Partial) => 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 = {}; + + 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 & { 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) => { + 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, + }; + }; +} diff --git a/plane-src/apps/web/core/store/root.store.ts b/plane-src/apps/web/core/store/root.store.ts index 56325cb..527c90a 100644 --- a/plane-src/apps/web/core/store/root.store.ts +++ b/plane-src/apps/web/core/store/root.store.ts @@ -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); diff --git a/plane-src/packages/i18n/src/locales/en/translations.ts b/plane-src/packages/i18n/src/locales/en/translations.ts index 616d757..c62c873 100644 --- a/plane-src/packages/i18n/src/locales/en/translations.ts +++ b/plane-src/packages/i18n/src/locales/en/translations.ts @@ -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.", diff --git a/plane-src/packages/i18n/src/locales/ru/translations.ts b/plane-src/packages/i18n/src/locales/ru/translations.ts index e891612..e92fa65 100644 --- a/plane-src/packages/i18n/src/locales/ru/translations.ts +++ b/plane-src/packages/i18n/src/locales/ru/translations.ts @@ -475,6 +475,8 @@ export default { modal: { title: "Добавить запрос", submit: "Отправить", + success_message: "Запрос отправлен во внешний контур.", + error_message: "Не удалось отправить запрос во внешний контур.", toast_title: "Маршрутизация ещё не подключена", toast_message: "UI уже переведён на shell предложений. На следующем этапе подключим backend-маршрутизацию и реальную отправку в целевой контур.", diff --git a/plane-src/packages/types/src/external-contours.ts b/plane-src/packages/types/src/external-contours.ts new file mode 100644 index 0000000..0f8dbc4 --- /dev/null +++ b/plane-src/packages/types/src/external-contours.ts @@ -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[]; + created_by_detail?: Pick | 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[]; +}; diff --git a/plane-src/packages/types/src/index.ts b/plane-src/packages/types/src/index.ts index 899b5d5..53106cb 100644 --- a/plane-src/packages/types/src/index.ts +++ b/plane-src/packages/types/src/index.ts @@ -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";