ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: realtime внешних контуров и действия исходящих карточек
This commit is contained in:
parent
b2a710a7ec
commit
248292bd52
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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:",
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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]);
|
||||||
|
};
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue