ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: зеркалирование комментариев, вложений и активности внешнего контура
This commit is contained in:
parent
8195c3fc80
commit
61a8625a5c
|
|
@ -150,11 +150,13 @@
|
||||||
- для закрытого внешнего запроса доступны source-side действия `Принять` и `Отклонить`
|
- для закрытого внешнего запроса доступны source-side действия `Принять` и `Отклонить`
|
||||||
- `Принять` фиксирует решение источника в bridge metadata и помечает запрос как принятый во внутренний контур
|
- `Принять` фиксирует решение источника в bridge metadata и помечает запрос как принятый во внутренний контур
|
||||||
- `Отклонить` возвращает target issue в default-state целевого проекта и переносит source-side карточку обратно в список `Открытые`
|
- `Отклонить` возвращает target issue в default-state целевого проекта и переносит source-side карточку обратно в список `Открытые`
|
||||||
|
- source-side readonly карточка зеркалит актуальные комментарии, вложения и activity целевой задачи
|
||||||
|
- вложения доступны через proxy download endpoint без прямого membership в target project
|
||||||
|
- detail карточка source-only пользователя обновляется polling-ом и подтягивает новые комментарии без ручной перезагрузки
|
||||||
|
|
||||||
Что остается:
|
Что остается:
|
||||||
- зеркалирование комментариев
|
- зеркалирование inline-файлов из комментариев и описания, а не только issue attachments
|
||||||
- зеркалирование файлов
|
- двусторонняя работа с комментариями из source-side карточки
|
||||||
- зеркалирование activity stream и обновлений описания
|
|
||||||
- комментарий причины отклонения и ответ обратно во внешний контур
|
- комментарий причины отклонения и ответ обратно во внешний контур
|
||||||
|
|
||||||
## Этап 4. Уведомления
|
## Этап 4. Уведомления
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ from .project import ProjectLiteSerializer
|
||||||
from .state import StateLiteSerializer
|
from .state import StateLiteSerializer
|
||||||
from .user import UserLiteSerializer
|
from .user import UserLiteSerializer
|
||||||
from plane.app.serializers.issue import LabelSerializer
|
from plane.app.serializers.issue import LabelSerializer
|
||||||
from plane.db.models import IntakeIssue, Issue, Label, Project
|
from plane.db.models import FileAsset, IntakeIssue, Issue, IssueActivity, IssueComment, Label, Project
|
||||||
|
|
||||||
|
|
||||||
class ExternalContourIssuePayloadSerializer(serializers.Serializer):
|
class ExternalContourIssuePayloadSerializer(serializers.Serializer):
|
||||||
|
|
@ -95,8 +95,54 @@ class ExternalContourIssueSerializer(BaseSerializer):
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class ExternalContourMirroredAttachmentSerializer(BaseSerializer):
|
||||||
|
uploaded_by = serializers.SerializerMethodField()
|
||||||
|
download_url = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = FileAsset
|
||||||
|
fields = ["id", "attributes", "asset_url", "download_url", "updated_at", "uploaded_by"]
|
||||||
|
read_only_fields = fields
|
||||||
|
|
||||||
|
def get_uploaded_by(self, obj):
|
||||||
|
user = obj.updated_by or obj.created_by
|
||||||
|
return getattr(user, "display_name", None)
|
||||||
|
|
||||||
|
def get_download_url(self, obj):
|
||||||
|
workspace_slug = self.context.get("workspace_slug")
|
||||||
|
source_project_id = self.context.get("source_project_id")
|
||||||
|
request_id = self.context.get("request_id")
|
||||||
|
if not workspace_slug or not source_project_id or not request_id:
|
||||||
|
return None
|
||||||
|
return (
|
||||||
|
f"/api/workspaces/{workspace_slug}/projects/{source_project_id}/external-contours/"
|
||||||
|
f"{request_id}/attachments/{obj.id}/"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ExternalContourMirroredCommentSerializer(BaseSerializer):
|
||||||
|
actor_detail = UserLiteSerializer(read_only=True, source="actor")
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = IssueComment
|
||||||
|
fields = ["id", "comment_html", "created_at", "updated_at", "edited_at", "parent_id", "actor_detail"]
|
||||||
|
read_only_fields = fields
|
||||||
|
|
||||||
|
|
||||||
|
class ExternalContourMirroredActivitySerializer(BaseSerializer):
|
||||||
|
actor_detail = UserLiteSerializer(read_only=True, source="actor")
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = IssueActivity
|
||||||
|
fields = ["id", "verb", "field", "old_value", "new_value", "comment", "created_at", "actor_detail"]
|
||||||
|
read_only_fields = fields
|
||||||
|
|
||||||
|
|
||||||
class ExternalContourRequestSerializer(BaseSerializer):
|
class ExternalContourRequestSerializer(BaseSerializer):
|
||||||
issue = ExternalContourIssueSerializer(read_only=True)
|
issue = ExternalContourIssueSerializer(read_only=True)
|
||||||
|
mirrored_activity = serializers.SerializerMethodField()
|
||||||
|
mirrored_attachments = serializers.SerializerMethodField()
|
||||||
|
mirrored_comments = serializers.SerializerMethodField()
|
||||||
source_project_id = serializers.SerializerMethodField()
|
source_project_id = serializers.SerializerMethodField()
|
||||||
source_project_name = serializers.SerializerMethodField()
|
source_project_name = serializers.SerializerMethodField()
|
||||||
source_decision = serializers.SerializerMethodField()
|
source_decision = serializers.SerializerMethodField()
|
||||||
|
|
@ -117,6 +163,9 @@ class ExternalContourRequestSerializer(BaseSerializer):
|
||||||
"updated_at",
|
"updated_at",
|
||||||
"created_by",
|
"created_by",
|
||||||
"issue",
|
"issue",
|
||||||
|
"mirrored_activity",
|
||||||
|
"mirrored_attachments",
|
||||||
|
"mirrored_comments",
|
||||||
"source_project_id",
|
"source_project_id",
|
||||||
"source_project_name",
|
"source_project_name",
|
||||||
"source_decision",
|
"source_decision",
|
||||||
|
|
@ -134,6 +183,53 @@ class ExternalContourRequestSerializer(BaseSerializer):
|
||||||
def get_source_project_id(self, obj):
|
def get_source_project_id(self, obj):
|
||||||
return obj.extra.get("source_project_id")
|
return obj.extra.get("source_project_id")
|
||||||
|
|
||||||
|
def get_mirrored_activity(self, obj):
|
||||||
|
if not self.context.get("include_mirror_data") or not obj.issue_id:
|
||||||
|
return []
|
||||||
|
|
||||||
|
activity = (
|
||||||
|
IssueActivity.objects.filter(issue_id=obj.issue_id)
|
||||||
|
.exclude(field__in=["comment", "vote", "reaction", "draft"])
|
||||||
|
.select_related("actor")
|
||||||
|
.order_by("-created_at")[:50]
|
||||||
|
)
|
||||||
|
return ExternalContourMirroredActivitySerializer(activity, many=True).data
|
||||||
|
|
||||||
|
def get_mirrored_attachments(self, obj):
|
||||||
|
if not self.context.get("include_mirror_data") or not obj.issue_id:
|
||||||
|
return []
|
||||||
|
|
||||||
|
attachments = (
|
||||||
|
FileAsset.objects.filter(
|
||||||
|
issue_id=obj.issue_id,
|
||||||
|
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
|
||||||
|
is_uploaded=True,
|
||||||
|
is_deleted=False,
|
||||||
|
)
|
||||||
|
.select_related("created_by", "updated_by")
|
||||||
|
.order_by("-updated_at")
|
||||||
|
)
|
||||||
|
return ExternalContourMirroredAttachmentSerializer(
|
||||||
|
attachments,
|
||||||
|
many=True,
|
||||||
|
context={
|
||||||
|
"workspace_slug": self.context.get("workspace_slug"),
|
||||||
|
"source_project_id": self.context.get("source_project_id"),
|
||||||
|
"request_id": str(obj.id),
|
||||||
|
},
|
||||||
|
).data
|
||||||
|
|
||||||
|
def get_mirrored_comments(self, obj):
|
||||||
|
if not self.context.get("include_mirror_data") or not obj.issue_id:
|
||||||
|
return []
|
||||||
|
|
||||||
|
comments = (
|
||||||
|
IssueComment.objects.filter(issue_id=obj.issue_id)
|
||||||
|
.select_related("actor")
|
||||||
|
.order_by("created_at")
|
||||||
|
)
|
||||||
|
return ExternalContourMirroredCommentSerializer(comments, many=True).data
|
||||||
|
|
||||||
def get_source_project_name(self, obj):
|
def get_source_project_name(self, obj):
|
||||||
return obj.extra.get("source_project_name")
|
return obj.extra.get("source_project_name")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
from plane.api.views import (
|
from plane.api.views import (
|
||||||
|
ExternalContourAttachmentDownloadAPIEndpoint,
|
||||||
ExternalContourDetailAPIEndpoint,
|
ExternalContourDetailAPIEndpoint,
|
||||||
ExternalContourDecisionAPIEndpoint,
|
ExternalContourDecisionAPIEndpoint,
|
||||||
ExternalContourListCreateAPIEndpoint,
|
ExternalContourListCreateAPIEndpoint,
|
||||||
|
|
@ -38,4 +39,9 @@ urlpatterns = [
|
||||||
ExternalContourDecisionAPIEndpoint.as_view(http_method_names=["post"]),
|
ExternalContourDecisionAPIEndpoint.as_view(http_method_names=["post"]),
|
||||||
name="external-contour-decision",
|
name="external-contour-decision",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"workspaces/<str:slug>/projects/<uuid:project_id>/external-contours/<uuid:request_id>/attachments/<uuid:attachment_id>/",
|
||||||
|
ExternalContourAttachmentDownloadAPIEndpoint.as_view(http_method_names=["get"]),
|
||||||
|
name="external-contour-attachment-download",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,7 @@ from .intake import (
|
||||||
IntakeIssueDetailAPIEndpoint,
|
IntakeIssueDetailAPIEndpoint,
|
||||||
)
|
)
|
||||||
from .external_contours import (
|
from .external_contours import (
|
||||||
|
ExternalContourAttachmentDownloadEndpoint as ExternalContourAttachmentDownloadAPIEndpoint,
|
||||||
ExternalContourListCreateEndpoint as ExternalContourListCreateAPIEndpoint,
|
ExternalContourListCreateEndpoint as ExternalContourListCreateAPIEndpoint,
|
||||||
ExternalContourDetailEndpoint as ExternalContourDetailAPIEndpoint,
|
ExternalContourDetailEndpoint as ExternalContourDetailAPIEndpoint,
|
||||||
ExternalContourDecisionEndpoint as ExternalContourDecisionAPIEndpoint,
|
ExternalContourDecisionEndpoint as ExternalContourDecisionAPIEndpoint,
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
# SPDX-License-Identifier: AGPL-3.0-only
|
# SPDX-License-Identifier: AGPL-3.0-only
|
||||||
# See the LICENSE file for details.
|
# See the LICENSE file for details.
|
||||||
|
|
||||||
|
from django.http import HttpResponseRedirect
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
|
|
@ -17,8 +18,9 @@ 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 .base import BaseAPIView
|
from .base import BaseAPIView
|
||||||
from plane.db.models import Intake, IntakeIssue, Label, Project, ProjectMember, State, StateGroup
|
from plane.db.models import FileAsset, Intake, IntakeIssue, Label, Project, ProjectMember, State, StateGroup
|
||||||
from plane.db.models.intake import IntakeIssueStatus, SourceType
|
from plane.db.models.intake import IntakeIssueStatus, SourceType
|
||||||
|
from plane.settings.storage import S3Storage
|
||||||
|
|
||||||
|
|
||||||
class ExternalContourListCreateEndpoint(BaseAPIView):
|
class ExternalContourListCreateEndpoint(BaseAPIView):
|
||||||
|
|
@ -269,7 +271,14 @@ class ExternalContourDetailEndpoint(BaseAPIView):
|
||||||
|
|
||||||
def get(self, request, slug, project_id, request_id):
|
def get(self, request, slug, project_id, request_id):
|
||||||
contour_request = get_object_or_404(self.get_queryset())
|
contour_request = get_object_or_404(self.get_queryset())
|
||||||
serializer = ExternalContourRequestSerializer(contour_request)
|
serializer = ExternalContourRequestSerializer(
|
||||||
|
contour_request,
|
||||||
|
context={
|
||||||
|
"include_mirror_data": True,
|
||||||
|
"workspace_slug": slug,
|
||||||
|
"source_project_id": str(project_id),
|
||||||
|
},
|
||||||
|
)
|
||||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -349,6 +358,42 @@ class ExternalContourDecisionEndpoint(BaseAPIView):
|
||||||
"issue__created_by",
|
"issue__created_by",
|
||||||
)
|
)
|
||||||
.prefetch_related("issue__issue_assignee__assignee", "issue__label_issue__label")
|
.prefetch_related("issue__issue_assignee__assignee", "issue__label_issue__label")
|
||||||
.get(pk=contour_request.id)
|
.get(pk=contour_request.id),
|
||||||
|
context={
|
||||||
|
"include_mirror_data": True,
|
||||||
|
"workspace_slug": slug,
|
||||||
|
"source_project_id": str(project_id),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
||||||
|
class ExternalContourAttachmentDownloadEndpoint(BaseAPIView):
|
||||||
|
permission_classes = [ProjectLitePermission]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return IntakeIssue.objects.filter(
|
||||||
|
workspace__slug=self.kwargs.get("slug"),
|
||||||
|
extra__bridge="external-contours",
|
||||||
|
extra__source_project_id=str(self.kwargs.get("project_id")),
|
||||||
|
pk=self.kwargs.get("request_id"),
|
||||||
|
).select_related("issue", "issue__project", "workspace")
|
||||||
|
|
||||||
|
def get(self, request, slug, project_id, request_id, attachment_id):
|
||||||
|
contour_request = get_object_or_404(self.get_queryset())
|
||||||
|
attachment = get_object_or_404(
|
||||||
|
FileAsset,
|
||||||
|
pk=attachment_id,
|
||||||
|
issue_id=contour_request.issue_id,
|
||||||
|
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
|
||||||
|
is_uploaded=True,
|
||||||
|
is_deleted=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
storage = S3Storage(request=request)
|
||||||
|
presigned_url = storage.generate_presigned_url(
|
||||||
|
object_name=attachment.asset.name,
|
||||||
|
disposition="attachment",
|
||||||
|
filename=attachment.attributes.get("name"),
|
||||||
|
)
|
||||||
|
return HttpResponseRedirect(presigned_url)
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
from plane.app.views import (
|
from plane.app.views import (
|
||||||
|
ExternalContourAttachmentDownloadEndpoint,
|
||||||
ExternalContourDetailEndpoint,
|
ExternalContourDetailEndpoint,
|
||||||
ExternalContourDecisionEndpoint,
|
ExternalContourDecisionEndpoint,
|
||||||
ExternalContourListCreateEndpoint,
|
ExternalContourListCreateEndpoint,
|
||||||
|
|
@ -39,4 +40,9 @@ urlpatterns = [
|
||||||
ExternalContourDecisionEndpoint.as_view(http_method_names=["post"]),
|
ExternalContourDecisionEndpoint.as_view(http_method_names=["post"]),
|
||||||
name="external-contour-decision",
|
name="external-contour-decision",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"workspaces/<str:slug>/projects/<uuid:project_id>/external-contours/<uuid:request_id>/attachments/<uuid:attachment_id>/",
|
||||||
|
ExternalContourAttachmentDownloadEndpoint.as_view(http_method_names=["get"]),
|
||||||
|
name="external-contour-attachment-download",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -225,6 +225,7 @@ from .notification.base import (
|
||||||
|
|
||||||
from .exporter.base import ExportIssuesEndpoint
|
from .exporter.base import ExportIssuesEndpoint
|
||||||
from .external_contours import (
|
from .external_contours import (
|
||||||
|
ExternalContourAttachmentDownloadEndpoint,
|
||||||
ExternalContourListCreateEndpoint,
|
ExternalContourListCreateEndpoint,
|
||||||
ExternalContourDetailEndpoint,
|
ExternalContourDetailEndpoint,
|
||||||
ExternalContourDecisionEndpoint,
|
ExternalContourDecisionEndpoint,
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
# SPDX-License-Identifier: AGPL-3.0-only
|
# SPDX-License-Identifier: AGPL-3.0-only
|
||||||
# See the LICENSE file for details.
|
# See the LICENSE file for details.
|
||||||
|
|
||||||
|
from django.http import HttpResponseRedirect
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
|
|
@ -17,8 +18,9 @@ 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.views.base import BaseAPIView
|
from plane.app.views.base import BaseAPIView
|
||||||
from plane.db.models import Intake, IntakeIssue, Label, Project, ProjectMember, State, StateGroup
|
from plane.db.models import FileAsset, Intake, IntakeIssue, Label, Project, ProjectMember, State, StateGroup
|
||||||
from plane.db.models.intake import IntakeIssueStatus, SourceType
|
from plane.db.models.intake import IntakeIssueStatus, SourceType
|
||||||
|
from plane.settings.storage import S3Storage
|
||||||
|
|
||||||
|
|
||||||
class ExternalContourListCreateEndpoint(BaseAPIView):
|
class ExternalContourListCreateEndpoint(BaseAPIView):
|
||||||
|
|
@ -269,7 +271,14 @@ class ExternalContourDetailEndpoint(BaseAPIView):
|
||||||
|
|
||||||
def get(self, request, slug, project_id, request_id):
|
def get(self, request, slug, project_id, request_id):
|
||||||
contour_request = get_object_or_404(self.get_queryset())
|
contour_request = get_object_or_404(self.get_queryset())
|
||||||
serializer = ExternalContourRequestSerializer(contour_request)
|
serializer = ExternalContourRequestSerializer(
|
||||||
|
contour_request,
|
||||||
|
context={
|
||||||
|
"include_mirror_data": True,
|
||||||
|
"workspace_slug": slug,
|
||||||
|
"source_project_id": str(project_id),
|
||||||
|
},
|
||||||
|
)
|
||||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -349,6 +358,42 @@ class ExternalContourDecisionEndpoint(BaseAPIView):
|
||||||
"issue__created_by",
|
"issue__created_by",
|
||||||
)
|
)
|
||||||
.prefetch_related("issue__issue_assignee__assignee", "issue__label_issue__label")
|
.prefetch_related("issue__issue_assignee__assignee", "issue__label_issue__label")
|
||||||
.get(pk=contour_request.id)
|
.get(pk=contour_request.id),
|
||||||
|
context={
|
||||||
|
"include_mirror_data": True,
|
||||||
|
"workspace_slug": slug,
|
||||||
|
"source_project_id": str(project_id),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
||||||
|
class ExternalContourAttachmentDownloadEndpoint(BaseAPIView):
|
||||||
|
permission_classes = [ProjectLitePermission]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return IntakeIssue.objects.filter(
|
||||||
|
workspace__slug=self.kwargs.get("slug"),
|
||||||
|
extra__bridge="external-contours",
|
||||||
|
extra__source_project_id=str(self.kwargs.get("project_id")),
|
||||||
|
pk=self.kwargs.get("request_id"),
|
||||||
|
).select_related("issue", "issue__project", "workspace")
|
||||||
|
|
||||||
|
def get(self, request, slug, project_id, request_id, attachment_id):
|
||||||
|
contour_request = get_object_or_404(self.get_queryset())
|
||||||
|
attachment = get_object_or_404(
|
||||||
|
FileAsset,
|
||||||
|
pk=attachment_id,
|
||||||
|
issue_id=contour_request.issue_id,
|
||||||
|
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
|
||||||
|
is_uploaded=True,
|
||||||
|
is_deleted=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
storage = S3Storage(request=request)
|
||||||
|
presigned_url = storage.generate_presigned_url(
|
||||||
|
object_name=attachment.asset.name,
|
||||||
|
disposition="attachment",
|
||||||
|
filename=attachment.attributes.get("name"),
|
||||||
|
)
|
||||||
|
return HttpResponseRedirect(presigned_url)
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,11 @@ export const ExternalContoursContentRoot = observer(function ExternalContoursCon
|
||||||
? `PROJECT_EXTERNAL_CONTOUR_DETAIL_${workspaceSlug}_${projectId}_${inboxIssueId}`
|
? `PROJECT_EXTERNAL_CONTOUR_DETAIL_${workspaceSlug}_${projectId}_${inboxIssueId}`
|
||||||
: null,
|
: null,
|
||||||
workspaceSlug && projectId && inboxIssueId ? () => fetchRequestById(workspaceSlug, projectId, inboxIssueId) : null,
|
workspaceSlug && projectId && inboxIssueId ? () => fetchRequestById(workspaceSlug, projectId, inboxIssueId) : null,
|
||||||
{ revalidateOnFocus: false, revalidateIfStale: false }
|
{
|
||||||
|
revalidateOnFocus: !hasDirectTargetAccess,
|
||||||
|
revalidateIfStale: !hasDirectTargetAccess,
|
||||||
|
refreshInterval: hasDirectTargetAccess ? 0 : 15000,
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const isEditable =
|
const isEditable =
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,9 @@ import { DeDupeIssuePopoverRoot } from "@/plane-web/components/de-dupe/duplicate
|
||||||
import { useDebouncedDuplicateIssues } from "@/plane-web/hooks/use-debounced-duplicate-issues";
|
import { useDebouncedDuplicateIssues } from "@/plane-web/hooks/use-debounced-duplicate-issues";
|
||||||
import { IssueService } from "@/services/issue/issue.service";
|
import { IssueService } from "@/services/issue/issue.service";
|
||||||
import { WorkItemVersionService } from "@/services/issue/work_item_version.service";
|
import { WorkItemVersionService } from "@/services/issue/work_item_version.service";
|
||||||
|
import { ExternalContoursMirroredActivity } from "./mirrored-activity";
|
||||||
|
import { ExternalContoursMirroredAttachments } from "./mirrored-attachments";
|
||||||
|
import { ExternalContoursMirroredComments } from "./mirrored-comments";
|
||||||
import { ExternalContoursIssueContentProperties } from "./issue-properties";
|
import { ExternalContoursIssueContentProperties } from "./issue-properties";
|
||||||
import { ExternalContoursRequestTraceability } from "./request-traceability";
|
import { ExternalContoursRequestTraceability } from "./request-traceability";
|
||||||
|
|
||||||
|
|
@ -63,6 +66,9 @@ export const ExternalContoursIssueMainContent = observer(function ExternalContou
|
||||||
}, [isSubmitting, setIsSubmitting, setShowAlert]);
|
}, [isSubmitting, setIsSubmitting, setShowAlert]);
|
||||||
|
|
||||||
const issue = contourRequest.issue;
|
const issue = contourRequest.issue;
|
||||||
|
const mirroredActivity = contourRequest.mirrored_activity ?? [];
|
||||||
|
const mirroredAttachments = contourRequest.mirrored_attachments ?? [];
|
||||||
|
const mirroredComments = contourRequest.mirrored_comments ?? [];
|
||||||
const targetProjectId = issue.project_id || sourceProjectId;
|
const targetProjectId = issue.project_id || sourceProjectId;
|
||||||
|
|
||||||
const { duplicateIssues } = useDebouncedDuplicateIssues(
|
const { duplicateIssues } = useDebouncedDuplicateIssues(
|
||||||
|
|
@ -164,6 +170,12 @@ export const ExternalContoursIssueMainContent = observer(function ExternalContou
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ExternalContoursMirroredAttachments attachments={mirroredAttachments} />
|
||||||
|
|
||||||
|
<ExternalContoursMirroredComments comments={mirroredComments} />
|
||||||
|
|
||||||
|
<ExternalContoursMirroredActivity activity={mirroredActivity} />
|
||||||
|
|
||||||
<div className="py-4 text-13 text-secondary">{t("external_contours_page.readonly_source_view")}</div>
|
<div className="py-4 text-13 text-secondary">{t("external_contours_page.readonly_source_view")}</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
/**
|
||||||
|
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
* See the LICENSE file for details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { observer } from "mobx-react";
|
||||||
|
import { useTranslation } from "@plane/i18n";
|
||||||
|
import type { TExternalContourMirroredActivity } from "@plane/types";
|
||||||
|
import { renderFormattedDate } from "@plane/utils";
|
||||||
|
|
||||||
|
const FIELD_TRANSLATIONS: Record<string, string> = {
|
||||||
|
name: "external_contours_page.mirror.fields.name",
|
||||||
|
description_html: "external_contours_page.mirror.fields.description",
|
||||||
|
state: "external_contours_page.mirror.fields.state",
|
||||||
|
priority: "external_contours_page.mirror.fields.priority",
|
||||||
|
target_date: "external_contours_page.mirror.fields.due_date",
|
||||||
|
assignees: "external_contours_page.mirror.fields.assignees",
|
||||||
|
labels: "external_contours_page.mirror.fields.labels",
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
activity: TExternalContourMirroredActivity[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ExternalContoursMirroredActivity = observer(function ExternalContoursMirroredActivity(props: Props) {
|
||||||
|
const { activity } = props;
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const getFieldLabel = (field?: string | null) => {
|
||||||
|
if (!field) return t("external_contours_page.mirror.fields.request");
|
||||||
|
const key = FIELD_TRANSLATIONS[field];
|
||||||
|
return key ? t(key) : field;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderMessage = (item: TExternalContourMirroredActivity) => {
|
||||||
|
const actorName = item.actor_detail?.display_name || t("external_contours_page.mirror.system_actor");
|
||||||
|
const fieldLabel = getFieldLabel(item.field);
|
||||||
|
|
||||||
|
if (item.verb === "created") {
|
||||||
|
return t("external_contours_page.mirror.activity_created", { actor: actorName });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.old_value && item.new_value) {
|
||||||
|
return t("external_contours_page.mirror.activity_changed_from_to", {
|
||||||
|
actor: actorName,
|
||||||
|
field: fieldLabel,
|
||||||
|
oldValue: item.old_value,
|
||||||
|
newValue: item.new_value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.new_value) {
|
||||||
|
return t("external_contours_page.mirror.activity_changed_to", {
|
||||||
|
actor: actorName,
|
||||||
|
field: fieldLabel,
|
||||||
|
newValue: item.new_value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return t("external_contours_page.mirror.activity_changed", {
|
||||||
|
actor: actorName,
|
||||||
|
field: fieldLabel,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3 py-4">
|
||||||
|
<div className="text-body-sm-medium">{t("external_contours_page.mirror.activity_title")}</div>
|
||||||
|
|
||||||
|
{activity.length > 0 ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{activity.map((item) => (
|
||||||
|
<div key={item.id} className="rounded-md border border-subtle bg-surface-1 px-4 py-3">
|
||||||
|
<div className="text-13 text-primary">{renderMessage(item)}</div>
|
||||||
|
<div className="mt-1 text-11 text-secondary">{renderFormattedDate(item.created_at)}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-md border border-dashed border-subtle px-4 py-6 text-13 text-secondary">
|
||||||
|
{t("external_contours_page.mirror.activity_empty")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
/**
|
||||||
|
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
* See the LICENSE file for details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { observer } from "mobx-react";
|
||||||
|
import { useTranslation } from "@plane/i18n";
|
||||||
|
import type { TExternalContourMirroredAttachment } from "@plane/types";
|
||||||
|
import { convertBytesToSize, getFileExtension, getFileName, renderFormattedDate } from "@plane/utils";
|
||||||
|
import { getFileIcon } from "@/components/icons";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
attachments: TExternalContourMirroredAttachment[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ExternalContoursMirroredAttachments = observer(function ExternalContoursMirroredAttachments(props: Props) {
|
||||||
|
const { attachments } = props;
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3 py-4">
|
||||||
|
<div className="text-body-sm-medium">{t("external_contours_page.mirror.attachments_title")}</div>
|
||||||
|
|
||||||
|
{attachments.length > 0 ? (
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{attachments.map((attachment) => {
|
||||||
|
const fileName = getFileName(attachment.attributes?.name ?? "");
|
||||||
|
const fileExtension = getFileExtension(attachment.attributes?.name ?? attachment.asset_url ?? "");
|
||||||
|
const fileIcon = getFileIcon(fileExtension, 28);
|
||||||
|
const fileSize = attachment.attributes?.size ? convertBytesToSize(attachment.attributes.size) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
key={attachment.id}
|
||||||
|
href={attachment.download_url || "#"}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="flex min-h-[60px] items-center justify-between gap-3 rounded-md border-[2px] border-subtle bg-surface-1 px-4 py-2 text-13 transition-colors hover:bg-surface-2"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 overflow-hidden">
|
||||||
|
<div className="h-7 w-7 flex-shrink-0">{fileIcon}</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-13 text-primary">{fileName || t("attachments")}</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-11 text-secondary">
|
||||||
|
{fileExtension ? <span>{fileExtension.toUpperCase()}</span> : null}
|
||||||
|
{fileSize ? <span>{fileSize}</span> : null}
|
||||||
|
<span>{renderFormattedDate(attachment.updated_at)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right text-11 text-secondary">{attachment.uploaded_by || t("external_contours_page.mirror.system_actor")}</div>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-md border border-dashed border-subtle px-4 py-6 text-13 text-secondary">
|
||||||
|
{t("external_contours_page.mirror.attachments_empty")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
/**
|
||||||
|
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
* See the LICENSE file for details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { observer } from "mobx-react";
|
||||||
|
import { useTranslation } from "@plane/i18n";
|
||||||
|
import type { TExternalContourMirroredComment } from "@plane/types";
|
||||||
|
import { Avatar } from "@plane/ui";
|
||||||
|
import { renderFormattedDate } from "@plane/utils";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
comments: TExternalContourMirroredComment[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ExternalContoursMirroredComments = observer(function ExternalContoursMirroredComments(props: Props) {
|
||||||
|
const { comments } = props;
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3 py-4">
|
||||||
|
<div className="text-body-sm-medium">{t("external_contours_page.mirror.comments_title")}</div>
|
||||||
|
|
||||||
|
{comments.length > 0 ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{comments.map((comment) => {
|
||||||
|
const actorName = comment.actor_detail?.display_name || t("external_contours_page.mirror.system_actor");
|
||||||
|
return (
|
||||||
|
<div key={comment.id} className="rounded-md border border-subtle bg-surface-1 p-4">
|
||||||
|
<div className="mb-3 flex items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Avatar src={comment.actor_detail?.avatar_url || ""} name={actorName} size="md" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-13 text-primary">{actorName}</div>
|
||||||
|
<div className="text-11 text-secondary">{renderFormattedDate(comment.created_at)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{comment.edited_at ? (
|
||||||
|
<div className="text-11 text-tertiary">{t("external_contours_page.mirror.comment_edited")}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="prose prose-invert max-w-none text-13 text-secondary [&_p]:mb-3"
|
||||||
|
dangerouslySetInnerHTML={{ __html: comment.comment_html || "<p></p>" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-md border border-dashed border-subtle px-4 py-6 text-13 text-secondary">
|
||||||
|
{t("external_contours_page.mirror.comments_empty")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
@ -336,7 +336,31 @@ export default {
|
||||||
duplicate_of: "Duplicate of",
|
duplicate_of: "Duplicate of",
|
||||||
},
|
},
|
||||||
readonly_source_view:
|
readonly_source_view:
|
||||||
"This request is shown in source-side mode. Direct access to the target contour is not required, and detailed activity and attachments will be synchronized in the next stage.",
|
"This request is shown in source-side mode. Direct access to the target contour is not required, and changes, comments, and attachments are mirrored here.",
|
||||||
|
mirror: {
|
||||||
|
attachments_title: "Attachments from the external contour",
|
||||||
|
attachments_empty: "No attachments have been added in the external contour yet.",
|
||||||
|
comments_title: "Comments from the external contour",
|
||||||
|
comments_empty: "No comments have been added in the external contour yet.",
|
||||||
|
activity_title: "External contour activity",
|
||||||
|
activity_empty: "No actions have been recorded for this request in the external contour yet.",
|
||||||
|
system_actor: "System",
|
||||||
|
comment_edited: "Edited",
|
||||||
|
activity_created: "{actor} created the request",
|
||||||
|
activity_changed: "{actor} changed the “{field}” field",
|
||||||
|
activity_changed_to: "{actor} changed the “{field}” field to “{newValue}”",
|
||||||
|
activity_changed_from_to: "{actor} changed the “{field}” field: “{oldValue}” → “{newValue}”",
|
||||||
|
fields: {
|
||||||
|
request: "request",
|
||||||
|
name: "title",
|
||||||
|
description: "description",
|
||||||
|
state: "status",
|
||||||
|
priority: "priority",
|
||||||
|
due_date: "due date",
|
||||||
|
assignees: "assignees",
|
||||||
|
labels: "labels",
|
||||||
|
},
|
||||||
|
},
|
||||||
traceability: {
|
traceability: {
|
||||||
title: "Routing",
|
title: "Routing",
|
||||||
description:
|
description:
|
||||||
|
|
|
||||||
|
|
@ -493,7 +493,31 @@ export default {
|
||||||
duplicate_of: "Дубликат",
|
duplicate_of: "Дубликат",
|
||||||
},
|
},
|
||||||
readonly_source_view:
|
readonly_source_view:
|
||||||
"Запрос показан в source-side режиме. Прямой доступ к целевому контуру не требуется, а подробная активность и вложения будут синхронизированы следующим этапом.",
|
"Запрос показан в source-side режиме. Прямой доступ к целевому контуру не требуется, а изменения, комментарии и вложения подтягиваются сюда в зеркальном виде.",
|
||||||
|
mirror: {
|
||||||
|
attachments_title: "Вложения из внешнего контура",
|
||||||
|
attachments_empty: "Во внешнем контуре пока нет вложений.",
|
||||||
|
comments_title: "Комментарии из внешнего контура",
|
||||||
|
comments_empty: "Во внешнем контуре пока нет комментариев.",
|
||||||
|
activity_title: "Активность внешнего контура",
|
||||||
|
activity_empty: "Во внешнем контуре пока нет действий по этому запросу.",
|
||||||
|
system_actor: "Система",
|
||||||
|
comment_edited: "Изменено",
|
||||||
|
activity_created: "{actor} создал(а) запрос",
|
||||||
|
activity_changed: "{actor} изменил(а) поле «{field}»",
|
||||||
|
activity_changed_to: "{actor} изменил(а) поле «{field}» на «{newValue}»",
|
||||||
|
activity_changed_from_to: "{actor} изменил(а) поле «{field}»: «{oldValue}» → «{newValue}»",
|
||||||
|
fields: {
|
||||||
|
request: "запрос",
|
||||||
|
name: "заголовок",
|
||||||
|
description: "описание",
|
||||||
|
state: "статус",
|
||||||
|
priority: "приоритет",
|
||||||
|
due_date: "срок выполнения",
|
||||||
|
assignees: "исполнители",
|
||||||
|
labels: "метки",
|
||||||
|
},
|
||||||
|
},
|
||||||
traceability: {
|
traceability: {
|
||||||
title: "Маршрутизация",
|
title: "Маршрутизация",
|
||||||
description: "Здесь отображается, из какого контура ушёл запрос, куда он направлен и с каким связанным элементом он сейчас работает.",
|
description: "Здесь отображается, из какого контура ушёл запрос, куда он направлен и с каким связанным элементом он сейчас работает.",
|
||||||
|
|
|
||||||
|
|
@ -19,11 +19,48 @@ export type TExternalContourIssue = TIssue & {
|
||||||
state_detail?: IStateLite | null;
|
state_detail?: IStateLite | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TExternalContourMirroredAttachment = {
|
||||||
|
id: string;
|
||||||
|
asset_url?: string | null;
|
||||||
|
attributes?: {
|
||||||
|
name?: string;
|
||||||
|
size?: number;
|
||||||
|
type?: string;
|
||||||
|
} | null;
|
||||||
|
download_url?: string | null;
|
||||||
|
updated_at: string;
|
||||||
|
uploaded_by?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TExternalContourMirroredComment = {
|
||||||
|
id: string;
|
||||||
|
comment_html: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
edited_at?: string | null;
|
||||||
|
parent_id?: string | null;
|
||||||
|
actor_detail?: Pick<IUser, "id" | "display_name" | "avatar_url"> | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TExternalContourMirroredActivity = {
|
||||||
|
id: string;
|
||||||
|
verb?: string | null;
|
||||||
|
field?: string | null;
|
||||||
|
old_value?: string | null;
|
||||||
|
new_value?: string | null;
|
||||||
|
comment?: string | null;
|
||||||
|
created_at: string;
|
||||||
|
actor_detail?: Pick<IUser, "id" | "display_name" | "avatar_url"> | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type TExternalContourRequest = {
|
export type TExternalContourRequest = {
|
||||||
created_at: string;
|
created_at: string;
|
||||||
created_by: string | null;
|
created_by: string | null;
|
||||||
id: string;
|
id: string;
|
||||||
issue: TExternalContourIssue;
|
issue: TExternalContourIssue;
|
||||||
|
mirrored_activity?: TExternalContourMirroredActivity[];
|
||||||
|
mirrored_attachments?: TExternalContourMirroredAttachment[];
|
||||||
|
mirrored_comments?: TExternalContourMirroredComment[];
|
||||||
source_decision?: "accepted" | null;
|
source_decision?: "accepted" | null;
|
||||||
source_decision_at?: string | null;
|
source_decision_at?: string | null;
|
||||||
source_decision_by_name?: string | null;
|
source_decision_by_name?: string | null;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue