ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: зеркалирование комментариев, вложений и активности внешнего контура

This commit is contained in:
DCCONSTRUCTIONS
2026-04-19 08:53:41 +03:00
parent 8195c3fc80
commit 61a8625a5c
16 changed files with 525 additions and 13 deletions
@@ -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 =
@@ -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
</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>
</>
);
@@ -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>
);
});