feat: init
This commit is contained in:
467
apps/web/core/components/inbox/content/inbox-issue-header.tsx
Normal file
467
apps/web/core/components/inbox/content/inbox-issue-header.tsx
Normal file
@@ -0,0 +1,467 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import {
|
||||
CircleCheck,
|
||||
CircleX,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Clock,
|
||||
ExternalLink,
|
||||
FileStack,
|
||||
Link,
|
||||
Trash2,
|
||||
MoveRight,
|
||||
Copy,
|
||||
} from "lucide-react";
|
||||
// plane imports
|
||||
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { TNameDescriptionLoader } from "@plane/types";
|
||||
import { EInboxIssueStatus } from "@plane/types";
|
||||
import { ControlLink, CustomMenu, Row } from "@plane/ui";
|
||||
import { copyUrlToClipboard, findHowManyDaysLeft, generateWorkItemLink } from "@plane/utils";
|
||||
// components
|
||||
import { CreateUpdateIssueModal } from "@/components/issues/issue-modal/modal";
|
||||
import { NameDescriptionUpdateStatus } from "@/components/issues/issue-update-status";
|
||||
// hooks
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useProjectInbox } from "@/hooks/store/use-project-inbox";
|
||||
import { useUser, useUserPermissions } from "@/hooks/store/user";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
// store
|
||||
import type { IInboxIssueStore } from "@/store/inbox/inbox-issue.store";
|
||||
// local imports
|
||||
import { InboxIssueStatus } from "../inbox-issue-status";
|
||||
import { DeclineIssueModal } from "../modals/decline-issue-modal";
|
||||
import { DeleteInboxIssueModal } from "../modals/delete-issue-modal";
|
||||
import { SelectDuplicateInboxIssueModal } from "../modals/select-duplicate";
|
||||
import { InboxIssueSnoozeModal } from "../modals/snooze-issue-modal";
|
||||
import { InboxIssueActionsMobileHeader } from "./inbox-issue-mobile-header";
|
||||
|
||||
type TInboxIssueActionsHeader = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
inboxIssue: IInboxIssueStore | undefined;
|
||||
isSubmitting: TNameDescriptionLoader;
|
||||
isMobileSidebar: boolean;
|
||||
setIsMobileSidebar: (value: boolean) => void;
|
||||
isNotificationEmbed: boolean;
|
||||
embedRemoveCurrentNotification?: () => void;
|
||||
};
|
||||
|
||||
export const InboxIssueActionsHeader: FC<TInboxIssueActionsHeader> = observer((props) => {
|
||||
const {
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
inboxIssue,
|
||||
isSubmitting,
|
||||
isMobileSidebar,
|
||||
setIsMobileSidebar,
|
||||
isNotificationEmbed = false,
|
||||
embedRemoveCurrentNotification,
|
||||
} = props;
|
||||
// states
|
||||
const [isSnoozeDateModalOpen, setIsSnoozeDateModalOpen] = useState(false);
|
||||
const [selectDuplicateIssue, setSelectDuplicateIssue] = useState(false);
|
||||
const [acceptIssueModal, setAcceptIssueModal] = useState(false);
|
||||
const [declineIssueModal, setDeclineIssueModal] = useState(false);
|
||||
const [deleteIssueModal, setDeleteIssueModal] = useState(false);
|
||||
// store
|
||||
const { currentTab, deleteInboxIssue, filteredInboxIssueIds } = useProjectInbox();
|
||||
const { data: currentUser } = useUser();
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
const { currentProjectDetails } = useProject();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const router = useAppRouter();
|
||||
const { getProjectById } = useProject();
|
||||
|
||||
const issue = inboxIssue?.issue;
|
||||
// derived values
|
||||
const isAllowed = allowPermissions(
|
||||
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
|
||||
EUserPermissionsLevel.PROJECT,
|
||||
workspaceSlug,
|
||||
projectId
|
||||
);
|
||||
const canMarkAsDuplicate = isAllowed && (inboxIssue?.status === 0 || inboxIssue?.status === -2);
|
||||
const canMarkAsAccepted = isAllowed && (inboxIssue?.status === 0 || inboxIssue?.status === -2);
|
||||
const canMarkAsDeclined = isAllowed && (inboxIssue?.status === 0 || inboxIssue?.status === -2);
|
||||
// can delete only if admin or is creator of the issue
|
||||
const canDelete =
|
||||
allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT, workspaceSlug, projectId) ||
|
||||
issue?.created_by === currentUser?.id;
|
||||
const isProjectAdmin = allowPermissions(
|
||||
[EUserPermissions.ADMIN],
|
||||
EUserPermissionsLevel.PROJECT,
|
||||
workspaceSlug,
|
||||
projectId
|
||||
);
|
||||
const isAcceptedOrDeclined = inboxIssue?.status ? [-1, 1, 2].includes(inboxIssue.status) : undefined;
|
||||
// days left for snooze
|
||||
const numberOfDaysLeft = findHowManyDaysLeft(inboxIssue?.snoozed_till);
|
||||
|
||||
const currentInboxIssueId = inboxIssue?.issue?.id;
|
||||
|
||||
const redirectIssue = (): string | undefined => {
|
||||
let nextOrPreviousIssueId: string | undefined = undefined;
|
||||
const currentIssueIndex = filteredInboxIssueIds.findIndex((id) => id === currentInboxIssueId);
|
||||
if (filteredInboxIssueIds[currentIssueIndex + 1])
|
||||
nextOrPreviousIssueId = filteredInboxIssueIds[currentIssueIndex + 1];
|
||||
else if (filteredInboxIssueIds[currentIssueIndex - 1])
|
||||
nextOrPreviousIssueId = filteredInboxIssueIds[currentIssueIndex - 1];
|
||||
else nextOrPreviousIssueId = undefined;
|
||||
return nextOrPreviousIssueId;
|
||||
};
|
||||
|
||||
const handleRedirection = (nextOrPreviousIssueId: string | undefined) => {
|
||||
if (!isNotificationEmbed) {
|
||||
if (nextOrPreviousIssueId)
|
||||
router.push(
|
||||
`/${workspaceSlug}/projects/${projectId}/intake?currentTab=${currentTab}&inboxIssueId=${nextOrPreviousIssueId}`
|
||||
);
|
||||
else router.push(`/${workspaceSlug}/projects/${projectId}/intake?currentTab=${currentTab}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInboxIssueAccept = async () => {
|
||||
const nextOrPreviousIssueId = redirectIssue();
|
||||
await inboxIssue?.updateInboxIssueStatus(EInboxIssueStatus.ACCEPTED);
|
||||
setAcceptIssueModal(false);
|
||||
handleRedirection(nextOrPreviousIssueId);
|
||||
};
|
||||
|
||||
const handleInboxIssueDecline = async () => {
|
||||
const nextOrPreviousIssueId = redirectIssue();
|
||||
await inboxIssue?.updateInboxIssueStatus(EInboxIssueStatus.DECLINED);
|
||||
setDeclineIssueModal(false);
|
||||
handleRedirection(nextOrPreviousIssueId);
|
||||
};
|
||||
|
||||
const handleInboxIssueSnooze = async (date: Date) => {
|
||||
const nextOrPreviousIssueId = redirectIssue();
|
||||
await inboxIssue?.updateInboxIssueSnoozeTill(date);
|
||||
setIsSnoozeDateModalOpen(false);
|
||||
handleRedirection(nextOrPreviousIssueId);
|
||||
};
|
||||
|
||||
const handleInboxIssueDuplicate = async (issueId: string) => {
|
||||
await inboxIssue?.updateInboxIssueDuplicateTo(issueId);
|
||||
};
|
||||
|
||||
const handleInboxIssueDelete = async () => {
|
||||
if (!inboxIssue || !currentInboxIssueId) return;
|
||||
await deleteInboxIssue(workspaceSlug, projectId, currentInboxIssueId).then(() => {
|
||||
if (!isNotificationEmbed) router.push(`/${workspaceSlug}/projects/${projectId}/intake`);
|
||||
});
|
||||
};
|
||||
|
||||
const handleIssueSnoozeAction = async () => {
|
||||
if (inboxIssue?.snoozed_till && numberOfDaysLeft && numberOfDaysLeft > 0) {
|
||||
const nextOrPreviousIssueId = redirectIssue();
|
||||
await inboxIssue?.updateInboxIssueSnoozeTill(undefined);
|
||||
handleRedirection(nextOrPreviousIssueId);
|
||||
} else {
|
||||
setIsSnoozeDateModalOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyIssueLink = (path: string) =>
|
||||
copyUrlToClipboard(path).then(() =>
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: t("common.link_copied"),
|
||||
message: t("common.copied_to_clipboard"),
|
||||
})
|
||||
);
|
||||
|
||||
const currentIssueIndex = filteredInboxIssueIds.findIndex((issueId) => issueId === currentInboxIssueId) ?? 0;
|
||||
|
||||
const handleInboxIssueNavigation = useCallback(
|
||||
(direction: "next" | "prev") => {
|
||||
if (!filteredInboxIssueIds || !currentInboxIssueId) return;
|
||||
const activeElement = document.activeElement as HTMLElement;
|
||||
if (activeElement && (activeElement.classList.contains("tiptap") || activeElement.id === "title-input")) return;
|
||||
const nextIssueIndex =
|
||||
direction === "next"
|
||||
? (currentIssueIndex + 1) % filteredInboxIssueIds.length
|
||||
: (currentIssueIndex - 1 + filteredInboxIssueIds.length) % filteredInboxIssueIds.length;
|
||||
const nextIssueId = filteredInboxIssueIds[nextIssueIndex];
|
||||
if (!nextIssueId) return;
|
||||
router.push(`/${workspaceSlug}/projects/${projectId}/intake?inboxIssueId=${nextIssueId}`);
|
||||
},
|
||||
[currentInboxIssueId, currentIssueIndex, filteredInboxIssueIds, projectId, router, workspaceSlug]
|
||||
);
|
||||
|
||||
const onKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (e.key === "ArrowUp") {
|
||||
handleInboxIssueNavigation("prev");
|
||||
} else if (e.key === "ArrowDown") {
|
||||
handleInboxIssueNavigation("next");
|
||||
}
|
||||
},
|
||||
[handleInboxIssueNavigation]
|
||||
);
|
||||
|
||||
const handleActionWithPermission = (isAdmin: boolean, action: () => void, errorMessage: string) => {
|
||||
if (isAdmin) action();
|
||||
else {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Permission denied",
|
||||
message: errorMessage,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isSubmitting === "submitting") return;
|
||||
if (!isNotificationEmbed) document.addEventListener("keydown", onKeyDown);
|
||||
return () => {
|
||||
if (!isNotificationEmbed) document.removeEventListener("keydown", onKeyDown);
|
||||
};
|
||||
}, [onKeyDown, isNotificationEmbed, isSubmitting]);
|
||||
|
||||
if (!inboxIssue) return null;
|
||||
|
||||
const workItemLink = generateWorkItemLink({
|
||||
workspaceSlug: workspaceSlug?.toString(),
|
||||
projectId: issue?.project_id,
|
||||
issueId: currentInboxIssueId,
|
||||
projectIdentifier: currentProjectDetails?.identifier,
|
||||
sequenceId: issue?.sequence_id,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<>
|
||||
<SelectDuplicateInboxIssueModal
|
||||
isOpen={selectDuplicateIssue}
|
||||
onClose={() => setSelectDuplicateIssue(false)}
|
||||
value={inboxIssue?.duplicate_to}
|
||||
onSubmit={handleInboxIssueDuplicate}
|
||||
/>
|
||||
<CreateUpdateIssueModal
|
||||
data={inboxIssue?.issue}
|
||||
isOpen={acceptIssueModal}
|
||||
onClose={() => setAcceptIssueModal(false)}
|
||||
beforeFormSubmit={handleInboxIssueAccept}
|
||||
withDraftIssueWrapper={false}
|
||||
fetchIssueDetails={false}
|
||||
modalTitle={t("inbox_issue.actions.move", {
|
||||
value: `${currentProjectDetails?.identifier}-${issue?.sequence_id}`,
|
||||
})}
|
||||
primaryButtonText={{
|
||||
default: t("add_to_project"),
|
||||
loading: t("adding"),
|
||||
}}
|
||||
/>
|
||||
<DeclineIssueModal
|
||||
data={inboxIssue?.issue || {}}
|
||||
isOpen={declineIssueModal}
|
||||
onClose={() => setDeclineIssueModal(false)}
|
||||
onSubmit={handleInboxIssueDecline}
|
||||
/>
|
||||
<DeleteInboxIssueModal
|
||||
data={inboxIssue?.issue}
|
||||
isOpen={deleteIssueModal}
|
||||
onClose={() => setDeleteIssueModal(false)}
|
||||
onSubmit={handleInboxIssueDelete}
|
||||
/>
|
||||
<InboxIssueSnoozeModal
|
||||
isOpen={isSnoozeDateModalOpen}
|
||||
handleClose={() => setIsSnoozeDateModalOpen(false)}
|
||||
value={inboxIssue?.snoozed_till}
|
||||
onConfirm={handleInboxIssueSnooze}
|
||||
/>
|
||||
</>
|
||||
|
||||
<Row className="hidden relative lg:flex h-full w-full items-center justify-between gap-2 bg-custom-background-100 z-[15] border-b border-custom-border-200">
|
||||
<div className="flex items-center gap-4">
|
||||
{isNotificationEmbed && (
|
||||
<button onClick={embedRemoveCurrentNotification}>
|
||||
<MoveRight className="h-4 w-4 text-custom-text-300 hover:text-custom-text-200" />
|
||||
</button>
|
||||
)}
|
||||
{issue?.project_id && issue.sequence_id && (
|
||||
<h3 className="text-base font-medium text-custom-text-300 flex-shrink-0">
|
||||
{getProjectById(issue.project_id)?.identifier}-{issue.sequence_id}
|
||||
</h3>
|
||||
)}
|
||||
<InboxIssueStatus inboxIssue={inboxIssue} iconSize={12} />
|
||||
<div className="flex items-center justify-end w-full">
|
||||
<NameDescriptionUpdateStatus isSubmitting={isSubmitting} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{!isNotificationEmbed && (
|
||||
<div className="flex items-center gap-x-2">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-custom-border-200 p-1.5"
|
||||
onClick={() => handleInboxIssueNavigation("prev")}
|
||||
>
|
||||
<ChevronUp size={14} strokeWidth={2} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-custom-border-200 p-1.5"
|
||||
onClick={() => handleInboxIssueNavigation("next")}
|
||||
>
|
||||
<ChevronDown size={14} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{canMarkAsAccepted && (
|
||||
<div className="flex-shrink-0">
|
||||
<Button
|
||||
variant="neutral-primary"
|
||||
size="sm"
|
||||
prependIcon={<CircleCheck className="w-3 h-3" />}
|
||||
className="text-green-500 border-0.5 border-green-500 bg-green-500/20 focus:bg-green-500/20 focus:text-green-500 hover:bg-green-500/40 bg-opacity-20"
|
||||
onClick={() =>
|
||||
handleActionWithPermission(
|
||||
isProjectAdmin,
|
||||
() => setAcceptIssueModal(true),
|
||||
t("inbox_issue.errors.accept_permission")
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("inbox_issue.actions.accept")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canMarkAsDeclined && (
|
||||
<div className="flex-shrink-0">
|
||||
<Button
|
||||
variant="neutral-primary"
|
||||
size="sm"
|
||||
prependIcon={<CircleX className="w-3 h-3" />}
|
||||
className="text-red-500 border-0.5 border-red-500 bg-red-500/20 focus:bg-red-500/20 focus:text-red-500 hover:bg-red-500/40 bg-opacity-20"
|
||||
onClick={() =>
|
||||
handleActionWithPermission(
|
||||
isProjectAdmin,
|
||||
() => setDeclineIssueModal(true),
|
||||
t("inbox_issue.errors.decline_permission")
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("inbox_issue.actions.decline")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isAcceptedOrDeclined ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="neutral-primary"
|
||||
prependIcon={<Link className="h-2.5 w-2.5" />}
|
||||
size="sm"
|
||||
onClick={() => handleCopyIssueLink(workItemLink)}
|
||||
>
|
||||
{t("inbox_issue.actions.copy")}
|
||||
</Button>
|
||||
<ControlLink href={workItemLink} onClick={() => router.push(workItemLink)} target="_self">
|
||||
<Button variant="neutral-primary" prependIcon={<ExternalLink className="h-2.5 w-2.5" />} size="sm">
|
||||
{t("inbox_issue.actions.open")}
|
||||
</Button>
|
||||
</ControlLink>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{isAllowed && (
|
||||
<CustomMenu verticalEllipsis placement="bottom-start">
|
||||
{canMarkAsAccepted && (
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() =>
|
||||
handleActionWithPermission(
|
||||
isProjectAdmin,
|
||||
handleIssueSnoozeAction,
|
||||
t("inbox_issue.errors.snooze_permission")
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock size={14} strokeWidth={2} />
|
||||
{inboxIssue?.snoozed_till && numberOfDaysLeft && numberOfDaysLeft > 0
|
||||
? t("inbox_issue.actions.unsnooze")
|
||||
: t("inbox_issue.actions.snooze")}
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
{canMarkAsDuplicate && (
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() =>
|
||||
handleActionWithPermission(
|
||||
isProjectAdmin,
|
||||
() => setSelectDuplicateIssue(true),
|
||||
"Only project admins can mark work item as duplicate"
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FileStack size={14} strokeWidth={2} />
|
||||
{t("inbox_issue.actions.mark_as_duplicate")}
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
<CustomMenu.MenuItem onClick={() => handleCopyIssueLink(workItemLink)}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Copy size={14} strokeWidth={2} />
|
||||
{t("inbox_issue.actions.copy")}
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
{canDelete && (
|
||||
<CustomMenu.MenuItem onClick={() => setDeleteIssueModal(true)}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Trash2 size={14} strokeWidth={2} />
|
||||
{t("inbox_issue.actions.delete")}
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
</CustomMenu>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Row>
|
||||
|
||||
<div className="lg:hidden">
|
||||
<InboxIssueActionsMobileHeader
|
||||
inboxIssue={inboxIssue}
|
||||
isSubmitting={isSubmitting}
|
||||
handleCopyIssueLink={() => handleCopyIssueLink(workItemLink)}
|
||||
setAcceptIssueModal={setAcceptIssueModal}
|
||||
setDeclineIssueModal={setDeclineIssueModal}
|
||||
handleIssueSnoozeAction={handleIssueSnoozeAction}
|
||||
setSelectDuplicateIssue={setSelectDuplicateIssue}
|
||||
setDeleteIssueModal={setDeleteIssueModal}
|
||||
canMarkAsAccepted={canMarkAsAccepted}
|
||||
canMarkAsDeclined={canMarkAsDeclined}
|
||||
canMarkAsDuplicate={canMarkAsDuplicate}
|
||||
canDelete={canDelete}
|
||||
isAcceptedOrDeclined={isAcceptedOrDeclined}
|
||||
handleInboxIssueNavigation={handleInboxIssueNavigation}
|
||||
workspaceSlug={workspaceSlug}
|
||||
isMobileSidebar={isMobileSidebar}
|
||||
setIsMobileSidebar={setIsMobileSidebar}
|
||||
isNotificationEmbed={isNotificationEmbed}
|
||||
embedRemoveCurrentNotification={embedRemoveCurrentNotification}
|
||||
isProjectAdmin={isProjectAdmin}
|
||||
handleActionWithPermission={handleActionWithPermission}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,231 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import {
|
||||
CircleCheck,
|
||||
CircleX,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Clock,
|
||||
ExternalLink,
|
||||
FileStack,
|
||||
Link,
|
||||
Trash2,
|
||||
PanelLeft,
|
||||
MoveRight,
|
||||
} from "lucide-react";
|
||||
import type { TNameDescriptionLoader } from "@plane/types";
|
||||
import { Header, CustomMenu, EHeaderVariant } from "@plane/ui";
|
||||
import { cn, findHowManyDaysLeft, generateWorkItemLink } from "@plane/utils";
|
||||
// components
|
||||
import { NameDescriptionUpdateStatus } from "@/components/issues/issue-update-status";
|
||||
// hooks
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
// store types
|
||||
import type { IInboxIssueStore } from "@/store/inbox/inbox-issue.store";
|
||||
// local imports
|
||||
import { InboxIssueStatus } from "../inbox-issue-status";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
inboxIssue: IInboxIssueStore | undefined;
|
||||
isSubmitting: TNameDescriptionLoader;
|
||||
handleInboxIssueNavigation: (direction: "next" | "prev") => void;
|
||||
canMarkAsAccepted: boolean;
|
||||
canMarkAsDeclined: boolean;
|
||||
isAcceptedOrDeclined: boolean | undefined;
|
||||
canMarkAsDuplicate: boolean;
|
||||
canDelete: boolean;
|
||||
setAcceptIssueModal: (value: boolean) => void;
|
||||
setDeclineIssueModal: (value: boolean) => void;
|
||||
setDeleteIssueModal: (value: boolean) => void;
|
||||
handleIssueSnoozeAction: () => Promise<void>;
|
||||
setSelectDuplicateIssue: (value: boolean) => void;
|
||||
handleCopyIssueLink: () => void;
|
||||
isMobileSidebar: boolean;
|
||||
setIsMobileSidebar: (value: boolean) => void;
|
||||
isNotificationEmbed: boolean;
|
||||
embedRemoveCurrentNotification?: () => void;
|
||||
isProjectAdmin: boolean;
|
||||
handleActionWithPermission: (isAdmin: boolean, action: () => void, errorMessage: string) => void;
|
||||
};
|
||||
|
||||
export const InboxIssueActionsMobileHeader: React.FC<Props> = observer((props) => {
|
||||
const {
|
||||
inboxIssue,
|
||||
isSubmitting,
|
||||
handleInboxIssueNavigation,
|
||||
canMarkAsAccepted,
|
||||
canMarkAsDeclined,
|
||||
canDelete,
|
||||
canMarkAsDuplicate,
|
||||
isAcceptedOrDeclined,
|
||||
workspaceSlug,
|
||||
setAcceptIssueModal,
|
||||
setDeclineIssueModal,
|
||||
setDeleteIssueModal,
|
||||
handleIssueSnoozeAction,
|
||||
setSelectDuplicateIssue,
|
||||
handleCopyIssueLink,
|
||||
isMobileSidebar,
|
||||
setIsMobileSidebar,
|
||||
isNotificationEmbed,
|
||||
embedRemoveCurrentNotification,
|
||||
isProjectAdmin,
|
||||
handleActionWithPermission,
|
||||
} = props;
|
||||
const router = useAppRouter();
|
||||
const { getProjectIdentifierById } = useProject();
|
||||
|
||||
const issue = inboxIssue?.issue;
|
||||
const currentInboxIssueId = issue?.id;
|
||||
// days left for snooze
|
||||
const numberOfDaysLeft = findHowManyDaysLeft(inboxIssue?.snoozed_till);
|
||||
|
||||
if (!issue || !inboxIssue) return null;
|
||||
|
||||
const projectIdentifier = getProjectIdentifierById(issue?.project_id);
|
||||
|
||||
const workItemLink = generateWorkItemLink({
|
||||
workspaceSlug: workspaceSlug?.toString(),
|
||||
projectId: issue?.project_id,
|
||||
issueId: currentInboxIssueId,
|
||||
projectIdentifier,
|
||||
sequenceId: issue?.sequence_id,
|
||||
});
|
||||
|
||||
return (
|
||||
<Header variant={EHeaderVariant.SECONDARY} className="justify-start">
|
||||
{isNotificationEmbed && (
|
||||
<button onClick={embedRemoveCurrentNotification}>
|
||||
<MoveRight className="h-4 w-4 text-custom-text-300 hover:text-custom-text-200 mr-2" />
|
||||
</button>
|
||||
)}
|
||||
<PanelLeft
|
||||
onClick={() => setIsMobileSidebar(!isMobileSidebar)}
|
||||
className={cn(
|
||||
"w-4 h-4 flex-shrink-0 mr-2 my-auto",
|
||||
isMobileSidebar ? "text-custom-primary-100" : "text-custom-text-200"
|
||||
)}
|
||||
/>
|
||||
<div className="flex items-center gap-2 w-full bg-custom-background-100 z-[15]">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-custom-border-200 p-1.5"
|
||||
onClick={() => handleInboxIssueNavigation("prev")}
|
||||
>
|
||||
<ChevronUp size={14} strokeWidth={2} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-custom-border-200 p-1.5"
|
||||
onClick={() => handleInboxIssueNavigation("next")}
|
||||
>
|
||||
<ChevronDown size={14} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<InboxIssueStatus inboxIssue={inboxIssue} iconSize={12} />
|
||||
<div className="flex items-center justify-end w-full">
|
||||
<NameDescriptionUpdateStatus isSubmitting={isSubmitting} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-auto">
|
||||
<CustomMenu verticalEllipsis placement="bottom-start">
|
||||
{isAcceptedOrDeclined && (
|
||||
<CustomMenu.MenuItem onClick={handleCopyIssueLink}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link size={14} strokeWidth={2} />
|
||||
Copy work item link
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
{isAcceptedOrDeclined && (
|
||||
<CustomMenu.MenuItem onClick={() => router.push(workItemLink)}>
|
||||
<div className="flex items-center gap-2">
|
||||
<ExternalLink size={14} strokeWidth={2} />
|
||||
Open work item
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
{canMarkAsAccepted && !isAcceptedOrDeclined && (
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() =>
|
||||
handleActionWithPermission(
|
||||
isProjectAdmin,
|
||||
handleIssueSnoozeAction,
|
||||
"Only project admins can snooze/Un-snooze work items"
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock size={14} strokeWidth={2} />
|
||||
{inboxIssue?.snoozed_till && numberOfDaysLeft && numberOfDaysLeft > 0 ? "Un-snooze" : "Snooze"}
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
{canMarkAsDuplicate && !isAcceptedOrDeclined && (
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() =>
|
||||
handleActionWithPermission(
|
||||
isProjectAdmin,
|
||||
() => setSelectDuplicateIssue(true),
|
||||
"Only project admins can mark work items as duplicate"
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FileStack size={14} strokeWidth={2} />
|
||||
Mark as duplicate
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
{canMarkAsAccepted && (
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() =>
|
||||
handleActionWithPermission(
|
||||
isProjectAdmin,
|
||||
() => setAcceptIssueModal(true),
|
||||
"Only project admins can accept work items"
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-green-500">
|
||||
<CircleCheck size={14} strokeWidth={2} />
|
||||
Accept
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
{canMarkAsDeclined && (
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() =>
|
||||
handleActionWithPermission(
|
||||
isProjectAdmin,
|
||||
() => setDeclineIssueModal(true),
|
||||
"Only project admins can deny work items"
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-red-500">
|
||||
<CircleX size={14} strokeWidth={2} />
|
||||
Decline
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
{canDelete && !isAcceptedOrDeclined && (
|
||||
<CustomMenu.MenuItem onClick={() => setDeleteIssueModal(true)}>
|
||||
<div className="flex items-center gap-2 text-red-500">
|
||||
<Trash2 size={14} strokeWidth={2} />
|
||||
Delete
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
</CustomMenu>
|
||||
</div>
|
||||
</div>
|
||||
</Header>
|
||||
);
|
||||
});
|
||||
1
apps/web/core/components/inbox/content/index.ts
Normal file
1
apps/web/core/components/inbox/content/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./root";
|
||||
204
apps/web/core/components/inbox/content/issue-properties.tsx
Normal file
204
apps/web/core/components/inbox/content/issue-properties.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { CalendarCheck2, CopyPlus, Signal, Tag, Users } from "lucide-react";
|
||||
import { DoubleCircleIcon } from "@plane/propel/icons";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import type { TInboxDuplicateIssueDetails, TIssue } from "@plane/types";
|
||||
import { ControlLink } from "@plane/ui";
|
||||
import { getDate, renderFormattedPayloadDate, generateWorkItemLink } from "@plane/utils";
|
||||
// components
|
||||
import { DateDropdown } from "@/components/dropdowns/date";
|
||||
import { MemberDropdown } from "@/components/dropdowns/member/dropdown";
|
||||
import { PriorityDropdown } from "@/components/dropdowns/priority";
|
||||
import { StateDropdown } from "@/components/dropdowns/state/dropdown";
|
||||
import type { TIssueOperations } from "@/components/issues/issue-detail";
|
||||
import { IssueLabel } from "@/components/issues/issue-detail/label";
|
||||
// hooks
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issue: Partial<TIssue>;
|
||||
issueOperations: TIssueOperations;
|
||||
isEditable: boolean;
|
||||
duplicateIssueDetails: TInboxDuplicateIssueDetails | undefined;
|
||||
};
|
||||
|
||||
export const InboxIssueContentProperties: React.FC<Props> = observer((props) => {
|
||||
const { workspaceSlug, projectId, issue, issueOperations, isEditable, duplicateIssueDetails } = props;
|
||||
|
||||
const router = useAppRouter();
|
||||
// store hooks
|
||||
const { currentProjectDetails } = useProject();
|
||||
|
||||
const minDate = issue.start_date ? getDate(issue.start_date) : null;
|
||||
minDate?.setDate(minDate.getDate());
|
||||
if (!issue || !issue?.id) return <></>;
|
||||
|
||||
const duplicateWorkItemLink = generateWorkItemLink({
|
||||
workspaceSlug: workspaceSlug?.toString(),
|
||||
projectId,
|
||||
issueId: duplicateIssueDetails?.id,
|
||||
projectIdentifier: currentProjectDetails?.identifier,
|
||||
sequenceId: duplicateIssueDetails?.sequence_id,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col divide-y-2 divide-custom-border-200">
|
||||
<div className="w-full overflow-y-auto">
|
||||
<h5 className="text-sm font-medium my-4">Properties</h5>
|
||||
<div className={`divide-y-2 divide-custom-border-200 ${!isEditable ? "opacity-60" : ""}`}>
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* State */}
|
||||
<div className="flex h-8 items-center gap-2">
|
||||
<div className="flex w-2/5 flex-shrink-0 items-center gap-1 text-sm text-custom-text-300">
|
||||
<DoubleCircleIcon className="h-4 w-4 flex-shrink-0" />
|
||||
<span>State</span>
|
||||
</div>
|
||||
{issue?.state_id && (
|
||||
<StateDropdown
|
||||
value={issue?.state_id}
|
||||
onChange={(val) =>
|
||||
issue?.id && issueOperations.update(workspaceSlug, projectId, issue?.id, { state_id: val })
|
||||
}
|
||||
projectId={projectId?.toString() ?? ""}
|
||||
disabled={!isEditable}
|
||||
buttonVariant="transparent-with-text"
|
||||
className="w-3/5 flex-grow group"
|
||||
buttonContainerClassName="w-full text-left"
|
||||
buttonClassName="text-sm"
|
||||
dropdownArrow
|
||||
dropdownArrowClassName="h-3.5 w-3.5 hidden group-hover:inline"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{/* Assignee */}
|
||||
<div className="flex h-8 items-center gap-2">
|
||||
<div className="flex w-2/5 flex-shrink-0 items-center gap-1 text-sm text-custom-text-300">
|
||||
<Users className="h-4 w-4 flex-shrink-0" />
|
||||
<span>Assignees</span>
|
||||
</div>
|
||||
<MemberDropdown
|
||||
value={issue?.assignee_ids ?? []}
|
||||
onChange={(val) =>
|
||||
issue?.id && issueOperations.update(workspaceSlug, projectId, issue?.id, { assignee_ids: val })
|
||||
}
|
||||
disabled={!isEditable}
|
||||
projectId={projectId?.toString() ?? ""}
|
||||
placeholder="Add assignees"
|
||||
multiple
|
||||
buttonVariant={
|
||||
(issue?.assignee_ids || [])?.length > 0 ? "transparent-without-text" : "transparent-with-text"
|
||||
}
|
||||
className="w-3/5 flex-grow group"
|
||||
buttonContainerClassName="w-full text-left"
|
||||
buttonClassName={`text-sm justify-between ${
|
||||
(issue?.assignee_ids || [])?.length > 0 ? "" : "text-custom-text-400"
|
||||
}`}
|
||||
hideIcon={issue.assignee_ids?.length === 0}
|
||||
dropdownArrow
|
||||
dropdownArrowClassName="h-3.5 w-3.5 hidden group-hover:inline"
|
||||
/>
|
||||
</div>
|
||||
{/* Priority */}
|
||||
<div className="flex h-8 items-center gap-2">
|
||||
<div className="flex w-2/5 flex-shrink-0 items-center gap-1 text-sm text-custom-text-300">
|
||||
<Signal className="h-4 w-4 flex-shrink-0" />
|
||||
<span>Priority</span>
|
||||
</div>
|
||||
<PriorityDropdown
|
||||
value={issue?.priority}
|
||||
onChange={(val) =>
|
||||
issue?.id && issueOperations.update(workspaceSlug, projectId, issue?.id, { priority: val })
|
||||
}
|
||||
disabled={!isEditable}
|
||||
buttonVariant="border-with-text"
|
||||
className="w-3/5 flex-grow rounded px-2 hover:bg-custom-background-80"
|
||||
buttonContainerClassName="w-full text-left"
|
||||
buttonClassName="w-min h-auto whitespace-nowrap"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`divide-y-2 divide-custom-border-200 mt-3 ${!isEditable ? "opacity-60" : ""}`}>
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Due Date */}
|
||||
<div className="flex h-8 items-center gap-2">
|
||||
<div className="flex w-2/5 flex-shrink-0 items-center gap-1 text-sm text-custom-text-300">
|
||||
<CalendarCheck2 className="h-4 w-4 flex-shrink-0" />
|
||||
<span>Due date</span>
|
||||
</div>
|
||||
<DateDropdown
|
||||
placeholder="Add due date"
|
||||
value={issue.target_date || null}
|
||||
onChange={(val) =>
|
||||
issue?.id &&
|
||||
issueOperations.update(workspaceSlug, projectId, issue?.id, {
|
||||
target_date: val ? renderFormattedPayloadDate(val) : null,
|
||||
})
|
||||
}
|
||||
minDate={minDate ?? undefined}
|
||||
disabled={!isEditable}
|
||||
buttonVariant="transparent-with-text"
|
||||
className="group w-3/5 flex-grow"
|
||||
buttonContainerClassName="w-full text-left"
|
||||
buttonClassName={`text-sm ${issue?.target_date ? "" : "text-custom-text-400"}`}
|
||||
hideIcon
|
||||
clearIconClassName="h-3 w-3 hidden group-hover:inline"
|
||||
/>
|
||||
</div>
|
||||
{/* Labels */}
|
||||
<div className="flex min-h-8 items-center gap-2">
|
||||
<div className="flex w-2/5 flex-shrink-0 items-center gap-1 text-sm text-custom-text-300">
|
||||
<Tag className="h-4 w-4 flex-shrink-0" />
|
||||
<span>Labels</span>
|
||||
</div>
|
||||
<div className="w-3/5 flex-grow min-h-8 h-full pt-1">
|
||||
{issue?.id && (
|
||||
<IssueLabel
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issue?.id}
|
||||
disabled={!isEditable}
|
||||
isInboxIssue
|
||||
onLabelUpdate={(val: string[]) =>
|
||||
issue?.id && issueOperations.update(workspaceSlug, projectId, issue?.id, { label_ids: val })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* duplicate to*/}
|
||||
{duplicateIssueDetails && (
|
||||
<div className="flex min-h-8 gap-2">
|
||||
<div className="flex w-2/5 flex-shrink-0 gap-1 pt-2 text-sm text-custom-text-300">
|
||||
<CopyPlus className="h-4 w-4 flex-shrink-0" />
|
||||
<span>Duplicate of</span>
|
||||
</div>
|
||||
|
||||
<ControlLink
|
||||
href={duplicateWorkItemLink}
|
||||
onClick={() => {
|
||||
router.push(duplicateWorkItemLink);
|
||||
}}
|
||||
target="_self"
|
||||
>
|
||||
<Tooltip tooltipContent={`${duplicateIssueDetails?.name}`}>
|
||||
<span className="flex items-center gap-1 cursor-pointer text-xs rounded px-1.5 py-1 pb-0.5 bg-custom-background-80 text-custom-text-200">
|
||||
{`${currentProjectDetails?.identifier}-${duplicateIssueDetails?.sequence_id}`}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</ControlLink>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
267
apps/web/core/components/inbox/content/issue-root.tsx
Normal file
267
apps/web/core/components/inbox/content/issue-root.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
"use client";
|
||||
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { WORK_ITEM_TRACKER_EVENTS } from "@plane/constants";
|
||||
import type { EditorRefApi } from "@plane/editor";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { TIssue, TNameDescriptionLoader } from "@plane/types";
|
||||
import { EInboxIssueSource } from "@plane/types";
|
||||
import { Loader } from "@plane/ui";
|
||||
import { getTextContent } from "@plane/utils";
|
||||
// components
|
||||
import { DescriptionVersionsRoot } from "@/components/core/description-versions";
|
||||
import { IssueAttachmentRoot } from "@/components/issues/attachment";
|
||||
import { IssueDescriptionInput } from "@/components/issues/description-input";
|
||||
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";
|
||||
// helpers
|
||||
// hooks
|
||||
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
import { useMember } from "@/hooks/store/use-member";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useProjectInbox } from "@/hooks/store/use-project-inbox";
|
||||
import { useUser } from "@/hooks/store/user";
|
||||
import useReloadConfirmations from "@/hooks/use-reload-confirmation";
|
||||
// store types
|
||||
import { DeDupeIssuePopoverRoot } from "@/plane-web/components/de-dupe/duplicate-popover";
|
||||
import { useDebouncedDuplicateIssues } from "@/plane-web/hooks/use-debounced-duplicate-issues";
|
||||
// services
|
||||
import { IntakeWorkItemVersionService } from "@/services/inbox";
|
||||
// stores
|
||||
import type { IInboxIssueStore } from "@/store/inbox/inbox-issue.store";
|
||||
// local imports
|
||||
import { InboxIssueContentProperties } from "./issue-properties";
|
||||
// services init
|
||||
const intakeWorkItemVersionService = new IntakeWorkItemVersionService();
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
inboxIssue: IInboxIssueStore;
|
||||
isEditable: boolean;
|
||||
isSubmitting: TNameDescriptionLoader;
|
||||
setIsSubmitting: Dispatch<SetStateAction<TNameDescriptionLoader>>;
|
||||
};
|
||||
|
||||
export const InboxIssueMainContent: React.FC<Props> = observer((props) => {
|
||||
const { workspaceSlug, projectId, inboxIssue, isEditable, isSubmitting, setIsSubmitting } = props;
|
||||
// refs
|
||||
const editorRef = useRef<EditorRefApi>(null);
|
||||
// store hooks
|
||||
const { data: currentUser } = useUser();
|
||||
const { getUserDetails } = useMember();
|
||||
const { loader } = useProjectInbox();
|
||||
const { getProjectById } = useProject();
|
||||
const { removeIssue, archiveIssue } = useIssueDetail();
|
||||
// reload confirmation
|
||||
const { setShowAlert } = useReloadConfirmations(isSubmitting === "submitting");
|
||||
|
||||
useEffect(() => {
|
||||
if (isSubmitting === "submitted") {
|
||||
setShowAlert(false);
|
||||
setTimeout(async () => {
|
||||
setIsSubmitting("saved");
|
||||
}, 3000);
|
||||
} else if (isSubmitting === "submitting") {
|
||||
setShowAlert(true);
|
||||
}
|
||||
}, [isSubmitting, setShowAlert, setIsSubmitting]);
|
||||
|
||||
// derived values
|
||||
const issue = inboxIssue.issue;
|
||||
const projectDetails = issue?.project_id ? getProjectById(issue?.project_id) : undefined;
|
||||
|
||||
// debounced duplicate issues swr
|
||||
const { duplicateIssues } = useDebouncedDuplicateIssues(
|
||||
workspaceSlug,
|
||||
projectDetails?.workspace.toString(),
|
||||
projectId,
|
||||
{
|
||||
name: issue?.name,
|
||||
description_html: getTextContent(issue?.description_html),
|
||||
issueId: issue?.id,
|
||||
}
|
||||
);
|
||||
|
||||
if (!issue) return <></>;
|
||||
|
||||
const issueOperations: TIssueOperations = useMemo(
|
||||
() => ({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars, arrow-body-style
|
||||
fetch: async (_workspaceSlug: string, _projectId: string, _issueId: string) => {
|
||||
return;
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars, arrow-body-style
|
||||
remove: async (_workspaceSlug: string, _projectId: string, _issueId: string) => {
|
||||
try {
|
||||
await removeIssue(workspaceSlug, projectId, _issueId);
|
||||
setToast({
|
||||
title: "Success!",
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
message: "Work item deleted successfully",
|
||||
});
|
||||
captureSuccess({
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.delete,
|
||||
payload: { id: _issueId },
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error in deleting work item:", error);
|
||||
setToast({
|
||||
title: "Error!",
|
||||
type: TOAST_TYPE.ERROR,
|
||||
message: "Work item delete failed",
|
||||
});
|
||||
captureError({
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.delete,
|
||||
payload: { id: _issueId },
|
||||
error: error as Error,
|
||||
});
|
||||
}
|
||||
},
|
||||
update: async (_workspaceSlug: string, _projectId: string, _issueId: string, data: Partial<TIssue>) => {
|
||||
try {
|
||||
await inboxIssue.updateIssue(data);
|
||||
captureSuccess({
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.update,
|
||||
payload: { id: _issueId },
|
||||
});
|
||||
} catch (error) {
|
||||
setToast({
|
||||
title: "Work item update failed",
|
||||
type: TOAST_TYPE.ERROR,
|
||||
message: "Work item update failed",
|
||||
});
|
||||
captureError({
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.update,
|
||||
payload: { id: _issueId },
|
||||
error: error as Error,
|
||||
});
|
||||
}
|
||||
},
|
||||
archive: async (workspaceSlug: string, projectId: string, issueId: string) => {
|
||||
try {
|
||||
await archiveIssue(workspaceSlug, projectId, issueId);
|
||||
captureSuccess({
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.archive,
|
||||
payload: { id: issueId },
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error in archiving issue:", error);
|
||||
captureError({
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.archive,
|
||||
payload: { id: issueId },
|
||||
error: error as Error,
|
||||
});
|
||||
}
|
||||
},
|
||||
}),
|
||||
[inboxIssue]
|
||||
);
|
||||
|
||||
if (!issue?.project_id || !issue?.id) return <></>;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="rounded-lg space-y-4">
|
||||
{duplicateIssues.length > 0 && (
|
||||
<DeDupeIssuePopoverRoot
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={issue.project_id}
|
||||
rootIssueId={issue.id}
|
||||
issues={duplicateIssues}
|
||||
issueOperations={issueOperations}
|
||||
isIntakeIssue
|
||||
/>
|
||||
)}
|
||||
<IssueTitleInput
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={issue.project_id}
|
||||
issueId={issue.id}
|
||||
isSubmitting={isSubmitting}
|
||||
setIsSubmitting={(value) => setIsSubmitting(value)}
|
||||
issueOperations={issueOperations}
|
||||
disabled={!isEditable}
|
||||
value={issue.name}
|
||||
containerClassName="-ml-3"
|
||||
/>
|
||||
|
||||
{loader === "issue-loading" ? (
|
||||
<Loader className="min-h-[6rem] rounded-md border border-custom-border-200">
|
||||
<Loader.Item width="100%" height="140px" />
|
||||
</Loader>
|
||||
) : (
|
||||
<IssueDescriptionInput
|
||||
editorRef={editorRef}
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={issue.project_id}
|
||||
issueId={issue.id}
|
||||
swrIssueDescription={issue.description_html ?? "<p></p>"}
|
||||
initialValue={issue.description_html ?? "<p></p>"}
|
||||
disabled={!isEditable}
|
||||
issueOperations={issueOperations}
|
||||
setIsSubmitting={(value) => setIsSubmitting(value)}
|
||||
containerClassName="-ml-3 border-none"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
{currentUser && (
|
||||
<IssueReaction
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issue.id}
|
||||
currentUser={currentUser}
|
||||
/>
|
||||
)}
|
||||
{isEditable && (
|
||||
<DescriptionVersionsRoot
|
||||
className="flex-shrink-0"
|
||||
entityInformation={{
|
||||
createdAt: issue.created_at ? new Date(issue.created_at) : new Date(),
|
||||
createdByDisplayName:
|
||||
inboxIssue.source === EInboxIssueSource.FORMS
|
||||
? "Intake Form user"
|
||||
: (getUserDetails(issue.created_by ?? "")?.display_name ?? ""),
|
||||
id: issue.id,
|
||||
isRestoreDisabled: !isEditable,
|
||||
}}
|
||||
fetchHandlers={{
|
||||
listDescriptionVersions: (issueId) =>
|
||||
intakeWorkItemVersionService.listDescriptionVersions(workspaceSlug, projectId, issueId),
|
||||
retrieveDescriptionVersion: (issueId, versionId) =>
|
||||
intakeWorkItemVersionService.retrieveDescriptionVersion(workspaceSlug, projectId, issueId, versionId),
|
||||
}}
|
||||
handleRestore={(descriptionHTML) => editorRef.current?.setEditorValue(descriptionHTML, true)}
|
||||
projectId={projectId}
|
||||
workspaceSlug={workspaceSlug}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<IssueAttachmentRoot
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issue.id}
|
||||
disabled={!isEditable}
|
||||
/>
|
||||
|
||||
<InboxIssueContentProperties
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issue={issue}
|
||||
issueOperations={issueOperations}
|
||||
isEditable={isEditable}
|
||||
duplicateIssueDetails={inboxIssue?.duplicate_issue_detail}
|
||||
/>
|
||||
|
||||
<IssueActivity workspaceSlug={workspaceSlug} projectId={projectId} issueId={issue.id} isIntakeIssue />
|
||||
</>
|
||||
);
|
||||
});
|
||||
110
apps/web/core/components/inbox/content/root.tsx
Normal file
110
apps/web/core/components/inbox/content/root.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import type { FC } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import useSWR from "swr";
|
||||
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import type { TNameDescriptionLoader } from "@plane/types";
|
||||
// components
|
||||
import { ContentWrapper } from "@plane/ui";
|
||||
// hooks
|
||||
import { useProjectInbox } from "@/hooks/store/use-project-inbox";
|
||||
import { useUser, useUserPermissions } from "@/hooks/store/user";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
// local imports
|
||||
import { InboxIssueActionsHeader } from "./inbox-issue-header";
|
||||
import { InboxIssueMainContent } from "./issue-root";
|
||||
|
||||
type TInboxContentRoot = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
inboxIssueId: string;
|
||||
isMobileSidebar: boolean;
|
||||
setIsMobileSidebar: (value: boolean) => void;
|
||||
isNotificationEmbed?: boolean;
|
||||
embedRemoveCurrentNotification?: () => void;
|
||||
};
|
||||
|
||||
export const InboxContentRoot: FC<TInboxContentRoot> = observer((props) => {
|
||||
const {
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
inboxIssueId,
|
||||
isMobileSidebar,
|
||||
setIsMobileSidebar,
|
||||
isNotificationEmbed = false,
|
||||
embedRemoveCurrentNotification,
|
||||
} = props;
|
||||
/// router
|
||||
const router = useAppRouter();
|
||||
// states
|
||||
const [isSubmitting, setIsSubmitting] = useState<TNameDescriptionLoader>("saved");
|
||||
// hooks
|
||||
const { data: currentUser } = useUser();
|
||||
const { currentTab, fetchInboxIssueById, getIssueInboxByIssueId, getIsIssueAvailable } = useProjectInbox();
|
||||
const inboxIssue = getIssueInboxByIssueId(inboxIssueId);
|
||||
const { allowPermissions, getProjectRoleByWorkspaceSlugAndProjectId } = useUserPermissions();
|
||||
|
||||
// derived values
|
||||
const isIssueAvailable = getIsIssueAvailable(inboxIssueId?.toString() || "");
|
||||
|
||||
useEffect(() => {
|
||||
if (!isIssueAvailable && inboxIssueId && !isNotificationEmbed) {
|
||||
router.replace(`/${workspaceSlug}/projects/${projectId}/intake?currentTab=${currentTab}`);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isIssueAvailable, isNotificationEmbed]);
|
||||
|
||||
useSWR(
|
||||
workspaceSlug && projectId && inboxIssueId
|
||||
? `PROJECT_INBOX_ISSUE_DETAIL_${workspaceSlug}_${projectId}_${inboxIssueId}`
|
||||
: null,
|
||||
workspaceSlug && projectId && inboxIssueId
|
||||
? () => fetchInboxIssueById(workspaceSlug, projectId, inboxIssueId)
|
||||
: null,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
revalidateIfStale: false,
|
||||
}
|
||||
);
|
||||
|
||||
const isEditable =
|
||||
allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT, workspaceSlug, projectId) ||
|
||||
inboxIssue?.issue.created_by === currentUser?.id;
|
||||
|
||||
const isGuest = getProjectRoleByWorkspaceSlugAndProjectId(workspaceSlug, projectId) === EUserPermissions.GUEST;
|
||||
const isOwner = inboxIssue?.issue.created_by === currentUser?.id;
|
||||
const readOnly = !isOwner && isGuest;
|
||||
|
||||
if (!inboxIssue) return <></>;
|
||||
|
||||
const isIssueDisabled = [-1, 1, 2].includes(inboxIssue.status);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-full h-full overflow-hidden relative flex flex-col">
|
||||
<div className="flex-shrink-0 min-h-[52px] z-[11]">
|
||||
<InboxIssueActionsHeader
|
||||
setIsMobileSidebar={setIsMobileSidebar}
|
||||
isMobileSidebar={isMobileSidebar}
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
inboxIssue={inboxIssue}
|
||||
isSubmitting={isSubmitting}
|
||||
isNotificationEmbed={isNotificationEmbed || false}
|
||||
embedRemoveCurrentNotification={embedRemoveCurrentNotification}
|
||||
/>
|
||||
</div>
|
||||
<ContentWrapper className="space-y-5 divide-y-2 divide-custom-border-200">
|
||||
<InboxIssueMainContent
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
inboxIssue={inboxIssue}
|
||||
isEditable={isEditable && !isIssueDisabled && !readOnly}
|
||||
isSubmitting={isSubmitting}
|
||||
setIsSubmitting={setIsSubmitting}
|
||||
/>
|
||||
</ContentWrapper>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user