NODEDC_TASKMANAGER/plane-src/apps/web/ce/components/projects/external-contours/issue-root.tsx

355 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 } from "@plane/propel/icons";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { TExternalContourRequest, TIssue, TNameDescriptionLoader } from "@plane/types";
import { EFileAssetType } 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 { IssueAttachmentRoot } from "@/components/issues/attachment";
import type { TIssueOperations } from "@/components/issues/issue-detail";
import { IssueActivity } from "@/components/issues/issue-detail/issue-activity";
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, {
name: data.name,
description_html: data.description_html,
});
} 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="flex flex-col 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 overflow-visible px-4 py-4">
<IssueAttachmentRoot workspaceSlug={workspaceSlug} projectId={targetProjectId} issueId={issue.id} disabled={!isEditable} />
</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} />
</div>
</div>
);
});