From 248292bd52a48b5ad53e5e5bdf696c6bd97c7071 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Wed, 29 Apr 2026 12:53:04 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A4=D0=A3=D0=9D=D0=9A=D0=A6=D0=98=D0=98=20-?= =?UTF-8?q?=20=D0=9C=D0=95=D0=96=D0=9F=D0=A0=D0=9E=D0=95=D0=9A=D0=A2=D0=9D?= =?UTF-8?q?=D0=90=D0=AF=20=D0=9A=D0=9E=D0=9C=D0=9C=D0=A3=D0=9D=D0=98=D0=9A?= =?UTF-8?q?=D0=90=D0=A6=D0=98=D0=AF:=20realtime=20=D0=B2=D0=BD=D0=B5=D1=88?= =?UTF-8?q?=D0=BD=D0=B8=D1=85=20=D0=BA=D0=BE=D0=BD=D1=82=D1=83=D1=80=D0=BE?= =?UTF-8?q?=D0=B2=20=D0=B8=20=D0=B4=D0=B5=D0=B9=D1=81=D1=82=D0=B2=D0=B8?= =?UTF-8?q?=D1=8F=20=D0=B8=D1=81=D1=85=D0=BE=D0=B4=D1=8F=D1=89=D0=B8=D1=85?= =?UTF-8?q?=20=D0=BA=D0=B0=D1=80=D1=82=D0=BE=D1=87=D0=B5=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/plane/api/urls/external_contours.py | 2 +- .../api/plane/api/views/external_contours.py | 85 ++++- .../api/plane/app/realtime/issue_events.py | 100 +++++- .../api/plane/app/urls/external_contours.py | 2 +- .../api/plane/app/views/external_contours.py | 91 ++++- .../controllers/issue-stream.controller.ts | 6 +- .../projects/external-contours/board-item.tsx | 317 +++++++++++++----- .../external-contours/delete-modal.tsx | 48 +++ .../projects/external-contours/root.tsx | 4 + .../use-external-contours-realtime-events.ts | 137 ++++++++ .../core/hooks/use-issue-realtime-events.ts | 40 ++- .../external-contour.service.ts | 8 + .../project-external-contours-board.store.ts | 59 ++++ .../project-external-contours.store.ts | 25 ++ 14 files changed, 819 insertions(+), 105 deletions(-) create mode 100644 plane-src/apps/web/ce/components/projects/external-contours/delete-modal.tsx create mode 100644 plane-src/apps/web/core/hooks/use-external-contours-realtime-events.ts diff --git a/plane-src/apps/api/plane/api/urls/external_contours.py b/plane-src/apps/api/plane/api/urls/external_contours.py index c5129a2..6425cdc 100644 --- a/plane-src/apps/api/plane/api/urls/external_contours.py +++ b/plane-src/apps/api/plane/api/urls/external_contours.py @@ -45,7 +45,7 @@ urlpatterns = [ ), path( "workspaces//projects//external-contours//", - ExternalContourDetailEndpoint.as_view(http_method_names=["get", "patch"]), + ExternalContourDetailEndpoint.as_view(http_method_names=["get", "patch", "delete"]), name="external-contour-detail", ), path( diff --git a/plane-src/apps/api/plane/api/views/external_contours.py b/plane-src/apps/api/plane/api/views/external_contours.py index 9f64380..7e3fe87 100644 --- a/plane-src/apps/api/plane/api/views/external_contours.py +++ b/plane-src/apps/api/plane/api/views/external_contours.py @@ -21,6 +21,7 @@ from plane.api.serializers import ( ) from plane.api.serializers.issue import IssueSerializer as IssueCreateSerializer from plane.app.permissions import ProjectLitePermission +from plane.app.realtime.issue_events import publish_external_contour_event_on_commit, publish_issue_event_on_commit from plane.app.views.base import BaseAPIView from plane.db.models import FileAsset, Intake, IntakeIssue, Label, Notification, Project, ProjectMember, State, StateGroup from plane.db.models.intake import IntakeIssueStatus, SourceType @@ -171,6 +172,29 @@ class ExternalContourListCreateEndpoint(BaseAPIView): issue.state = target_default_state issue.save() + publish_issue_event_on_commit( + "issue.created", + issue, + actor_id=request.user.id, + changed_fields=[ + "name", + "description_html", + "priority", + "assignees", + "labels", + "target_date", + "state", + "external_contour", + ], + publish_external_bridge=False, + ) + publish_external_contour_event_on_commit( + "external_contour.created", + intake_issue, + actor_id=request.user.id, + changed_fields=["issue", "state", "external_contour"], + ) + response_serializer = ExternalContourRequestSerializer( IntakeIssue.objects.select_related( "issue", @@ -331,6 +355,7 @@ class ExternalContourDetailEndpoint(BaseAPIView): serializer = ExternalContourRequestUpdateSerializer(data=request.data) serializer.is_valid(raise_exception=True) issue_update_data = serializer.validated_data.copy() + changed_fields = list(issue_update_data.keys()) assignee_ids = issue_update_data.pop("assignee_ids", None) label_ids = issue_update_data.pop("label_ids", None) if assignee_ids is not None: @@ -348,11 +373,24 @@ class ExternalContourDetailEndpoint(BaseAPIView): }, ) issue_serializer.is_valid(raise_exception=True) - issue_serializer.save() + updated_issue = issue_serializer.save() + publish_issue_event_on_commit( + "issue.updated", + updated_issue, + actor_id=request.user.id, + changed_fields=changed_fields, + publish_external_bridge=False, + ) contour_request.updated_at = timezone.now() contour_request.save(update_fields=["updated_at"]) contour_request.refresh_from_db() + publish_external_contour_event_on_commit( + "external_contour.updated", + contour_request, + actor_id=request.user.id, + changed_fields=changed_fields, + ) response_serializer = ExternalContourRequestSerializer( contour_request, @@ -365,6 +403,37 @@ class ExternalContourDetailEndpoint(BaseAPIView): ) return Response(response_serializer.data, status=status.HTTP_200_OK) + def delete(self, request, slug, project_id, request_id): + contour_request = get_object_or_404(self.get_queryset()) + issue = contour_request.issue + + if not issue: + contour_request.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + requested_by_id = contour_request.extra.get("requested_by_id") or ( + str(issue.created_by_id) if issue.created_by_id else None + ) + if str(request.user.id) != str(requested_by_id): + return Response({"error": "Only the sender can delete this request"}, status=status.HTTP_403_FORBIDDEN) + + publish_external_contour_event_on_commit( + "external_contour.deleted", + contour_request, + actor_id=request.user.id, + changed_fields=["deleted_at"], + ) + publish_issue_event_on_commit( + "issue.deleted", + issue, + actor_id=request.user.id, + changed_fields=["deleted_at", "external_contour"], + publish_external_bridge=False, + ) + contour_request.delete() + issue.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + class ExternalContourDecisionEndpoint(BaseAPIView): permission_classes = [ProjectLitePermission] @@ -434,6 +503,13 @@ class ExternalContourDecisionEndpoint(BaseAPIView): issue.state = target_default_state issue.save(update_fields=["state", "updated_at"]) + publish_issue_event_on_commit( + "issue.updated", + issue, + actor_id=request.user.id, + changed_fields=["state", "external_contour_reopen"], + publish_external_bridge=False, + ) extra = dict(contour_request.extra or {}) extra.pop("source_decision", None) @@ -445,6 +521,13 @@ class ExternalContourDecisionEndpoint(BaseAPIView): contour_request.extra = extra contour_request.save(update_fields=["extra", "updated_at"]) + publish_external_contour_event_on_commit( + "external_contour.updated", + contour_request, + actor_id=request.user.id, + changed_fields=["source_decision", "state"], + ) + contour_request.refresh_from_db() Notification.objects.filter( receiver_id=request.user.id, diff --git a/plane-src/apps/api/plane/app/realtime/issue_events.py b/plane-src/apps/api/plane/app/realtime/issue_events.py index 5887991..5389b2f 100644 --- a/plane-src/apps/api/plane/app/realtime/issue_events.py +++ b/plane-src/apps/api/plane/app/realtime/issue_events.py @@ -21,7 +21,98 @@ def issue_event_channel(project_id): return f"{ISSUE_EVENT_CHANNEL_PREFIX}:{project_id}" -def publish_issue_event_on_commit(event_type, issue, actor_id=None, changed_fields=None): +def _publish_payload(project_id, payload): + next_payload = { + **payload, + "project_id": str(project_id), + } + redis_instance().publish( + issue_event_channel(project_id), + json.dumps(next_payload, cls=DjangoJSONEncoder), + ) + + +def _external_contour_project_ids(contour_request): + extra = contour_request.extra or {} + project_ids = { + str(project_id) + for project_id in [ + extra.get("source_project_id"), + extra.get("target_project_id"), + contour_request.project_id, + getattr(contour_request.issue, "project_id", None), + ] + if project_id + } + + return sorted(project_ids) + + +def publish_external_contour_event_on_commit(event_type, contour_request, actor_id=None, changed_fields=None): + issue = contour_request.issue + extra = contour_request.extra or {} + payload = { + "event_id": str(uuid4()), + "type": event_type, + "workspace_id": str(contour_request.workspace_id), + "workspace_slug": contour_request.workspace.slug if getattr(contour_request, "workspace", None) else None, + "request_id": str(contour_request.id), + "issue_id": str(issue.id) if issue else None, + "sequence_id": issue.sequence_id if issue else None, + "source_project_id": str(extra.get("source_project_id")) if extra.get("source_project_id") else None, + "target_project_id": str(extra.get("target_project_id") or contour_request.project_id), + "updated_at": contour_request.updated_at or timezone.now(), + "actor_id": str(actor_id) if actor_id else None, + "changed_fields": sorted(set(changed_fields or [])), + } + + def _publish(): + try: + for project_id in _external_contour_project_ids(contour_request): + _publish_payload(project_id, payload) + except Exception: + logger.exception("Failed to publish external contour realtime event") + + transaction.on_commit(_publish) + + +def publish_external_contour_issue_event_on_commit(event_type, issue, actor_id=None, changed_fields=None): + def _publish(): + try: + from plane.db.models import IntakeIssue + + contour_requests = ( + IntakeIssue.objects.filter(issue_id=issue.id, extra__bridge="external-contours") + .select_related("issue", "workspace") + .only("id", "workspace_id", "workspace__slug", "project_id", "issue_id", "extra", "updated_at") + ) + for contour_request in contour_requests: + event_name = "external_contour.deleted" if event_type == "issue.deleted" else "external_contour.updated" + payload = { + "event_id": str(uuid4()), + "type": event_name, + "workspace_id": str(contour_request.workspace_id), + "workspace_slug": contour_request.workspace.slug if getattr(contour_request, "workspace", None) else None, + "request_id": str(contour_request.id), + "issue_id": str(issue.id), + "sequence_id": issue.sequence_id, + "source_project_id": str(contour_request.extra.get("source_project_id")) + if contour_request.extra.get("source_project_id") + else None, + "target_project_id": str(contour_request.extra.get("target_project_id") or contour_request.project_id), + "updated_at": issue.updated_at or timezone.now(), + "actor_id": str(actor_id) if actor_id else None, + "changed_fields": sorted(set(changed_fields or [])), + } + for project_id in _external_contour_project_ids(contour_request): + _publish_payload(project_id, payload) + except Exception: + logger.exception("Failed to publish external contour bridge event") + + transaction.on_commit(_publish) + + +def publish_issue_event_on_commit(event_type, issue, actor_id=None, changed_fields=None, publish_external_bridge=True): payload = { "event_id": str(uuid4()), "type": event_type, @@ -37,11 +128,10 @@ def publish_issue_event_on_commit(event_type, issue, actor_id=None, changed_fiel def _publish(): try: - redis_instance().publish( - issue_event_channel(issue.project_id), - json.dumps(payload, cls=DjangoJSONEncoder), - ) + _publish_payload(issue.project_id, payload) except Exception: logger.exception("Failed to publish issue realtime event") transaction.on_commit(_publish) + if publish_external_bridge: + publish_external_contour_issue_event_on_commit(event_type, issue, actor_id=actor_id, changed_fields=changed_fields) diff --git a/plane-src/apps/api/plane/app/urls/external_contours.py b/plane-src/apps/api/plane/app/urls/external_contours.py index c5129a2..6425cdc 100644 --- a/plane-src/apps/api/plane/app/urls/external_contours.py +++ b/plane-src/apps/api/plane/app/urls/external_contours.py @@ -45,7 +45,7 @@ urlpatterns = [ ), path( "workspaces//projects//external-contours//", - ExternalContourDetailEndpoint.as_view(http_method_names=["get", "patch"]), + ExternalContourDetailEndpoint.as_view(http_method_names=["get", "patch", "delete"]), name="external-contour-detail", ), path( diff --git a/plane-src/apps/api/plane/app/views/external_contours.py b/plane-src/apps/api/plane/app/views/external_contours.py index 27502ef..d2209c9 100644 --- a/plane-src/apps/api/plane/app/views/external_contours.py +++ b/plane-src/apps/api/plane/app/views/external_contours.py @@ -23,6 +23,7 @@ from plane.api.serializers import ( ) from plane.api.serializers.issue import IssueSerializer as IssueCreateSerializer from plane.app.permissions import ProjectLitePermission +from plane.app.realtime.issue_events import publish_external_contour_event_on_commit, publish_issue_event_on_commit from plane.app.views.base import BaseAPIView from plane.db.models import FileAsset, Intake, IntakeIssue, Label, Notification, Project, ProjectMember, State, StateGroup from plane.db.models.intake import IntakeIssueStatus, SourceType @@ -173,6 +174,29 @@ class ExternalContourListCreateEndpoint(BaseAPIView): issue.state = target_default_state issue.save() + publish_issue_event_on_commit( + "issue.created", + issue, + actor_id=request.user.id, + changed_fields=[ + "name", + "description_html", + "priority", + "assignees", + "labels", + "target_date", + "state", + "external_contour", + ], + publish_external_bridge=False, + ) + publish_external_contour_event_on_commit( + "external_contour.created", + intake_issue, + actor_id=request.user.id, + changed_fields=["issue", "state", "external_contour"], + ) + response_serializer = ExternalContourRequestSerializer( IntakeIssue.objects.select_related( "issue", @@ -650,6 +674,7 @@ class ExternalContourDetailEndpoint(BaseAPIView): serializer = ExternalContourRequestUpdateSerializer(data=request.data) serializer.is_valid(raise_exception=True) issue_update_data = serializer.validated_data.copy() + changed_fields = list(issue_update_data.keys()) assignee_ids = issue_update_data.pop("assignee_ids", None) label_ids = issue_update_data.pop("label_ids", None) if assignee_ids is not None: @@ -667,11 +692,24 @@ class ExternalContourDetailEndpoint(BaseAPIView): }, ) issue_serializer.is_valid(raise_exception=True) - issue_serializer.save() + updated_issue = issue_serializer.save() contour_request.updated_at = timezone.now() contour_request.save(update_fields=["updated_at"]) contour_request.refresh_from_db() + publish_issue_event_on_commit( + "issue.updated", + updated_issue, + actor_id=request.user.id, + changed_fields=changed_fields, + publish_external_bridge=False, + ) + publish_external_contour_event_on_commit( + "external_contour.updated", + contour_request, + actor_id=request.user.id, + changed_fields=changed_fields, + ) response_serializer = ExternalContourRequestSerializer( contour_request, @@ -684,6 +722,37 @@ class ExternalContourDetailEndpoint(BaseAPIView): ) return Response(response_serializer.data, status=status.HTTP_200_OK) + def delete(self, request, slug, project_id, request_id): + contour_request = get_object_or_404(self.get_queryset()) + issue = contour_request.issue + + if not issue: + contour_request.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + requested_by_id = contour_request.extra.get("requested_by_id") or ( + str(issue.created_by_id) if issue.created_by_id else None + ) + if str(request.user.id) != str(requested_by_id): + return Response({"error": "Only the sender can delete this request"}, status=status.HTTP_403_FORBIDDEN) + + publish_external_contour_event_on_commit( + "external_contour.deleted", + contour_request, + actor_id=request.user.id, + changed_fields=["deleted_at"], + ) + publish_issue_event_on_commit( + "issue.deleted", + issue, + actor_id=request.user.id, + changed_fields=["deleted_at", "external_contour"], + publish_external_bridge=False, + ) + contour_request.delete() + issue.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + class ExternalContourDecisionEndpoint(BaseAPIView): permission_classes = [ProjectLitePermission] @@ -753,6 +822,13 @@ class ExternalContourDecisionEndpoint(BaseAPIView): issue.state = target_default_state issue.save(update_fields=["state", "updated_at"]) + publish_issue_event_on_commit( + "issue.updated", + issue, + actor_id=request.user.id, + changed_fields=["state", "external_contour_reopen"], + publish_external_bridge=False, + ) extra = dict(contour_request.extra or {}) extra.pop("source_decision", None) @@ -764,6 +840,13 @@ class ExternalContourDecisionEndpoint(BaseAPIView): contour_request.extra = extra contour_request.save(update_fields=["extra", "updated_at"]) + publish_external_contour_event_on_commit( + "external_contour.updated", + contour_request, + actor_id=request.user.id, + changed_fields=["source_decision", "state"], + ) + contour_request.refresh_from_db() Notification.objects.filter( receiver_id=request.user.id, @@ -831,6 +914,12 @@ class ExternalContourReplyEndpoint(BaseAPIView): return Response({"error": exc.message_dict if hasattr(exc, "message_dict") else str(exc)}, status=status.HTTP_400_BAD_REQUEST) contour_request.refresh_from_db() + publish_external_contour_event_on_commit( + "external_contour.updated", + contour_request, + actor_id=request.user.id, + changed_fields=["comments"], + ) Notification.objects.filter( receiver_id=request.user.id, sender__startswith="in_app:external_contours:", diff --git a/plane-src/apps/live/src/controllers/issue-stream.controller.ts b/plane-src/apps/live/src/controllers/issue-stream.controller.ts index 0b09367..bab122b 100644 --- a/plane-src/apps/live/src/controllers/issue-stream.controller.ts +++ b/plane-src/apps/live/src/controllers/issue-stream.controller.ts @@ -88,7 +88,11 @@ export class IssueStreamController { subscriber.on("message", (_channel, message) => { try { const event = JSON.parse(message) as TIssueRealtimeEvent; - if (event.project_id !== projectId || !event.type?.startsWith("issue.")) return; + if ( + event.project_id !== projectId || + (!event.type?.startsWith("issue.") && !event.type?.startsWith("external_contour.")) + ) + return; sendJson(ws, event as Record); } catch (error) { diff --git a/plane-src/apps/web/ce/components/projects/external-contours/board-item.tsx b/plane-src/apps/web/ce/components/projects/external-contours/board-item.tsx index 4be5db9..14193b3 100644 --- a/plane-src/apps/web/ce/components/projects/external-contours/board-item.tsx +++ b/plane-src/apps/web/ce/components/projects/external-contours/board-item.tsx @@ -6,29 +6,28 @@ import { useMemo, useState } from "react"; import { useSearchParams } from "next/navigation"; -import { CalendarDays } from "lucide-react"; +import { Archive, CalendarDays, Copy, MoreHorizontal, Pencil, Trash2 } from "lucide-react"; import { observer } from "mobx-react"; -import { EUserPermissions } from "@plane/constants"; +import { ARCHIVABLE_STATE_GROUPS, EUserPermissions } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; import { PriorityIcon, StateGroupIcon, getStateGroupColor } from "@plane/propel/icons"; import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import type { IState, TExternalContourBoardDirection, TExternalContourRequest, TIssue } from "@plane/types"; -import { Avatar } from "@plane/ui"; +import { ActionDropdown, Avatar } from "@plane/ui"; import { cn, renderFormattedDate, renderFormattedPayloadDate } from "@plane/utils"; import { DateDropdown } from "@/components/dropdowns/date"; import { ButtonAvatars } from "@/components/dropdowns/member/avatar"; import { MemberDropdown } from "@/components/dropdowns/member/dropdown"; import { MemberDropdownBase } from "@/components/dropdowns/member/base"; -import { PriorityDropdown } from "@/components/dropdowns/priority"; -import { WorkItemStateDropdownBase } from "@/components/dropdowns/state/base"; -import { StateDropdown } from "@/components/dropdowns/state/dropdown"; import { useAppRouter } from "@/hooks/use-app-router"; import { useMember } from "@/hooks/store/use-member"; import { useProjectExternalContoursBoard } from "@/hooks/store/use-project-external-contours-board"; import { useProjectExternalContours } from "@/hooks/store/use-project-external-contours"; import { useProjectState } from "@/hooks/store/use-project-state"; import { useUserPermissions } from "@/hooks/store/user"; +import { IssueArchiveService } from "@/services/issue/issue_archive.service"; import { IssueService } from "@/services/issue/issue.service"; +import { ExternalContourDeleteModal } from "./delete-modal"; type Props = { direction: TExternalContourBoardDirection; @@ -38,6 +37,7 @@ type Props = { }; const issueService = new IssueService(); +const issueArchiveService = new IssueArchiveService(); const basePillClasses = "inline-flex min-h-9 items-center gap-1.5 rounded-full border-0 px-2.5 py-1 text-[11px] font-medium shadow-none outline-none transition-colors"; @@ -45,7 +45,7 @@ const basePillClasses = const buildSourceStateMap = ( states: { id: string; name: string; color: string; group: IState["group"] }[] | undefined, projectId: string | null -) => +): Record => Object.fromEntries( (states ?? []).map((state, index) => [ state.id, @@ -81,11 +81,12 @@ export const ExternalContoursBoardItem = observer(function ExternalContoursBoard const { getUserDetails, workspace } = useMember(); const { getProjectRoleByWorkspaceSlugAndProjectId } = useUserPermissions(); const { getStateById, getProjectStateIds } = useProjectState(); - const { fetchBoard, upsertBoardItems } = useProjectExternalContoursBoard(); - const { fetchTargetOptions, getTargetOptionsByProjectId, updateRequest, updateRequestIssue } = + const { fetchBoard, removeBoardItem, upsertBoardItems } = useProjectExternalContoursBoard(); + const { deleteRequest, fetchTargetOptions, getTargetOptionsByProjectId, updateRequest, updateRequestIssue } = useProjectExternalContours(); const [isUpdating, setIsUpdating] = useState(false); const [isSourceOptionsLoading, setIsSourceOptionsLoading] = useState(false); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const issue = request.issue; const selectedInboxIssueId = searchParams.get("inboxIssueId"); @@ -120,15 +121,18 @@ export const ExternalContoursBoardItem = observer(function ExternalContoursBoard ); const sourceStateIds = useMemo(() => targetOptions?.states?.map((state) => state.id) ?? [], [targetOptions?.states]); const selectedState = canEditTargetIssue ? getStateById(issue.state_id) : sourceStateMap[issue.state_id ?? ""]; - const projectStateIds = issue.project_id ? getProjectStateIds(issue.project_id) : []; + const projectStateIds = issue.project_id ? (getProjectStateIds(issue.project_id) ?? []) : []; + const stateOptions = canEditTargetIssue + ? projectStateIds.map((stateId) => getStateById(stateId)).filter((state): state is IState => !!state) + : sourceStateIds.map((stateId) => sourceStateMap[stateId]).filter((state): state is IState => !!state); const foregroundClasses = isActive ? "text-[rgb(var(--nodedc-on-card-active-rgb))]" : "text-white"; const subtleTextClasses = isActive ? "text-[#2F4721]" : "text-[#B3B3B8]"; const pillBackgroundClasses = isActive ? "bg-black/10 text-[rgb(var(--nodedc-on-card-active-rgb))]" : "bg-[rgb(var(--nodedc-card-passive-rgb))] text-white"; const iconBubbleClasses = isActive ? "bg-black text-[rgb(var(--nodedc-card-active-rgb))]" : "bg-[#111214] text-white"; - const statusIconColor = getStateGroupColor(selectedState?.group, selectedState?.color); const dueDateLabel = issue.target_date ? renderFormattedDate(issue.target_date, "d MMM, yyyy") : t("common.none"); + const canArchive = canEditTargetIssue && !!selectedState && ARCHIVABLE_STATE_GROUPS.includes(selectedState.group); if (!issue) return null; @@ -215,8 +219,66 @@ export const ExternalContoursBoardItem = observer(function ExternalContoursBoard await handleSourceRequestUpdate(data); }; + const handleCopyLink = async () => { + const absoluteLink = `${window.location.origin}${requestLink}`; + await navigator.clipboard?.writeText(absoluteLink); + setToast({ title: "Ссылка скопирована", type: TOAST_TYPE.SUCCESS }); + }; + + const handleArchiveIssue = async () => { + if (!targetProjectId || !issue.id || !canArchive || isUpdating) return; + + setIsUpdating(true); + try { + await issueArchiveService.archiveIssue(workspaceSlug, targetProjectId, issue.id); + await syncBoardAfterMutation(); + setToast({ title: "Задача архивирована", type: TOAST_TYPE.SUCCESS }); + } catch { + setToast({ title: t("error"), type: TOAST_TYPE.ERROR, message: "Не удалось архивировать задачу" }); + } finally { + setIsUpdating(false); + } + }; + + const handleDeleteRequest = async () => { + if (direction !== "outgoing" || isUpdating) return; + + setIsUpdating(true); + try { + await deleteRequest(workspaceSlug, projectId, request.id); + removeBoardItem(request.id); + if (isActive) router.push(`/${workspaceSlug}/projects/${projectId}/external-contours`); + setToast({ title: "Исходящая задача удалена", type: TOAST_TYPE.SUCCESS }); + setIsDeleteModalOpen(false); + } catch { + setToast({ title: t("error"), type: TOAST_TYPE.ERROR, message: "Не удалось удалить исходящую задачу" }); + } finally { + setIsUpdating(false); + } + }; + + const priorityOptions: NonNullable[] = ["urgent", "high", "medium", "low", "none"]; + const priorityLabels: Record, string> = { + urgent: "Срочный", + high: "Высокий", + medium: "Средний", + low: "Низкий", + none: "Без приоритета", + }; + + const menuItemClasses = + "flex w-full items-center gap-2 rounded-[0.9rem] px-2.5 py-2 text-left text-12 text-secondary transition-colors hover:bg-white/6 disabled:cursor-not-allowed disabled:text-placeholder disabled:hover:bg-transparent"; + return ( -
+ <> + setIsDeleteModalOpen(false)} + onSubmit={handleDeleteRequest} + /> +
)} - void handleCardUpdate({ priority })} - disabled={!canEditCard || isUpdating} - buttonVariant="transparent-without-text" - button={ -
- -
- } - /> +
} + buttonClassName="h-8 w-8" + menuClassName="min-w-[18rem]" + onOpenChange={(isOpen) => { + if (isOpen) void ensureSourceOptions(); + }} + items={[]} + menuContent={({ closeDropdown }) => ( +
+
+
Приоритет
+ {priorityOptions.map((priority) => ( + + ))} +
- {canEditTargetIssue ? ( - void handleCardUpdate({ state_id: stateId })} - disabled={!canEditCard || isUpdating} - buttonVariant="transparent-without-text" - button={ -
- +
+
Статус
+ {isSourceOptionsLoading && stateOptions.length === 0 ? ( +
Загрузка статусов...
+ ) : ( + stateOptions.map((state) => ( + + )) + )}
- } - /> - ) : ( - (stateId ? sourceStateMap[stateId] : undefined)} - onChange={(stateId) => void handleCardUpdate({ state_id: stateId })} - disabled={!canEditCard || isUpdating || !targetProjectId} - isInitializing={isSourceOptionsLoading} - onDropdownOpen={() => { - void ensureSourceOptions(); - }} - buttonVariant="transparent-without-text" - button={ -
- + +
+
Быстрые действия
+ + + +
- } - /> - )} +
+ )} + />
@@ -323,6 +434,29 @@ export const ExternalContoursBoardItem = observer(function ExternalContoursBoard
+ {direction === "outgoing" && ( + + void handleCardUpdate({ + target_date: targetDate ? renderFormattedPayloadDate(targetDate) : null, + }) + } + disabled={!canEditCard || isUpdating} + buttonVariant="transparent-without-text" + button={ +
+ + {dueDateLabel} +
+ } + /> + )} + {canEditTargetIssue ? ( )} - - void handleCardUpdate({ - target_date: targetDate ? renderFormattedPayloadDate(targetDate) : null, - }) - } - disabled={!canEditCard || isUpdating} - buttonVariant="transparent-without-text" - button={ -
- - {dueDateLabel} -
- } - /> + {direction !== "outgoing" && ( + + void handleCardUpdate({ + target_date: targetDate ? renderFormattedPayloadDate(targetDate) : null, + }) + } + disabled={!canEditCard || isUpdating} + buttonVariant="transparent-without-text" + button={ +
+ + {dueDateLabel} +
+ } + /> + )}
+ ); }); diff --git a/plane-src/apps/web/ce/components/projects/external-contours/delete-modal.tsx b/plane-src/apps/web/ce/components/projects/external-contours/delete-modal.tsx new file mode 100644 index 0000000..0872c10 --- /dev/null +++ b/plane-src/apps/web/ce/components/projects/external-contours/delete-modal.tsx @@ -0,0 +1,48 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { Button } from "@plane/propel/button"; +import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui"; + +type Props = { + isOpen: boolean; + isSubmitting?: boolean; + issueName: string; + onClose: () => void; + onSubmit: () => Promise; +}; + +export function ExternalContourDeleteModal(props: Props) { + const { isOpen, isSubmitting = false, issueName, onClose, onSubmit } = props; + + return ( + +
+
+

Удалить исходящую задачу

+

+ Задача «{issueName}» будет удалена из внешнего контура и из целевого проекта. Это действие нельзя отменить. +

+
+ +
+ + +
+
+
+ ); +} 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 aa9600b..fa1f93c 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 @@ -10,6 +10,7 @@ import { TransferIcon } from "@plane/propel/icons"; import type { TInboxIssueCurrentTab } from "@plane/types"; import { EInboxIssueCurrentTab } from "@plane/types"; import { FiltersRow } from "@/components/rich-filters/filters-row"; +import { useExternalContoursRealtimeEvents } from "@/hooks/use-external-contours-realtime-events"; import { useProjectExternalContoursBoard } from "@/hooks/store/use-project-external-contours-board"; import { useProjectExternalContours } from "@/hooks/store/use-project-external-contours"; import { ExternalContoursBoardRoot } from "./board-root"; @@ -32,9 +33,12 @@ export const ExternalContoursRoot = observer(function ExternalContoursRoot(props currentProjectId: boardProjectId, fetchBoard, loader: boardLoader, + syncBoard, } = useProjectExternalContoursBoard(); const filter = useExternalContoursFilter(); + useExternalContoursRealtimeEvents(workspaceSlug?.toString(), projectId?.toString(), syncBoard); + useEffect(() => { if (!workspaceSlug || !projectId) return; diff --git a/plane-src/apps/web/core/hooks/use-external-contours-realtime-events.ts b/plane-src/apps/web/core/hooks/use-external-contours-realtime-events.ts new file mode 100644 index 0000000..05c6e4e --- /dev/null +++ b/plane-src/apps/web/core/hooks/use-external-contours-realtime-events.ts @@ -0,0 +1,137 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useEffect, useRef } from "react"; +import { LIVE_BASE_PATH, LIVE_BASE_URL } from "@plane/constants"; + +type TExternalContourRealtimeEvent = { + event_id?: string; + type?: string; + workspace_slug?: string; + project_id?: string; +}; + +const SYNC_DEBOUNCE_MS = 350; + +const buildIssueStreamUrl = (workspaceSlug: string, projectId: string) => { + const liveBaseUrl = LIVE_BASE_URL?.trim() || window.location.origin; + const liveBasePath = LIVE_BASE_PATH?.trim() || "/live"; + const url = new URL(liveBaseUrl); + + url.protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + url.pathname = `${liveBasePath.replace(/\/$/, "")}/issues/stream`; + url.searchParams.set("workspaceSlug", workspaceSlug); + url.searchParams.set("projectId", projectId); + + return url.toString(); +}; + +export const useExternalContoursRealtimeEvents = ( + workspaceSlug: string | undefined, + projectId: string | undefined, + syncBoard: (workspaceSlug: string, projectId: string) => Promise +) => { + const syncBoardRef = useRef(syncBoard); + const processedEventIdsRef = useRef([]); + const processedEventSetRef = useRef(new Set()); + const syncTimerRef = useRef | undefined>(); + + useEffect(() => { + syncBoardRef.current = syncBoard; + }, [syncBoard]); + + useEffect(() => { + if (!workspaceSlug || !projectId || typeof window === "undefined") return; + + let socket: WebSocket | undefined; + let reconnectTimer: ReturnType | undefined; + let cancelled = false; + let reconnectAttempt = 0; + + const rememberEvent = (eventId?: string) => { + if (!eventId) return true; + if (processedEventSetRef.current.has(eventId)) return false; + + processedEventIdsRef.current.push(eventId); + processedEventSetRef.current.add(eventId); + + if (processedEventIdsRef.current.length > 250) { + const removedEventId = processedEventIdsRef.current.shift(); + if (removedEventId) processedEventSetRef.current.delete(removedEventId); + } + + return true; + }; + + const scheduleSync = () => { + if (cancelled) return; + if (syncTimerRef.current) clearTimeout(syncTimerRef.current); + + syncTimerRef.current = setTimeout(() => { + void syncBoardRef.current(workspaceSlug, projectId); + }, SYNC_DEBOUNCE_MS); + }; + + const scheduleReconnect = () => { + if (cancelled) return; + const delay = Math.min(1000 * 2 ** reconnectAttempt, 15000); + reconnectAttempt += 1; + reconnectTimer = setTimeout(connect, delay); + }; + + const connect = () => { + try { + socket = new WebSocket(buildIssueStreamUrl(workspaceSlug, projectId)); + + socket.onopen = () => { + reconnectAttempt = 0; + scheduleSync(); + }; + + socket.onmessage = (message) => { + try { + const event = JSON.parse(message.data) as TExternalContourRealtimeEvent; + + if (event.type === "issue.stream.ping") { + socket?.send(JSON.stringify({ type: "issue.stream.pong" })); + return; + } + + if (event.type === "issue.stream.ready") return; + if (event.workspace_slug && event.workspace_slug !== workspaceSlug) return; + if (event.project_id && event.project_id !== projectId) return; + if (!event.type?.startsWith("external_contour.") && !event.type?.startsWith("issue.")) return; + if (!rememberEvent(event.event_id)) return; + + scheduleSync(); + } catch (error) { + console.error("Failed to process external contour realtime event", error); + } + }; + + socket.onclose = () => { + scheduleReconnect(); + }; + + socket.onerror = () => { + socket?.close(); + }; + } catch (error) { + console.error("Failed to connect external contour realtime stream", error); + scheduleReconnect(); + } + }; + + connect(); + + return () => { + cancelled = true; + if (syncTimerRef.current) clearTimeout(syncTimerRef.current); + if (reconnectTimer) clearTimeout(reconnectTimer); + socket?.close(); + }; + }, [workspaceSlug, projectId]); +}; diff --git a/plane-src/apps/web/core/hooks/use-issue-realtime-events.ts b/plane-src/apps/web/core/hooks/use-issue-realtime-events.ts index 647199a..2f5c1d4 100644 --- a/plane-src/apps/web/core/hooks/use-issue-realtime-events.ts +++ b/plane-src/apps/web/core/hooks/use-issue-realtime-events.ts @@ -41,6 +41,7 @@ type TIssueFilterSnapshot = { const REALTIME_STORE_TYPES = new Set([EIssuesStoreType.PROJECT, EIssuesStoreType.PROJECT_VIEW]); const MAX_PROCESSED_EVENTS = 250; +const INITIAL_CATCH_UP_WINDOW_MS = 5 * 60 * 1000; const hasIssueId = (value: unknown, issueId: string): boolean => { if (Array.isArray(value)) return value.includes(issueId); @@ -91,7 +92,6 @@ export const useIssueRealtimeEvents = (storeType: EIssuesStoreType, workspaceSlu let reconnectTimer: ReturnType | undefined; let cancelled = false; let reconnectAttempt = 0; - let hasConnectedOnce = false; const getFilterParams = () => { const filters = { ...(issueFilterRef.current?.appliedFilters ?? {}) }; @@ -103,6 +103,32 @@ export const useIssueRealtimeEvents = (storeType: EIssuesStoreType, workspaceSlu return filters; }; + const rememberUpdatedAt = (updatedAt?: string) => { + if (!updatedAt) return; + + const currentUpdatedAt = lastSeenUpdatedAtRef.current; + if (!currentUpdatedAt || Date.parse(updatedAt) > Date.parse(currentUpdatedAt)) { + lastSeenUpdatedAtRef.current = updatedAt; + } + }; + + const getInitialCatchUpStart = () => { + const latestKnownUpdatedAt = Object.values(issueMapRef.current ?? {}).reduce( + (latestUpdatedAt, issue) => { + if (!issue?.updated_at) return latestUpdatedAt; + if (!latestUpdatedAt || Date.parse(issue.updated_at) > Date.parse(latestUpdatedAt)) return issue.updated_at; + + return latestUpdatedAt; + }, + undefined + ); + + const fallbackUpdatedAt = new Date(Date.now() - INITIAL_CATCH_UP_WINDOW_MS).toISOString(); + if (!latestKnownUpdatedAt) return fallbackUpdatedAt; + + return Date.parse(latestKnownUpdatedAt) < Date.parse(fallbackUpdatedAt) ? latestKnownUpdatedAt : fallbackUpdatedAt; + }; + const rememberEvent = (eventId: string) => { if (processedEventSetRef.current.has(eventId)) return false; @@ -146,7 +172,7 @@ export const useIssueRealtimeEvents = (storeType: EIssuesStoreType, workspaceSlu const handleIssueEvent = async (event: TIssueRealtimeEvent) => { if (!event.event_id || !event.issue_id) return; if (!rememberEvent(event.event_id)) return; - if (event.updated_at) lastSeenUpdatedAtRef.current = event.updated_at; + rememberUpdatedAt(event.updated_at); if (event.type === "issue.deleted") { removeIssue(event.issue_id, true); @@ -175,7 +201,10 @@ export const useIssueRealtimeEvents = (storeType: EIssuesStoreType, workspaceSlu const results = response?.results; if (!Array.isArray(results)) return; - results.forEach(applyIssue); + results.forEach((issue) => { + rememberUpdatedAt(issue.updated_at); + applyIssue(issue); + }); }; const scheduleReconnect = () => { @@ -191,8 +220,8 @@ export const useIssueRealtimeEvents = (storeType: EIssuesStoreType, workspaceSlu socket.onopen = () => { reconnectAttempt = 0; - if (hasConnectedOnce) void catchUpMissedEvents(); - hasConnectedOnce = true; + lastSeenUpdatedAtRef.current = lastSeenUpdatedAtRef.current ?? getInitialCatchUpStart(); + void catchUpMissedEvents(); }; socket.onmessage = (message) => { @@ -205,6 +234,7 @@ export const useIssueRealtimeEvents = (storeType: EIssuesStoreType, workspaceSlu } if (event.type === "issue.stream.ready") return; + if (!event.type?.startsWith("issue.")) return; if (event.workspace_slug && event.workspace_slug !== workspaceSlug) return; if (event.project_id && event.project_id !== projectId) return; 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 index 589291d..8061359 100644 --- 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 @@ -80,6 +80,14 @@ export class ExternalContourService extends APIService { }); } + async deleteRequest(workspaceSlug: string, projectId: string, requestId: string): Promise { + return this.delete(`/api/workspaces/${workspaceSlug}/projects/${projectId}/external-contours/${requestId}/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + async listTargetProjects(workspaceSlug: string, projectId: string): Promise { return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/external-contours/targets/`) .then((response) => response?.data) diff --git a/plane-src/apps/web/core/store/external-contours/project-external-contours-board.store.ts b/plane-src/apps/web/core/store/external-contours/project-external-contours-board.store.ts index 37771a7..b633fcb 100644 --- a/plane-src/apps/web/core/store/external-contours/project-external-contours-board.store.ts +++ b/plane-src/apps/web/core/store/external-contours/project-external-contours-board.store.ts @@ -53,7 +53,9 @@ export interface IProjectExternalContoursBoardStore { replaceFilters: (workspaceSlug: string, projectId: string, filters: Partial) => Promise; updateFilters: (workspaceSlug: string, projectId: string, filters: Partial) => Promise; updateSorting: (workspaceSlug: string, projectId: string, sorting: TExternalContourBoardSorting) => Promise; + syncBoard: (workspaceSlug: string, projectId: string) => Promise; upsertBoardItems: (items: TExternalContourRequest[]) => void; + removeBoardItem: (requestId: string) => void; } export class ProjectExternalContoursBoardStore implements IProjectExternalContoursBoardStore { @@ -101,9 +103,11 @@ export class ProjectExternalContoursBoardStore implements IProjectExternalContou fetchBoard: action, handleCurrentTab: action, replaceFilters: action, + syncBoard: action, updateFilters: action, updateSorting: action, upsertBoardItems: action, + removeBoardItem: action, }); this.externalContourService = new ExternalContourService(); @@ -145,6 +149,19 @@ export class ProjectExternalContoursBoardStore implements IProjectExternalContou this.store.projectExternalContours.upsertRequests(items); }; + removeBoardItem = (requestId: string) => { + delete this.items[requestId]; + this.columnIdsMap = { + outgoing: this.columnIdsMap.outgoing.filter((id) => id !== requestId), + incoming: this.columnIdsMap.incoming.filter((id) => id !== requestId), + }; + this.columnCountMap = { + outgoing: this.columnIdsMap.outgoing.length, + incoming: this.columnIdsMap.incoming.length, + }; + this.store.projectExternalContours.removeRequest(requestId); + }; + handleCurrentTab = async (workspaceSlug: string, projectId: string, tab: TInboxIssueCurrentTab) => { this.currentTab = tab; await this.fetchBoard(workspaceSlug, projectId, tab); @@ -248,4 +265,46 @@ export class ProjectExternalContoursBoardStore implements IProjectExternalContou }); } }; + + syncBoard = async (workspaceSlug: string, projectId: string) => { + if (this.currentProjectId && this.currentProjectId !== projectId) return; + + const requestId = ++this.lastIssuedRequestId; + const nextFilters = sanitizeBoardFilters(this.filters); + const nextSorting = this.sorting; + + try { + const response = await this.externalContourService.listBoard(workspaceSlug, projectId, nextFilters, nextSorting); + if (requestId !== this.lastIssuedRequestId) return; + + runInAction(() => { + this.columnIdsMap = { outgoing: [], incoming: [] }; + this.columnCountMap = { outgoing: 0, incoming: 0 }; + this.filters = sanitizeBoardFilters(response.filters || nextFilters); + this.sorting = response.sorting || nextSorting; + this.currentProjectId = projectId; + this.hydratedProjectId = projectId; + let openCount = 0; + let closedCount = 0; + + response.columns.forEach((column) => { + this.columnIdsMap[column.key] = column.results.map((request) => request.id); + this.columnCountMap[column.key] = column.total_count; + column.results.forEach((request) => { + if (request.status === EInboxIssueCurrentTab.CLOSED) closedCount += 1; + else openCount += 1; + }); + this.upsertBoardItems(column.results); + }); + + this.tabCountMap = { + [EInboxIssueCurrentTab.OPEN]: openCount, + [EInboxIssueCurrentTab.CLOSED]: closedCount, + }; + this.error = undefined; + }); + } catch { + // Realtime sync is best-effort; the next explicit board fetch will surface errors. + } + }; } 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 index 2b3e352..e6afd81 100644 --- 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 @@ -53,6 +53,7 @@ export interface IProjectExternalContoursStore { requestId: string, comment: string ) => Promise; + deleteRequest: (workspaceSlug: string, projectId: string, requestId: string) => Promise; fetchTargetProjects: (workspaceSlug: string, projectId: string) => Promise; fetchTargetOptions: (workspaceSlug: string, projectId: string, targetProjectId: string) => Promise; fetchRequestById: (workspaceSlug: string, projectId: string, requestId: string) => Promise; @@ -66,6 +67,7 @@ export interface IProjectExternalContoursStore { closedRequestIds: string[]; filteredRequestIds: string[]; upsertRequests: (requests: TExternalContourRequest[]) => void; + removeRequest: (requestId: string) => void; updateRequestIssue: (requestId: string, issueData: Partial) => void; } @@ -102,9 +104,11 @@ export class ProjectExternalContoursStore implements IProjectExternalContoursSto fetchRequestById: action, createRequest: action, updateRequest: action, + deleteRequest: action, decideRequest: action, replyToRequest: action, handleCurrentTab: action, + removeRequest: action, upsertRequests: action, updateRequestIssue: action, }); @@ -143,6 +147,11 @@ export class ProjectExternalContoursStore implements IProjectExternalContoursSto }); }; + removeRequest = (requestId: string) => { + delete this.requests[requestId]; + this.requestIds = this.requestIds.filter((id) => id !== requestId); + }; + fetchTargetProjects = async (workspaceSlug: string, projectId: string) => { try { const projects = await this.externalContourService.listTargetProjects(workspaceSlug, projectId); @@ -269,6 +278,22 @@ export class ProjectExternalContoursStore implements IProjectExternalContoursSto } }; + deleteRequest = async (workspaceSlug: string, projectId: string, requestId: string) => { + this.loader = "mutation-loading"; + try { + await this.externalContourService.deleteRequest(workspaceSlug, projectId, requestId); + runInAction(() => { + this.removeRequest(requestId); + this.loader = undefined; + }); + } catch (error) { + runInAction(() => { + this.loader = undefined; + }); + throw error; + } + }; + decideRequest = async ( workspaceSlug: string, projectId: string,