360 lines
15 KiB
TypeScript
360 lines
15 KiB
TypeScript
/**
|
|
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
|
* SPDX-License-Identifier: AGPL-3.0-only
|
|
* See the LICENSE file for details.
|
|
*/
|
|
|
|
import type { Dispatch, SetStateAction } from "react";
|
|
import { useEffect, useMemo, useRef } from "react";
|
|
import { observer } from "mobx-react";
|
|
import type { EditorRefApi } from "@plane/editor";
|
|
import { ISSUE_PRIORITIES } from "@plane/constants";
|
|
import { useTranslation } from "@plane/i18n";
|
|
import { LabelPropertyIcon, PriorityIcon, PriorityPropertyIcon } from "@plane/propel/icons";
|
|
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
|
import type { TExternalContourRequest, TIssue, TNameDescriptionLoader } from "@plane/types";
|
|
import { EFileAssetType, EIssueServiceType } from "@plane/types";
|
|
import { getTextContent } from "@plane/utils";
|
|
import { DescriptionVersionsRoot } from "@/components/core/description-versions";
|
|
import { DescriptionInput } from "@/components/editor/rich-text/description-input";
|
|
import { DescriptionInputLoader } from "@/components/editor/rich-text/description-input/loader";
|
|
import type { TIssueOperations } from "@/components/issues/issue-detail";
|
|
import { IssueActivity } from "@/components/issues/issue-detail/issue-activity";
|
|
import { IssueDetailWidgets } from "@/components/issues/issue-detail-widgets";
|
|
import { IssueReaction } from "@/components/issues/issue-detail/reactions";
|
|
import { IssueTitleInput } from "@/components/issues/title-input";
|
|
import { useProjectExternalContours } from "@/hooks/store/use-project-external-contours";
|
|
import { useUser } from "@/hooks/store/user";
|
|
import useReloadConfirmations from "@/hooks/use-reload-confirmation";
|
|
import { DeDupeIssuePopoverRoot } from "@/plane-web/components/de-dupe/duplicate-popover";
|
|
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";
|
|
import { ExternalContoursSourceReplyBox } from "./source-reply-box";
|
|
|
|
const workItemVersionService = new WorkItemVersionService();
|
|
const issueService = new IssueService();
|
|
|
|
type Props = {
|
|
workspaceSlug: string;
|
|
sourceProjectId: string;
|
|
contourRequest: TExternalContourRequest;
|
|
hasDirectTargetAccess: boolean;
|
|
isEditable: boolean;
|
|
isSourceEditable: boolean;
|
|
isSubmitting: TNameDescriptionLoader;
|
|
setIsSubmitting: Dispatch<SetStateAction<TNameDescriptionLoader>>;
|
|
};
|
|
|
|
export const ExternalContoursIssueMainContent = observer(function ExternalContoursIssueMainContent(props: Props) {
|
|
const {
|
|
workspaceSlug,
|
|
sourceProjectId,
|
|
contourRequest,
|
|
hasDirectTargetAccess,
|
|
isEditable,
|
|
isSourceEditable,
|
|
isSubmitting,
|
|
setIsSubmitting,
|
|
} = props;
|
|
const { t } = useTranslation();
|
|
const editorRef = useRef<EditorRefApi>(null);
|
|
const { data: currentUser } = useUser();
|
|
const { loader, updateRequest, updateRequestIssue } = useProjectExternalContours();
|
|
const { setShowAlert } = useReloadConfirmations(isSubmitting === "submitting");
|
|
|
|
useEffect(() => {
|
|
if (isSubmitting === "submitted") {
|
|
setShowAlert(false);
|
|
setTimeout(async () => setIsSubmitting("saved"), 3000);
|
|
} else if (isSubmitting === "submitting") {
|
|
setShowAlert(true);
|
|
}
|
|
}, [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 priorityDetails = ISSUE_PRIORITIES.find((priority) => priority.key === issue.priority);
|
|
|
|
const { duplicateIssues } = useDebouncedDuplicateIssues(
|
|
workspaceSlug,
|
|
targetProjectId,
|
|
targetProjectId,
|
|
{
|
|
name: issue?.name,
|
|
description_html: getTextContent(issue?.description_html),
|
|
issueId: issue?.id,
|
|
}
|
|
);
|
|
|
|
const issueOperations: TIssueOperations = useMemo(
|
|
() => ({
|
|
fetch: async () => undefined,
|
|
remove: async (_workspaceSlug: string, _projectId: string, issueId: string) => {
|
|
try {
|
|
await issueService.deleteIssue(workspaceSlug, targetProjectId, issueId);
|
|
setToast({ title: t("success"), type: TOAST_TYPE.SUCCESS, message: t("inbox_issue.modals.delete.success") });
|
|
} catch {
|
|
setToast({ title: t("error"), type: TOAST_TYPE.ERROR, message: t("something_went_wrong_please_try_again") });
|
|
}
|
|
},
|
|
update: async (_workspaceSlug: string, _projectId: string, issueId: string, data: Partial<TIssue>) => {
|
|
try {
|
|
const updatedIssue = await issueService.patchIssue(workspaceSlug, targetProjectId, issueId, data);
|
|
updateRequestIssue(contourRequest.id, { ...data, ...updatedIssue });
|
|
} catch {
|
|
setToast({ title: t("error"), type: TOAST_TYPE.ERROR, message: t("issue_could_not_be_updated") });
|
|
}
|
|
},
|
|
archive: async () => undefined,
|
|
}),
|
|
[contourRequest.id, targetProjectId, t, updateRequestIssue, workspaceSlug]
|
|
);
|
|
|
|
const sourceIssueOperations: TIssueOperations = useMemo(
|
|
() => ({
|
|
fetch: async () => undefined,
|
|
remove: async () => undefined,
|
|
update: async (_workspaceSlug: string, _projectId: string, requestId: string, data: Partial<TIssue>) => {
|
|
try {
|
|
await updateRequest(workspaceSlug, sourceProjectId, requestId, data);
|
|
} catch {
|
|
setToast({ title: t("error"), type: TOAST_TYPE.ERROR, message: t("issue_could_not_be_updated") });
|
|
}
|
|
},
|
|
}),
|
|
[sourceProjectId, t, updateRequest, workspaceSlug]
|
|
);
|
|
|
|
if (!issue || !issue.project_id || !issue.id) return <></>;
|
|
|
|
if (!hasDirectTargetAccess) {
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="nodedc-external-content-shell space-y-4 p-5">
|
|
{isSourceEditable ? (
|
|
<>
|
|
<IssueTitleInput
|
|
workspaceSlug={workspaceSlug}
|
|
projectId={sourceProjectId}
|
|
issueId={contourRequest.id}
|
|
isSubmitting={isSubmitting}
|
|
setIsSubmitting={(value) => setIsSubmitting(value)}
|
|
issueOperations={sourceIssueOperations}
|
|
disabled={false}
|
|
value={issue.name}
|
|
containerClassName="-ml-3"
|
|
/>
|
|
|
|
{loader === "issue-loading" || issue.description_html === undefined ? (
|
|
<DescriptionInputLoader />
|
|
) : (
|
|
<DescriptionInput
|
|
issueSequenceId={issue.sequence_id}
|
|
containerClassName="-ml-3 border-none"
|
|
disabled={false}
|
|
disabledExtensions={["image"]}
|
|
editorRef={editorRef}
|
|
entityId={issue.id}
|
|
fileAssetType={EFileAssetType.ISSUE_DESCRIPTION}
|
|
initialValue={!issue.description_html || issue.description_html === "" ? "<p></p>" : issue.description_html}
|
|
key={`${issue.id}-source`}
|
|
onSubmit={async (value) => {
|
|
await sourceIssueOperations.update(workspaceSlug, sourceProjectId, contourRequest.id, {
|
|
description_html: value.description_html,
|
|
});
|
|
}}
|
|
projectId={sourceProjectId}
|
|
setIsSubmitting={(value) => setIsSubmitting(value)}
|
|
workspaceSlug={workspaceSlug}
|
|
/>
|
|
)}
|
|
</>
|
|
) : (
|
|
<>
|
|
<h1 className="text-xl font-semibold text-primary">{issue.name}</h1>
|
|
<div
|
|
className="prose prose-invert max-w-none text-sm text-secondary [&_p]:mb-3"
|
|
dangerouslySetInnerHTML={{ __html: issue.description_html || "<p></p>" }}
|
|
/>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
<ExternalContoursRequestTraceability contourRequest={contourRequest} />
|
|
|
|
<div className="nodedc-external-section flex flex-col gap-3 px-4 py-4">
|
|
<div className="text-body-sm-medium">{t("external_contours_page.properties.section_title")}</div>
|
|
<div className="grid grid-cols-2 gap-3 text-13 text-secondary">
|
|
<div className="nodedc-external-property-row">
|
|
<div className="nodedc-external-property-label">
|
|
<PriorityPropertyIcon className="h-4 w-4 flex-shrink-0" />
|
|
<span>{t("priority")}</span>
|
|
</div>
|
|
<div className="nodedc-external-property-value flex-1 text-[13px] font-medium">
|
|
<PriorityIcon priority={issue.priority ?? "none"} className="h-3.5 w-3.5 flex-shrink-0 text-tertiary" />
|
|
<span className="text-12 font-medium text-primary">
|
|
{issue.priority && issue.priority !== "none"
|
|
? priorityDetails?.title ?? t(issue.priority)
|
|
: t("external_contours_page.form.priority")}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="nodedc-external-property-row">
|
|
<div className="nodedc-external-property-label">
|
|
<LabelPropertyIcon className="h-4 w-4 flex-shrink-0" />
|
|
<span>{t("labels")}</span>
|
|
</div>
|
|
{issue.label_details?.length ? (
|
|
<div className="nodedc-external-property-value flex-1 flex-wrap text-[13px] font-medium">
|
|
<LabelPropertyIcon className="h-3.5 w-3.5 flex-shrink-0 text-tertiary" />
|
|
<span className="text-12 font-medium text-primary">
|
|
{issue.label_details.map((label) => label.name).join(", ")}
|
|
</span>
|
|
</div>
|
|
) : (
|
|
<div className="nodedc-external-property-value flex-1 text-[13px] font-medium">
|
|
<LabelPropertyIcon className="h-3.5 w-3.5 flex-shrink-0 text-tertiary" />
|
|
<span className="text-12 font-medium text-tertiary">{t("labels")}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<ExternalContoursMirroredAttachments attachments={mirroredAttachments} />
|
|
|
|
<ExternalContoursSourceReplyBox
|
|
workspaceSlug={workspaceSlug}
|
|
sourceProjectId={sourceProjectId}
|
|
requestId={contourRequest.id}
|
|
/>
|
|
|
|
<ExternalContoursMirroredComments comments={mirroredComments} />
|
|
|
|
<ExternalContoursMirroredActivity activity={mirroredActivity} />
|
|
|
|
{!isSourceEditable && (
|
|
<div className="nodedc-external-empty px-4 py-4 text-13 text-secondary">
|
|
{t("external_contours_page.readonly_source_view")}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="nodedc-external-content-shell space-y-4 p-5">
|
|
{duplicateIssues.length > 0 && (
|
|
<DeDupeIssuePopoverRoot
|
|
workspaceSlug={workspaceSlug}
|
|
projectId={targetProjectId}
|
|
rootIssueId={issue.id}
|
|
issues={duplicateIssues}
|
|
issueOperations={issueOperations}
|
|
/>
|
|
)}
|
|
<IssueTitleInput
|
|
workspaceSlug={workspaceSlug}
|
|
projectId={targetProjectId}
|
|
issueId={issue.id}
|
|
isSubmitting={isSubmitting}
|
|
setIsSubmitting={(value) => setIsSubmitting(value)}
|
|
issueOperations={issueOperations}
|
|
disabled={!isEditable}
|
|
value={issue.name}
|
|
containerClassName="-ml-3"
|
|
/>
|
|
|
|
{loader === "issue-loading" || issue.description_html === undefined ? (
|
|
<DescriptionInputLoader />
|
|
) : (
|
|
<DescriptionInput
|
|
issueSequenceId={issue.sequence_id}
|
|
containerClassName="-ml-3 border-none"
|
|
disabled={!isEditable}
|
|
editorRef={editorRef}
|
|
entityId={issue.id}
|
|
fileAssetType={EFileAssetType.ISSUE_DESCRIPTION}
|
|
initialValue={!issue.description_html || issue.description_html === "" ? "<p></p>" : issue.description_html}
|
|
key={issue.id}
|
|
onSubmit={async (value, isMigrationUpdate) => {
|
|
await issueOperations.update(workspaceSlug, targetProjectId, issue.id, {
|
|
description_html: value.description_html,
|
|
...(isMigrationUpdate ? { skip_activity: "true" } : {}),
|
|
});
|
|
}}
|
|
projectId={targetProjectId}
|
|
setIsSubmitting={(value) => setIsSubmitting(value)}
|
|
workspaceSlug={workspaceSlug}
|
|
/>
|
|
)}
|
|
|
|
<div className="flex items-center justify-between gap-2">
|
|
{currentUser && (
|
|
<IssueReaction workspaceSlug={workspaceSlug} projectId={targetProjectId} issueId={issue.id} currentUser={currentUser} />
|
|
)}
|
|
{isEditable && (
|
|
<DescriptionVersionsRoot
|
|
className="flex-shrink-0"
|
|
entityInformation={{
|
|
createdAt: issue.created_at ? new Date(issue.created_at) : new Date(),
|
|
createdByDisplayName: issue.created_by_detail?.display_name ?? "",
|
|
id: issue.id,
|
|
isRestoreDisabled: !isEditable,
|
|
}}
|
|
fetchHandlers={{
|
|
listDescriptionVersions: (issueId) =>
|
|
workItemVersionService.listDescriptionVersions(workspaceSlug, targetProjectId, issueId),
|
|
retrieveDescriptionVersion: (issueId, versionId) =>
|
|
workItemVersionService.retrieveDescriptionVersion(workspaceSlug, targetProjectId, issueId, versionId),
|
|
}}
|
|
handleRestore={(descriptionHTML) => editorRef.current?.setEditorValue(descriptionHTML, true)}
|
|
projectId={targetProjectId}
|
|
workspaceSlug={workspaceSlug}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<ExternalContoursRequestTraceability contourRequest={contourRequest} />
|
|
|
|
<div className="nodedc-external-section nodedc-external-detail-widgets overflow-visible px-4 py-4">
|
|
<IssueDetailWidgets
|
|
workspaceSlug={workspaceSlug}
|
|
projectId={targetProjectId}
|
|
issueId={issue.id}
|
|
disabled={!isEditable}
|
|
issueOperations={issueOperations}
|
|
issueServiceType={EIssueServiceType.ISSUES}
|
|
compactView
|
|
/>
|
|
</div>
|
|
|
|
<div className="nodedc-external-section overflow-visible px-4 py-4">
|
|
<ExternalContoursIssueContentProperties
|
|
workspaceSlug={workspaceSlug}
|
|
targetProjectId={targetProjectId}
|
|
issue={issue}
|
|
issueOperations={issueOperations}
|
|
isEditable={isEditable}
|
|
/>
|
|
</div>
|
|
|
|
<div className="nodedc-external-section overflow-visible px-4 py-4">
|
|
<IssueActivity workspaceSlug={workspaceSlug} projectId={targetProjectId} issueId={issue.id} compactComposer />
|
|
</div>
|
|
</div>
|
|
);
|
|
});
|