ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: realtime внешних контуров и действия исходящих карточек

This commit is contained in:
DCCONSTRUCTIONS 2026-04-29 12:53:04 +03:00
parent b2a710a7ec
commit 248292bd52
14 changed files with 819 additions and 105 deletions

View File

@ -45,7 +45,7 @@ urlpatterns = [
), ),
path( path(
"workspaces/<str:slug>/projects/<uuid:project_id>/external-contours/<uuid:request_id>/", "workspaces/<str:slug>/projects/<uuid:project_id>/external-contours/<uuid:request_id>/",
ExternalContourDetailEndpoint.as_view(http_method_names=["get", "patch"]), ExternalContourDetailEndpoint.as_view(http_method_names=["get", "patch", "delete"]),
name="external-contour-detail", name="external-contour-detail",
), ),
path( path(

View File

@ -21,6 +21,7 @@ from plane.api.serializers import (
) )
from plane.api.serializers.issue import IssueSerializer as IssueCreateSerializer from plane.api.serializers.issue import IssueSerializer as IssueCreateSerializer
from plane.app.permissions import ProjectLitePermission 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.app.views.base import BaseAPIView
from plane.db.models import FileAsset, Intake, IntakeIssue, Label, Notification, Project, ProjectMember, State, StateGroup from plane.db.models import FileAsset, Intake, IntakeIssue, Label, Notification, Project, ProjectMember, State, StateGroup
from plane.db.models.intake import IntakeIssueStatus, SourceType from plane.db.models.intake import IntakeIssueStatus, SourceType
@ -171,6 +172,29 @@ class ExternalContourListCreateEndpoint(BaseAPIView):
issue.state = target_default_state issue.state = target_default_state
issue.save() 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( response_serializer = ExternalContourRequestSerializer(
IntakeIssue.objects.select_related( IntakeIssue.objects.select_related(
"issue", "issue",
@ -331,6 +355,7 @@ class ExternalContourDetailEndpoint(BaseAPIView):
serializer = ExternalContourRequestUpdateSerializer(data=request.data) serializer = ExternalContourRequestUpdateSerializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
issue_update_data = serializer.validated_data.copy() issue_update_data = serializer.validated_data.copy()
changed_fields = list(issue_update_data.keys())
assignee_ids = issue_update_data.pop("assignee_ids", None) assignee_ids = issue_update_data.pop("assignee_ids", None)
label_ids = issue_update_data.pop("label_ids", None) label_ids = issue_update_data.pop("label_ids", None)
if assignee_ids is not None: if assignee_ids is not None:
@ -348,11 +373,24 @@ class ExternalContourDetailEndpoint(BaseAPIView):
}, },
) )
issue_serializer.is_valid(raise_exception=True) 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.updated_at = timezone.now()
contour_request.save(update_fields=["updated_at"]) contour_request.save(update_fields=["updated_at"])
contour_request.refresh_from_db() 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( response_serializer = ExternalContourRequestSerializer(
contour_request, contour_request,
@ -365,6 +403,37 @@ class ExternalContourDetailEndpoint(BaseAPIView):
) )
return Response(response_serializer.data, status=status.HTTP_200_OK) 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): class ExternalContourDecisionEndpoint(BaseAPIView):
permission_classes = [ProjectLitePermission] permission_classes = [ProjectLitePermission]
@ -434,6 +503,13 @@ class ExternalContourDecisionEndpoint(BaseAPIView):
issue.state = target_default_state issue.state = target_default_state
issue.save(update_fields=["state", "updated_at"]) 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 = dict(contour_request.extra or {})
extra.pop("source_decision", None) extra.pop("source_decision", None)
@ -445,6 +521,13 @@ class ExternalContourDecisionEndpoint(BaseAPIView):
contour_request.extra = extra contour_request.extra = extra
contour_request.save(update_fields=["extra", "updated_at"]) 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() contour_request.refresh_from_db()
Notification.objects.filter( Notification.objects.filter(
receiver_id=request.user.id, receiver_id=request.user.id,

View File

@ -21,7 +21,98 @@ def issue_event_channel(project_id):
return f"{ISSUE_EVENT_CHANNEL_PREFIX}:{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 = { payload = {
"event_id": str(uuid4()), "event_id": str(uuid4()),
"type": event_type, "type": event_type,
@ -37,11 +128,10 @@ def publish_issue_event_on_commit(event_type, issue, actor_id=None, changed_fiel
def _publish(): def _publish():
try: try:
redis_instance().publish( _publish_payload(issue.project_id, payload)
issue_event_channel(issue.project_id),
json.dumps(payload, cls=DjangoJSONEncoder),
)
except Exception: except Exception:
logger.exception("Failed to publish issue realtime event") logger.exception("Failed to publish issue realtime event")
transaction.on_commit(_publish) 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)

View File

@ -45,7 +45,7 @@ urlpatterns = [
), ),
path( path(
"workspaces/<str:slug>/projects/<uuid:project_id>/external-contours/<uuid:request_id>/", "workspaces/<str:slug>/projects/<uuid:project_id>/external-contours/<uuid:request_id>/",
ExternalContourDetailEndpoint.as_view(http_method_names=["get", "patch"]), ExternalContourDetailEndpoint.as_view(http_method_names=["get", "patch", "delete"]),
name="external-contour-detail", name="external-contour-detail",
), ),
path( path(

View File

@ -23,6 +23,7 @@ from plane.api.serializers import (
) )
from plane.api.serializers.issue import IssueSerializer as IssueCreateSerializer from plane.api.serializers.issue import IssueSerializer as IssueCreateSerializer
from plane.app.permissions import ProjectLitePermission 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.app.views.base import BaseAPIView
from plane.db.models import FileAsset, Intake, IntakeIssue, Label, Notification, Project, ProjectMember, State, StateGroup from plane.db.models import FileAsset, Intake, IntakeIssue, Label, Notification, Project, ProjectMember, State, StateGroup
from plane.db.models.intake import IntakeIssueStatus, SourceType from plane.db.models.intake import IntakeIssueStatus, SourceType
@ -173,6 +174,29 @@ class ExternalContourListCreateEndpoint(BaseAPIView):
issue.state = target_default_state issue.state = target_default_state
issue.save() 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( response_serializer = ExternalContourRequestSerializer(
IntakeIssue.objects.select_related( IntakeIssue.objects.select_related(
"issue", "issue",
@ -650,6 +674,7 @@ class ExternalContourDetailEndpoint(BaseAPIView):
serializer = ExternalContourRequestUpdateSerializer(data=request.data) serializer = ExternalContourRequestUpdateSerializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
issue_update_data = serializer.validated_data.copy() issue_update_data = serializer.validated_data.copy()
changed_fields = list(issue_update_data.keys())
assignee_ids = issue_update_data.pop("assignee_ids", None) assignee_ids = issue_update_data.pop("assignee_ids", None)
label_ids = issue_update_data.pop("label_ids", None) label_ids = issue_update_data.pop("label_ids", None)
if assignee_ids is not None: if assignee_ids is not None:
@ -667,11 +692,24 @@ class ExternalContourDetailEndpoint(BaseAPIView):
}, },
) )
issue_serializer.is_valid(raise_exception=True) issue_serializer.is_valid(raise_exception=True)
issue_serializer.save() updated_issue = issue_serializer.save()
contour_request.updated_at = timezone.now() contour_request.updated_at = timezone.now()
contour_request.save(update_fields=["updated_at"]) contour_request.save(update_fields=["updated_at"])
contour_request.refresh_from_db() 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( response_serializer = ExternalContourRequestSerializer(
contour_request, contour_request,
@ -684,6 +722,37 @@ class ExternalContourDetailEndpoint(BaseAPIView):
) )
return Response(response_serializer.data, status=status.HTTP_200_OK) 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): class ExternalContourDecisionEndpoint(BaseAPIView):
permission_classes = [ProjectLitePermission] permission_classes = [ProjectLitePermission]
@ -753,6 +822,13 @@ class ExternalContourDecisionEndpoint(BaseAPIView):
issue.state = target_default_state issue.state = target_default_state
issue.save(update_fields=["state", "updated_at"]) 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 = dict(contour_request.extra or {})
extra.pop("source_decision", None) extra.pop("source_decision", None)
@ -764,6 +840,13 @@ class ExternalContourDecisionEndpoint(BaseAPIView):
contour_request.extra = extra contour_request.extra = extra
contour_request.save(update_fields=["extra", "updated_at"]) 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() contour_request.refresh_from_db()
Notification.objects.filter( Notification.objects.filter(
receiver_id=request.user.id, 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) 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() 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( Notification.objects.filter(
receiver_id=request.user.id, receiver_id=request.user.id,
sender__startswith="in_app:external_contours:", sender__startswith="in_app:external_contours:",

View File

@ -88,7 +88,11 @@ export class IssueStreamController {
subscriber.on("message", (_channel, message) => { subscriber.on("message", (_channel, message) => {
try { try {
const event = JSON.parse(message) as TIssueRealtimeEvent; 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<string, unknown>); sendJson(ws, event as Record<string, unknown>);
} catch (error) { } catch (error) {

View File

@ -6,29 +6,28 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { useSearchParams } from "next/navigation"; 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 { observer } from "mobx-react";
import { EUserPermissions } from "@plane/constants"; import { ARCHIVABLE_STATE_GROUPS, EUserPermissions } from "@plane/constants";
import { useTranslation } from "@plane/i18n"; import { useTranslation } from "@plane/i18n";
import { PriorityIcon, StateGroupIcon, getStateGroupColor } from "@plane/propel/icons"; import { PriorityIcon, StateGroupIcon, getStateGroupColor } from "@plane/propel/icons";
import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { IState, TExternalContourBoardDirection, TExternalContourRequest, TIssue } from "@plane/types"; 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 { cn, renderFormattedDate, renderFormattedPayloadDate } from "@plane/utils";
import { DateDropdown } from "@/components/dropdowns/date"; import { DateDropdown } from "@/components/dropdowns/date";
import { ButtonAvatars } from "@/components/dropdowns/member/avatar"; import { ButtonAvatars } from "@/components/dropdowns/member/avatar";
import { MemberDropdown } from "@/components/dropdowns/member/dropdown"; import { MemberDropdown } from "@/components/dropdowns/member/dropdown";
import { MemberDropdownBase } from "@/components/dropdowns/member/base"; 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 { useAppRouter } from "@/hooks/use-app-router";
import { useMember } from "@/hooks/store/use-member"; import { useMember } from "@/hooks/store/use-member";
import { useProjectExternalContoursBoard } from "@/hooks/store/use-project-external-contours-board"; import { useProjectExternalContoursBoard } from "@/hooks/store/use-project-external-contours-board";
import { useProjectExternalContours } from "@/hooks/store/use-project-external-contours"; import { useProjectExternalContours } from "@/hooks/store/use-project-external-contours";
import { useProjectState } from "@/hooks/store/use-project-state"; import { useProjectState } from "@/hooks/store/use-project-state";
import { useUserPermissions } from "@/hooks/store/user"; import { useUserPermissions } from "@/hooks/store/user";
import { IssueArchiveService } from "@/services/issue/issue_archive.service";
import { IssueService } from "@/services/issue/issue.service"; import { IssueService } from "@/services/issue/issue.service";
import { ExternalContourDeleteModal } from "./delete-modal";
type Props = { type Props = {
direction: TExternalContourBoardDirection; direction: TExternalContourBoardDirection;
@ -38,6 +37,7 @@ type Props = {
}; };
const issueService = new IssueService(); const issueService = new IssueService();
const issueArchiveService = new IssueArchiveService();
const basePillClasses = 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"; "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 = ( const buildSourceStateMap = (
states: { id: string; name: string; color: string; group: IState["group"] }[] | undefined, states: { id: string; name: string; color: string; group: IState["group"] }[] | undefined,
projectId: string | null projectId: string | null
) => ): Record<string, IState> =>
Object.fromEntries( Object.fromEntries(
(states ?? []).map((state, index) => [ (states ?? []).map((state, index) => [
state.id, state.id,
@ -81,11 +81,12 @@ export const ExternalContoursBoardItem = observer(function ExternalContoursBoard
const { getUserDetails, workspace } = useMember(); const { getUserDetails, workspace } = useMember();
const { getProjectRoleByWorkspaceSlugAndProjectId } = useUserPermissions(); const { getProjectRoleByWorkspaceSlugAndProjectId } = useUserPermissions();
const { getStateById, getProjectStateIds } = useProjectState(); const { getStateById, getProjectStateIds } = useProjectState();
const { fetchBoard, upsertBoardItems } = useProjectExternalContoursBoard(); const { fetchBoard, removeBoardItem, upsertBoardItems } = useProjectExternalContoursBoard();
const { fetchTargetOptions, getTargetOptionsByProjectId, updateRequest, updateRequestIssue } = const { deleteRequest, fetchTargetOptions, getTargetOptionsByProjectId, updateRequest, updateRequestIssue } =
useProjectExternalContours(); useProjectExternalContours();
const [isUpdating, setIsUpdating] = useState(false); const [isUpdating, setIsUpdating] = useState(false);
const [isSourceOptionsLoading, setIsSourceOptionsLoading] = useState(false); const [isSourceOptionsLoading, setIsSourceOptionsLoading] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const issue = request.issue; const issue = request.issue;
const selectedInboxIssueId = searchParams.get("inboxIssueId"); 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 sourceStateIds = useMemo(() => targetOptions?.states?.map((state) => state.id) ?? [], [targetOptions?.states]);
const selectedState = canEditTargetIssue ? getStateById(issue.state_id) : sourceStateMap[issue.state_id ?? ""]; 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 foregroundClasses = isActive ? "text-[rgb(var(--nodedc-on-card-active-rgb))]" : "text-white";
const subtleTextClasses = isActive ? "text-[#2F4721]" : "text-[#B3B3B8]"; const subtleTextClasses = isActive ? "text-[#2F4721]" : "text-[#B3B3B8]";
const pillBackgroundClasses = isActive const pillBackgroundClasses = isActive
? "bg-black/10 text-[rgb(var(--nodedc-on-card-active-rgb))]" ? "bg-black/10 text-[rgb(var(--nodedc-on-card-active-rgb))]"
: "bg-[rgb(var(--nodedc-card-passive-rgb))] text-white"; : "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 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 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; if (!issue) return null;
@ -215,8 +219,66 @@ export const ExternalContoursBoardItem = observer(function ExternalContoursBoard
await handleSourceRequestUpdate(data); 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<TIssue["priority"]>[] = ["urgent", "high", "medium", "low", "none"];
const priorityLabels: Record<NonNullable<TIssue["priority"]>, 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 ( return (
<div className="group/kanban-block relative mb-2"> <>
<ExternalContourDeleteModal
isOpen={isDeleteModalOpen}
isSubmitting={isUpdating}
issueName={issue.name}
onClose={() => setIsDeleteModalOpen(false)}
onSubmit={handleDeleteRequest}
/>
<div className="group/kanban-block relative mb-2">
<div <div
data-active={isActive} data-active={isActive}
data-priority={issue.priority ?? "none"} data-priority={issue.priority ?? "none"}
@ -249,67 +311,116 @@ export const ExternalContoursBoardItem = observer(function ExternalContoursBoard
/> />
)} )}
<PriorityDropdown <ActionDropdown
value={issue.priority} placement="bottom-end"
onChange={(priority) => void handleCardUpdate({ priority })} button={<div className={cn("flex h-8 w-8 items-center justify-center rounded-full", iconBubbleClasses)}><MoreHorizontal className="h-4 w-4" /></div>}
disabled={!canEditCard || isUpdating} buttonClassName="h-8 w-8"
buttonVariant="transparent-without-text" menuClassName="min-w-[18rem]"
button={ onOpenChange={(isOpen) => {
<div if (isOpen) void ensureSourceOptions();
className={cn( }}
"flex h-8 w-8 items-center justify-center rounded-full border-0 shadow-none outline-none", items={[]}
iconBubbleClasses menuContent={({ closeDropdown }) => (
)} <div className="max-h-[min(75vh,34rem)] space-y-2 overflow-y-auto" onClick={stopCardPropagation}>
> <div className="space-y-1">
<PriorityIcon priority={issue.priority} className="h-3.5 w-3.5" /> <div className="px-2 text-[10px] font-semibold tracking-[0.16em] text-tertiary uppercase">Приоритет</div>
</div> {priorityOptions.map((priority) => (
} <button
/> key={priority}
type="button"
className={cn(menuItemClasses, { "bg-white/7 text-primary": issue.priority === priority })}
disabled={!canEditCard || isUpdating}
onClick={() => {
void handleCardUpdate({ priority });
closeDropdown();
}}
>
<PriorityIcon priority={priority} className="h-3.5 w-3.5" />
<span>{priorityLabels[priority]}</span>
</button>
))}
</div>
{canEditTargetIssue ? ( <div className="space-y-1 border-t border-white/8 pt-2">
<StateDropdown <div className="px-2 text-[10px] font-semibold tracking-[0.16em] text-tertiary uppercase">Статус</div>
projectId={issue.project_id ?? undefined} {isSourceOptionsLoading && stateOptions.length === 0 ? (
stateIds={projectStateIds ?? []} <div className="px-2.5 py-2 text-12 text-tertiary">Загрузка статусов...</div>
value={issue.state_id} ) : (
onChange={(stateId) => void handleCardUpdate({ state_id: stateId })} stateOptions.map((state) => (
disabled={!canEditCard || isUpdating} <button
buttonVariant="transparent-without-text" key={state.id}
button={ type="button"
<div className={cn("flex h-8 w-8 items-center justify-center rounded-full", iconBubbleClasses)}> className={cn(menuItemClasses, { "bg-white/7 text-primary": issue.state_id === state.id })}
<StateGroupIcon disabled={!canEditCard || isUpdating}
stateGroup={selectedState?.group ?? "backlog"} onClick={() => {
color={statusIconColor} void handleCardUpdate({ state_id: state.id });
className="h-3.5 w-3.5" closeDropdown();
percentage={selectedState?.order} }}
/> >
<StateGroupIcon
stateGroup={state.group}
color={getStateGroupColor(state.group, state.color)}
className="h-3.5 w-3.5"
percentage={state.order}
/>
<span>{state.name}</span>
</button>
))
)}
</div> </div>
}
/> <div className="space-y-1 border-t border-white/8 pt-2">
) : ( <div className="px-2 text-[10px] font-semibold tracking-[0.16em] text-tertiary uppercase">Быстрые действия</div>
<WorkItemStateDropdownBase <button
projectId={targetProjectId ?? undefined} type="button"
value={issue.state_id} className={menuItemClasses}
stateIds={sourceStateIds} onClick={() => {
getStateById={(stateId) => (stateId ? sourceStateMap[stateId] : undefined)} router.push(requestLink);
onChange={(stateId) => void handleCardUpdate({ state_id: stateId })} closeDropdown();
disabled={!canEditCard || isUpdating || !targetProjectId} }}
isInitializing={isSourceOptionsLoading} >
onDropdownOpen={() => { <Pencil className="h-3.5 w-3.5" />
void ensureSourceOptions(); <span>Редактировать</span>
}} </button>
buttonVariant="transparent-without-text" <button
button={ type="button"
<div className={cn("flex h-8 w-8 items-center justify-center rounded-full", iconBubbleClasses)}> className={menuItemClasses}
<StateGroupIcon onClick={() => {
stateGroup={selectedState?.group ?? "backlog"} void handleCopyLink();
color={statusIconColor} closeDropdown();
className="h-3.5 w-3.5" }}
percentage={selectedState?.order} >
/> <Copy className="h-3.5 w-3.5" />
<span>Копировать ссылку</span>
</button>
<button
type="button"
className={menuItemClasses}
disabled={!canArchive || isUpdating}
onClick={() => {
void handleArchiveIssue();
closeDropdown();
}}
>
<Archive className="h-3.5 w-3.5" />
<span>Архивировать</span>
</button>
<button
type="button"
className={cn(menuItemClasses, "text-red-300 hover:bg-red-500/10 disabled:text-placeholder")}
disabled={direction !== "outgoing" || isUpdating}
onClick={() => {
setIsDeleteModalOpen(true);
closeDropdown();
}}
>
<Trash2 className="h-3.5 w-3.5" />
<span>Удалить</span>
</button>
</div> </div>
} </div>
/> )}
)} />
</div> </div>
</div> </div>
@ -323,6 +434,29 @@ export const ExternalContoursBoardItem = observer(function ExternalContoursBoard
</div> </div>
<div className="flex items-center justify-between gap-3" onClick={stopCardPropagation}> <div className="flex items-center justify-between gap-3" onClick={stopCardPropagation}>
{direction === "outgoing" && (
<DateDropdown
value={issue.target_date}
rangePreview={{
from: issue.start_date,
to: issue.target_date,
}}
onChange={(targetDate) =>
void handleCardUpdate({
target_date: targetDate ? renderFormattedPayloadDate(targetDate) : null,
})
}
disabled={!canEditCard || isUpdating}
buttonVariant="transparent-without-text"
button={
<div className={cn(basePillClasses, pillBackgroundClasses)}>
<CalendarDays className="h-3.5 w-3.5" />
<span className="truncate">{dueDateLabel}</span>
</div>
}
/>
)}
{canEditTargetIssue ? ( {canEditTargetIssue ? (
<MemberDropdown <MemberDropdown
multiple multiple
@ -357,29 +491,32 @@ export const ExternalContoursBoardItem = observer(function ExternalContoursBoard
/> />
)} )}
<DateDropdown {direction !== "outgoing" && (
value={issue.target_date} <DateDropdown
rangePreview={{ value={issue.target_date}
from: issue.start_date, rangePreview={{
to: issue.target_date, from: issue.start_date,
}} to: issue.target_date,
onChange={(targetDate) => }}
void handleCardUpdate({ onChange={(targetDate) =>
target_date: targetDate ? renderFormattedPayloadDate(targetDate) : null, void handleCardUpdate({
}) target_date: targetDate ? renderFormattedPayloadDate(targetDate) : null,
} })
disabled={!canEditCard || isUpdating} }
buttonVariant="transparent-without-text" disabled={!canEditCard || isUpdating}
button={ buttonVariant="transparent-without-text"
<div className={cn(basePillClasses, pillBackgroundClasses)}> button={
<CalendarDays className="h-3.5 w-3.5" /> <div className={cn(basePillClasses, pillBackgroundClasses)}>
<span className="truncate">{dueDateLabel}</span> <CalendarDays className="h-3.5 w-3.5" />
</div> <span className="truncate">{dueDateLabel}</span>
} </div>
/> }
/>
)}
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</>
); );
}); });

View File

@ -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<void>;
};
export function ExternalContourDeleteModal(props: Props) {
const { isOpen, isSubmitting = false, issueName, onClose, onSubmit } = props;
return (
<ModalCore
isOpen={isOpen}
handleClose={onClose}
position={EModalPosition.CENTER}
width={EModalWidth.MD}
className="nodedc-glass-modal rounded-[1.75rem]"
>
<div className="space-y-5 p-6">
<div className="space-y-1.5">
<h3 className="text-18 font-semibold text-primary">Удалить исходящую задачу</h3>
<p className="text-13 leading-5 text-secondary">
Задача «{issueName}» будет удалена из внешнего контура и из целевого проекта. Это действие нельзя отменить.
</p>
</div>
<div className="flex justify-end gap-2">
<Button variant="primary" onClick={onClose} disabled={isSubmitting} className="nodedc-modal-primary-button">
Отмена
</Button>
<Button variant="secondary" onClick={onSubmit} disabled={isSubmitting} className="nodedc-modal-secondary-button">
{isSubmitting ? "Удаление..." : "Удалить"}
</Button>
</div>
</div>
</ModalCore>
);
}

View File

@ -10,6 +10,7 @@ import { TransferIcon } from "@plane/propel/icons";
import type { TInboxIssueCurrentTab } from "@plane/types"; import type { TInboxIssueCurrentTab } from "@plane/types";
import { EInboxIssueCurrentTab } from "@plane/types"; import { EInboxIssueCurrentTab } from "@plane/types";
import { FiltersRow } from "@/components/rich-filters/filters-row"; 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 { useProjectExternalContoursBoard } from "@/hooks/store/use-project-external-contours-board";
import { useProjectExternalContours } from "@/hooks/store/use-project-external-contours"; import { useProjectExternalContours } from "@/hooks/store/use-project-external-contours";
import { ExternalContoursBoardRoot } from "./board-root"; import { ExternalContoursBoardRoot } from "./board-root";
@ -32,9 +33,12 @@ export const ExternalContoursRoot = observer(function ExternalContoursRoot(props
currentProjectId: boardProjectId, currentProjectId: boardProjectId,
fetchBoard, fetchBoard,
loader: boardLoader, loader: boardLoader,
syncBoard,
} = useProjectExternalContoursBoard(); } = useProjectExternalContoursBoard();
const filter = useExternalContoursFilter(); const filter = useExternalContoursFilter();
useExternalContoursRealtimeEvents(workspaceSlug?.toString(), projectId?.toString(), syncBoard);
useEffect(() => { useEffect(() => {
if (!workspaceSlug || !projectId) return; if (!workspaceSlug || !projectId) return;

View File

@ -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<void>
) => {
const syncBoardRef = useRef(syncBoard);
const processedEventIdsRef = useRef<string[]>([]);
const processedEventSetRef = useRef(new Set<string>());
const syncTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>();
useEffect(() => {
syncBoardRef.current = syncBoard;
}, [syncBoard]);
useEffect(() => {
if (!workspaceSlug || !projectId || typeof window === "undefined") return;
let socket: WebSocket | undefined;
let reconnectTimer: ReturnType<typeof setTimeout> | 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]);
};

View File

@ -41,6 +41,7 @@ type TIssueFilterSnapshot = {
const REALTIME_STORE_TYPES = new Set<EIssuesStoreType>([EIssuesStoreType.PROJECT, EIssuesStoreType.PROJECT_VIEW]); const REALTIME_STORE_TYPES = new Set<EIssuesStoreType>([EIssuesStoreType.PROJECT, EIssuesStoreType.PROJECT_VIEW]);
const MAX_PROCESSED_EVENTS = 250; const MAX_PROCESSED_EVENTS = 250;
const INITIAL_CATCH_UP_WINDOW_MS = 5 * 60 * 1000;
const hasIssueId = (value: unknown, issueId: string): boolean => { const hasIssueId = (value: unknown, issueId: string): boolean => {
if (Array.isArray(value)) return value.includes(issueId); if (Array.isArray(value)) return value.includes(issueId);
@ -91,7 +92,6 @@ export const useIssueRealtimeEvents = (storeType: EIssuesStoreType, workspaceSlu
let reconnectTimer: ReturnType<typeof setTimeout> | undefined; let reconnectTimer: ReturnType<typeof setTimeout> | undefined;
let cancelled = false; let cancelled = false;
let reconnectAttempt = 0; let reconnectAttempt = 0;
let hasConnectedOnce = false;
const getFilterParams = () => { const getFilterParams = () => {
const filters = { ...(issueFilterRef.current?.appliedFilters ?? {}) }; const filters = { ...(issueFilterRef.current?.appliedFilters ?? {}) };
@ -103,6 +103,32 @@ export const useIssueRealtimeEvents = (storeType: EIssuesStoreType, workspaceSlu
return filters; 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<string | undefined>(
(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) => { const rememberEvent = (eventId: string) => {
if (processedEventSetRef.current.has(eventId)) return false; if (processedEventSetRef.current.has(eventId)) return false;
@ -146,7 +172,7 @@ export const useIssueRealtimeEvents = (storeType: EIssuesStoreType, workspaceSlu
const handleIssueEvent = async (event: TIssueRealtimeEvent) => { const handleIssueEvent = async (event: TIssueRealtimeEvent) => {
if (!event.event_id || !event.issue_id) return; if (!event.event_id || !event.issue_id) return;
if (!rememberEvent(event.event_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") { if (event.type === "issue.deleted") {
removeIssue(event.issue_id, true); removeIssue(event.issue_id, true);
@ -175,7 +201,10 @@ export const useIssueRealtimeEvents = (storeType: EIssuesStoreType, workspaceSlu
const results = response?.results; const results = response?.results;
if (!Array.isArray(results)) return; if (!Array.isArray(results)) return;
results.forEach(applyIssue); results.forEach((issue) => {
rememberUpdatedAt(issue.updated_at);
applyIssue(issue);
});
}; };
const scheduleReconnect = () => { const scheduleReconnect = () => {
@ -191,8 +220,8 @@ export const useIssueRealtimeEvents = (storeType: EIssuesStoreType, workspaceSlu
socket.onopen = () => { socket.onopen = () => {
reconnectAttempt = 0; reconnectAttempt = 0;
if (hasConnectedOnce) void catchUpMissedEvents(); lastSeenUpdatedAtRef.current = lastSeenUpdatedAtRef.current ?? getInitialCatchUpStart();
hasConnectedOnce = true; void catchUpMissedEvents();
}; };
socket.onmessage = (message) => { socket.onmessage = (message) => {
@ -205,6 +234,7 @@ export const useIssueRealtimeEvents = (storeType: EIssuesStoreType, workspaceSlu
} }
if (event.type === "issue.stream.ready") return; if (event.type === "issue.stream.ready") return;
if (!event.type?.startsWith("issue.")) return;
if (event.workspace_slug && event.workspace_slug !== workspaceSlug) return; if (event.workspace_slug && event.workspace_slug !== workspaceSlug) return;
if (event.project_id && event.project_id !== projectId) return; if (event.project_id && event.project_id !== projectId) return;

View File

@ -80,6 +80,14 @@ export class ExternalContourService extends APIService {
}); });
} }
async deleteRequest(workspaceSlug: string, projectId: string, requestId: string): Promise<void> {
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<TExternalContourTargetProject[]> { async listTargetProjects(workspaceSlug: string, projectId: string): Promise<TExternalContourTargetProject[]> {
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/external-contours/targets/`) return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/external-contours/targets/`)
.then((response) => response?.data) .then((response) => response?.data)

View File

@ -53,7 +53,9 @@ export interface IProjectExternalContoursBoardStore {
replaceFilters: (workspaceSlug: string, projectId: string, filters: Partial<TExternalContourBoardFilter>) => Promise<void>; replaceFilters: (workspaceSlug: string, projectId: string, filters: Partial<TExternalContourBoardFilter>) => Promise<void>;
updateFilters: (workspaceSlug: string, projectId: string, filters: Partial<TExternalContourBoardFilter>) => Promise<void>; updateFilters: (workspaceSlug: string, projectId: string, filters: Partial<TExternalContourBoardFilter>) => Promise<void>;
updateSorting: (workspaceSlug: string, projectId: string, sorting: TExternalContourBoardSorting) => Promise<void>; updateSorting: (workspaceSlug: string, projectId: string, sorting: TExternalContourBoardSorting) => Promise<void>;
syncBoard: (workspaceSlug: string, projectId: string) => Promise<void>;
upsertBoardItems: (items: TExternalContourRequest[]) => void; upsertBoardItems: (items: TExternalContourRequest[]) => void;
removeBoardItem: (requestId: string) => void;
} }
export class ProjectExternalContoursBoardStore implements IProjectExternalContoursBoardStore { export class ProjectExternalContoursBoardStore implements IProjectExternalContoursBoardStore {
@ -101,9 +103,11 @@ export class ProjectExternalContoursBoardStore implements IProjectExternalContou
fetchBoard: action, fetchBoard: action,
handleCurrentTab: action, handleCurrentTab: action,
replaceFilters: action, replaceFilters: action,
syncBoard: action,
updateFilters: action, updateFilters: action,
updateSorting: action, updateSorting: action,
upsertBoardItems: action, upsertBoardItems: action,
removeBoardItem: action,
}); });
this.externalContourService = new ExternalContourService(); this.externalContourService = new ExternalContourService();
@ -145,6 +149,19 @@ export class ProjectExternalContoursBoardStore implements IProjectExternalContou
this.store.projectExternalContours.upsertRequests(items); 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) => { handleCurrentTab = async (workspaceSlug: string, projectId: string, tab: TInboxIssueCurrentTab) => {
this.currentTab = tab; this.currentTab = tab;
await this.fetchBoard(workspaceSlug, projectId, 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.
}
};
} }

View File

@ -53,6 +53,7 @@ export interface IProjectExternalContoursStore {
requestId: string, requestId: string,
comment: string comment: string
) => Promise<TExternalContourRequest | undefined>; ) => Promise<TExternalContourRequest | undefined>;
deleteRequest: (workspaceSlug: string, projectId: string, requestId: string) => Promise<void>;
fetchTargetProjects: (workspaceSlug: string, projectId: string) => Promise<void>; fetchTargetProjects: (workspaceSlug: string, projectId: string) => Promise<void>;
fetchTargetOptions: (workspaceSlug: string, projectId: string, targetProjectId: string) => Promise<TExternalContourTargetOptions | undefined>; fetchTargetOptions: (workspaceSlug: string, projectId: string, targetProjectId: string) => Promise<TExternalContourTargetOptions | undefined>;
fetchRequestById: (workspaceSlug: string, projectId: string, requestId: string) => Promise<TExternalContourRequest | undefined>; fetchRequestById: (workspaceSlug: string, projectId: string, requestId: string) => Promise<TExternalContourRequest | undefined>;
@ -66,6 +67,7 @@ export interface IProjectExternalContoursStore {
closedRequestIds: string[]; closedRequestIds: string[];
filteredRequestIds: string[]; filteredRequestIds: string[];
upsertRequests: (requests: TExternalContourRequest[]) => void; upsertRequests: (requests: TExternalContourRequest[]) => void;
removeRequest: (requestId: string) => void;
updateRequestIssue: (requestId: string, issueData: Partial<TExternalContourRequest["issue"]>) => void; updateRequestIssue: (requestId: string, issueData: Partial<TExternalContourRequest["issue"]>) => void;
} }
@ -102,9 +104,11 @@ export class ProjectExternalContoursStore implements IProjectExternalContoursSto
fetchRequestById: action, fetchRequestById: action,
createRequest: action, createRequest: action,
updateRequest: action, updateRequest: action,
deleteRequest: action,
decideRequest: action, decideRequest: action,
replyToRequest: action, replyToRequest: action,
handleCurrentTab: action, handleCurrentTab: action,
removeRequest: action,
upsertRequests: action, upsertRequests: action,
updateRequestIssue: 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) => { fetchTargetProjects = async (workspaceSlug: string, projectId: string) => {
try { try {
const projects = await this.externalContourService.listTargetProjects(workspaceSlug, projectId); 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 ( decideRequest = async (
workspaceSlug: string, workspaceSlug: string,
projectId: string, projectId: string,