[WEB-4451] chore: work item header quick action enhancements (#7414)
* chore: work item header quick action enhancements * chore: code refactor
This commit is contained in:
parent
df762afaef
commit
ac22df3f88
6 changed files with 486 additions and 194 deletions
|
|
@ -1,26 +1,22 @@
|
|||
"use client";
|
||||
|
||||
import React, { FC, useState } from "react";
|
||||
import React, { FC, useRef } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { ArchiveIcon, ArchiveRestoreIcon, LinkIcon, Trash2 } from "lucide-react";
|
||||
import {
|
||||
ARCHIVABLE_STATE_GROUPS,
|
||||
EUserPermissions,
|
||||
EUserPermissionsLevel,
|
||||
WORK_ITEM_TRACKER_EVENTS,
|
||||
} from "@plane/constants";
|
||||
import { LinkIcon } from "lucide-react";
|
||||
import { WORK_ITEM_TRACKER_EVENTS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { EIssuesStoreType } from "@plane/types";
|
||||
import { TOAST_TYPE, Tooltip, setToast } from "@plane/ui";
|
||||
import { cn, generateWorkItemLink, copyTextToClipboard } from "@plane/utils";
|
||||
import { generateWorkItemLink, copyTextToClipboard } from "@plane/utils";
|
||||
// components
|
||||
import { ArchiveIssueModal, DeleteIssueModal, IssueSubscription } from "@/components/issues";
|
||||
import { IssueSubscription } from "@/components/issues";
|
||||
// helpers
|
||||
// hooks
|
||||
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
|
||||
import { useIssueDetail, useIssues, useProject, useProjectState, useUser, useUserPermissions } from "@/hooks/store";
|
||||
import { useIssueDetail, useIssues, useProject, useUser } from "@/hooks/store";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
import { WorkItemDetailQuickActions } from "../issue-layouts/quick-action-dropdowns";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
|
|
@ -31,19 +27,16 @@ type Props = {
|
|||
export const IssueDetailQuickActions: FC<Props> = observer((props) => {
|
||||
const { workspaceSlug, projectId, issueId } = props;
|
||||
const { t } = useTranslation();
|
||||
// states
|
||||
const [deleteIssueModal, setDeleteIssueModal] = useState(false);
|
||||
const [archiveIssueModal, setArchiveIssueModal] = useState(false);
|
||||
const [isRestoring, setIsRestoring] = useState(false);
|
||||
|
||||
// ref
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// router
|
||||
const router = useAppRouter();
|
||||
|
||||
// hooks
|
||||
const { data: currentUser } = useUser();
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
const { isMobile } = usePlatformOS();
|
||||
const { getStateById } = useProjectState();
|
||||
const { getProjectIdentifierById } = useProject();
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
|
|
@ -61,7 +54,6 @@ export const IssueDetailQuickActions: FC<Props> = observer((props) => {
|
|||
const issue = getIssueById(issueId);
|
||||
if (!issue) return <></>;
|
||||
|
||||
const stateDetails = getStateById(issue.state_id);
|
||||
const projectIdentifier = getProjectIdentifierById(projectId);
|
||||
|
||||
const workItemLink = generateWorkItemLink({
|
||||
|
|
@ -133,8 +125,6 @@ export const IssueDetailQuickActions: FC<Props> = observer((props) => {
|
|||
const handleRestore = async () => {
|
||||
if (!workspaceSlug || !projectId || !issueId) return;
|
||||
|
||||
setIsRestoring(true);
|
||||
|
||||
await restoreIssue(workspaceSlug.toString(), projectId.toString(), issueId.toString())
|
||||
.then(() => {
|
||||
setToast({
|
||||
|
|
@ -150,40 +140,11 @@ export const IssueDetailQuickActions: FC<Props> = observer((props) => {
|
|||
title: t("toast.error"),
|
||||
message: t("issue.restore.failed.message"),
|
||||
});
|
||||
})
|
||||
.finally(() => setIsRestoring(false));
|
||||
});
|
||||
};
|
||||
|
||||
// auth
|
||||
const isEditable = allowPermissions(
|
||||
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
|
||||
EUserPermissionsLevel.PROJECT,
|
||||
workspaceSlug,
|
||||
projectId
|
||||
);
|
||||
const canRestoreIssue = allowPermissions(
|
||||
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
|
||||
EUserPermissionsLevel.PROJECT,
|
||||
workspaceSlug,
|
||||
projectId
|
||||
);
|
||||
const isArchivingAllowed = !issue?.archived_at && isEditable;
|
||||
const isInArchivableGroup = !!stateDetails && ARCHIVABLE_STATE_GROUPS.includes(stateDetails?.group);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DeleteIssueModal
|
||||
handleClose={() => setDeleteIssueModal(false)}
|
||||
isOpen={deleteIssueModal}
|
||||
data={issue}
|
||||
onSubmit={handleDeleteIssue}
|
||||
/>
|
||||
<ArchiveIssueModal
|
||||
isOpen={archiveIssueModal}
|
||||
handleClose={() => setArchiveIssueModal(false)}
|
||||
data={issue}
|
||||
onSubmit={handleArchiveIssue}
|
||||
/>
|
||||
<div className="flex items-center justify-end flex-shrink-0">
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
{currentUser && !issue?.archived_at && (
|
||||
|
|
@ -199,64 +160,13 @@ export const IssueDetailQuickActions: FC<Props> = observer((props) => {
|
|||
<LinkIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{issue?.archived_at && canRestoreIssue ? (
|
||||
<>
|
||||
<Tooltip isMobile={isMobile} tooltipContent="Restore">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"grid h-5 w-5 place-items-center rounded focus:outline-none focus:ring-2 focus:ring-custom-primary",
|
||||
{
|
||||
"hover:text-custom-text-200": isInArchivableGroup,
|
||||
"cursor-not-allowed text-custom-text-400": !isInArchivableGroup,
|
||||
}
|
||||
)}
|
||||
onClick={handleRestore}
|
||||
disabled={isRestoring}
|
||||
>
|
||||
<ArchiveRestoreIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{isArchivingAllowed && (
|
||||
<Tooltip
|
||||
isMobile={isMobile}
|
||||
tooltipContent={isInArchivableGroup ? t("common.actions.archive") : t("issue.archive.description")}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"grid h-5 w-5 place-items-center rounded focus:outline-none focus:ring-2 focus:ring-custom-primary",
|
||||
{
|
||||
"hover:text-custom-text-200": isInArchivableGroup,
|
||||
"cursor-not-allowed text-custom-text-400": !isInArchivableGroup,
|
||||
}
|
||||
)}
|
||||
onClick={() => {
|
||||
if (!isInArchivableGroup) return;
|
||||
setArchiveIssueModal(true);
|
||||
}}
|
||||
>
|
||||
<ArchiveIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{isEditable && (
|
||||
<Tooltip tooltipContent={t("common.actions.delete")} isMobile={isMobile}>
|
||||
<button
|
||||
type="button"
|
||||
className="grid h-5 w-5 place-items-center rounded hover:text-custom-text-200 focus:outline-none focus:ring-2 focus:ring-custom-primary"
|
||||
onClick={() => setDeleteIssueModal(true)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<WorkItemDetailQuickActions
|
||||
parentRef={parentRef}
|
||||
issue={issue}
|
||||
handleDelete={handleDeleteIssue}
|
||||
handleArchive={handleArchiveIssue}
|
||||
handleRestore={handleRestore}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -274,6 +274,21 @@ export const useProjectIssueMenuItems = (props: MenuItemFactoryProps): TContextM
|
|||
);
|
||||
};
|
||||
|
||||
export const useWorkItemDetailMenuItems = (props: MenuItemFactoryProps): TContextMenuItem[] => {
|
||||
const factory = useMenuItemFactory(props);
|
||||
|
||||
return useMemo(
|
||||
() => [
|
||||
factory.createCopyMenuItem(),
|
||||
factory.createOpenInNewTabMenuItem(),
|
||||
factory.createArchiveMenuItem(),
|
||||
factory.createRestoreMenuItem(),
|
||||
factory.createDeleteMenuItem(),
|
||||
],
|
||||
[factory]
|
||||
);
|
||||
};
|
||||
|
||||
export const useAllIssueMenuItems = (props: MenuItemFactoryProps): TContextMenuItem[] => {
|
||||
const factory = useMenuItemFactory(props);
|
||||
|
||||
|
|
|
|||
|
|
@ -6,3 +6,4 @@ export * from "./module-issue";
|
|||
export * from "./project-issue";
|
||||
export * from "./helper";
|
||||
export * from "../../workspace-draft/quick-action";
|
||||
export * from "./issue-detail";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,351 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import omit from "lodash/omit";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams, usePathname } from "next/navigation";
|
||||
// plane imports
|
||||
import {
|
||||
ARCHIVABLE_STATE_GROUPS,
|
||||
EUserPermissions,
|
||||
EUserPermissionsLevel,
|
||||
WORK_ITEM_TRACKER_ELEMENTS,
|
||||
} from "@plane/constants";
|
||||
import { EIssuesStoreType, TIssue } from "@plane/types";
|
||||
import { ContextMenu, CustomMenu, TContextMenuItem } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
// components
|
||||
import { ArchiveIssueModal, CreateUpdateIssueModal, DeleteIssueModal } from "@/components/issues";
|
||||
// hooks
|
||||
import { captureClick } from "@/helpers/event-tracker.helper";
|
||||
import { useIssues, useProject, useProjectState, useUserPermissions } from "@/hooks/store";
|
||||
// plane-web components
|
||||
import { DuplicateWorkItemModal } from "@/plane-web/components/issues/issue-layouts/quick-action-dropdowns";
|
||||
import { IQuickActionProps } from "../list/list-view-types";
|
||||
// helper
|
||||
import { MenuItemFactoryProps, useWorkItemDetailMenuItems } from "./helper";
|
||||
|
||||
type TWorkItemDetailQuickActionProps = IQuickActionProps & {
|
||||
toggleEditIssueModal?: (value: boolean) => void;
|
||||
toggleDeleteIssueModal?: (value: boolean) => void;
|
||||
toggleDuplicateIssueModal?: (value: boolean) => void;
|
||||
toggleArchiveIssueModal?: (value: boolean) => void;
|
||||
isPeekMode?: boolean;
|
||||
};
|
||||
|
||||
export const WorkItemDetailQuickActions: React.FC<TWorkItemDetailQuickActionProps> = observer((props) => {
|
||||
const {
|
||||
issue,
|
||||
handleDelete,
|
||||
handleUpdate,
|
||||
handleArchive,
|
||||
handleRestore,
|
||||
customActionButton,
|
||||
portalElement,
|
||||
readOnly = false,
|
||||
placements = "bottom-end",
|
||||
parentRef,
|
||||
toggleEditIssueModal,
|
||||
toggleDeleteIssueModal,
|
||||
toggleDuplicateIssueModal,
|
||||
toggleArchiveIssueModal,
|
||||
isPeekMode = false,
|
||||
} = props;
|
||||
// router
|
||||
const { workspaceSlug } = useParams();
|
||||
const pathname = usePathname();
|
||||
// states
|
||||
const [createUpdateIssueModal, setCreateUpdateIssueModal] = useState(false);
|
||||
const [issueToEdit, setIssueToEdit] = useState<TIssue | undefined>(undefined);
|
||||
const [deleteIssueModal, setDeleteIssueModal] = useState(false);
|
||||
const [archiveIssueModal, setArchiveIssueModal] = useState(false);
|
||||
const [duplicateWorkItemModal, setDuplicateWorkItemModal] = useState(false);
|
||||
// store hooks
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
const { issuesFilter } = useIssues(EIssuesStoreType.PROJECT);
|
||||
const { getStateById } = useProjectState();
|
||||
const { getProjectIdentifierById } = useProject();
|
||||
// derived values
|
||||
const activeLayout = `${issuesFilter.issueFilters?.displayFilters?.layout} layout`;
|
||||
const stateDetails = getStateById(issue.state_id);
|
||||
const projectIdentifier = getProjectIdentifierById(issue?.project_id);
|
||||
// auth
|
||||
const isEditingAllowed =
|
||||
allowPermissions(
|
||||
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
|
||||
EUserPermissionsLevel.PROJECT,
|
||||
workspaceSlug?.toString(),
|
||||
issue.project_id ?? undefined
|
||||
) && !readOnly;
|
||||
|
||||
const isArchivingAllowed = !issue.archived_at && isEditingAllowed;
|
||||
const isInArchivableGroup = !!stateDetails && ARCHIVABLE_STATE_GROUPS.includes(stateDetails?.group);
|
||||
const isRestoringAllowed = !!issue.archived_at && isEditingAllowed;
|
||||
|
||||
const isDeletingAllowed = isEditingAllowed;
|
||||
|
||||
const isDraftIssue = pathname?.includes("draft-issues") || false;
|
||||
|
||||
const duplicateIssuePayload = omit(
|
||||
{
|
||||
...issue,
|
||||
name: `${issue.name} (copy)`,
|
||||
is_draft: isDraftIssue ? false : issue.is_draft,
|
||||
sourceIssueId: issue.id,
|
||||
},
|
||||
["id"]
|
||||
);
|
||||
|
||||
const customEditAction = () => {
|
||||
setCreateUpdateIssueModal(true);
|
||||
if (toggleEditIssueModal) toggleEditIssueModal(true);
|
||||
};
|
||||
|
||||
const customDeleteAction = async () => {
|
||||
setDeleteIssueModal(true);
|
||||
if (toggleDeleteIssueModal) toggleDeleteIssueModal(true);
|
||||
};
|
||||
|
||||
const customDuplicateAction = async () => {
|
||||
setDuplicateWorkItemModal(true);
|
||||
if (toggleDuplicateIssueModal) {
|
||||
toggleDuplicateIssueModal(true);
|
||||
}
|
||||
};
|
||||
|
||||
const customArchiveAction = async () => {
|
||||
setArchiveIssueModal(true);
|
||||
if (toggleArchiveIssueModal) toggleArchiveIssueModal(true);
|
||||
};
|
||||
|
||||
const customRestoreAction = async () => {
|
||||
if (handleRestore) await handleRestore();
|
||||
};
|
||||
|
||||
// Menu items and modals using helper
|
||||
const menuItemProps: MenuItemFactoryProps = {
|
||||
issue,
|
||||
workspaceSlug: workspaceSlug?.toString(),
|
||||
projectIdentifier,
|
||||
activeLayout,
|
||||
isEditingAllowed,
|
||||
isArchivingAllowed,
|
||||
isRestoringAllowed,
|
||||
isDeletingAllowed,
|
||||
isInArchivableGroup,
|
||||
isDraftIssue,
|
||||
setIssueToEdit,
|
||||
setCreateUpdateIssueModal: customEditAction,
|
||||
setDeleteIssueModal: customDeleteAction,
|
||||
setArchiveIssueModal: customArchiveAction,
|
||||
setDuplicateWorkItemModal: customDuplicateAction,
|
||||
handleDelete: customDeleteAction,
|
||||
handleUpdate,
|
||||
handleArchive: customArchiveAction,
|
||||
handleRestore: customRestoreAction,
|
||||
storeType: EIssuesStoreType.PROJECT,
|
||||
};
|
||||
|
||||
// const MENU_ITEMS = useWorkItemDetailMenuItems(menuItemProps);
|
||||
const baseMenuItems = useWorkItemDetailMenuItems(menuItemProps);
|
||||
|
||||
const MENU_ITEMS = baseMenuItems
|
||||
.map((item) => {
|
||||
// Customize edit action for work item
|
||||
if (item.key === "edit") {
|
||||
return {
|
||||
...item,
|
||||
shouldRender: isEditingAllowed && !isPeekMode,
|
||||
};
|
||||
}
|
||||
// Customize delete action for work item
|
||||
if (item.key === "delete") {
|
||||
return {
|
||||
...item,
|
||||
};
|
||||
}
|
||||
// Hide copy link in peek mode
|
||||
if (item.key === "copy-link") {
|
||||
return {
|
||||
...item,
|
||||
shouldRender: !isPeekMode,
|
||||
};
|
||||
}
|
||||
return item;
|
||||
})
|
||||
.filter((item) => item.shouldRender !== false);
|
||||
|
||||
const CONTEXT_MENU_ITEMS: TContextMenuItem[] = MENU_ITEMS.map((item) => ({
|
||||
...item,
|
||||
onClick: () => {
|
||||
captureClick({ elementName: WORK_ITEM_TRACKER_ELEMENTS.QUICK_ACTIONS.PROJECT_VIEW });
|
||||
item.action();
|
||||
},
|
||||
}));
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Modals */}
|
||||
<ArchiveIssueModal
|
||||
data={issue}
|
||||
isOpen={archiveIssueModal}
|
||||
handleClose={() => {
|
||||
setArchiveIssueModal(false);
|
||||
if (toggleArchiveIssueModal) toggleArchiveIssueModal(false);
|
||||
}}
|
||||
onSubmit={handleArchive}
|
||||
/>
|
||||
<DeleteIssueModal
|
||||
data={issue}
|
||||
isOpen={deleteIssueModal}
|
||||
handleClose={() => {
|
||||
setDeleteIssueModal(false);
|
||||
if (toggleDeleteIssueModal) toggleDeleteIssueModal(false);
|
||||
}}
|
||||
onSubmit={handleDelete}
|
||||
/>
|
||||
<CreateUpdateIssueModal
|
||||
isOpen={createUpdateIssueModal}
|
||||
onClose={() => {
|
||||
setCreateUpdateIssueModal(false);
|
||||
setIssueToEdit(undefined);
|
||||
if (toggleEditIssueModal) toggleEditIssueModal(false);
|
||||
}}
|
||||
data={issueToEdit ?? duplicateIssuePayload}
|
||||
onSubmit={async (data) => {
|
||||
if (issueToEdit && handleUpdate) await handleUpdate(data);
|
||||
}}
|
||||
storeType={EIssuesStoreType.PROJECT}
|
||||
isDraft={isDraftIssue}
|
||||
/>
|
||||
{issue.project_id && workspaceSlug && (
|
||||
<DuplicateWorkItemModal
|
||||
workItemId={issue.id}
|
||||
isOpen={duplicateWorkItemModal}
|
||||
onClose={() => {
|
||||
setDuplicateWorkItemModal(false);
|
||||
if (toggleDuplicateIssueModal) toggleDuplicateIssueModal(false);
|
||||
}}
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
projectId={issue.project_id}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ContextMenu parentRef={parentRef} items={CONTEXT_MENU_ITEMS} />
|
||||
<CustomMenu
|
||||
ellipsis
|
||||
placement={placements}
|
||||
customButton={customActionButton}
|
||||
portalElement={portalElement}
|
||||
menuItemsClassName="z-[14]"
|
||||
maxHeight="lg"
|
||||
closeOnSelect
|
||||
>
|
||||
{MENU_ITEMS.map((item) => {
|
||||
if (item.shouldRender === false) return null;
|
||||
|
||||
// Render submenu if nestedMenuItems exist
|
||||
if (item.nestedMenuItems && item.nestedMenuItems.length > 0) {
|
||||
return (
|
||||
<CustomMenu.SubMenu
|
||||
key={item.key}
|
||||
trigger={
|
||||
<div className="flex items-center gap-2">
|
||||
{item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />}
|
||||
<h5>{item.title}</h5>
|
||||
{item.description && (
|
||||
<p
|
||||
className={cn("text-custom-text-300 whitespace-pre-line", {
|
||||
"text-custom-text-400": item.disabled,
|
||||
})}
|
||||
>
|
||||
{item.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
disabled={item.disabled}
|
||||
className={cn(
|
||||
"flex items-center gap-2",
|
||||
{
|
||||
"text-custom-text-400": item.disabled,
|
||||
},
|
||||
item.className
|
||||
)}
|
||||
>
|
||||
{item.nestedMenuItems.map((nestedItem) => (
|
||||
<CustomMenu.MenuItem
|
||||
key={nestedItem.key}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
captureClick({ elementName: WORK_ITEM_TRACKER_ELEMENTS.QUICK_ACTIONS.PROJECT_VIEW });
|
||||
nestedItem.action();
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center gap-2",
|
||||
{
|
||||
"text-custom-text-400": nestedItem.disabled,
|
||||
},
|
||||
nestedItem.className
|
||||
)}
|
||||
disabled={nestedItem.disabled}
|
||||
>
|
||||
{nestedItem.icon && <nestedItem.icon className={cn("h-3 w-3", nestedItem.iconClassName)} />}
|
||||
<div>
|
||||
<h5>{nestedItem.title}</h5>
|
||||
{nestedItem.description && (
|
||||
<p
|
||||
className={cn("text-custom-text-300 whitespace-pre-line", {
|
||||
"text-custom-text-400": nestedItem.disabled,
|
||||
})}
|
||||
>
|
||||
{nestedItem.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
))}
|
||||
</CustomMenu.SubMenu>
|
||||
);
|
||||
}
|
||||
|
||||
// Render regular menu item
|
||||
return (
|
||||
<CustomMenu.MenuItem
|
||||
key={item.key}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
captureClick({ elementName: WORK_ITEM_TRACKER_ELEMENTS.QUICK_ACTIONS.PROJECT_VIEW });
|
||||
item.action();
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center gap-2",
|
||||
{
|
||||
"text-custom-text-400": item.disabled,
|
||||
},
|
||||
item.className
|
||||
)}
|
||||
disabled={item.disabled}
|
||||
>
|
||||
{item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />}
|
||||
<div>
|
||||
<h5>{item.title}</h5>
|
||||
{item.description && (
|
||||
<p
|
||||
className={cn("text-custom-text-300 whitespace-pre-line", {
|
||||
"text-custom-text-400": item.disabled,
|
||||
})}
|
||||
>
|
||||
{item.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
);
|
||||
})}
|
||||
</CustomMenu>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
@ -1,15 +1,14 @@
|
|||
"use client";
|
||||
|
||||
import { FC } from "react";
|
||||
import { FC, useRef } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { ArchiveRestoreIcon, Link2, MoveDiagonal, MoveRight, Trash2 } from "lucide-react";
|
||||
import { Link2, MoveDiagonal, MoveRight } from "lucide-react";
|
||||
// plane imports
|
||||
import { ARCHIVABLE_STATE_GROUPS } from "@plane/constants";
|
||||
import { WORK_ITEM_TRACKER_EVENTS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { TNameDescriptionLoader } from "@plane/types";
|
||||
import { EIssuesStoreType, TNameDescriptionLoader } from "@plane/types";
|
||||
import {
|
||||
ArchiveIcon,
|
||||
CenterPanelIcon,
|
||||
CustomSelect,
|
||||
FullScreenPanelIcon,
|
||||
|
|
@ -18,12 +17,15 @@ import {
|
|||
Tooltip,
|
||||
setToast,
|
||||
} from "@plane/ui";
|
||||
import { copyUrlToClipboard, cn, generateWorkItemLink } from "@plane/utils";
|
||||
import { copyUrlToClipboard, generateWorkItemLink } from "@plane/utils";
|
||||
// components
|
||||
import { IssueSubscription, NameDescriptionUpdateStatus } from "@/components/issues";
|
||||
import { IssueSubscription, NameDescriptionUpdateStatus, WorkItemDetailQuickActions } from "@/components/issues";
|
||||
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
|
||||
// helpers
|
||||
// store hooks
|
||||
import { useIssueDetail, useProject, useProjectState, useUser } from "@/hooks/store";
|
||||
|
||||
import { useIssueDetail, useIssues, useProject, useUser } from "@/hooks/store";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
// hooks
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
export type TPeekModes = "side-peek" | "modal" | "full-screen";
|
||||
|
|
@ -56,9 +58,11 @@ export type PeekOverviewHeaderProps = {
|
|||
isArchived: boolean;
|
||||
disabled: boolean;
|
||||
embedIssue: boolean;
|
||||
toggleDeleteIssueModal: (issueId: string | null) => void;
|
||||
toggleArchiveIssueModal: (issueId: string | null) => void;
|
||||
handleRestoreIssue: () => void;
|
||||
toggleDeleteIssueModal: (value: boolean) => void;
|
||||
toggleArchiveIssueModal: (value: boolean) => void;
|
||||
toggleDuplicateIssueModal: (value: boolean) => void;
|
||||
toggleEditIssueModal: (value: boolean) => void;
|
||||
handleRestoreIssue: () => Promise<void>;
|
||||
isSubmitting: TNameDescriptionLoader;
|
||||
};
|
||||
|
||||
|
|
@ -75,23 +79,33 @@ export const IssuePeekOverviewHeader: FC<PeekOverviewHeaderProps> = observer((pr
|
|||
removeRoutePeekId,
|
||||
toggleDeleteIssueModal,
|
||||
toggleArchiveIssueModal,
|
||||
toggleDuplicateIssueModal,
|
||||
toggleEditIssueModal,
|
||||
handleRestoreIssue,
|
||||
isSubmitting,
|
||||
} = props;
|
||||
// ref
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
// router
|
||||
const router = useAppRouter();
|
||||
const { t } = useTranslation();
|
||||
// store hooks
|
||||
const { data: currentUser } = useUser();
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
setPeekIssue,
|
||||
removeIssue,
|
||||
archiveIssue,
|
||||
} = useIssueDetail();
|
||||
const { getStateById } = useProjectState();
|
||||
const { isMobile } = usePlatformOS();
|
||||
const { getProjectIdentifierById } = useProject();
|
||||
// derived values
|
||||
const issueDetails = getIssueById(issueId);
|
||||
const stateDetails = issueDetails ? getStateById(issueDetails?.state_id) : undefined;
|
||||
const currentMode = PEEK_OPTIONS.find((m) => m.key === peekMode);
|
||||
const projectIdentifier = getProjectIdentifierById(issueDetails?.project_id);
|
||||
const {
|
||||
issues: { removeIssue: removeArchivedIssue },
|
||||
} = useIssues(EIssuesStoreType.ARCHIVED);
|
||||
|
||||
const workItemLink = generateWorkItemLink({
|
||||
workspaceSlug,
|
||||
|
|
@ -113,10 +127,49 @@ export const IssuePeekOverviewHeader: FC<PeekOverviewHeaderProps> = observer((pr
|
|||
});
|
||||
});
|
||||
};
|
||||
// auth
|
||||
const isArchivingAllowed = !isArchived && !disabled;
|
||||
const isInArchivableGroup = !!stateDetails && ARCHIVABLE_STATE_GROUPS.includes(stateDetails?.group);
|
||||
const isRestoringAllowed = isArchived && !disabled;
|
||||
|
||||
const handleDeleteIssue = async () => {
|
||||
try {
|
||||
const deleteIssue = issueDetails?.archived_at ? removeArchivedIssue : removeIssue;
|
||||
|
||||
return deleteIssue(workspaceSlug, projectId, issueId).then(() => {
|
||||
setPeekIssue(undefined);
|
||||
captureSuccess({
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.delete,
|
||||
payload: { id: issueId },
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
setToast({
|
||||
title: t("toast.error"),
|
||||
type: TOAST_TYPE.ERROR,
|
||||
message: t("entity.delete.failed", { entity: t("issue.label", { count: 1 }) }),
|
||||
});
|
||||
captureError({
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.delete,
|
||||
payload: { id: issueId },
|
||||
error: error as Error,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleArchiveIssue = async () => {
|
||||
try {
|
||||
await archiveIssue(workspaceSlug, projectId, issueId).then(() => {
|
||||
router.push(`/${workspaceSlug}/projects/${projectId}/archives/issues/${issueDetails?.id}`);
|
||||
});
|
||||
captureSuccess({
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.archive,
|
||||
payload: { id: issueId },
|
||||
});
|
||||
} catch (error) {
|
||||
captureError({
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.archive,
|
||||
payload: { id: issueId },
|
||||
error: error as Error,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -178,39 +231,20 @@ export const IssuePeekOverviewHeader: FC<PeekOverviewHeaderProps> = observer((pr
|
|||
<Link2 className="h-4 w-4 -rotate-45 text-custom-text-300 hover:text-custom-text-200" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{isArchivingAllowed && (
|
||||
<Tooltip
|
||||
isMobile={isMobile}
|
||||
tooltipContent={isInArchivableGroup ? t("common.actions.archive") : t("issue.archive.description")}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={cn("text-custom-text-300", {
|
||||
"hover:text-custom-text-200": isInArchivableGroup,
|
||||
"cursor-not-allowed text-custom-text-400": !isInArchivableGroup,
|
||||
})}
|
||||
onClick={() => {
|
||||
if (!isInArchivableGroup) return;
|
||||
toggleArchiveIssueModal(issueId);
|
||||
}}
|
||||
>
|
||||
<ArchiveIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{isRestoringAllowed && (
|
||||
<Tooltip tooltipContent={t("common.actions.restore")} isMobile={isMobile}>
|
||||
<button type="button" onClick={handleRestoreIssue}>
|
||||
<ArchiveRestoreIcon className="h-4 w-4 text-custom-text-300 hover:text-custom-text-200" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!disabled && (
|
||||
<Tooltip tooltipContent={t("common.actions.delete")} isMobile={isMobile}>
|
||||
<button type="button" onClick={() => toggleDeleteIssueModal(issueId)}>
|
||||
<Trash2 className="h-4 w-4 text-custom-text-300 hover:text-custom-text-200" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{issueDetails && (
|
||||
<WorkItemDetailQuickActions
|
||||
parentRef={parentRef}
|
||||
issue={issueDetails}
|
||||
handleDelete={handleDeleteIssue}
|
||||
handleArchive={handleArchiveIssue}
|
||||
handleRestore={handleRestoreIssue}
|
||||
readOnly={disabled}
|
||||
toggleDeleteIssueModal={toggleDeleteIssueModal}
|
||||
toggleArchiveIssueModal={toggleArchiveIssueModal}
|
||||
toggleDuplicateIssueModal={toggleDuplicateIssueModal}
|
||||
toggleEditIssueModal={toggleEditIssueModal}
|
||||
isPeekMode
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,17 +1,16 @@
|
|||
import { FC, useRef, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { createPortal } from "react-dom";
|
||||
// types
|
||||
import { EIssueServiceType, TNameDescriptionLoader } from "@plane/types";
|
||||
// components
|
||||
import { cn } from "@plane/utils";
|
||||
import {
|
||||
DeleteIssueModal,
|
||||
IssuePeekOverviewHeader,
|
||||
TPeekModes,
|
||||
PeekOverviewIssueDetails,
|
||||
PeekOverviewProperties,
|
||||
TIssueOperations,
|
||||
ArchiveIssueModal,
|
||||
IssuePeekOverviewLoader,
|
||||
IssuePeekOverviewError,
|
||||
IssueDetailWidgets,
|
||||
|
|
@ -23,7 +22,6 @@ import useKeypress from "@/hooks/use-keypress";
|
|||
import usePeekOverviewOutsideClickDetector from "@/hooks/use-peek-overview-outside-click";
|
||||
// store hooks
|
||||
import { IssueActivity } from "../issue-detail/issue-activity";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
interface IIssueView {
|
||||
workspaceSlug: string;
|
||||
|
|
@ -54,16 +52,16 @@ export const IssueView: FC<IIssueView> = observer((props) => {
|
|||
// states
|
||||
const [peekMode, setPeekMode] = useState<TPeekModes>("side-peek");
|
||||
const [isSubmitting, setIsSubmitting] = useState<TNameDescriptionLoader>("saved");
|
||||
const [isDeleteIssueModalOpen, setIsDeleteIssueModalOpen] = useState(false);
|
||||
const [isArchiveIssueModalOpen, setIsArchiveIssueModalOpen] = useState(false);
|
||||
const [isDuplicateIssueModalOpen, setIsDuplicateIssueModalOpen] = useState(false);
|
||||
const [isEditIssueModalOpen, setIsEditIssueModalOpen] = useState(false);
|
||||
// ref
|
||||
const issuePeekOverviewRef = useRef<HTMLDivElement>(null);
|
||||
// store hooks
|
||||
const {
|
||||
setPeekIssue,
|
||||
isAnyModalOpen,
|
||||
isDeleteIssueModalOpen,
|
||||
isArchiveIssueModalOpen,
|
||||
toggleDeleteIssueModal,
|
||||
toggleArchiveIssueModal,
|
||||
issue: { getIssueById, getIsLocalDBIssueDescription },
|
||||
} = useIssueDetail();
|
||||
const { isAnyModalOpen: isAnyEpicModalOpen } = useIssueDetail(EIssueServiceType.EPICS);
|
||||
|
|
@ -76,11 +74,19 @@ export const IssueView: FC<IIssueView> = observer((props) => {
|
|||
|
||||
const isLocalDBIssueDescription = getIsLocalDBIssueDescription(issueId);
|
||||
|
||||
const toggleDeleteIssueModal = (value: boolean) => setIsDeleteIssueModalOpen(value);
|
||||
const toggleArchiveIssueModal = (value: boolean) => setIsArchiveIssueModalOpen(value);
|
||||
const toggleDuplicateIssueModal = (value: boolean) => setIsDuplicateIssueModalOpen(value);
|
||||
const toggleEditIssueModal = (value: boolean) => setIsEditIssueModalOpen(value);
|
||||
|
||||
const isAnyLocalModalOpen =
|
||||
isDeleteIssueModalOpen || isArchiveIssueModalOpen || isDuplicateIssueModalOpen || isEditIssueModalOpen;
|
||||
|
||||
usePeekOverviewOutsideClickDetector(
|
||||
issuePeekOverviewRef,
|
||||
() => {
|
||||
if (!embedIssue) {
|
||||
if (!isAnyModalOpen && !isAnyEpicModalOpen) {
|
||||
if (!isAnyModalOpen && !isAnyEpicModalOpen && !isAnyLocalModalOpen) {
|
||||
removeRoutePeekId();
|
||||
}
|
||||
}
|
||||
|
|
@ -149,6 +155,8 @@ export const IssueView: FC<IIssueView> = observer((props) => {
|
|||
removeRoutePeekId={removeRoutePeekId}
|
||||
toggleDeleteIssueModal={toggleDeleteIssueModal}
|
||||
toggleArchiveIssueModal={toggleArchiveIssueModal}
|
||||
toggleDuplicateIssueModal={toggleDuplicateIssueModal}
|
||||
toggleEditIssueModal={toggleEditIssueModal}
|
||||
handleRestoreIssue={handleRestore}
|
||||
isArchived={is_archived}
|
||||
issueId={issueId}
|
||||
|
|
@ -254,32 +262,5 @@ export const IssueView: FC<IIssueView> = observer((props) => {
|
|||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{issue && !is_archived && (
|
||||
<ArchiveIssueModal
|
||||
isOpen={isArchiveIssueModalOpen === issueId}
|
||||
handleClose={() => toggleArchiveIssueModal(null)}
|
||||
data={issue}
|
||||
onSubmit={async () => {
|
||||
if (issueOperations.archive) await issueOperations.archive(workspaceSlug, projectId, issueId);
|
||||
removeRoutePeekId();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{issue && isDeleteIssueModalOpen === issue.id && (
|
||||
<DeleteIssueModal
|
||||
isOpen={!!isDeleteIssueModalOpen}
|
||||
handleClose={() => {
|
||||
toggleDeleteIssueModal(null);
|
||||
}}
|
||||
data={issue}
|
||||
onSubmit={async () => issueOperations.remove(workspaceSlug, projectId, issueId)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{shouldUsePortal && portalContainer ? createPortal(content, portalContainer) : content}
|
||||
</>
|
||||
);
|
||||
return <>{shouldUsePortal && portalContainer ? createPortal(content, portalContainer) : content}</>;
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue