diff --git a/docs_prod/cross-project-task-routing/phase-roadmap.md b/docs_prod/cross-project-task-routing/phase-roadmap.md index 67faabf..d96f2d6 100644 --- a/docs_prod/cross-project-task-routing/phase-roadmap.md +++ b/docs_prod/cross-project-task-routing/phase-roadmap.md @@ -150,11 +150,13 @@ - для закрытого внешнего запроса доступны source-side действия `Принять` и `Отклонить` - `Принять` фиксирует решение источника в bridge metadata и помечает запрос как принятый во внутренний контур - `Отклонить` возвращает target issue в default-state целевого проекта и переносит source-side карточку обратно в список `Открытые` +- source-side readonly карточка зеркалит актуальные комментарии, вложения и activity целевой задачи +- вложения доступны через proxy download endpoint без прямого membership в target project +- detail карточка source-only пользователя обновляется polling-ом и подтягивает новые комментарии без ручной перезагрузки Что остается: -- зеркалирование комментариев -- зеркалирование файлов -- зеркалирование activity stream и обновлений описания +- зеркалирование inline-файлов из комментариев и описания, а не только issue attachments +- двусторонняя работа с комментариями из source-side карточки - комментарий причины отклонения и ответ обратно во внешний контур ## Этап 4. Уведомления diff --git a/plane-src/apps/api/plane/api/serializers/external_contours.py b/plane-src/apps/api/plane/api/serializers/external_contours.py index 1d6a13e..3cf42df 100644 --- a/plane-src/apps/api/plane/api/serializers/external_contours.py +++ b/plane-src/apps/api/plane/api/serializers/external_contours.py @@ -10,7 +10,7 @@ from .project import ProjectLiteSerializer from .state import StateLiteSerializer from .user import UserLiteSerializer 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): @@ -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): issue = ExternalContourIssueSerializer(read_only=True) + mirrored_activity = serializers.SerializerMethodField() + mirrored_attachments = serializers.SerializerMethodField() + mirrored_comments = serializers.SerializerMethodField() source_project_id = serializers.SerializerMethodField() source_project_name = serializers.SerializerMethodField() source_decision = serializers.SerializerMethodField() @@ -117,6 +163,9 @@ class ExternalContourRequestSerializer(BaseSerializer): "updated_at", "created_by", "issue", + "mirrored_activity", + "mirrored_attachments", + "mirrored_comments", "source_project_id", "source_project_name", "source_decision", @@ -134,6 +183,53 @@ class ExternalContourRequestSerializer(BaseSerializer): def get_source_project_id(self, obj): 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): return obj.extra.get("source_project_name") diff --git a/plane-src/apps/api/plane/api/urls/external_contours.py b/plane-src/apps/api/plane/api/urls/external_contours.py index 9c43849..4c95cd6 100644 --- a/plane-src/apps/api/plane/api/urls/external_contours.py +++ b/plane-src/apps/api/plane/api/urls/external_contours.py @@ -5,6 +5,7 @@ from django.urls import path from plane.api.views import ( + ExternalContourAttachmentDownloadAPIEndpoint, ExternalContourDetailAPIEndpoint, ExternalContourDecisionAPIEndpoint, ExternalContourListCreateAPIEndpoint, @@ -38,4 +39,9 @@ urlpatterns = [ ExternalContourDecisionAPIEndpoint.as_view(http_method_names=["post"]), name="external-contour-decision", ), + path( + "workspaces//projects//external-contours//attachments//", + ExternalContourAttachmentDownloadAPIEndpoint.as_view(http_method_names=["get"]), + name="external-contour-attachment-download", + ), ] diff --git a/plane-src/apps/api/plane/api/views/__init__.py b/plane-src/apps/api/plane/api/views/__init__.py index 54d0d8f..ee4d72e 100644 --- a/plane-src/apps/api/plane/api/views/__init__.py +++ b/plane-src/apps/api/plane/api/views/__init__.py @@ -56,6 +56,7 @@ from .intake import ( IntakeIssueDetailAPIEndpoint, ) from .external_contours import ( + ExternalContourAttachmentDownloadEndpoint as ExternalContourAttachmentDownloadAPIEndpoint, ExternalContourListCreateEndpoint as ExternalContourListCreateAPIEndpoint, ExternalContourDetailEndpoint as ExternalContourDetailAPIEndpoint, ExternalContourDecisionEndpoint as ExternalContourDecisionAPIEndpoint, diff --git a/plane-src/apps/api/plane/api/views/external_contours.py b/plane-src/apps/api/plane/api/views/external_contours.py index 50f010d..df00978 100644 --- a/plane-src/apps/api/plane/api/views/external_contours.py +++ b/plane-src/apps/api/plane/api/views/external_contours.py @@ -2,6 +2,7 @@ # SPDX-License-Identifier: AGPL-3.0-only # See the LICENSE file for details. +from django.http import HttpResponseRedirect from django.shortcuts import get_object_or_404 from django.utils import timezone 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.app.permissions import ProjectLitePermission 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.settings.storage import S3Storage class ExternalContourListCreateEndpoint(BaseAPIView): @@ -269,7 +271,14 @@ class ExternalContourDetailEndpoint(BaseAPIView): def get(self, request, slug, project_id, request_id): 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) @@ -349,6 +358,42 @@ class ExternalContourDecisionEndpoint(BaseAPIView): "issue__created_by", ) .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) + + +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) diff --git a/plane-src/apps/api/plane/app/urls/external_contours.py b/plane-src/apps/api/plane/app/urls/external_contours.py index 9791177..af8a28e 100644 --- a/plane-src/apps/api/plane/app/urls/external_contours.py +++ b/plane-src/apps/api/plane/app/urls/external_contours.py @@ -5,6 +5,7 @@ from django.urls import path from plane.app.views import ( + ExternalContourAttachmentDownloadEndpoint, ExternalContourDetailEndpoint, ExternalContourDecisionEndpoint, ExternalContourListCreateEndpoint, @@ -39,4 +40,9 @@ urlpatterns = [ ExternalContourDecisionEndpoint.as_view(http_method_names=["post"]), name="external-contour-decision", ), + path( + "workspaces//projects//external-contours//attachments//", + ExternalContourAttachmentDownloadEndpoint.as_view(http_method_names=["get"]), + name="external-contour-attachment-download", + ), ] diff --git a/plane-src/apps/api/plane/app/views/__init__.py b/plane-src/apps/api/plane/app/views/__init__.py index f82ea8b..3b70432 100644 --- a/plane-src/apps/api/plane/app/views/__init__.py +++ b/plane-src/apps/api/plane/app/views/__init__.py @@ -225,6 +225,7 @@ from .notification.base import ( from .exporter.base import ExportIssuesEndpoint from .external_contours import ( + ExternalContourAttachmentDownloadEndpoint, ExternalContourListCreateEndpoint, ExternalContourDetailEndpoint, ExternalContourDecisionEndpoint, diff --git a/plane-src/apps/api/plane/app/views/external_contours.py b/plane-src/apps/api/plane/app/views/external_contours.py index 721634d..9117906 100644 --- a/plane-src/apps/api/plane/app/views/external_contours.py +++ b/plane-src/apps/api/plane/app/views/external_contours.py @@ -2,6 +2,7 @@ # SPDX-License-Identifier: AGPL-3.0-only # See the LICENSE file for details. +from django.http import HttpResponseRedirect from django.shortcuts import get_object_or_404 from django.utils import timezone 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.app.permissions import ProjectLitePermission 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.settings.storage import S3Storage class ExternalContourListCreateEndpoint(BaseAPIView): @@ -269,7 +271,14 @@ class ExternalContourDetailEndpoint(BaseAPIView): def get(self, request, slug, project_id, request_id): 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) @@ -349,6 +358,42 @@ class ExternalContourDecisionEndpoint(BaseAPIView): "issue__created_by", ) .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) + + +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) diff --git a/plane-src/apps/web/ce/components/projects/external-contours/content-root.tsx b/plane-src/apps/web/ce/components/projects/external-contours/content-root.tsx index a71a2a8..4dbaa21 100644 --- a/plane-src/apps/web/ce/components/projects/external-contours/content-root.tsx +++ b/plane-src/apps/web/ce/components/projects/external-contours/content-root.tsx @@ -52,7 +52,11 @@ export const ExternalContoursContentRoot = observer(function ExternalContoursCon ? `PROJECT_EXTERNAL_CONTOUR_DETAIL_${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 = diff --git a/plane-src/apps/web/ce/components/projects/external-contours/issue-root.tsx b/plane-src/apps/web/ce/components/projects/external-contours/issue-root.tsx index f839dbf..671c353 100644 --- a/plane-src/apps/web/ce/components/projects/external-contours/issue-root.tsx +++ b/plane-src/apps/web/ce/components/projects/external-contours/issue-root.tsx @@ -29,6 +29,9 @@ import { DeDupeIssuePopoverRoot } from "@/plane-web/components/de-dupe/duplicate import { useDebouncedDuplicateIssues } from "@/plane-web/hooks/use-debounced-duplicate-issues"; import { IssueService } from "@/services/issue/issue.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 { ExternalContoursRequestTraceability } from "./request-traceability"; @@ -63,6 +66,9 @@ export const ExternalContoursIssueMainContent = observer(function ExternalContou }, [isSubmitting, setIsSubmitting, setShowAlert]); 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 { duplicateIssues } = useDebouncedDuplicateIssues( @@ -164,6 +170,12 @@ export const ExternalContoursIssueMainContent = observer(function ExternalContou + + + + + +
{t("external_contours_page.readonly_source_view")}
); diff --git a/plane-src/apps/web/ce/components/projects/external-contours/mirrored-activity.tsx b/plane-src/apps/web/ce/components/projects/external-contours/mirrored-activity.tsx new file mode 100644 index 0000000..3171b0f --- /dev/null +++ b/plane-src/apps/web/ce/components/projects/external-contours/mirrored-activity.tsx @@ -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 = { + 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 ( +
+
{t("external_contours_page.mirror.activity_title")}
+ + {activity.length > 0 ? ( +
+ {activity.map((item) => ( +
+
{renderMessage(item)}
+
{renderFormattedDate(item.created_at)}
+
+ ))} +
+ ) : ( +
+ {t("external_contours_page.mirror.activity_empty")} +
+ )} +
+ ); +}); diff --git a/plane-src/apps/web/ce/components/projects/external-contours/mirrored-attachments.tsx b/plane-src/apps/web/ce/components/projects/external-contours/mirrored-attachments.tsx new file mode 100644 index 0000000..280be5f --- /dev/null +++ b/plane-src/apps/web/ce/components/projects/external-contours/mirrored-attachments.tsx @@ -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 ( +
+
{t("external_contours_page.mirror.attachments_title")}
+ + {attachments.length > 0 ? ( +
+ {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 ( + +
+
{fileIcon}
+
+
{fileName || t("attachments")}
+
+ {fileExtension ? {fileExtension.toUpperCase()} : null} + {fileSize ? {fileSize} : null} + {renderFormattedDate(attachment.updated_at)} +
+
+
+
{attachment.uploaded_by || t("external_contours_page.mirror.system_actor")}
+
+ ); + })} +
+ ) : ( +
+ {t("external_contours_page.mirror.attachments_empty")} +
+ )} +
+ ); +}); diff --git a/plane-src/apps/web/ce/components/projects/external-contours/mirrored-comments.tsx b/plane-src/apps/web/ce/components/projects/external-contours/mirrored-comments.tsx new file mode 100644 index 0000000..589d299 --- /dev/null +++ b/plane-src/apps/web/ce/components/projects/external-contours/mirrored-comments.tsx @@ -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 ( +
+
{t("external_contours_page.mirror.comments_title")}
+ + {comments.length > 0 ? ( +
+ {comments.map((comment) => { + const actorName = comment.actor_detail?.display_name || t("external_contours_page.mirror.system_actor"); + return ( +
+
+
+ +
+
{actorName}
+
{renderFormattedDate(comment.created_at)}
+
+
+ {comment.edited_at ? ( +
{t("external_contours_page.mirror.comment_edited")}
+ ) : null} +
+

" }} + /> +
+ ); + })} +
+ ) : ( +
+ {t("external_contours_page.mirror.comments_empty")} +
+ )} +
+ ); +}); diff --git a/plane-src/packages/i18n/src/locales/en/translations.ts b/plane-src/packages/i18n/src/locales/en/translations.ts index 150f0d4..00967aa 100644 --- a/plane-src/packages/i18n/src/locales/en/translations.ts +++ b/plane-src/packages/i18n/src/locales/en/translations.ts @@ -336,7 +336,31 @@ export default { duplicate_of: "Duplicate of", }, 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: { title: "Routing", description: diff --git a/plane-src/packages/i18n/src/locales/ru/translations.ts b/plane-src/packages/i18n/src/locales/ru/translations.ts index 629047c..7ce4596 100644 --- a/plane-src/packages/i18n/src/locales/ru/translations.ts +++ b/plane-src/packages/i18n/src/locales/ru/translations.ts @@ -493,7 +493,31 @@ export default { duplicate_of: "Дубликат", }, 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: { title: "Маршрутизация", description: "Здесь отображается, из какого контура ушёл запрос, куда он направлен и с каким связанным элементом он сейчас работает.", diff --git a/plane-src/packages/types/src/external-contours.ts b/plane-src/packages/types/src/external-contours.ts index 6e54b09..1a27faf 100644 --- a/plane-src/packages/types/src/external-contours.ts +++ b/plane-src/packages/types/src/external-contours.ts @@ -19,11 +19,48 @@ export type TExternalContourIssue = TIssue & { 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 | 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 | null; +}; + export type TExternalContourRequest = { created_at: string; created_by: string | null; id: string; issue: TExternalContourIssue; + mirrored_activity?: TExternalContourMirroredActivity[]; + mirrored_attachments?: TExternalContourMirroredAttachment[]; + mirrored_comments?: TExternalContourMirroredComment[]; source_decision?: "accepted" | null; source_decision_at?: string | null; source_decision_by_name?: string | null;