forked from github/plane
[WEB-1110] dev: custom context menu for issues, cycles, modules, views, pages and projects (#4267)
* dev: context menu * chore: handle menu position on close * chore: project quick actions * chore: add more options to the project context menu * chore: cycle item context menu * refactor: context menu folder structure * chore: module custom context menu * chore: view custom context menu * chore: issues custom context menu * chore: reorder options * chore: issues custom context menu * chore: render the context menu in a portal
This commit is contained in:
parent
cb6ecc86cc
commit
d2717a221c
2
packages/ui/src/dropdowns/context-menu/index.ts
Normal file
2
packages/ui/src/dropdowns/context-menu/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from "./item";
|
||||
export * from "./root";
|
54
packages/ui/src/dropdowns/context-menu/item.tsx
Normal file
54
packages/ui/src/dropdowns/context-menu/item.tsx
Normal file
@ -0,0 +1,54 @@
|
||||
import React from "react";
|
||||
// helpers
|
||||
import { cn } from "../../../helpers";
|
||||
// types
|
||||
import { TContextMenuItem } from "./root";
|
||||
|
||||
type ContextMenuItemProps = {
|
||||
handleActiveItem: () => void;
|
||||
handleClose: () => void;
|
||||
isActive: boolean;
|
||||
item: TContextMenuItem;
|
||||
};
|
||||
|
||||
export const ContextMenuItem: React.FC<ContextMenuItemProps> = (props) => {
|
||||
const { handleActiveItem, handleClose, isActive, item } = props;
|
||||
|
||||
if (item.shouldRender === false) return null;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2 px-1 py-1.5 text-left text-custom-text-200 rounded text-xs select-none",
|
||||
{
|
||||
"bg-custom-background-90": isActive,
|
||||
"text-custom-text-400": item.disabled,
|
||||
},
|
||||
item.className
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
item.action();
|
||||
if (item.closeOnClick !== false) handleClose();
|
||||
}}
|
||||
onMouseEnter={handleActiveItem}
|
||||
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>
|
||||
</button>
|
||||
);
|
||||
};
|
157
packages/ui/src/dropdowns/context-menu/root.tsx
Normal file
157
packages/ui/src/dropdowns/context-menu/root.tsx
Normal file
@ -0,0 +1,157 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
// components
|
||||
import { ContextMenuItem } from "./item";
|
||||
// helpers
|
||||
import { cn } from "../../../helpers";
|
||||
// hooks
|
||||
import useOutsideClickDetector from "../../hooks/use-outside-click-detector";
|
||||
|
||||
export type TContextMenuItem = {
|
||||
key: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
icon?: React.FC<any>;
|
||||
action: () => void;
|
||||
shouldRender?: boolean;
|
||||
closeOnClick?: boolean;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
iconClassName?: string;
|
||||
};
|
||||
|
||||
type ContextMenuProps = {
|
||||
parentRef: React.RefObject<HTMLElement>;
|
||||
items: TContextMenuItem[];
|
||||
};
|
||||
|
||||
const ContextMenuWithoutPortal: React.FC<ContextMenuProps> = (props) => {
|
||||
const { parentRef, items } = props;
|
||||
// states
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [position, setPosition] = useState({
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
const [activeItemIndex, setActiveItemIndex] = useState<number>(0);
|
||||
// refs
|
||||
const contextMenuRef = useRef<HTMLDivElement>(null);
|
||||
// derived values
|
||||
const renderedItems = items.filter((item) => item.shouldRender !== false);
|
||||
|
||||
const handleClose = () => {
|
||||
setIsOpen(false);
|
||||
setActiveItemIndex(0);
|
||||
};
|
||||
|
||||
// calculate position of context menu
|
||||
useEffect(() => {
|
||||
const parentElement = parentRef.current;
|
||||
const contextMenu = contextMenuRef.current;
|
||||
if (!parentElement || !contextMenu) return;
|
||||
|
||||
const handleContextMenu = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const contextMenuWidth = contextMenu.clientWidth;
|
||||
const contextMenuHeight = contextMenu.clientHeight;
|
||||
|
||||
const clickX = e?.pageX || 0;
|
||||
const clickY = e?.pageY || 0;
|
||||
|
||||
// check if there's enough space at the bottom, otherwise show at the top
|
||||
let top = clickY;
|
||||
if (clickY + contextMenuHeight > window.innerHeight) top = clickY - contextMenuHeight;
|
||||
|
||||
// check if there's enough space on the right, otherwise show on the left
|
||||
let left = clickX;
|
||||
if (clickX + contextMenuWidth > window.innerWidth) left = clickX - contextMenuWidth;
|
||||
|
||||
setPosition({ x: left, y: top });
|
||||
setIsOpen(true);
|
||||
};
|
||||
|
||||
const hideContextMenu = (e: KeyboardEvent) => {
|
||||
if (isOpen && e.key === "Escape") handleClose();
|
||||
};
|
||||
|
||||
parentElement.addEventListener("contextmenu", handleContextMenu);
|
||||
window.addEventListener("keydown", hideContextMenu);
|
||||
|
||||
return () => {
|
||||
parentElement.removeEventListener("contextmenu", handleContextMenu);
|
||||
window.removeEventListener("keydown", hideContextMenu);
|
||||
};
|
||||
}, [contextMenuRef, isOpen, parentRef, setIsOpen, setPosition]);
|
||||
|
||||
// handle keyboard navigation
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (!isOpen) return;
|
||||
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setActiveItemIndex((prev) => (prev + 1) % renderedItems.length);
|
||||
}
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setActiveItemIndex((prev) => (prev - 1 + renderedItems.length) % renderedItems.length);
|
||||
}
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const item = renderedItems[activeItemIndex];
|
||||
if (!item.disabled) {
|
||||
renderedItems[activeItemIndex].action();
|
||||
if (item.closeOnClick !== false) handleClose();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [activeItemIndex, isOpen, renderedItems, setIsOpen]);
|
||||
|
||||
// close on clicking outside
|
||||
useOutsideClickDetector(contextMenuRef, handleClose);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"fixed h-screen w-screen top-0 left-0 cursor-default z-20 opacity-0 pointer-events-none transition-opacity",
|
||||
{
|
||||
"opacity-100 pointer-events-auto": isOpen,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div
|
||||
ref={contextMenuRef}
|
||||
className="fixed border-[0.5px] border-custom-border-300 bg-custom-background-100 shadow-custom-shadow-rg rounded-md px-2 py-2.5 max-h-72 min-w-[12rem] overflow-y-scroll vertical-scrollbar scrollbar-sm"
|
||||
style={{
|
||||
top: position.y,
|
||||
left: position.x,
|
||||
}}
|
||||
>
|
||||
{renderedItems.map((item, index) => (
|
||||
<ContextMenuItem
|
||||
key={item.key}
|
||||
handleActiveItem={() => setActiveItemIndex(index)}
|
||||
handleClose={handleClose}
|
||||
isActive={index === activeItemIndex}
|
||||
item={item}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ContextMenu: React.FC<ContextMenuProps> = (props) => {
|
||||
let contextMenu = <ContextMenuWithoutPortal {...props} />;
|
||||
const portal = document.querySelector("#context-menu-portal");
|
||||
if (portal) contextMenu = ReactDOM.createPortal(contextMenu, portal);
|
||||
return contextMenu;
|
||||
};
|
@ -1,3 +1,4 @@
|
||||
export * from "./context-menu";
|
||||
export * from "./custom-menu";
|
||||
export * from "./custom-select";
|
||||
export * from "./custom-search-select";
|
||||
|
@ -6,6 +6,7 @@ class MyDocument extends Document {
|
||||
<Html>
|
||||
<Head />
|
||||
<body className="w-100 bg-custom-background-100 antialiased">
|
||||
<div id="context-menu-portal" />
|
||||
<Main />
|
||||
<NextScript />
|
||||
</body>
|
||||
|
@ -11,6 +11,7 @@ interface IListItemProps {
|
||||
appendTitleElement?: JSX.Element;
|
||||
actionableItems?: JSX.Element;
|
||||
isMobile?: boolean;
|
||||
parentRef: React.RefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
export const ListItem: FC<IListItemProps> = (props) => {
|
||||
@ -22,9 +23,11 @@ export const ListItem: FC<IListItemProps> = (props) => {
|
||||
itemLink,
|
||||
onItemClick,
|
||||
isMobile = false,
|
||||
parentRef,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div ref={parentRef} className="relative">
|
||||
<Link href={itemLink} onClick={onItemClick}>
|
||||
<div className="group h-24 sm:h-[52px] flex w-full flex-col items-center justify-between gap-3 sm:gap-5 px-6 py-4 sm:py-0 text-sm border-b border-custom-border-200 bg-custom-background-100 hover:bg-custom-background-90 sm:flex-row">
|
||||
<div className="relative flex w-full items-center justify-between gap-3 overflow-hidden">
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { useRef } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
@ -20,6 +21,8 @@ type Props = {
|
||||
|
||||
export const UpcomingCycleListItem: React.FC<Props> = observer((props) => {
|
||||
const { cycleId } = props;
|
||||
// refs
|
||||
const parentRef = useRef(null);
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
@ -90,6 +93,7 @@ export const UpcomingCycleListItem: React.FC<Props> = observer((props) => {
|
||||
|
||||
return (
|
||||
<Link
|
||||
ref={parentRef}
|
||||
href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycleId}`}
|
||||
className="py-5 px-2 flex items-center justify-between gap-2 hover:bg-custom-background-90"
|
||||
>
|
||||
@ -123,6 +127,7 @@ export const UpcomingCycleListItem: React.FC<Props> = observer((props) => {
|
||||
|
||||
{workspaceSlug && projectId && (
|
||||
<CycleQuickActions
|
||||
parentRef={parentRef}
|
||||
cycleId={cycleId}
|
||||
projectId={projectId.toString()}
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { FC, MouseEvent } from "react";
|
||||
import { FC, MouseEvent, useRef } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
@ -28,6 +28,8 @@ export interface ICyclesBoardCard {
|
||||
|
||||
export const CyclesBoardCard: FC<ICyclesBoardCard> = observer((props) => {
|
||||
const { cycleId, workspaceSlug, projectId } = props;
|
||||
// refs
|
||||
const parentRef = useRef(null);
|
||||
// router
|
||||
const router = useRouter();
|
||||
// store
|
||||
@ -150,7 +152,7 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = observer((props) => {
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<Link href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycleDetails.id}`}>
|
||||
<Link ref={parentRef} href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycleDetails.id}`}>
|
||||
<div className="flex h-44 w-full flex-col justify-between rounded border border-custom-border-100 bg-custom-background-100 p-4 text-sm hover:shadow-md">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-3 truncate">
|
||||
@ -246,7 +248,12 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = observer((props) => {
|
||||
/>
|
||||
)}
|
||||
|
||||
<CycleQuickActions cycleId={cycleId} projectId={projectId} workspaceSlug={workspaceSlug} />
|
||||
<CycleQuickActions
|
||||
parentRef={parentRef}
|
||||
cycleId={cycleId}
|
||||
projectId={projectId}
|
||||
workspaceSlug={workspaceSlug}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -23,11 +23,11 @@ type Props = {
|
||||
projectId: string;
|
||||
cycleId: string;
|
||||
cycleDetails: ICycle;
|
||||
isArchived: boolean;
|
||||
parentRef: React.RefObject<HTMLDivElement>;
|
||||
};
|
||||
|
||||
export const CycleListItemAction: FC<Props> = observer((props) => {
|
||||
const { workspaceSlug, projectId, cycleId, cycleDetails, isArchived } = props;
|
||||
const { workspaceSlug, projectId, cycleId, cycleDetails, parentRef } = props;
|
||||
// hooks
|
||||
const { isMobile } = usePlatformOS();
|
||||
// store hooks
|
||||
@ -103,6 +103,7 @@ export const CycleListItemAction: FC<Props> = observer((props) => {
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="text-xs text-custom-text-300 flex-shrink-0">
|
||||
@ -140,7 +141,7 @@ export const CycleListItemAction: FC<Props> = observer((props) => {
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
{isEditingAllowed && !isArchived && (
|
||||
{isEditingAllowed && !cycleDetails.archived_at && (
|
||||
<FavoriteStar
|
||||
onClick={(e) => {
|
||||
if (cycleDetails.is_favorite) handleRemoveFromFavorites(e);
|
||||
@ -149,12 +150,7 @@ export const CycleListItemAction: FC<Props> = observer((props) => {
|
||||
selected={!!cycleDetails.is_favorite}
|
||||
/>
|
||||
)}
|
||||
<CycleQuickActions
|
||||
cycleId={cycleId}
|
||||
projectId={projectId}
|
||||
workspaceSlug={workspaceSlug}
|
||||
isArchived={isArchived}
|
||||
/>
|
||||
<CycleQuickActions parentRef={parentRef} cycleId={cycleId} projectId={projectId} workspaceSlug={workspaceSlug} />
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { FC, MouseEvent } from "react";
|
||||
import { FC, MouseEvent, useRef } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useRouter } from "next/router";
|
||||
// icons
|
||||
@ -22,11 +22,12 @@ type TCyclesListItem = {
|
||||
handleRemoveFromFavorites?: () => void;
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
isArchived?: boolean;
|
||||
};
|
||||
|
||||
export const CyclesListItem: FC<TCyclesListItem> = observer((props) => {
|
||||
const { cycleId, workspaceSlug, projectId, isArchived = false } = props;
|
||||
const { cycleId, workspaceSlug, projectId } = props;
|
||||
// refs
|
||||
const parentRef = useRef(null);
|
||||
// router
|
||||
const router = useRouter();
|
||||
// hooks
|
||||
@ -80,9 +81,7 @@ export const CyclesListItem: FC<TCyclesListItem> = observer((props) => {
|
||||
title={cycleDetails?.name ?? ""}
|
||||
itemLink={`/${workspaceSlug}/projects/${projectId}/cycles/${cycleDetails.id}`}
|
||||
onItemClick={(e) => {
|
||||
if (isArchived) {
|
||||
openCycleOverview(e);
|
||||
}
|
||||
if (cycleDetails.archived_at) openCycleOverview(e);
|
||||
}}
|
||||
prependTitleElement={
|
||||
<CircularProgressIndicator size={30} percentage={progress} strokeWidth={3}>
|
||||
@ -113,10 +112,11 @@ export const CyclesListItem: FC<TCyclesListItem> = observer((props) => {
|
||||
projectId={projectId}
|
||||
cycleId={cycleId}
|
||||
cycleDetails={cycleDetails}
|
||||
isArchived={isArchived}
|
||||
parentRef={parentRef}
|
||||
/>
|
||||
}
|
||||
isMobile={isMobile}
|
||||
parentRef={parentRef}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
@ -5,22 +5,15 @@ type Props = {
|
||||
cycleIds: string[];
|
||||
projectId: string;
|
||||
workspaceSlug: string;
|
||||
isArchived?: boolean;
|
||||
};
|
||||
|
||||
export const CyclesListMap: React.FC<Props> = (props) => {
|
||||
const { cycleIds, projectId, workspaceSlug, isArchived } = props;
|
||||
const { cycleIds, projectId, workspaceSlug } = props;
|
||||
|
||||
return (
|
||||
<>
|
||||
{cycleIds.map((cycleId) => (
|
||||
<CyclesListItem
|
||||
key={cycleId}
|
||||
cycleId={cycleId}
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
isArchived={isArchived}
|
||||
/>
|
||||
<CyclesListItem key={cycleId} cycleId={cycleId} workspaceSlug={workspaceSlug} projectId={projectId} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
|
@ -27,7 +27,6 @@ export const CyclesList: FC<ICyclesList> = observer((props) => {
|
||||
cycleIds={cycleIds}
|
||||
projectId={projectId}
|
||||
workspaceSlug={workspaceSlug}
|
||||
isArchived={isArchived}
|
||||
/>
|
||||
{completedCycleIds.length !== 0 && (
|
||||
<Disclosure as="div" className="py-8 pl-3 space-y-4">
|
||||
@ -44,12 +43,7 @@ export const CyclesList: FC<ICyclesList> = observer((props) => {
|
||||
)}
|
||||
</Disclosure.Button>
|
||||
<Disclosure.Panel>
|
||||
<CyclesListMap
|
||||
cycleIds={completedCycleIds}
|
||||
projectId={projectId}
|
||||
workspaceSlug={workspaceSlug}
|
||||
isArchived={isArchived}
|
||||
/>
|
||||
<CyclesListMap cycleIds={completedCycleIds} projectId={projectId} workspaceSlug={workspaceSlug} />
|
||||
</Disclosure.Panel>
|
||||
</Disclosure>
|
||||
)}
|
||||
|
@ -2,27 +2,28 @@ import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useRouter } from "next/router";
|
||||
// icons
|
||||
import { ArchiveRestoreIcon, LinkIcon, Pencil, Trash2 } from "lucide-react";
|
||||
import { ArchiveRestoreIcon, ExternalLink, LinkIcon, Pencil, Trash2 } from "lucide-react";
|
||||
// ui
|
||||
import { ArchiveIcon, CustomMenu, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
import { ArchiveIcon, ContextMenu, CustomMenu, TContextMenuItem, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
// components
|
||||
import { ArchiveCycleModal, CycleCreateUpdateModal, CycleDeleteModal } from "@/components/cycles";
|
||||
// constants
|
||||
import { EUserProjectRoles } from "@/constants/project";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
import { copyUrlToClipboard } from "@/helpers/string.helper";
|
||||
// hooks
|
||||
import { useCycle, useEventTracker, useUser } from "@/hooks/store";
|
||||
|
||||
type Props = {
|
||||
parentRef: React.RefObject<HTMLElement>;
|
||||
cycleId: string;
|
||||
projectId: string;
|
||||
workspaceSlug: string;
|
||||
isArchived?: boolean;
|
||||
};
|
||||
|
||||
export const CycleQuickActions: React.FC<Props> = observer((props) => {
|
||||
const { cycleId, projectId, workspaceSlug, isArchived } = props;
|
||||
const { parentRef, cycleId, projectId, workspaceSlug } = props;
|
||||
// router
|
||||
const router = useRouter();
|
||||
// states
|
||||
@ -37,40 +38,31 @@ export const CycleQuickActions: React.FC<Props> = observer((props) => {
|
||||
const { getCycleById, restoreCycle } = useCycle();
|
||||
// derived values
|
||||
const cycleDetails = getCycleById(cycleId);
|
||||
const isArchived = !!cycleDetails?.archived_at;
|
||||
const isCompleted = cycleDetails?.status?.toLowerCase() === "completed";
|
||||
// auth
|
||||
const isEditingAllowed =
|
||||
!!currentWorkspaceAllProjectsRole && currentWorkspaceAllProjectsRole[projectId] >= EUserProjectRoles.MEMBER;
|
||||
|
||||
const handleCopyText = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
copyUrlToClipboard(`${workspaceSlug}/projects/${projectId}/cycles/${cycleId}`).then(() => {
|
||||
const cycleLink = `${workspaceSlug}/projects/${projectId}/cycles/${cycleId}`;
|
||||
const handleCopyText = () =>
|
||||
copyUrlToClipboard(cycleLink).then(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Link Copied!",
|
||||
message: "Cycle link copied to clipboard.",
|
||||
});
|
||||
});
|
||||
};
|
||||
const handleOpenInNewTab = () => window.open(`/${cycleLink}`, "_blank");
|
||||
|
||||
const handleEditCycle = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const handleEditCycle = () => {
|
||||
setTrackElement("Cycles page list layout");
|
||||
setUpdateModal(true);
|
||||
};
|
||||
|
||||
const handleArchiveCycle = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setArchiveCycleModal(true);
|
||||
};
|
||||
const handleArchiveCycle = () => setArchiveCycleModal(true);
|
||||
|
||||
const handleRestoreCycle = async (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const handleRestoreCycle = async () =>
|
||||
await restoreCycle(workspaceSlug, projectId, cycleId)
|
||||
.then(() => {
|
||||
setToast({
|
||||
@ -87,15 +79,61 @@ export const CycleQuickActions: React.FC<Props> = observer((props) => {
|
||||
message: "Cycle could not be restored. Please try again.",
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const handleDeleteCycle = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const handleDeleteCycle = () => {
|
||||
setTrackElement("Cycles page list layout");
|
||||
setDeleteModal(true);
|
||||
};
|
||||
|
||||
const MENU_ITEMS: TContextMenuItem[] = [
|
||||
{
|
||||
key: "edit",
|
||||
title: "Edit",
|
||||
icon: Pencil,
|
||||
action: handleEditCycle,
|
||||
shouldRender: isEditingAllowed && !isCompleted && !isArchived,
|
||||
},
|
||||
{
|
||||
key: "open-new-tab",
|
||||
action: handleOpenInNewTab,
|
||||
title: "Open in new tab",
|
||||
icon: ExternalLink,
|
||||
shouldRender: !isArchived,
|
||||
},
|
||||
{
|
||||
key: "copy-link",
|
||||
action: handleCopyText,
|
||||
title: "Copy link",
|
||||
icon: LinkIcon,
|
||||
shouldRender: !isArchived,
|
||||
},
|
||||
{
|
||||
key: "archive",
|
||||
action: handleArchiveCycle,
|
||||
title: "Archive",
|
||||
description: isCompleted ? undefined : "Only completed cycle can\nbe archived.",
|
||||
icon: ArchiveIcon,
|
||||
className: "items-start",
|
||||
iconClassName: "mt-1",
|
||||
shouldRender: isEditingAllowed && !isArchived,
|
||||
disabled: !isCompleted,
|
||||
},
|
||||
{
|
||||
key: "restore",
|
||||
action: handleRestoreCycle,
|
||||
title: "Restore",
|
||||
icon: ArchiveRestoreIcon,
|
||||
shouldRender: isEditingAllowed && isArchived,
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
action: handleDeleteCycle,
|
||||
title: "Delete",
|
||||
icon: Trash2,
|
||||
shouldRender: isEditingAllowed && !isCompleted && !isArchived,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{cycleDetails && (
|
||||
@ -123,63 +161,42 @@ export const CycleQuickActions: React.FC<Props> = observer((props) => {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<CustomMenu ellipsis placement="bottom-end">
|
||||
{!isCompleted && isEditingAllowed && !isArchived && (
|
||||
<CustomMenu.MenuItem onClick={handleEditCycle}>
|
||||
<span className="flex items-center justify-start gap-2">
|
||||
<Pencil className="h-3 w-3" />
|
||||
<span>Edit cycle</span>
|
||||
</span>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
{isEditingAllowed && !isArchived && (
|
||||
<CustomMenu.MenuItem onClick={handleArchiveCycle} disabled={!isCompleted}>
|
||||
{isCompleted ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<ArchiveIcon className="h-3 w-3" />
|
||||
Archive cycle
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-start gap-2">
|
||||
<ArchiveIcon className="h-3 w-3" />
|
||||
<div className="-mt-1">
|
||||
<p>Archive cycle</p>
|
||||
<p className="text-xs text-custom-text-400">
|
||||
Only completed cycle <br /> can be archived.
|
||||
<ContextMenu parentRef={parentRef} items={MENU_ITEMS} />
|
||||
<CustomMenu ellipsis placement="bottom-end" closeOnSelect>
|
||||
{MENU_ITEMS.map((item) => {
|
||||
if (item.shouldRender === false) return null;
|
||||
return (
|
||||
<CustomMenu.MenuItem
|
||||
key={item.key}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
item.action();
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center gap-2",
|
||||
{
|
||||
"text-custom-text-400": item.disabled,
|
||||
},
|
||||
item.className
|
||||
)}
|
||||
>
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
{isEditingAllowed && isArchived && (
|
||||
<CustomMenu.MenuItem onClick={handleRestoreCycle}>
|
||||
<span className="flex items-center justify-start gap-2">
|
||||
<ArchiveRestoreIcon className="h-3 w-3" />
|
||||
<span>Restore cycle</span>
|
||||
</span>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
{!isArchived && (
|
||||
<CustomMenu.MenuItem onClick={handleCopyText}>
|
||||
<span className="flex items-center justify-start gap-2">
|
||||
<LinkIcon className="h-3 w-3" />
|
||||
<span>Copy cycle link</span>
|
||||
</span>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
|
||||
{!isCompleted && isEditingAllowed && (
|
||||
<>
|
||||
<hr className="my-2 border-custom-border-200" />
|
||||
<CustomMenu.MenuItem onClick={handleDeleteCycle}>
|
||||
<span className="flex items-center justify-start gap-2">
|
||||
<Trash2 className="h-3 w-3" />
|
||||
<span>Delete cycle</span>
|
||||
</span>
|
||||
</CustomMenu.MenuItem>
|
||||
</>
|
||||
)}
|
||||
);
|
||||
})}
|
||||
</CustomMenu>
|
||||
</>
|
||||
);
|
||||
|
@ -89,8 +89,9 @@ export const BaseCalendarRoot = observer((props: IBaseCalendarRoot) => {
|
||||
groupedIssueIds={groupedIssueIds}
|
||||
layout={displayFilters?.calendar?.layout}
|
||||
showWeekends={displayFilters?.calendar?.show_weekends ?? false}
|
||||
quickActions={(issue, customActionButton, placement) => (
|
||||
quickActions={({ issue, parentRef, customActionButton, placement }) => (
|
||||
<QuickActions
|
||||
parentRef={parentRef}
|
||||
customActionButton={customActionButton}
|
||||
issue={issue}
|
||||
handleDelete={async () => removeIssue(issue.project_id, issue.id)}
|
||||
|
@ -1,5 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { Placement } from "@popperjs/core";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import type {
|
||||
IIssueDisplayFilterOptions,
|
||||
@ -28,6 +27,7 @@ import { ICycleIssuesFilter } from "@/store/issue/cycle";
|
||||
import { IModuleIssuesFilter } from "@/store/issue/module";
|
||||
import { IProjectIssuesFilter } from "@/store/issue/project";
|
||||
import { IProjectViewIssuesFilter } from "@/store/issue/project-views";
|
||||
import { TRenderQuickActions } from "../list/list-view-types";
|
||||
import type { ICalendarWeek } from "./types";
|
||||
// helpers
|
||||
// constants
|
||||
@ -38,7 +38,7 @@ type Props = {
|
||||
groupedIssueIds: TGroupedIssues;
|
||||
layout: "month" | "week" | undefined;
|
||||
showWeekends: boolean;
|
||||
quickActions: (issue: TIssue, customActionButton?: React.ReactElement, placement?: Placement) => React.ReactNode;
|
||||
quickActions: TRenderQuickActions;
|
||||
quickAddCallback?: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
|
@ -1,5 +1,4 @@
|
||||
import { Droppable } from "@hello-pangea/dnd";
|
||||
import { Placement } from "@popperjs/core";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { TGroupedIssues, TIssue, TIssueMap } from "@plane/types";
|
||||
// components
|
||||
@ -14,13 +13,14 @@ import { ICycleIssuesFilter } from "@/store/issue/cycle";
|
||||
import { IModuleIssuesFilter } from "@/store/issue/module";
|
||||
import { IProjectIssuesFilter } from "@/store/issue/project";
|
||||
import { IProjectViewIssuesFilter } from "@/store/issue/project-views";
|
||||
import { TRenderQuickActions } from "../list/list-view-types";
|
||||
|
||||
type Props = {
|
||||
issuesFilterStore: IProjectIssuesFilter | IModuleIssuesFilter | ICycleIssuesFilter | IProjectViewIssuesFilter;
|
||||
date: ICalendarDate;
|
||||
issues: TIssueMap | undefined;
|
||||
groupedIssueIds: TGroupedIssues;
|
||||
quickActions: (issue: TIssue, customActionButton?: React.ReactElement, placement?: Placement) => React.ReactNode;
|
||||
quickActions: TRenderQuickActions;
|
||||
enableQuickIssueCreate?: boolean;
|
||||
disableIssueCreation?: boolean;
|
||||
quickAddCallback?: (
|
||||
|
@ -1,14 +1,14 @@
|
||||
import React from "react";
|
||||
import { Placement } from "@popperjs/core";
|
||||
// components
|
||||
import { TIssue, TIssueMap } from "@plane/types";
|
||||
import { TIssueMap } from "@plane/types";
|
||||
import { CalendarIssueBlock } from "@/components/issues";
|
||||
import { TRenderQuickActions } from "../list/list-view-types";
|
||||
// types
|
||||
|
||||
type Props = {
|
||||
issues: TIssueMap | undefined;
|
||||
issueId: string;
|
||||
quickActions: (issue: TIssue, customActionButton?: React.ReactElement, placement?: Placement) => React.ReactNode;
|
||||
quickActions: TRenderQuickActions;
|
||||
isDragging?: boolean;
|
||||
};
|
||||
|
||||
|
@ -1,5 +1,4 @@
|
||||
import { useState, useRef } from "react";
|
||||
import { Placement } from "@popperjs/core";
|
||||
import { observer } from "mobx-react";
|
||||
import { MoreHorizontal } from "lucide-react";
|
||||
import { TIssue } from "@plane/types";
|
||||
@ -12,15 +11,21 @@ import useOutsideClickDetector from "@/hooks/use-outside-click-detector";
|
||||
// helpers
|
||||
// types
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
import { TRenderQuickActions } from "../list/list-view-types";
|
||||
|
||||
type Props = {
|
||||
issue: TIssue;
|
||||
quickActions: (issue: TIssue, customActionButton?: React.ReactElement, placement?: Placement) => React.ReactNode;
|
||||
quickActions: TRenderQuickActions;
|
||||
isDragging?: boolean;
|
||||
};
|
||||
|
||||
export const CalendarIssueBlock: React.FC<Props> = observer((props) => {
|
||||
const { issue, quickActions, isDragging = false } = props;
|
||||
// states
|
||||
const [isMenuActive, setIsMenuActive] = useState(false);
|
||||
// refs
|
||||
const blockRef = useRef(null);
|
||||
const menuActionRef = useRef<HTMLDivElement | null>(null);
|
||||
// hooks
|
||||
const {
|
||||
router: { workspaceSlug, projectId },
|
||||
@ -29,10 +34,6 @@ export const CalendarIssueBlock: React.FC<Props> = observer((props) => {
|
||||
const { getProjectStates } = useProjectState();
|
||||
const { getIsIssuePeeked, setPeekIssue } = useIssueDetail();
|
||||
const { isMobile } = usePlatformOS();
|
||||
// states
|
||||
const [isMenuActive, setIsMenuActive] = useState(false);
|
||||
|
||||
const menuActionRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const stateColor = getProjectStates(issue?.project_id)?.find((state) => state?.id == issue?.state_id)?.color || "";
|
||||
|
||||
@ -78,6 +79,7 @@ export const CalendarIssueBlock: React.FC<Props> = observer((props) => {
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={blockRef}
|
||||
className={cn(
|
||||
"group/calendar-block flex h-10 md:h-8 w-full items-center justify-between gap-1.5 rounded border-b md:border-[0.5px] border-custom-border-200 hover:border-custom-border-400 md:px-1 px-4 py-1.5 ",
|
||||
{
|
||||
@ -110,7 +112,12 @@ export const CalendarIssueBlock: React.FC<Props> = observer((props) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
{quickActions(issue, customActionButton, placement)}
|
||||
{quickActions({
|
||||
issue,
|
||||
parentRef: blockRef,
|
||||
customActionButton,
|
||||
placement,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
@ -1,19 +1,19 @@
|
||||
import { useState } from "react";
|
||||
import { Draggable } from "@hello-pangea/dnd";
|
||||
import { Placement } from "@popperjs/core";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { TIssue, TIssueMap } from "@plane/types";
|
||||
// components
|
||||
import { CalendarQuickAddIssueForm, CalendarIssueBlockRoot } from "@/components/issues";
|
||||
// helpers
|
||||
import { renderFormattedPayloadDate } from "@/helpers/date-time.helper";
|
||||
import { TRenderQuickActions } from "../list/list-view-types";
|
||||
// types
|
||||
|
||||
type Props = {
|
||||
date: Date;
|
||||
issues: TIssueMap | undefined;
|
||||
issueIdList: string[] | null;
|
||||
quickActions: (issue: TIssue, customActionButton?: React.ReactElement, placement?: Placement) => React.ReactNode;
|
||||
quickActions: TRenderQuickActions;
|
||||
isDragDisabled?: boolean;
|
||||
enableQuickIssueCreate?: boolean;
|
||||
disableIssueCreation?: boolean;
|
||||
|
@ -1,4 +1,3 @@
|
||||
import { Placement } from "@popperjs/core";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { TGroupedIssues, TIssue, TIssueMap } from "@plane/types";
|
||||
// components
|
||||
@ -10,6 +9,7 @@ import { ICycleIssuesFilter } from "@/store/issue/cycle";
|
||||
import { IModuleIssuesFilter } from "@/store/issue/module";
|
||||
import { IProjectIssuesFilter } from "@/store/issue/project";
|
||||
import { IProjectViewIssuesFilter } from "@/store/issue/project-views";
|
||||
import { TRenderQuickActions } from "../list/list-view-types";
|
||||
import { ICalendarDate, ICalendarWeek } from "./types";
|
||||
|
||||
type Props = {
|
||||
@ -17,7 +17,7 @@ type Props = {
|
||||
issues: TIssueMap | undefined;
|
||||
groupedIssueIds: TGroupedIssues;
|
||||
week: ICalendarWeek | undefined;
|
||||
quickActions: (issue: TIssue, customActionButton?: React.ReactElement, placement?: Placement) => React.ReactNode;
|
||||
quickActions: TRenderQuickActions;
|
||||
enableQuickIssueCreate?: boolean;
|
||||
disableIssueCreation?: boolean;
|
||||
quickAddCallback?: (
|
||||
|
@ -4,7 +4,6 @@ import { dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element
|
||||
import { autoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useRouter } from "next/router";
|
||||
import { TIssue } from "@plane/types";
|
||||
// hooks
|
||||
import { Spinner, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
import { DeleteIssueModal } from "@/components/issues";
|
||||
@ -15,7 +14,7 @@ import { useEventTracker, useIssueDetail, useIssues, useKanbanView, useUser } fr
|
||||
import { useIssuesActions } from "@/hooks/use-issues-actions";
|
||||
// ui
|
||||
// types
|
||||
import { IQuickActionProps } from "../list/list-view-types";
|
||||
import { IQuickActionProps, TRenderQuickActions } from "../list/list-view-types";
|
||||
//components
|
||||
import { KanBan } from "./default";
|
||||
import { KanBanSwimLanes } from "./swimlanes";
|
||||
@ -168,9 +167,10 @@ export const BaseKanBanRoot: React.FC<IBaseKanBanLayout> = observer((props: IBas
|
||||
});
|
||||
};
|
||||
|
||||
const renderQuickActions = useCallback(
|
||||
(issue: TIssue, customActionButton?: React.ReactElement) => (
|
||||
const renderQuickActions: TRenderQuickActions = useCallback(
|
||||
({ issue, parentRef, customActionButton }) => (
|
||||
<QuickActions
|
||||
parentRef={parentRef}
|
||||
customActionButton={customActionButton}
|
||||
issue={issue}
|
||||
handleDelete={async () => removeIssue(issue.project_id, issue.id)}
|
||||
|
@ -10,6 +10,7 @@ import { cn } from "@/helpers/common.helper";
|
||||
import { useApplication, useIssueDetail, useKanbanView, useProject } from "@/hooks/store";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// components
|
||||
import { TRenderQuickActions } from "../list/list-view-types";
|
||||
import { IssueProperties } from "../properties/all-properties";
|
||||
import { WithDisplayPropertiesHOC } from "../properties/with-display-properties-HOC";
|
||||
// ui
|
||||
@ -23,22 +24,23 @@ interface IssueBlockProps {
|
||||
isDragDisabled: boolean;
|
||||
draggableId: string;
|
||||
updateIssue: ((projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined;
|
||||
quickActions: (issue: TIssue) => React.ReactNode;
|
||||
quickActions: TRenderQuickActions;
|
||||
canEditProperties: (projectId: string | undefined) => boolean;
|
||||
scrollableContainerRef?: MutableRefObject<HTMLDivElement | null>;
|
||||
issueIds: string[]; //DO NOT REMOVE< needed to force render for virtualization
|
||||
}
|
||||
|
||||
interface IssueDetailsBlockProps {
|
||||
cardRef: React.RefObject<HTMLElement>;
|
||||
issue: TIssue;
|
||||
displayProperties: IIssueDisplayProperties | undefined;
|
||||
updateIssue: ((projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined;
|
||||
quickActions: (issue: TIssue) => React.ReactNode;
|
||||
quickActions: TRenderQuickActions;
|
||||
isReadOnly: boolean;
|
||||
}
|
||||
|
||||
const KanbanIssueDetailsBlock: React.FC<IssueDetailsBlockProps> = observer((props: IssueDetailsBlockProps) => {
|
||||
const { issue, updateIssue, quickActions, isReadOnly, displayProperties } = props;
|
||||
const KanbanIssueDetailsBlock: React.FC<IssueDetailsBlockProps> = observer((props) => {
|
||||
const { cardRef, issue, updateIssue, quickActions, isReadOnly, displayProperties } = props;
|
||||
// hooks
|
||||
const { isMobile } = usePlatformOS();
|
||||
const { getProjectIdentifierById } = useProject();
|
||||
@ -59,7 +61,10 @@ const KanbanIssueDetailsBlock: React.FC<IssueDetailsBlockProps> = observer((prop
|
||||
className="absolute -top-1 right-0 hidden group-hover/kanban-block:block"
|
||||
onClick={handleEventPropagation}
|
||||
>
|
||||
{quickActions(issue)}
|
||||
{quickActions({
|
||||
issue,
|
||||
parentRef: cardRef,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</WithDisplayPropertiesHOC>
|
||||
@ -200,6 +205,7 @@ export const KanbanIssueBlock: React.FC<IssueBlockProps> = observer((props) => {
|
||||
changingReference={issueIds}
|
||||
>
|
||||
<KanbanIssueDetailsBlock
|
||||
cardRef={cardRef}
|
||||
issue={issue}
|
||||
displayProperties={displayProperties}
|
||||
updateIssue={updateIssue}
|
||||
|
@ -2,6 +2,7 @@ import { MutableRefObject, memo } from "react";
|
||||
//types
|
||||
import { TIssue, IIssueDisplayProperties, IIssueMap } from "@plane/types";
|
||||
import { KanbanIssueBlock } from "@/components/issues";
|
||||
import { TRenderQuickActions } from "../list/list-view-types";
|
||||
// components
|
||||
|
||||
interface IssueBlocksListProps {
|
||||
@ -12,7 +13,7 @@ interface IssueBlocksListProps {
|
||||
displayProperties: IIssueDisplayProperties | undefined;
|
||||
isDragDisabled: boolean;
|
||||
updateIssue: ((projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined;
|
||||
quickActions: (issue: TIssue, customActionButton?: React.ReactElement) => React.ReactNode;
|
||||
quickActions: TRenderQuickActions;
|
||||
canEditProperties: (projectId: string | undefined) => boolean;
|
||||
scrollableContainerRef?: MutableRefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
@ -17,6 +17,7 @@ import {
|
||||
import { useCycle, useKanbanView, useLabel, useMember, useModule, useProject, useProjectState } from "@/hooks/store";
|
||||
// types
|
||||
// parent components
|
||||
import { TRenderQuickActions } from "../list/list-view-types";
|
||||
import { getGroupByColumns, isWorkspaceLevel } from "../utils";
|
||||
// components
|
||||
import { KanbanStoreType } from "./base-kanban-root";
|
||||
@ -33,7 +34,7 @@ export interface IGroupByKanBan {
|
||||
sub_group_id: string;
|
||||
isDragDisabled: boolean;
|
||||
updateIssue: ((projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined;
|
||||
quickActions: (issue: TIssue, customActionButton?: React.ReactElement) => React.ReactNode;
|
||||
quickActions: TRenderQuickActions;
|
||||
kanbanFilters: TIssueKanbanFilters;
|
||||
handleKanbanFilters: any;
|
||||
enableQuickIssueCreate?: boolean;
|
||||
@ -197,7 +198,7 @@ export interface IKanBan {
|
||||
group_by: TIssueGroupByOptions | undefined;
|
||||
sub_group_id?: string;
|
||||
updateIssue: ((projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined;
|
||||
quickActions: (issue: TIssue, customActionButton?: React.ReactElement) => React.ReactNode;
|
||||
quickActions: TRenderQuickActions;
|
||||
kanbanFilters: TIssueKanbanFilters;
|
||||
handleKanbanFilters: (toggle: "group_by" | "sub_group_by", value: string) => void;
|
||||
showEmptyGroup: boolean;
|
||||
|
@ -17,6 +17,7 @@ import { cn } from "@/helpers/common.helper";
|
||||
// hooks
|
||||
import { useProjectState } from "@/hooks/store";
|
||||
//components
|
||||
import { TRenderQuickActions } from "../list/list-view-types";
|
||||
import { KanbanDropLocation, getSourceFromDropPayload, getDestinationFromDropPayload } from "./utils";
|
||||
import { KanbanIssueBlocksList, KanBanQuickAddIssueForm } from ".";
|
||||
|
||||
@ -30,7 +31,7 @@ interface IKanbanGroup {
|
||||
sub_group_id: string;
|
||||
isDragDisabled: boolean;
|
||||
updateIssue: ((projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined;
|
||||
quickActions: (issue: TIssue, customActionButton?: React.ReactElement) => React.ReactNode;
|
||||
quickActions: TRenderQuickActions;
|
||||
enableQuickIssueCreate?: boolean;
|
||||
quickAddCallback?: (
|
||||
workspaceSlug: string,
|
||||
|
@ -14,6 +14,7 @@ import {
|
||||
} from "@plane/types";
|
||||
// components
|
||||
import { useCycle, useLabel, useMember, useModule, useProject, useProjectState } from "@/hooks/store";
|
||||
import { TRenderQuickActions } from "../list/list-view-types";
|
||||
import { getGroupByColumns, isWorkspaceLevel } from "../utils";
|
||||
import { KanbanStoreType } from "./base-kanban-root";
|
||||
import { KanBan } from "./default";
|
||||
@ -106,7 +107,7 @@ interface ISubGroupSwimlane extends ISubGroupSwimlaneHeader {
|
||||
showEmptyGroup: boolean;
|
||||
displayProperties: IIssueDisplayProperties | undefined;
|
||||
updateIssue: ((projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined;
|
||||
quickActions: (issue: TIssue, customActionButton?: React.ReactElement) => React.ReactNode;
|
||||
quickActions: TRenderQuickActions;
|
||||
kanbanFilters: TIssueKanbanFilters;
|
||||
handleKanbanFilters: (toggle: "group_by" | "sub_group_by", value: string) => void;
|
||||
handleOnDrop: (source: KanbanDropLocation, destination: KanbanDropLocation) => Promise<void>;
|
||||
@ -235,7 +236,7 @@ export interface IKanBanSwimLanes {
|
||||
sub_group_by: TIssueGroupByOptions | undefined;
|
||||
group_by: TIssueGroupByOptions | undefined;
|
||||
updateIssue: ((projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined;
|
||||
quickActions: (issue: TIssue, customActionButton?: React.ReactElement) => React.ReactNode;
|
||||
quickActions: TRenderQuickActions;
|
||||
kanbanFilters: TIssueKanbanFilters;
|
||||
handleKanbanFilters: (toggle: "group_by" | "sub_group_by", value: string) => void;
|
||||
showEmptyGroup: boolean;
|
||||
|
@ -1,6 +1,5 @@
|
||||
import { FC, useCallback } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { TIssue } from "@plane/types";
|
||||
// types
|
||||
import { EIssuesStoreType } from "@/constants/issue";
|
||||
import { EUserProjectRoles } from "@/constants/project";
|
||||
@ -9,7 +8,7 @@ import { useIssues, useUser } from "@/hooks/store";
|
||||
import { useIssuesActions } from "@/hooks/use-issues-actions";
|
||||
// components
|
||||
import { List } from "./default";
|
||||
import { IQuickActionProps } from "./list-view-types";
|
||||
import { IQuickActionProps, TRenderQuickActions } from "./list-view-types";
|
||||
// constants
|
||||
// hooks
|
||||
|
||||
@ -69,9 +68,10 @@ export const BaseListRoot = observer((props: IBaseListRoot) => {
|
||||
const group_by = displayFilters?.group_by || null;
|
||||
const showEmptyGroup = displayFilters?.show_empty_groups ?? false;
|
||||
|
||||
const renderQuickActions = useCallback(
|
||||
(issue: TIssue) => (
|
||||
const renderQuickActions: TRenderQuickActions = useCallback(
|
||||
({ issue, parentRef }) => (
|
||||
<QuickActions
|
||||
parentRef={parentRef}
|
||||
issue={issue}
|
||||
handleDelete={async () => removeIssue(issue.project_id, issue.id)}
|
||||
handleUpdate={async (data) => updateIssue && updateIssue(issue.project_id, issue.id, data)}
|
||||
|
@ -1,27 +1,30 @@
|
||||
import { useRef } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { TIssue, IIssueDisplayProperties, TIssueMap } from "@plane/types";
|
||||
// components
|
||||
// hooks
|
||||
// ui
|
||||
import { Spinner, Tooltip, ControlLink } from "@plane/ui";
|
||||
// helper
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
// hooks
|
||||
import { useApplication, useIssueDetail, useProject } from "@/hooks/store";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// types
|
||||
import { IssueProperties } from "../properties/all-properties";
|
||||
import { TRenderQuickActions } from "./list-view-types";
|
||||
|
||||
interface IssueBlockProps {
|
||||
issueId: string;
|
||||
issuesMap: TIssueMap;
|
||||
updateIssue: ((projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined;
|
||||
quickActions: (issue: TIssue) => React.ReactNode;
|
||||
quickActions: TRenderQuickActions;
|
||||
displayProperties: IIssueDisplayProperties | undefined;
|
||||
canEditProperties: (projectId: string | undefined) => boolean;
|
||||
}
|
||||
|
||||
export const IssueBlock: React.FC<IssueBlockProps> = observer((props: IssueBlockProps) => {
|
||||
const { issuesMap, issueId, updateIssue, quickActions, displayProperties, canEditProperties } = props;
|
||||
// refs
|
||||
const parentRef = useRef(null);
|
||||
// hooks
|
||||
const {
|
||||
router: { workspaceSlug },
|
||||
@ -46,6 +49,7 @@ export const IssueBlock: React.FC<IssueBlockProps> = observer((props: IssueBlock
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={parentRef}
|
||||
className={cn(
|
||||
"min-h-[52px] relative flex flex-col md:flex-row md:items-center gap-3 bg-custom-background-100 p-3 text-sm",
|
||||
{
|
||||
@ -88,7 +92,12 @@ export const IssueBlock: React.FC<IssueBlockProps> = observer((props: IssueBlock
|
||||
)}
|
||||
</div>
|
||||
{!issue?.tempId && (
|
||||
<div className="block md:hidden border border-custom-border-300 rounded ">{quickActions(issue)}</div>
|
||||
<div className="block md:hidden border border-custom-border-300 rounded ">
|
||||
{quickActions({
|
||||
issue,
|
||||
parentRef,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-shrink-0 items-center gap-2">
|
||||
@ -102,7 +111,12 @@ export const IssueBlock: React.FC<IssueBlockProps> = observer((props: IssueBlock
|
||||
displayProperties={displayProperties}
|
||||
activeLayout="List"
|
||||
/>
|
||||
<div className="hidden md:block">{quickActions(issue)}</div>
|
||||
<div className="hidden md:block">
|
||||
{quickActions({
|
||||
issue,
|
||||
parentRef,
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="h-4 w-4">
|
||||
|
@ -3,6 +3,7 @@ import { FC, MutableRefObject } from "react";
|
||||
import { TGroupedIssues, TIssue, IIssueDisplayProperties, TIssueMap, TUnGroupedIssues } from "@plane/types";
|
||||
import RenderIfVisible from "@/components/core/render-if-visible-HOC";
|
||||
import { IssueBlock } from "@/components/issues";
|
||||
import { TRenderQuickActions } from "./list-view-types";
|
||||
// types
|
||||
|
||||
interface Props {
|
||||
@ -10,7 +11,7 @@ interface Props {
|
||||
issuesMap: TIssueMap;
|
||||
canEditProperties: (projectId: string | undefined) => boolean;
|
||||
updateIssue: ((projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined;
|
||||
quickActions: (issue: TIssue) => React.ReactNode;
|
||||
quickActions: TRenderQuickActions;
|
||||
displayProperties: IIssueDisplayProperties | undefined;
|
||||
containerRef: MutableRefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
@ -16,13 +16,14 @@ import { useCycle, useLabel, useMember, useModule, useProject, useProjectState }
|
||||
// utils
|
||||
import { getGroupByColumns, isWorkspaceLevel } from "../utils";
|
||||
import { HeaderGroupByCard } from "./headers/group-by-card";
|
||||
import { TRenderQuickActions } from "./list-view-types";
|
||||
|
||||
export interface IGroupByList {
|
||||
issueIds: TGroupedIssues | TUnGroupedIssues | any;
|
||||
issuesMap: TIssueMap;
|
||||
group_by: string | null;
|
||||
updateIssue: ((projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined;
|
||||
quickActions: (issue: TIssue) => React.ReactNode;
|
||||
quickActions: TRenderQuickActions;
|
||||
displayProperties: IIssueDisplayProperties | undefined;
|
||||
enableIssueQuickAdd: boolean;
|
||||
showEmptyGroup?: boolean;
|
||||
@ -177,7 +178,7 @@ export interface IList {
|
||||
issuesMap: TIssueMap;
|
||||
group_by: string | null;
|
||||
updateIssue: ((projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined;
|
||||
quickActions: (issue: TIssue) => React.ReactNode;
|
||||
quickActions: TRenderQuickActions;
|
||||
displayProperties: IIssueDisplayProperties | undefined;
|
||||
showEmptyGroup: boolean;
|
||||
enableIssueQuickAdd: boolean;
|
||||
|
@ -2,6 +2,7 @@ import { Placement } from "@popperjs/core";
|
||||
import { TIssue } from "@plane/types";
|
||||
|
||||
export interface IQuickActionProps {
|
||||
parentRef: React.RefObject<HTMLElement>;
|
||||
issue: TIssue;
|
||||
handleDelete: () => Promise<void>;
|
||||
handleUpdate?: (data: TIssue) => Promise<void>;
|
||||
@ -13,3 +14,17 @@ export interface IQuickActionProps {
|
||||
readOnly?: boolean;
|
||||
placements?: Placement;
|
||||
}
|
||||
|
||||
export type TRenderQuickActions = ({
|
||||
issue,
|
||||
parentRef,
|
||||
customActionButton,
|
||||
placement,
|
||||
portalElement,
|
||||
}: {
|
||||
issue: TIssue;
|
||||
parentRef: React.RefObject<HTMLElement>;
|
||||
customActionButton?: React.ReactElement;
|
||||
placement?: Placement;
|
||||
portalElement?: HTMLDivElement | null;
|
||||
}) => React.ReactNode;
|
||||
|
@ -3,21 +3,22 @@ import omit from "lodash/omit";
|
||||
import { observer } from "mobx-react";
|
||||
import { useRouter } from "next/router";
|
||||
import { Copy, ExternalLink, Link, Pencil, Trash2 } from "lucide-react";
|
||||
// types
|
||||
import { TIssue } from "@plane/types";
|
||||
// hooks
|
||||
import { ArchiveIcon, CustomMenu, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
import { ArchiveIssueModal, CreateUpdateIssueModal, DeleteIssueModal } from "@/components/issues";
|
||||
// ui
|
||||
import { ArchiveIcon, ContextMenu, CustomMenu, TContextMenuItem, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
// components
|
||||
import { ArchiveIssueModal, CreateUpdateIssueModal, DeleteIssueModal } from "@/components/issues";
|
||||
// constants
|
||||
import { EIssuesStoreType } from "@/constants/issue";
|
||||
import { STATE_GROUPS } from "@/constants/state";
|
||||
import { copyUrlToClipboard } from "@/helpers/string.helper";
|
||||
import { useEventTracker, useProjectState } from "@/hooks/store";
|
||||
// components
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
import { copyUrlToClipboard } from "@/helpers/string.helper";
|
||||
// hooks
|
||||
import { useEventTracker, useProjectState } from "@/hooks/store";
|
||||
// types
|
||||
import { IQuickActionProps } from "../list/list-view-types";
|
||||
// constants
|
||||
|
||||
export const AllIssueQuickActions: React.FC<IQuickActionProps> = observer((props) => {
|
||||
const {
|
||||
@ -28,6 +29,8 @@ export const AllIssueQuickActions: React.FC<IQuickActionProps> = observer((props
|
||||
customActionButton,
|
||||
portalElement,
|
||||
readOnly = false,
|
||||
placements = "bottom-start",
|
||||
parentRef,
|
||||
} = props;
|
||||
// states
|
||||
const [createUpdateIssueModal, setCreateUpdateIssueModal] = useState(false);
|
||||
@ -68,6 +71,63 @@ export const AllIssueQuickActions: React.FC<IQuickActionProps> = observer((props
|
||||
["id"]
|
||||
);
|
||||
|
||||
const MENU_ITEMS: TContextMenuItem[] = [
|
||||
{
|
||||
key: "edit",
|
||||
title: "Edit",
|
||||
icon: Pencil,
|
||||
action: () => {
|
||||
setTrackElement("Global issues");
|
||||
setIssueToEdit(issue);
|
||||
setCreateUpdateIssueModal(true);
|
||||
},
|
||||
shouldRender: isEditingAllowed,
|
||||
},
|
||||
{
|
||||
key: "make-a-copy",
|
||||
title: "Make a copy",
|
||||
icon: Copy,
|
||||
action: () => {
|
||||
setTrackElement("Global issues");
|
||||
setCreateUpdateIssueModal(true);
|
||||
},
|
||||
shouldRender: isEditingAllowed,
|
||||
},
|
||||
{
|
||||
key: "open-in-new-tab",
|
||||
title: "Open in new tab",
|
||||
icon: ExternalLink,
|
||||
action: handleOpenInNewTab,
|
||||
},
|
||||
{
|
||||
key: "copy-link",
|
||||
title: "Copy link",
|
||||
icon: Link,
|
||||
action: handleCopyIssueLink,
|
||||
},
|
||||
{
|
||||
key: "archive",
|
||||
title: "Archive",
|
||||
description: isInArchivableGroup ? undefined : "Only completed or canceled\nissues can be archived",
|
||||
icon: ArchiveIcon,
|
||||
className: "items-start",
|
||||
iconClassName: "mt-1",
|
||||
action: () => setArchiveIssueModal(true),
|
||||
disabled: !isInArchivableGroup,
|
||||
shouldRender: isArchivingAllowed,
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
title: "Delete",
|
||||
icon: Trash2,
|
||||
action: () => {
|
||||
setTrackElement("Global issues");
|
||||
setDeleteIssueModal(true);
|
||||
},
|
||||
shouldRender: isEditingAllowed,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<ArchiveIssueModal
|
||||
@ -94,89 +154,49 @@ export const AllIssueQuickActions: React.FC<IQuickActionProps> = observer((props
|
||||
}}
|
||||
storeType={EIssuesStoreType.PROJECT}
|
||||
/>
|
||||
<ContextMenu parentRef={parentRef} items={MENU_ITEMS} />
|
||||
<CustomMenu
|
||||
menuItemsClassName="z-[14]"
|
||||
placement="bottom-start"
|
||||
ellipsis
|
||||
customButton={customActionButton}
|
||||
portalElement={portalElement}
|
||||
maxHeight="lg"
|
||||
placement={placements}
|
||||
menuItemsClassName="z-[14]"
|
||||
closeOnSelect
|
||||
ellipsis
|
||||
>
|
||||
{isEditingAllowed && (
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => {
|
||||
setTrackElement("Global issues");
|
||||
setIssueToEdit(issue);
|
||||
setCreateUpdateIssueModal(true);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Pencil className="h-3 w-3" />
|
||||
Edit
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
<CustomMenu.MenuItem onClick={handleOpenInNewTab}>
|
||||
<div className="flex items-center gap-2">
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
Open in new tab
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem onClick={handleCopyIssueLink}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link className="h-3 w-3" />
|
||||
Copy link
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
{isEditingAllowed && (
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => {
|
||||
setTrackElement("Global issues");
|
||||
setCreateUpdateIssueModal(true);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Copy className="h-3 w-3" />
|
||||
Make a copy
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
{isArchivingAllowed && (
|
||||
<CustomMenu.MenuItem onClick={() => setArchiveIssueModal(true)} disabled={!isInArchivableGroup}>
|
||||
{isInArchivableGroup ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<ArchiveIcon className="h-3 w-3" />
|
||||
Archive
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-start gap-2">
|
||||
<ArchiveIcon className="h-3 w-3" />
|
||||
<div className="-mt-1">
|
||||
<p>Archive</p>
|
||||
<p className="text-xs text-custom-text-400">
|
||||
Only completed or canceled
|
||||
<br />
|
||||
issues can be archived
|
||||
{MENU_ITEMS.map((item) => {
|
||||
if (item.shouldRender === false) return null;
|
||||
return (
|
||||
<CustomMenu.MenuItem
|
||||
key={item.key}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
item.action();
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center gap-2",
|
||||
{
|
||||
"text-custom-text-400": item.disabled,
|
||||
},
|
||||
item.className
|
||||
)}
|
||||
>
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
{isEditingAllowed && (
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => {
|
||||
setTrackElement("Global issues");
|
||||
setDeleteIssueModal(true);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Trash2 className="h-3 w-3" />
|
||||
Delete
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
</CustomMenu.MenuItem>
|
||||
);
|
||||
})}
|
||||
</CustomMenu>
|
||||
</>
|
||||
);
|
||||
|
@ -4,13 +4,14 @@ import { useRouter } from "next/router";
|
||||
// icons
|
||||
import { ArchiveRestoreIcon, ExternalLink, Link, Trash2 } from "lucide-react";
|
||||
// ui
|
||||
import { CustomMenu, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
import { ContextMenu, CustomMenu, TContextMenuItem, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
// components
|
||||
import { DeleteIssueModal } from "@/components/issues";
|
||||
// constants
|
||||
import { EIssuesStoreType } from "@/constants/issue";
|
||||
import { EUserProjectRoles } from "@/constants/project";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
import { copyUrlToClipboard } from "@/helpers/string.helper";
|
||||
// hooks
|
||||
import { useEventTracker, useIssues, useUser } from "@/hooks/store";
|
||||
@ -18,7 +19,16 @@ import { useEventTracker, useIssues, useUser } from "@/hooks/store";
|
||||
import { IQuickActionProps } from "../list/list-view-types";
|
||||
|
||||
export const ArchivedIssueQuickActions: React.FC<IQuickActionProps> = observer((props) => {
|
||||
const { issue, handleDelete, handleRestore, customActionButton, portalElement, readOnly = false } = props;
|
||||
const {
|
||||
issue,
|
||||
handleDelete,
|
||||
handleRestore,
|
||||
customActionButton,
|
||||
portalElement,
|
||||
readOnly = false,
|
||||
placements = "bottom-end",
|
||||
parentRef,
|
||||
} = props;
|
||||
// states
|
||||
const [deleteIssueModal, setDeleteIssueModal] = useState(false);
|
||||
// router
|
||||
@ -66,6 +76,38 @@ export const ArchivedIssueQuickActions: React.FC<IQuickActionProps> = observer((
|
||||
});
|
||||
};
|
||||
|
||||
const MENU_ITEMS: TContextMenuItem[] = [
|
||||
{
|
||||
key: "restore",
|
||||
title: "Restore",
|
||||
icon: ArchiveRestoreIcon,
|
||||
action: handleIssueRestore,
|
||||
shouldRender: isRestoringAllowed,
|
||||
},
|
||||
{
|
||||
key: "open-in-new-tab",
|
||||
title: "Open in new tab",
|
||||
icon: ExternalLink,
|
||||
action: handleOpenInNewTab,
|
||||
},
|
||||
{
|
||||
key: "copy-link",
|
||||
title: "Copy link",
|
||||
icon: Link,
|
||||
action: handleCopyIssueLink,
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
title: "Delete",
|
||||
icon: Trash2,
|
||||
action: () => {
|
||||
setTrackElement(activeLayout);
|
||||
setDeleteIssueModal(true);
|
||||
},
|
||||
shouldRender: isEditingAllowed,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<DeleteIssueModal
|
||||
@ -74,48 +116,49 @@ export const ArchivedIssueQuickActions: React.FC<IQuickActionProps> = observer((
|
||||
handleClose={() => setDeleteIssueModal(false)}
|
||||
onSubmit={handleDelete}
|
||||
/>
|
||||
<ContextMenu parentRef={parentRef} items={MENU_ITEMS} />
|
||||
<CustomMenu
|
||||
menuItemsClassName="z-[14]"
|
||||
placement="bottom-start"
|
||||
ellipsis
|
||||
customButton={customActionButton}
|
||||
portalElement={portalElement}
|
||||
maxHeight="lg"
|
||||
placement={placements}
|
||||
menuItemsClassName="z-[14]"
|
||||
closeOnSelect
|
||||
ellipsis
|
||||
>
|
||||
{isRestoringAllowed && (
|
||||
<CustomMenu.MenuItem onClick={handleIssueRestore}>
|
||||
<div className="flex items-center gap-2">
|
||||
<ArchiveRestoreIcon className="h-3 w-3" />
|
||||
Restore
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
<CustomMenu.MenuItem onClick={handleOpenInNewTab}>
|
||||
<div className="flex items-center gap-2">
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
Open in new tab
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem onClick={handleCopyIssueLink}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link className="h-3 w-3" />
|
||||
Copy link
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
{isEditingAllowed && (
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => {
|
||||
setTrackElement(activeLayout);
|
||||
setDeleteIssueModal(true);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Trash2 className="h-3 w-3" />
|
||||
Delete issue
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
{MENU_ITEMS.map((item) => {
|
||||
if (item.shouldRender === false) return null;
|
||||
return (
|
||||
<CustomMenu.MenuItem
|
||||
key={item.key}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
item.action();
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center gap-2",
|
||||
{
|
||||
"text-custom-text-400": item.disabled,
|
||||
},
|
||||
item.className
|
||||
)}
|
||||
>
|
||||
{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>
|
||||
</>
|
||||
);
|
||||
|
@ -2,24 +2,24 @@ import { useState } from "react";
|
||||
import omit from "lodash/omit";
|
||||
import { observer } from "mobx-react";
|
||||
import { useRouter } from "next/router";
|
||||
// hooks
|
||||
// ui
|
||||
import { Copy, ExternalLink, Link, Pencil, Trash2, XCircle } from "lucide-react";
|
||||
// types
|
||||
import { TIssue } from "@plane/types";
|
||||
import { ArchiveIcon, CustomMenu, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
// icons
|
||||
// ui
|
||||
import { ArchiveIcon, ContextMenu, CustomMenu, TContextMenuItem, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
// components
|
||||
import { ArchiveIssueModal, CreateUpdateIssueModal, DeleteIssueModal } from "@/components/issues";
|
||||
// constants
|
||||
import { EIssuesStoreType } from "@/constants/issue";
|
||||
import { EUserProjectRoles } from "@/constants/project";
|
||||
import { STATE_GROUPS } from "@/constants/state";
|
||||
import { copyUrlToClipboard } from "@/helpers/string.helper";
|
||||
import { useEventTracker, useIssues, useProjectState, useUser } from "@/hooks/store";
|
||||
// components
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
import { copyUrlToClipboard } from "@/helpers/string.helper";
|
||||
// hooks
|
||||
import { useEventTracker, useIssues, useProjectState, useUser } from "@/hooks/store";
|
||||
// types
|
||||
import { IQuickActionProps } from "../list/list-view-types";
|
||||
// constants
|
||||
|
||||
export const CycleIssueQuickActions: React.FC<IQuickActionProps> = observer((props) => {
|
||||
const {
|
||||
@ -32,6 +32,7 @@ export const CycleIssueQuickActions: React.FC<IQuickActionProps> = observer((pro
|
||||
portalElement,
|
||||
readOnly = false,
|
||||
placements = "bottom-start",
|
||||
parentRef,
|
||||
} = props;
|
||||
// states
|
||||
const [createUpdateIssueModal, setCreateUpdateIssueModal] = useState(false);
|
||||
@ -80,6 +81,73 @@ export const CycleIssueQuickActions: React.FC<IQuickActionProps> = observer((pro
|
||||
["id"]
|
||||
);
|
||||
|
||||
const MENU_ITEMS: TContextMenuItem[] = [
|
||||
{
|
||||
key: "edit",
|
||||
title: "Edit",
|
||||
icon: Pencil,
|
||||
action: () => {
|
||||
setIssueToEdit({
|
||||
...issue,
|
||||
cycle_id: cycleId?.toString() ?? null,
|
||||
});
|
||||
setTrackElement(activeLayout);
|
||||
setCreateUpdateIssueModal(true);
|
||||
},
|
||||
shouldRender: isEditingAllowed,
|
||||
},
|
||||
{
|
||||
key: "make-a-copy",
|
||||
title: "Make a copy",
|
||||
icon: Copy,
|
||||
action: () => {
|
||||
setTrackElement(activeLayout);
|
||||
setCreateUpdateIssueModal(true);
|
||||
},
|
||||
shouldRender: isEditingAllowed,
|
||||
},
|
||||
{
|
||||
key: "open-in-new-tab",
|
||||
title: "Open in new tab",
|
||||
icon: ExternalLink,
|
||||
action: handleOpenInNewTab,
|
||||
},
|
||||
{
|
||||
key: "copy-link",
|
||||
title: "Copy link",
|
||||
icon: Link,
|
||||
action: handleCopyIssueLink,
|
||||
},
|
||||
{
|
||||
key: "remove-from-cycle",
|
||||
title: "Remove from cycle",
|
||||
icon: XCircle,
|
||||
action: () => handleRemoveFromView?.(),
|
||||
shouldRender: isEditingAllowed,
|
||||
},
|
||||
{
|
||||
key: "archive",
|
||||
title: "Archive",
|
||||
description: isInArchivableGroup ? undefined : "Only completed or canceled\nissues can be archived",
|
||||
icon: ArchiveIcon,
|
||||
className: "items-start",
|
||||
iconClassName: "mt-1",
|
||||
action: () => setArchiveIssueModal(true),
|
||||
disabled: !isInArchivableGroup,
|
||||
shouldRender: isArchivingAllowed,
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
title: "Delete",
|
||||
icon: Trash2,
|
||||
action: () => {
|
||||
setTrackElement(activeLayout);
|
||||
setDeleteIssueModal(true);
|
||||
},
|
||||
shouldRender: isDeletingAllowed,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<ArchiveIssueModal
|
||||
@ -106,104 +174,49 @@ export const CycleIssueQuickActions: React.FC<IQuickActionProps> = observer((pro
|
||||
}}
|
||||
storeType={EIssuesStoreType.CYCLE}
|
||||
/>
|
||||
<ContextMenu parentRef={parentRef} items={MENU_ITEMS} />
|
||||
<CustomMenu
|
||||
menuItemsClassName="z-[14]"
|
||||
ellipsis
|
||||
placement={placements}
|
||||
customButton={customActionButton}
|
||||
portalElement={portalElement}
|
||||
maxHeight="lg"
|
||||
menuItemsClassName="z-[14]"
|
||||
closeOnSelect
|
||||
ellipsis
|
||||
>
|
||||
{isEditingAllowed && (
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => {
|
||||
setIssueToEdit({
|
||||
...issue,
|
||||
cycle_id: cycleId?.toString() ?? null,
|
||||
});
|
||||
setTrackElement(activeLayout);
|
||||
setCreateUpdateIssueModal(true);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Pencil className="h-3 w-3" />
|
||||
Edit
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
<CustomMenu.MenuItem onClick={handleOpenInNewTab}>
|
||||
<div className="flex items-center gap-2">
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
Open in new tab
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem onClick={handleCopyIssueLink}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link className="h-3 w-3" />
|
||||
Copy link
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
{isEditingAllowed && (
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => {
|
||||
setTrackElement(activeLayout);
|
||||
setCreateUpdateIssueModal(true);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Copy className="h-3 w-3" />
|
||||
Make a copy
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
{isEditingAllowed && (
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => {
|
||||
handleRemoveFromView && handleRemoveFromView();
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<XCircle className="h-3 w-3" />
|
||||
Remove from cycle
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
{isArchivingAllowed && (
|
||||
<CustomMenu.MenuItem onClick={() => setArchiveIssueModal(true)} disabled={!isInArchivableGroup}>
|
||||
{isInArchivableGroup ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<ArchiveIcon className="h-3 w-3" />
|
||||
Archive
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-start gap-2">
|
||||
<ArchiveIcon className="h-3 w-3" />
|
||||
<div className="-mt-1">
|
||||
<p>Archive</p>
|
||||
<p className="text-xs text-custom-text-400">
|
||||
Only completed or canceled
|
||||
<br />
|
||||
issues can be archived
|
||||
{MENU_ITEMS.map((item) => {
|
||||
if (item.shouldRender === false) return null;
|
||||
return (
|
||||
<CustomMenu.MenuItem
|
||||
key={item.key}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
item.action();
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center gap-2",
|
||||
{
|
||||
"text-custom-text-400": item.disabled,
|
||||
},
|
||||
item.className
|
||||
)}
|
||||
>
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
{isDeletingAllowed && (
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => {
|
||||
setTrackElement(activeLayout);
|
||||
setDeleteIssueModal(true);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Trash2 className="h-3 w-3" />
|
||||
Delete
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
</CustomMenu.MenuItem>
|
||||
);
|
||||
})}
|
||||
</CustomMenu>
|
||||
</>
|
||||
);
|
||||
|
@ -6,19 +6,30 @@ import { Pencil, Trash2 } from "lucide-react";
|
||||
// types
|
||||
import { TIssue } from "@plane/types";
|
||||
// ui
|
||||
import { CustomMenu } from "@plane/ui";
|
||||
import { ContextMenu, CustomMenu, TContextMenuItem } from "@plane/ui";
|
||||
// components
|
||||
import { CreateUpdateIssueModal, DeleteIssueModal } from "@/components/issues";
|
||||
// constant
|
||||
import { EIssuesStoreType } from "@/constants/issue";
|
||||
import { EUserProjectRoles } from "@/constants/project";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
// hooks
|
||||
import { useEventTracker, useIssues, useUser } from "@/hooks/store";
|
||||
// types
|
||||
import { IQuickActionProps } from "../list/list-view-types";
|
||||
|
||||
export const DraftIssueQuickActions: React.FC<IQuickActionProps> = observer((props) => {
|
||||
const { issue, handleDelete, handleUpdate, customActionButton, portalElement, readOnly = false } = props;
|
||||
const {
|
||||
issue,
|
||||
handleDelete,
|
||||
handleUpdate,
|
||||
customActionButton,
|
||||
portalElement,
|
||||
readOnly = false,
|
||||
placements = "bottom-end",
|
||||
parentRef,
|
||||
} = props;
|
||||
// states
|
||||
const [createUpdateIssueModal, setCreateUpdateIssueModal] = useState(false);
|
||||
const [issueToEdit, setIssueToEdit] = useState<TIssue | undefined>(undefined);
|
||||
@ -44,6 +55,30 @@ export const DraftIssueQuickActions: React.FC<IQuickActionProps> = observer((pro
|
||||
["id"]
|
||||
);
|
||||
|
||||
const MENU_ITEMS: TContextMenuItem[] = [
|
||||
{
|
||||
key: "edit",
|
||||
title: "Edit",
|
||||
icon: Pencil,
|
||||
action: () => {
|
||||
setTrackElement(activeLayout);
|
||||
setIssueToEdit(issue);
|
||||
setCreateUpdateIssueModal(true);
|
||||
},
|
||||
shouldRender: isEditingAllowed,
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
title: "Delete",
|
||||
icon: Trash2,
|
||||
action: () => {
|
||||
setTrackElement(activeLayout);
|
||||
setDeleteIssueModal(true);
|
||||
},
|
||||
shouldRender: isDeletingAllowed,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<DeleteIssueModal
|
||||
@ -52,7 +87,6 @@ export const DraftIssueQuickActions: React.FC<IQuickActionProps> = observer((pro
|
||||
handleClose={() => setDeleteIssueModal(false)}
|
||||
onSubmit={handleDelete}
|
||||
/>
|
||||
|
||||
<CreateUpdateIssueModal
|
||||
isOpen={createUpdateIssueModal}
|
||||
onClose={() => {
|
||||
@ -66,43 +100,49 @@ export const DraftIssueQuickActions: React.FC<IQuickActionProps> = observer((pro
|
||||
storeType={EIssuesStoreType.PROJECT}
|
||||
isDraft
|
||||
/>
|
||||
|
||||
<ContextMenu parentRef={parentRef} items={MENU_ITEMS} />
|
||||
<CustomMenu
|
||||
menuItemsClassName="z-[14]"
|
||||
placement="bottom-start"
|
||||
ellipsis
|
||||
customButton={customActionButton}
|
||||
portalElement={portalElement}
|
||||
maxHeight="lg"
|
||||
placement={placements}
|
||||
menuItemsClassName="z-[14]"
|
||||
closeOnSelect
|
||||
ellipsis
|
||||
>
|
||||
{isEditingAllowed && (
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => {
|
||||
setTrackElement(activeLayout);
|
||||
setIssueToEdit(issue);
|
||||
setCreateUpdateIssueModal(true);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Pencil className="h-3 w-3" />
|
||||
Edit
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
{isDeletingAllowed && (
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => {
|
||||
setTrackElement(activeLayout);
|
||||
setDeleteIssueModal(true);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Trash2 className="h-3 w-3" />
|
||||
Delete
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
{MENU_ITEMS.map((item) => {
|
||||
if (item.shouldRender === false) return null;
|
||||
return (
|
||||
<CustomMenu.MenuItem
|
||||
key={item.key}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
item.action();
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center gap-2",
|
||||
{
|
||||
"text-custom-text-400": item.disabled,
|
||||
},
|
||||
item.className
|
||||
)}
|
||||
>
|
||||
{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>
|
||||
</>
|
||||
);
|
||||
|
@ -2,23 +2,24 @@ import { useState } from "react";
|
||||
import omit from "lodash/omit";
|
||||
import { observer } from "mobx-react";
|
||||
import { useRouter } from "next/router";
|
||||
// hooks
|
||||
// ui
|
||||
import { Copy, ExternalLink, Link, Pencil, Trash2, XCircle } from "lucide-react";
|
||||
// types
|
||||
import { TIssue } from "@plane/types";
|
||||
import { ArchiveIcon, CustomMenu, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
// ui
|
||||
import { ArchiveIcon, ContextMenu, CustomMenu, TContextMenuItem, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
// components
|
||||
import { ArchiveIssueModal, CreateUpdateIssueModal, DeleteIssueModal } from "@/components/issues";
|
||||
// constants
|
||||
import { EIssuesStoreType } from "@/constants/issue";
|
||||
import { EUserProjectRoles } from "@/constants/project";
|
||||
import { STATE_GROUPS } from "@/constants/state";
|
||||
import { copyUrlToClipboard } from "@/helpers/string.helper";
|
||||
import { useIssues, useEventTracker, useUser, useProjectState } from "@/hooks/store";
|
||||
// components
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
import { copyUrlToClipboard } from "@/helpers/string.helper";
|
||||
// hooks
|
||||
import { useIssues, useEventTracker, useUser, useProjectState } from "@/hooks/store";
|
||||
// types
|
||||
import { IQuickActionProps } from "../list/list-view-types";
|
||||
// constants
|
||||
|
||||
export const ModuleIssueQuickActions: React.FC<IQuickActionProps> = observer((props) => {
|
||||
const {
|
||||
@ -31,6 +32,7 @@ export const ModuleIssueQuickActions: React.FC<IQuickActionProps> = observer((pr
|
||||
portalElement,
|
||||
readOnly = false,
|
||||
placements = "bottom-start",
|
||||
parentRef,
|
||||
} = props;
|
||||
// states
|
||||
const [createUpdateIssueModal, setCreateUpdateIssueModal] = useState(false);
|
||||
@ -79,6 +81,70 @@ export const ModuleIssueQuickActions: React.FC<IQuickActionProps> = observer((pr
|
||||
["id"]
|
||||
);
|
||||
|
||||
const MENU_ITEMS: TContextMenuItem[] = [
|
||||
{
|
||||
key: "edit",
|
||||
title: "Edit",
|
||||
icon: Pencil,
|
||||
action: () => {
|
||||
setIssueToEdit({ ...issue, module_ids: moduleId ? [moduleId.toString()] : [] });
|
||||
setTrackElement(activeLayout);
|
||||
setCreateUpdateIssueModal(true);
|
||||
},
|
||||
shouldRender: isEditingAllowed,
|
||||
},
|
||||
{
|
||||
key: "make-a-copy",
|
||||
title: "Make a copy",
|
||||
icon: Copy,
|
||||
action: () => {
|
||||
setTrackElement(activeLayout);
|
||||
setCreateUpdateIssueModal(true);
|
||||
},
|
||||
shouldRender: isEditingAllowed,
|
||||
},
|
||||
{
|
||||
key: "open-in-new-tab",
|
||||
title: "Open in new tab",
|
||||
icon: ExternalLink,
|
||||
action: handleOpenInNewTab,
|
||||
},
|
||||
{
|
||||
key: "copy-link",
|
||||
title: "Copy link",
|
||||
icon: Link,
|
||||
action: handleCopyIssueLink,
|
||||
},
|
||||
{
|
||||
key: "remove-from-module",
|
||||
title: "Remove from module",
|
||||
icon: XCircle,
|
||||
action: () => handleRemoveFromView?.(),
|
||||
shouldRender: isEditingAllowed,
|
||||
},
|
||||
{
|
||||
key: "archive",
|
||||
title: "Archive",
|
||||
description: isInArchivableGroup ? undefined : "Only completed or canceled\nissues can be archived",
|
||||
icon: ArchiveIcon,
|
||||
className: "items-start",
|
||||
iconClassName: "mt-1",
|
||||
action: () => setArchiveIssueModal(true),
|
||||
disabled: !isInArchivableGroup,
|
||||
shouldRender: isArchivingAllowed,
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
title: "Delete",
|
||||
icon: Trash2,
|
||||
action: () => {
|
||||
setTrackElement(activeLayout);
|
||||
setDeleteIssueModal(true);
|
||||
},
|
||||
shouldRender: isDeletingAllowed,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<ArchiveIssueModal
|
||||
@ -105,103 +171,49 @@ export const ModuleIssueQuickActions: React.FC<IQuickActionProps> = observer((pr
|
||||
}}
|
||||
storeType={EIssuesStoreType.MODULE}
|
||||
/>
|
||||
<ContextMenu parentRef={parentRef} items={MENU_ITEMS} />
|
||||
<CustomMenu
|
||||
menuItemsClassName="z-[14]"
|
||||
ellipsis
|
||||
placement={placements}
|
||||
customButton={customActionButton}
|
||||
portalElement={portalElement}
|
||||
maxHeight="lg"
|
||||
menuItemsClassName="z-[14]"
|
||||
closeOnSelect
|
||||
ellipsis
|
||||
>
|
||||
{isEditingAllowed && (
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => {
|
||||
setIssueToEdit({ ...issue, module_ids: moduleId ? [moduleId.toString()] : [] });
|
||||
setTrackElement(activeLayout);
|
||||
setCreateUpdateIssueModal(true);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Pencil className="h-3 w-3" />
|
||||
Edit
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
<CustomMenu.MenuItem onClick={handleOpenInNewTab}>
|
||||
<div className="flex items-center gap-2">
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
Open in new tab
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem onClick={handleCopyIssueLink}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link className="h-3 w-3" />
|
||||
Copy link
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
{isEditingAllowed && (
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => {
|
||||
setTrackElement(activeLayout);
|
||||
setCreateUpdateIssueModal(true);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Copy className="h-3 w-3" />
|
||||
Make a copy
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
{isEditingAllowed && (
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => {
|
||||
handleRemoveFromView && handleRemoveFromView();
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<XCircle className="h-3 w-3" />
|
||||
Remove from module
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
{isArchivingAllowed && (
|
||||
<CustomMenu.MenuItem onClick={() => setArchiveIssueModal(true)} disabled={!isInArchivableGroup}>
|
||||
{isInArchivableGroup ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<ArchiveIcon className="h-3 w-3" />
|
||||
Archive
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-start gap-2">
|
||||
<ArchiveIcon className="h-3 w-3" />
|
||||
<div className="-mt-1">
|
||||
<p>Archive</p>
|
||||
<p className="text-xs text-custom-text-400">
|
||||
Only completed or canceled
|
||||
<br />
|
||||
issues can be archived
|
||||
{MENU_ITEMS.map((item) => {
|
||||
if (item.shouldRender === false) return null;
|
||||
return (
|
||||
<CustomMenu.MenuItem
|
||||
key={item.key}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
item.action();
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center gap-2",
|
||||
{
|
||||
"text-custom-text-400": item.disabled,
|
||||
},
|
||||
item.className
|
||||
)}
|
||||
>
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
{isDeletingAllowed && (
|
||||
<CustomMenu.MenuItem
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setTrackElement(activeLayout);
|
||||
setDeleteIssueModal(true);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Trash2 className="h-3 w-3" />
|
||||
Delete
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
</CustomMenu.MenuItem>
|
||||
);
|
||||
})}
|
||||
</CustomMenu>
|
||||
</>
|
||||
);
|
||||
|
@ -2,22 +2,24 @@ import { useState } from "react";
|
||||
import omit from "lodash/omit";
|
||||
import { observer } from "mobx-react";
|
||||
import { useRouter } from "next/router";
|
||||
// hooks
|
||||
import { Copy, ExternalLink, Link, Pencil, Trash2 } from "lucide-react";
|
||||
// types
|
||||
import { TIssue } from "@plane/types";
|
||||
import { ArchiveIcon, CustomMenu, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
// ui
|
||||
import { ArchiveIcon, ContextMenu, CustomMenu, TContextMenuItem, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
// components
|
||||
import { ArchiveIssueModal, CreateUpdateIssueModal, DeleteIssueModal } from "@/components/issues";
|
||||
// constants
|
||||
import { EIssuesStoreType } from "@/constants/issue";
|
||||
import { EUserProjectRoles } from "@/constants/project";
|
||||
import { STATE_GROUPS } from "@/constants/state";
|
||||
import { copyUrlToClipboard } from "@/helpers/string.helper";
|
||||
import { useEventTracker, useIssues, useProjectState, useUser } from "@/hooks/store";
|
||||
// ui
|
||||
// components
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
import { copyUrlToClipboard } from "@/helpers/string.helper";
|
||||
// hooks
|
||||
import { useEventTracker, useIssues, useProjectState, useUser } from "@/hooks/store";
|
||||
// types
|
||||
import { IQuickActionProps } from "../list/list-view-types";
|
||||
// constant
|
||||
|
||||
export const ProjectIssueQuickActions: React.FC<IQuickActionProps> = observer((props) => {
|
||||
const {
|
||||
@ -28,7 +30,8 @@ export const ProjectIssueQuickActions: React.FC<IQuickActionProps> = observer((p
|
||||
customActionButton,
|
||||
portalElement,
|
||||
readOnly = false,
|
||||
placements = "bottom-start",
|
||||
placements = "bottom-end",
|
||||
parentRef,
|
||||
} = props;
|
||||
// router
|
||||
const router = useRouter();
|
||||
@ -56,9 +59,6 @@ export const ProjectIssueQuickActions: React.FC<IQuickActionProps> = observer((p
|
||||
const isDeletingAllowed = isEditingAllowed;
|
||||
|
||||
const issueLink = `${workspaceSlug}/projects/${issue.project_id}/issues/${issue.id}`;
|
||||
|
||||
const handleOpenInNewTab = () => window.open(`/${issueLink}`, "_blank");
|
||||
|
||||
const handleCopyIssueLink = () =>
|
||||
copyUrlToClipboard(issueLink).then(() =>
|
||||
setToast({
|
||||
@ -67,6 +67,7 @@ export const ProjectIssueQuickActions: React.FC<IQuickActionProps> = observer((p
|
||||
message: "Issue link copied to clipboard",
|
||||
})
|
||||
);
|
||||
const handleOpenInNewTab = () => window.open(`/${issueLink}`, "_blank");
|
||||
|
||||
const isDraftIssue = router?.asPath?.includes("draft-issues") || false;
|
||||
|
||||
@ -79,6 +80,63 @@ export const ProjectIssueQuickActions: React.FC<IQuickActionProps> = observer((p
|
||||
["id"]
|
||||
);
|
||||
|
||||
const MENU_ITEMS: TContextMenuItem[] = [
|
||||
{
|
||||
key: "edit",
|
||||
title: "Edit",
|
||||
icon: Pencil,
|
||||
action: () => {
|
||||
setTrackElement(activeLayout);
|
||||
setIssueToEdit(issue);
|
||||
setCreateUpdateIssueModal(true);
|
||||
},
|
||||
shouldRender: isEditingAllowed,
|
||||
},
|
||||
{
|
||||
key: "make-a-copy",
|
||||
title: "Make a copy",
|
||||
icon: Copy,
|
||||
action: () => {
|
||||
setTrackElement(activeLayout);
|
||||
setCreateUpdateIssueModal(true);
|
||||
},
|
||||
shouldRender: isEditingAllowed,
|
||||
},
|
||||
{
|
||||
key: "open-in-new-tab",
|
||||
title: "Open in new tab",
|
||||
icon: ExternalLink,
|
||||
action: handleOpenInNewTab,
|
||||
},
|
||||
{
|
||||
key: "copy-link",
|
||||
title: "Copy link",
|
||||
icon: Link,
|
||||
action: handleCopyIssueLink,
|
||||
},
|
||||
{
|
||||
key: "archive",
|
||||
title: "Archive",
|
||||
description: isInArchivableGroup ? undefined : "Only completed or canceled\nissues can be archived",
|
||||
icon: ArchiveIcon,
|
||||
className: "items-start",
|
||||
iconClassName: "mt-1",
|
||||
action: () => setArchiveIssueModal(true),
|
||||
disabled: !isInArchivableGroup,
|
||||
shouldRender: isArchivingAllowed,
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
title: "Delete",
|
||||
icon: Trash2,
|
||||
action: () => {
|
||||
setTrackElement(activeLayout);
|
||||
setDeleteIssueModal(true);
|
||||
},
|
||||
shouldRender: isDeletingAllowed,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<ArchiveIssueModal
|
||||
@ -106,89 +164,49 @@ export const ProjectIssueQuickActions: React.FC<IQuickActionProps> = observer((p
|
||||
storeType={EIssuesStoreType.PROJECT}
|
||||
isDraft={isDraftIssue}
|
||||
/>
|
||||
<ContextMenu parentRef={parentRef} items={MENU_ITEMS} />
|
||||
<CustomMenu
|
||||
menuItemsClassName="z-[14]"
|
||||
ellipsis
|
||||
placement={placements}
|
||||
customButton={customActionButton}
|
||||
portalElement={portalElement}
|
||||
maxHeight="lg"
|
||||
menuItemsClassName="z-[14]"
|
||||
closeOnSelect
|
||||
ellipsis
|
||||
>
|
||||
{isEditingAllowed && (
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => {
|
||||
setTrackElement(activeLayout);
|
||||
setIssueToEdit(issue);
|
||||
setCreateUpdateIssueModal(true);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Pencil className="h-3 w-3" />
|
||||
Edit
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
<CustomMenu.MenuItem onClick={handleOpenInNewTab}>
|
||||
<div className="flex items-center gap-2">
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
Open in new tab
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem onClick={handleCopyIssueLink}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link className="h-3 w-3" />
|
||||
Copy link
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
{isEditingAllowed && (
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => {
|
||||
setTrackElement(activeLayout);
|
||||
setCreateUpdateIssueModal(true);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Copy className="h-3 w-3" />
|
||||
Make a copy
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
{isArchivingAllowed && (
|
||||
<CustomMenu.MenuItem onClick={() => setArchiveIssueModal(true)} disabled={!isInArchivableGroup}>
|
||||
{isInArchivableGroup ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<ArchiveIcon className="h-3 w-3" />
|
||||
Archive
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-start gap-2">
|
||||
<ArchiveIcon className="h-3 w-3" />
|
||||
<div className="-mt-1">
|
||||
<p>Archive</p>
|
||||
<p className="text-xs text-custom-text-400">
|
||||
Only completed or canceled
|
||||
<br />
|
||||
issues can be archived
|
||||
{MENU_ITEMS.map((item) => {
|
||||
if (item.shouldRender === false) return null;
|
||||
return (
|
||||
<CustomMenu.MenuItem
|
||||
key={item.key}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
item.action();
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center gap-2",
|
||||
{
|
||||
"text-custom-text-400": item.disabled,
|
||||
},
|
||||
item.className
|
||||
)}
|
||||
>
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
{isDeletingAllowed && (
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => {
|
||||
setTrackElement(activeLayout);
|
||||
setDeleteIssueModal(true);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Trash2 className="h-3 w-3" />
|
||||
Delete
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
</CustomMenu.MenuItem>
|
||||
);
|
||||
})}
|
||||
</CustomMenu>
|
||||
</>
|
||||
);
|
||||
|
@ -3,7 +3,7 @@ import isEmpty from "lodash/isEmpty";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useRouter } from "next/router";
|
||||
import useSWR from "swr";
|
||||
import { TIssue, IIssueDisplayFilterOptions } from "@plane/types";
|
||||
import { IIssueDisplayFilterOptions } from "@plane/types";
|
||||
// hooks
|
||||
// components
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
@ -19,6 +19,7 @@ import { EUserProjectRoles } from "@/constants/project";
|
||||
import { useApplication, useEventTracker, useGlobalView, useIssues, useProject, useUser } from "@/hooks/store";
|
||||
import { useIssuesActions } from "@/hooks/use-issues-actions";
|
||||
import { useWorkspaceIssueProperties } from "@/hooks/use-workspace-issue-properties";
|
||||
import { TRenderQuickActions } from "../list/list-view-types";
|
||||
|
||||
export const AllIssueLayoutRoot: React.FC = observer(() => {
|
||||
// router
|
||||
@ -127,9 +128,10 @@ export const AllIssueLayoutRoot: React.FC = observer(() => {
|
||||
[updateFilters, workspaceSlug, globalViewId]
|
||||
);
|
||||
|
||||
const renderQuickActions = useCallback(
|
||||
(issue: TIssue, customActionButton?: React.ReactElement, portalElement?: HTMLDivElement | null) => (
|
||||
const renderQuickActions: TRenderQuickActions = useCallback(
|
||||
({ issue, parentRef, customActionButton, placement, portalElement }) => (
|
||||
<AllIssueQuickActions
|
||||
parentRef={parentRef}
|
||||
customActionButton={customActionButton}
|
||||
issue={issue}
|
||||
handleDelete={async () => removeIssue(issue.project_id, issue.id)}
|
||||
@ -137,6 +139,7 @@ export const AllIssueLayoutRoot: React.FC = observer(() => {
|
||||
handleArchive={async () => archiveIssue && archiveIssue(issue.project_id, issue.id)}
|
||||
portalElement={portalElement}
|
||||
readOnly={!canEditProperties(issue.project_id)}
|
||||
placements={placement}
|
||||
/>
|
||||
),
|
||||
[canEditProperties, removeIssue, updateIssue, archiveIssue]
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { FC, useCallback } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useRouter } from "next/router";
|
||||
import { TIssue, IIssueDisplayFilterOptions, TUnGroupedIssues } from "@plane/types";
|
||||
import { IIssueDisplayFilterOptions, TUnGroupedIssues } from "@plane/types";
|
||||
// hooks
|
||||
import { EIssueFilterType, EIssuesStoreType } from "@/constants/issue";
|
||||
import { EUserProjectRoles } from "@/constants/project";
|
||||
@ -10,7 +10,7 @@ import { useIssuesActions } from "@/hooks/use-issues-actions";
|
||||
// views
|
||||
// types
|
||||
// constants
|
||||
import { IQuickActionProps } from "../list/list-view-types";
|
||||
import { IQuickActionProps, TRenderQuickActions } from "../list/list-view-types";
|
||||
import { SpreadsheetView } from "./spreadsheet-view";
|
||||
|
||||
export type SpreadsheetStoreType =
|
||||
@ -66,9 +66,10 @@ export const BaseSpreadsheetRoot = observer((props: IBaseSpreadsheetRoot) => {
|
||||
[projectId, updateFilters]
|
||||
);
|
||||
|
||||
const renderQuickActions = useCallback(
|
||||
(issue: TIssue, customActionButton?: React.ReactElement, portalElement?: HTMLDivElement | null) => (
|
||||
const renderQuickActions: TRenderQuickActions = useCallback(
|
||||
({ issue, parentRef, customActionButton, placement, portalElement }) => (
|
||||
<QuickActions
|
||||
parentRef={parentRef}
|
||||
customActionButton={customActionButton}
|
||||
issue={issue}
|
||||
handleDelete={async () => removeIssue(issue.project_id, issue.id)}
|
||||
@ -78,6 +79,7 @@ export const BaseSpreadsheetRoot = observer((props: IBaseSpreadsheetRoot) => {
|
||||
handleRestore={async () => restoreIssue && restoreIssue(issue.project_id, issue.id)}
|
||||
portalElement={portalElement}
|
||||
readOnly={!isEditingAllowed || isCompletedCycle}
|
||||
placements={placement}
|
||||
/>
|
||||
),
|
||||
[isEditingAllowed, isCompletedCycle, removeIssue, updateIssue, removeIssueFromView, archiveIssue, restoreIssue]
|
||||
|
@ -16,17 +16,14 @@ import useOutsideClickDetector from "@/hooks/use-outside-click-detector";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// types
|
||||
// local components
|
||||
import { TRenderQuickActions } from "../list/list-view-types";
|
||||
import { WithDisplayPropertiesHOC } from "../properties/with-display-properties-HOC";
|
||||
import { IssueColumn } from "./issue-column";
|
||||
|
||||
interface Props {
|
||||
displayProperties: IIssueDisplayProperties;
|
||||
isEstimateEnabled: boolean;
|
||||
quickActions: (
|
||||
issue: TIssue,
|
||||
customActionButton?: React.ReactElement,
|
||||
portalElement?: HTMLDivElement | null
|
||||
) => React.ReactNode;
|
||||
quickActions: TRenderQuickActions;
|
||||
canEditProperties: (projectId: string | undefined) => boolean;
|
||||
updateIssue: ((projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined;
|
||||
portalElement: React.MutableRefObject<HTMLDivElement | null>;
|
||||
@ -112,11 +109,7 @@ export const SpreadsheetIssueRow = observer((props: Props) => {
|
||||
interface IssueRowDetailsProps {
|
||||
displayProperties: IIssueDisplayProperties;
|
||||
isEstimateEnabled: boolean;
|
||||
quickActions: (
|
||||
issue: TIssue,
|
||||
customActionButton?: React.ReactElement,
|
||||
portalElement?: HTMLDivElement | null
|
||||
) => React.ReactNode;
|
||||
quickActions: TRenderQuickActions;
|
||||
canEditProperties: (projectId: string | undefined) => boolean;
|
||||
updateIssue: ((projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined;
|
||||
portalElement: React.MutableRefObject<HTMLDivElement | null>;
|
||||
@ -143,16 +136,18 @@ const IssueRowDetails = observer((props: IssueRowDetailsProps) => {
|
||||
setExpanded,
|
||||
spreadsheetColumnsList,
|
||||
} = props;
|
||||
// states
|
||||
const [isMenuActive, setIsMenuActive] = useState(false);
|
||||
// refs
|
||||
const cellRef = useRef(null);
|
||||
const menuActionRef = useRef<HTMLDivElement | null>(null);
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
//hooks
|
||||
// hooks
|
||||
const { getProjectIdentifierById } = useProject();
|
||||
const { getIsIssuePeeked, setPeekIssue } = useIssueDetail();
|
||||
const { isMobile } = usePlatformOS();
|
||||
// states
|
||||
const [isMenuActive, setIsMenuActive] = useState(false);
|
||||
const menuActionRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const handleIssuePeekOverview = (issue: TIssue) =>
|
||||
workspaceSlug &&
|
||||
@ -196,9 +191,10 @@ const IssueRowDetails = observer((props: IssueRowDetailsProps) => {
|
||||
return (
|
||||
<>
|
||||
<td
|
||||
ref={cellRef}
|
||||
id={`issue-${issueDetail.id}`}
|
||||
className={cn(
|
||||
"sticky group left-0 h-11 w-[28rem] flex items-center bg-custom-background-100 text-sm after:absolute border-r-[0.5px] z-10 border-custom-border-200",
|
||||
"group sticky left-0 h-11 w-[28rem] flex items-center bg-custom-background-100 text-sm after:absolute border-r-[0.5px] z-10 border-custom-border-200",
|
||||
{
|
||||
"border-b-[0.5px]": !getIsIssuePeeked(issueDetail.id),
|
||||
"border border-custom-primary-70 hover:border-custom-primary-70": getIsIssuePeeked(issueDetail.id),
|
||||
@ -214,7 +210,7 @@ const IssueRowDetails = observer((props: IssueRowDetailsProps) => {
|
||||
>
|
||||
<div className="relative flex cursor-pointer items-center text-center text-xs hover:text-custom-text-100">
|
||||
<span
|
||||
className={`flex items-center justify-center font-medium group-hover:opacity-0 ${
|
||||
className={`flex items-center justify-center font-medium group-hover:opacity-0 ${
|
||||
isMenuActive ? "opacity-0" : "opacity-100"
|
||||
}`}
|
||||
>
|
||||
@ -222,7 +218,12 @@ const IssueRowDetails = observer((props: IssueRowDetailsProps) => {
|
||||
</span>
|
||||
|
||||
<div className={`absolute left-2.5 top-0 hidden group-hover:block ${isMenuActive ? "!block" : ""}`}>
|
||||
{quickActions(issueDetail, customActionButton, portalElement.current)}
|
||||
{quickActions({
|
||||
issue: issueDetail,
|
||||
parentRef: cellRef,
|
||||
customActionButton,
|
||||
portalElement: portalElement.current,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -4,6 +4,7 @@ import { IIssueDisplayFilterOptions, IIssueDisplayProperties, TIssue } from "@pl
|
||||
//types
|
||||
import { useTableKeyboardNavigation } from "@/hooks/use-table-keyboard-navigation";
|
||||
//components
|
||||
import { TRenderQuickActions } from "../list/list-view-types";
|
||||
import { SpreadsheetIssueRow } from "./issue-row";
|
||||
import { SpreadsheetHeader } from "./spreadsheet-header";
|
||||
|
||||
@ -13,11 +14,7 @@ type Props = {
|
||||
handleDisplayFilterUpdate: (data: Partial<IIssueDisplayFilterOptions>) => void;
|
||||
issueIds: string[];
|
||||
isEstimateEnabled: boolean;
|
||||
quickActions: (
|
||||
issue: TIssue,
|
||||
customActionButton?: React.ReactElement,
|
||||
portalElement?: HTMLDivElement | null
|
||||
) => React.ReactNode;
|
||||
quickActions: TRenderQuickActions;
|
||||
updateIssue: ((projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined;
|
||||
canEditProperties: (projectId: string | undefined) => boolean;
|
||||
portalElement: React.MutableRefObject<HTMLDivElement | null>;
|
||||
|
@ -6,6 +6,7 @@ import { Spinner } from "@plane/ui";
|
||||
import { SpreadsheetQuickAddIssueForm } from "@/components/issues";
|
||||
import { SPREADSHEET_PROPERTY_LIST } from "@/constants/spreadsheet";
|
||||
import { useProject } from "@/hooks/store";
|
||||
import { TRenderQuickActions } from "../list/list-view-types";
|
||||
import { SpreadsheetTable } from "./spreadsheet-table";
|
||||
// types
|
||||
//hooks
|
||||
@ -15,11 +16,7 @@ type Props = {
|
||||
displayFilters: IIssueDisplayFilterOptions;
|
||||
handleDisplayFilterUpdate: (data: Partial<IIssueDisplayFilterOptions>) => void;
|
||||
issueIds: string[] | undefined;
|
||||
quickActions: (
|
||||
issue: TIssue,
|
||||
customActionButton?: React.ReactElement,
|
||||
portalElement?: HTMLDivElement | null
|
||||
) => React.ReactNode;
|
||||
quickActions: TRenderQuickActions;
|
||||
updateIssue: ((projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined;
|
||||
openIssuesListModal?: (() => void) | null;
|
||||
quickAddCallback?: (
|
||||
|
@ -50,7 +50,7 @@ export const ArchivedModulesView: FC<IArchivedModulesView> = observer((props) =>
|
||||
<div className="flex h-full w-full justify-between">
|
||||
<div className="flex h-full w-full flex-col overflow-y-auto vertical-scrollbar scrollbar-lg">
|
||||
{filteredArchivedModuleIds.map((moduleId) => (
|
||||
<ModuleListItem key={moduleId} moduleId={moduleId} isArchived />
|
||||
<ModuleListItem key={moduleId} moduleId={moduleId} />
|
||||
))}
|
||||
</div>
|
||||
<ModulePeekOverview
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React from "react";
|
||||
import React, { useRef } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
@ -24,6 +24,8 @@ type Props = {
|
||||
|
||||
export const ModuleCardItem: React.FC<Props> = observer((props) => {
|
||||
const { moduleId } = props;
|
||||
// refs
|
||||
const parentRef = useRef(null);
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
@ -145,7 +147,7 @@ export const ModuleCardItem: React.FC<Props> = observer((props) => {
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<Link href={`/${workspaceSlug}/projects/${moduleDetails.project_id}/modules/${moduleDetails.id}`}>
|
||||
<Link ref={parentRef} href={`/${workspaceSlug}/projects/${moduleDetails.project_id}/modules/${moduleDetails.id}`}>
|
||||
<div className="flex h-44 w-full flex-col justify-between rounded border border-custom-border-100 bg-custom-background-100 p-4 text-sm hover:shadow-md">
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
@ -239,6 +241,7 @@ export const ModuleCardItem: React.FC<Props> = observer((props) => {
|
||||
)}
|
||||
{workspaceSlug && projectId && (
|
||||
<ModuleQuickActions
|
||||
parentRef={parentRef}
|
||||
moduleId={moduleId}
|
||||
projectId={projectId.toString()}
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
|
@ -23,11 +23,11 @@ import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
type Props = {
|
||||
moduleId: string;
|
||||
moduleDetails: IModule;
|
||||
isArchived: boolean;
|
||||
parentRef: React.RefObject<HTMLDivElement>;
|
||||
};
|
||||
|
||||
export const ModuleListItemAction: FC<Props> = observer((props) => {
|
||||
const { moduleId, moduleDetails, isArchived } = props;
|
||||
const { moduleId, moduleDetails, parentRef } = props;
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
@ -146,7 +146,7 @@ export const ModuleListItemAction: FC<Props> = observer((props) => {
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
{isEditingAllowed && !isArchived && (
|
||||
{isEditingAllowed && !moduleDetails.archived_at && (
|
||||
<FavoriteStar
|
||||
onClick={(e) => {
|
||||
if (moduleDetails.is_favorite) handleRemoveFromFavorites(e);
|
||||
@ -157,10 +157,10 @@ export const ModuleListItemAction: FC<Props> = observer((props) => {
|
||||
)}
|
||||
{workspaceSlug && projectId && (
|
||||
<ModuleQuickActions
|
||||
parentRef={parentRef}
|
||||
moduleId={moduleId}
|
||||
projectId={projectId.toString()}
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
isArchived={isArchived}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React from "react";
|
||||
import React, { useRef } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useRouter } from "next/router";
|
||||
// icons
|
||||
@ -14,11 +14,12 @@ import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
|
||||
type Props = {
|
||||
moduleId: string;
|
||||
isArchived?: boolean;
|
||||
};
|
||||
|
||||
export const ModuleListItem: React.FC<Props> = observer((props) => {
|
||||
const { moduleId, isArchived = false } = props;
|
||||
const { moduleId } = props;
|
||||
// refs
|
||||
const parentRef = useRef(null);
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
@ -63,9 +64,7 @@ export const ModuleListItem: React.FC<Props> = observer((props) => {
|
||||
title={moduleDetails?.name ?? ""}
|
||||
itemLink={`/${workspaceSlug}/projects/${moduleDetails.project_id}/modules/${moduleDetails.id}`}
|
||||
onItemClick={(e) => {
|
||||
if (isArchived) {
|
||||
openModuleOverview(e);
|
||||
}
|
||||
if (moduleDetails.archived_at) openModuleOverview(e);
|
||||
}}
|
||||
prependTitleElement={
|
||||
<CircularProgressIndicator size={30} percentage={progress} strokeWidth={3}>
|
||||
@ -91,9 +90,10 @@ export const ModuleListItem: React.FC<Props> = observer((props) => {
|
||||
</button>
|
||||
}
|
||||
actionableItems={
|
||||
<ModuleListItemAction moduleId={moduleId} moduleDetails={moduleDetails} isArchived={isArchived} />
|
||||
<ModuleListItemAction moduleId={moduleId} moduleDetails={moduleDetails} parentRef={parentRef} />
|
||||
}
|
||||
isMobile={isMobile}
|
||||
parentRef={parentRef}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
@ -2,27 +2,28 @@ import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useRouter } from "next/router";
|
||||
// icons
|
||||
import { ArchiveRestoreIcon, LinkIcon, Pencil, Trash2 } from "lucide-react";
|
||||
import { ArchiveRestoreIcon, ExternalLink, LinkIcon, Pencil, Trash2 } from "lucide-react";
|
||||
// ui
|
||||
import { ArchiveIcon, CustomMenu, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
import { ArchiveIcon, ContextMenu, CustomMenu, TContextMenuItem, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
// components
|
||||
import { ArchiveModuleModal, CreateUpdateModuleModal, DeleteModuleModal } from "@/components/modules";
|
||||
// constants
|
||||
import { EUserProjectRoles } from "@/constants/project";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
import { copyUrlToClipboard } from "@/helpers/string.helper";
|
||||
// hooks
|
||||
import { useModule, useEventTracker, useUser } from "@/hooks/store";
|
||||
|
||||
type Props = {
|
||||
parentRef: React.RefObject<HTMLDivElement>;
|
||||
moduleId: string;
|
||||
projectId: string;
|
||||
workspaceSlug: string;
|
||||
isArchived?: boolean;
|
||||
};
|
||||
|
||||
export const ModuleQuickActions: React.FC<Props> = observer((props) => {
|
||||
const { moduleId, projectId, workspaceSlug, isArchived } = props;
|
||||
const { parentRef, moduleId, projectId, workspaceSlug } = props;
|
||||
// router
|
||||
const router = useRouter();
|
||||
// states
|
||||
@ -37,6 +38,7 @@ export const ModuleQuickActions: React.FC<Props> = observer((props) => {
|
||||
const { getModuleById, restoreModule } = useModule();
|
||||
// derived values
|
||||
const moduleDetails = getModuleById(moduleId);
|
||||
const isArchived = !!moduleDetails?.archived_at;
|
||||
// auth
|
||||
const isEditingAllowed =
|
||||
!!currentWorkspaceAllProjectsRole && currentWorkspaceAllProjectsRole[projectId] >= EUserProjectRoles.MEMBER;
|
||||
@ -44,34 +46,25 @@ export const ModuleQuickActions: React.FC<Props> = observer((props) => {
|
||||
const moduleState = moduleDetails?.status?.toLocaleLowerCase();
|
||||
const isInArchivableGroup = !!moduleState && ["completed", "cancelled"].includes(moduleState);
|
||||
|
||||
const handleCopyText = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
copyUrlToClipboard(`${workspaceSlug}/projects/${projectId}/modules/${moduleId}`).then(() => {
|
||||
const moduleLink = `${workspaceSlug}/projects/${projectId}/modules/${moduleId}`;
|
||||
const handleCopyText = () =>
|
||||
copyUrlToClipboard(moduleLink).then(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Link Copied!",
|
||||
message: "Module link copied to clipboard.",
|
||||
});
|
||||
});
|
||||
};
|
||||
const handleOpenInNewTab = () => window.open(`/${moduleLink}`, "_blank");
|
||||
|
||||
const handleEditModule = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const handleEditModule = () => {
|
||||
setTrackElement("Modules page list layout");
|
||||
setEditModal(true);
|
||||
};
|
||||
|
||||
const handleArchiveModule = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setArchiveModuleModal(true);
|
||||
};
|
||||
const handleArchiveModule = () => setArchiveModuleModal(true);
|
||||
|
||||
const handleRestoreModule = async (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const handleRestoreModule = async () =>
|
||||
await restoreModule(workspaceSlug, projectId, moduleId)
|
||||
.then(() => {
|
||||
setToast({
|
||||
@ -88,15 +81,61 @@ export const ModuleQuickActions: React.FC<Props> = observer((props) => {
|
||||
message: "Module could not be restored. Please try again.",
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const handleDeleteModule = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const handleDeleteModule = () => {
|
||||
setTrackElement("Modules page list layout");
|
||||
setDeleteModal(true);
|
||||
};
|
||||
|
||||
const MENU_ITEMS: TContextMenuItem[] = [
|
||||
{
|
||||
key: "edit",
|
||||
title: "Edit",
|
||||
icon: Pencil,
|
||||
action: handleEditModule,
|
||||
shouldRender: isEditingAllowed && !isArchived,
|
||||
},
|
||||
{
|
||||
key: "open-new-tab",
|
||||
action: handleOpenInNewTab,
|
||||
title: "Open in new tab",
|
||||
icon: ExternalLink,
|
||||
shouldRender: !isArchived,
|
||||
},
|
||||
{
|
||||
key: "copy-link",
|
||||
action: handleCopyText,
|
||||
title: "Copy link",
|
||||
icon: LinkIcon,
|
||||
shouldRender: !isArchived,
|
||||
},
|
||||
{
|
||||
key: "archive",
|
||||
action: handleArchiveModule,
|
||||
title: "Archive",
|
||||
description: isInArchivableGroup ? undefined : "Only completed or canceled\nmodule can be archived.",
|
||||
icon: ArchiveIcon,
|
||||
className: "items-start",
|
||||
iconClassName: "mt-1",
|
||||
shouldRender: isEditingAllowed && !isArchived,
|
||||
disabled: !isInArchivableGroup,
|
||||
},
|
||||
{
|
||||
key: "restore",
|
||||
action: handleRestoreModule,
|
||||
title: "Restore",
|
||||
icon: ArchiveRestoreIcon,
|
||||
shouldRender: isEditingAllowed && isArchived,
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
action: handleDeleteModule,
|
||||
title: "Delete",
|
||||
icon: Trash2,
|
||||
shouldRender: isEditingAllowed,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{moduleDetails && (
|
||||
@ -118,60 +157,42 @@ export const ModuleQuickActions: React.FC<Props> = observer((props) => {
|
||||
<DeleteModuleModal data={moduleDetails} isOpen={deleteModal} onClose={() => setDeleteModal(false)} />
|
||||
</div>
|
||||
)}
|
||||
<CustomMenu ellipsis placement="left-start">
|
||||
{isEditingAllowed && !isArchived && (
|
||||
<CustomMenu.MenuItem onClick={handleEditModule}>
|
||||
<span className="flex items-center justify-start gap-2">
|
||||
<Pencil className="h-3 w-3" />
|
||||
<span>Edit module</span>
|
||||
</span>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
{isEditingAllowed && !isArchived && (
|
||||
<CustomMenu.MenuItem onClick={handleArchiveModule} disabled={!isInArchivableGroup}>
|
||||
{isInArchivableGroup ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<ArchiveIcon className="h-3 w-3" />
|
||||
Archive module
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-start gap-2">
|
||||
<ArchiveIcon className="h-3 w-3" />
|
||||
<div className="-mt-1">
|
||||
<p>Archive module</p>
|
||||
<p className="text-xs text-custom-text-400">
|
||||
Only completed or cancelled <br /> module can be archived.
|
||||
<ContextMenu parentRef={parentRef} items={MENU_ITEMS} />
|
||||
<CustomMenu ellipsis placement="bottom-end" closeOnSelect>
|
||||
{MENU_ITEMS.map((item) => {
|
||||
if (item.shouldRender === false) return null;
|
||||
return (
|
||||
<CustomMenu.MenuItem
|
||||
key={item.key}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
item.action();
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center gap-2",
|
||||
{
|
||||
"text-custom-text-400": item.disabled,
|
||||
},
|
||||
item.className
|
||||
)}
|
||||
>
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
{isEditingAllowed && isArchived && (
|
||||
<CustomMenu.MenuItem onClick={handleRestoreModule}>
|
||||
<span className="flex items-center justify-start gap-2">
|
||||
<ArchiveRestoreIcon className="h-3 w-3" />
|
||||
<span>Restore module</span>
|
||||
</span>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
{!isArchived && (
|
||||
<CustomMenu.MenuItem onClick={handleCopyText}>
|
||||
<span className="flex items-center justify-start gap-2">
|
||||
<LinkIcon className="h-3 w-3" />
|
||||
<span>Copy module link</span>
|
||||
</span>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
<hr className="my-2 border-custom-border-200" />
|
||||
{isEditingAllowed && (
|
||||
<CustomMenu.MenuItem onClick={handleDeleteModule}>
|
||||
<span className="flex items-center justify-start gap-2">
|
||||
<Trash2 className="h-3 w-3" />
|
||||
<span>Delete module</span>
|
||||
</span>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
</CustomMenu.MenuItem>
|
||||
);
|
||||
})}
|
||||
</CustomMenu>
|
||||
</>
|
||||
);
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { ArchiveRestoreIcon, ExternalLink, Link, Lock, Trash2, UsersRound } from "lucide-react";
|
||||
import { ArchiveIcon, CustomMenu, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
import { ArchiveIcon, ContextMenu, CustomMenu, TContextMenuItem, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
// components
|
||||
import { DeletePageModal } from "@/components/pages";
|
||||
// helpers
|
||||
@ -11,12 +11,13 @@ import { usePage } from "@/hooks/store";
|
||||
|
||||
type Props = {
|
||||
pageId: string;
|
||||
parentRef: React.RefObject<HTMLElement>;
|
||||
projectId: string;
|
||||
workspaceSlug: string;
|
||||
};
|
||||
|
||||
export const PageQuickActions: React.FC<Props> = observer((props) => {
|
||||
const { pageId, projectId, workspaceSlug } = props;
|
||||
const { pageId, parentRef, projectId, workspaceSlug } = props;
|
||||
// states
|
||||
const [deletePageModal, setDeletePageModal] = useState(false);
|
||||
// store hooks
|
||||
@ -44,45 +45,39 @@ export const PageQuickActions: React.FC<Props> = observer((props) => {
|
||||
|
||||
const handleOpenInNewTab = () => window.open(`/${pageLink}`, "_blank");
|
||||
|
||||
const MENU_ITEMS: {
|
||||
key: string;
|
||||
action: () => void;
|
||||
label: string;
|
||||
icon: React.FC<any>;
|
||||
shouldRender: boolean;
|
||||
}[] = [
|
||||
const MENU_ITEMS: TContextMenuItem[] = [
|
||||
{
|
||||
key: "copy-link",
|
||||
action: handleCopyText,
|
||||
label: "Copy link",
|
||||
icon: Link,
|
||||
shouldRender: true,
|
||||
key: "make-public-private",
|
||||
action: access === 0 ? makePrivate : makePublic,
|
||||
title: access === 0 ? "Make private" : "Make public",
|
||||
icon: access === 0 ? Lock : UsersRound,
|
||||
shouldRender: canCurrentUserChangeAccess && !archived_at,
|
||||
},
|
||||
{
|
||||
key: "open-new-tab",
|
||||
action: handleOpenInNewTab,
|
||||
label: "Open in new tab",
|
||||
title: "Open in new tab",
|
||||
icon: ExternalLink,
|
||||
shouldRender: true,
|
||||
},
|
||||
{
|
||||
key: "copy-link",
|
||||
action: handleCopyText,
|
||||
title: "Copy link",
|
||||
icon: Link,
|
||||
shouldRender: true,
|
||||
},
|
||||
{
|
||||
key: "archive-restore",
|
||||
action: archived_at ? restore : archive,
|
||||
label: archived_at ? "Restore" : "Archive",
|
||||
title: archived_at ? "Restore" : "Archive",
|
||||
icon: archived_at ? ArchiveRestoreIcon : ArchiveIcon,
|
||||
shouldRender: canCurrentUserArchivePage,
|
||||
},
|
||||
{
|
||||
key: "make-public-private",
|
||||
action: access === 0 ? makePrivate : makePublic,
|
||||
label: access === 0 ? "Make private" : "Make public",
|
||||
icon: access === 0 ? Lock : UsersRound,
|
||||
shouldRender: canCurrentUserChangeAccess && !archived_at,
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
action: () => setDeletePageModal(true),
|
||||
label: "Delete",
|
||||
title: "Delete",
|
||||
icon: Trash2,
|
||||
shouldRender: canCurrentUserDeletePage && !!archived_at,
|
||||
},
|
||||
@ -96,6 +91,7 @@ export const PageQuickActions: React.FC<Props> = observer((props) => {
|
||||
pageId={pageId}
|
||||
projectId={projectId}
|
||||
/>
|
||||
<ContextMenu parentRef={parentRef} items={MENU_ITEMS} />
|
||||
<CustomMenu placement="bottom-end" ellipsis closeOnSelect>
|
||||
{MENU_ITEMS.map((item) => {
|
||||
if (!item.shouldRender) return null;
|
||||
@ -109,8 +105,8 @@ export const PageQuickActions: React.FC<Props> = observer((props) => {
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<item.icon className="h-3 w-3" />
|
||||
{item.label}
|
||||
{item.icon && <item.icon className="h-3 w-3" />}
|
||||
{item.title}
|
||||
</CustomMenu.MenuItem>
|
||||
);
|
||||
})}
|
||||
|
@ -15,10 +15,11 @@ type Props = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
pageId: string;
|
||||
parentRef: React.RefObject<HTMLElement>;
|
||||
};
|
||||
|
||||
export const BlockItemAction: FC<Props> = observer((props) => {
|
||||
const { workspaceSlug, projectId, pageId } = props;
|
||||
const { workspaceSlug, projectId, pageId, parentRef } = props;
|
||||
|
||||
// store hooks
|
||||
const { access, created_at, is_favorite, owned_by, addToFavorites, removeFromFavorites } = usePage(pageId);
|
||||
@ -88,7 +89,7 @@ export const BlockItemAction: FC<Props> = observer((props) => {
|
||||
/>
|
||||
|
||||
{/* quick actions dropdown */}
|
||||
<PageQuickActions pageId={pageId} projectId={projectId} workspaceSlug={workspaceSlug} />
|
||||
<PageQuickActions parentRef={parentRef} pageId={pageId} projectId={projectId} workspaceSlug={workspaceSlug} />
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { FC } from "react";
|
||||
import { FC, useRef } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// components
|
||||
import { ListItem } from "@/components/core/list";
|
||||
@ -15,6 +15,8 @@ type TPageListBlock = {
|
||||
|
||||
export const PageListBlock: FC<TPageListBlock> = observer((props) => {
|
||||
const { workspaceSlug, projectId, pageId } = props;
|
||||
// refs
|
||||
const parentRef = useRef(null);
|
||||
// hooks
|
||||
const { name } = usePage(pageId);
|
||||
const { isMobile } = usePlatformOS();
|
||||
@ -23,8 +25,9 @@ export const PageListBlock: FC<TPageListBlock> = observer((props) => {
|
||||
<ListItem
|
||||
title={name ?? ""}
|
||||
itemLink={`/${workspaceSlug}/projects/${projectId}/pages/${pageId}`}
|
||||
actionableItems={<BlockItemAction workspaceSlug={workspaceSlug} projectId={projectId} pageId={pageId} />}
|
||||
actionableItems={<BlockItemAction workspaceSlug={workspaceSlug} projectId={projectId} pageId={pageId} parentRef={parentRef} />}
|
||||
isMobile={isMobile}
|
||||
parentRef={parentRef}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
@ -1,12 +1,22 @@
|
||||
import React, { useState } from "react";
|
||||
import React, { useRef, useState } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { ArchiveRestoreIcon, Check, LinkIcon, Lock, Pencil, Trash2 } from "lucide-react";
|
||||
import { ArchiveRestoreIcon, Check, ExternalLink, LinkIcon, Lock, Pencil, Trash2, UserPlus } from "lucide-react";
|
||||
// types
|
||||
import type { IProject } from "@plane/types";
|
||||
// ui
|
||||
import { Avatar, AvatarGroup, Button, Tooltip, TOAST_TYPE, setToast, setPromiseToast } from "@plane/ui";
|
||||
import {
|
||||
Avatar,
|
||||
AvatarGroup,
|
||||
Button,
|
||||
Tooltip,
|
||||
TOAST_TYPE,
|
||||
setToast,
|
||||
setPromiseToast,
|
||||
ContextMenu,
|
||||
TContextMenuItem,
|
||||
} from "@plane/ui";
|
||||
// components
|
||||
import { FavoriteStar } from "@/components/core";
|
||||
import { ArchiveRestoreProjectModal, DeleteProjectModal, JoinProjectModal, ProjectLogo } from "@/components/project";
|
||||
@ -30,6 +40,8 @@ export const ProjectCard: React.FC<Props> = observer((props) => {
|
||||
const [deleteProjectModalOpen, setDeleteProjectModal] = useState(false);
|
||||
const [joinProjectModalOpen, setJoinProjectModal] = useState(false);
|
||||
const [restoreProject, setRestoreProject] = useState(false);
|
||||
// refs
|
||||
const projectCardRef = useRef(null);
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
@ -80,14 +92,61 @@ export const ProjectCard: React.FC<Props> = observer((props) => {
|
||||
});
|
||||
};
|
||||
|
||||
const projectLink = `${workspaceSlug}/projects/${project.id}/issues`;
|
||||
const handleCopyText = () =>
|
||||
copyUrlToClipboard(`${workspaceSlug}/projects/${project.id}/issues`).then(() =>
|
||||
copyUrlToClipboard(projectLink).then(() =>
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Link Copied!",
|
||||
message: "Project link copied to clipboard.",
|
||||
})
|
||||
);
|
||||
const handleOpenInNewTab = () => window.open(`/${projectLink}`, "_blank");
|
||||
|
||||
const MENU_ITEMS: TContextMenuItem[] = [
|
||||
{
|
||||
key: "edit",
|
||||
action: () => router.push(`/${workspaceSlug}/projects/${project.id}/settings`),
|
||||
title: "Edit",
|
||||
icon: Pencil,
|
||||
shouldRender: !isArchived && (isOwner || isMember),
|
||||
},
|
||||
{
|
||||
key: "join",
|
||||
action: () => setJoinProjectModal(true),
|
||||
title: "Join",
|
||||
icon: UserPlus,
|
||||
shouldRender: !project.is_member && !isArchived,
|
||||
},
|
||||
{
|
||||
key: "open-new-tab",
|
||||
action: handleOpenInNewTab,
|
||||
title: "Open in new tab",
|
||||
icon: ExternalLink,
|
||||
shouldRender: project.is_member,
|
||||
},
|
||||
{
|
||||
key: "copy-link",
|
||||
action: handleCopyText,
|
||||
title: "Copy link",
|
||||
icon: LinkIcon,
|
||||
shouldRender: true,
|
||||
},
|
||||
{
|
||||
key: "restore",
|
||||
action: () => setRestoreProject(true),
|
||||
title: "Restore",
|
||||
icon: ArchiveRestoreIcon,
|
||||
shouldRender: isArchived && isOwner,
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
action: () => setDeleteProjectModal(true),
|
||||
title: "Delete",
|
||||
icon: Trash2,
|
||||
shouldRender: isArchived && isOwner,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -117,6 +176,7 @@ export const ProjectCard: React.FC<Props> = observer((props) => {
|
||||
/>
|
||||
)}
|
||||
<Link
|
||||
ref={projectCardRef}
|
||||
href={`/${workspaceSlug}/projects/${project.id}/issues`}
|
||||
onClick={(e) => {
|
||||
if (!project.is_member || isArchived) {
|
||||
@ -127,6 +187,7 @@ export const ProjectCard: React.FC<Props> = observer((props) => {
|
||||
}}
|
||||
className="flex flex-col rounded border border-custom-border-200 bg-custom-background-100"
|
||||
>
|
||||
<ContextMenu parentRef={projectCardRef} items={MENU_ITEMS} />
|
||||
<div className="relative h-[118px] w-full rounded-t ">
|
||||
<div className="absolute inset-0 z-[1] bg-gradient-to-t from-black/60 to-transparent" />
|
||||
|
||||
|
@ -1,6 +1,7 @@
|
||||
export * from "./delete-view-modal";
|
||||
export * from "./form";
|
||||
export * from "./modal";
|
||||
export * from "./quick-actions";
|
||||
export * from "./view-list-item";
|
||||
export * from "./views-list";
|
||||
export * from "./view-list-item-action";
|
||||
|
126
web/components/views/quick-actions.tsx
Normal file
126
web/components/views/quick-actions.tsx
Normal file
@ -0,0 +1,126 @@
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { ExternalLink, Link, Pencil, Trash2 } from "lucide-react";
|
||||
// types
|
||||
import { IProjectView } from "@plane/types";
|
||||
// ui
|
||||
import { ContextMenu, CustomMenu, TContextMenuItem, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
// components
|
||||
import { CreateUpdateProjectViewModal, DeleteProjectViewModal } from "@/components/views";
|
||||
// constants
|
||||
import { EUserProjectRoles } from "@/constants/project";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
import { copyUrlToClipboard } from "@/helpers/string.helper";
|
||||
// hooks
|
||||
import { useUser } from "@/hooks/store";
|
||||
|
||||
type Props = {
|
||||
parentRef: React.RefObject<HTMLElement>;
|
||||
projectId: string;
|
||||
view: IProjectView;
|
||||
workspaceSlug: string;
|
||||
};
|
||||
|
||||
export const ViewQuickActions: React.FC<Props> = observer((props) => {
|
||||
const { parentRef, projectId, view, workspaceSlug } = props;
|
||||
// states
|
||||
const [createUpdateViewModal, setCreateUpdateViewModal] = useState(false);
|
||||
const [deleteViewModal, setDeleteViewModal] = useState(false);
|
||||
// store hooks
|
||||
const {
|
||||
membership: { currentProjectRole },
|
||||
} = useUser();
|
||||
// auth
|
||||
const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER;
|
||||
|
||||
const viewLink = `${workspaceSlug}/projects/${projectId}/views/${view.id}`;
|
||||
const handleCopyText = () =>
|
||||
copyUrlToClipboard(viewLink).then(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Link Copied!",
|
||||
message: "View link copied to clipboard.",
|
||||
});
|
||||
});
|
||||
const handleOpenInNewTab = () => window.open(`/${viewLink}`, "_blank");
|
||||
|
||||
const MENU_ITEMS: TContextMenuItem[] = [
|
||||
{
|
||||
key: "edit",
|
||||
action: () => setCreateUpdateViewModal(true),
|
||||
title: "Edit",
|
||||
icon: Pencil,
|
||||
shouldRender: isEditingAllowed,
|
||||
},
|
||||
{
|
||||
key: "open-new-tab",
|
||||
action: handleOpenInNewTab,
|
||||
title: "Open in new tab",
|
||||
icon: ExternalLink,
|
||||
},
|
||||
{
|
||||
key: "copy-link",
|
||||
action: handleCopyText,
|
||||
title: "Copy link",
|
||||
icon: Link,
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
action: () => setDeleteViewModal(true),
|
||||
title: "Delete",
|
||||
icon: Trash2,
|
||||
shouldRender: isEditingAllowed,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<CreateUpdateProjectViewModal
|
||||
isOpen={createUpdateViewModal}
|
||||
onClose={() => setCreateUpdateViewModal(false)}
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
data={view}
|
||||
/>
|
||||
<DeleteProjectViewModal data={view} isOpen={deleteViewModal} onClose={() => setDeleteViewModal(false)} />
|
||||
<ContextMenu parentRef={parentRef} items={MENU_ITEMS} />
|
||||
<CustomMenu ellipsis placement="bottom-end" closeOnSelect>
|
||||
{MENU_ITEMS.map((item) => {
|
||||
if (item.shouldRender === false) return null;
|
||||
return (
|
||||
<CustomMenu.MenuItem
|
||||
key={item.key}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
item.action();
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center gap-2",
|
||||
{
|
||||
"text-custom-text-400": item.disabled,
|
||||
},
|
||||
item.className
|
||||
)}
|
||||
>
|
||||
{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,29 +1,25 @@
|
||||
import React, { FC, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useRouter } from "next/router";
|
||||
// icons
|
||||
import { LinkIcon, PencilIcon, TrashIcon } from "lucide-react";
|
||||
// types
|
||||
import { IProjectView } from "@plane/types";
|
||||
// ui
|
||||
import { CustomMenu, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
// components
|
||||
import { FavoriteStar } from "@/components/core";
|
||||
import { DeleteProjectViewModal, CreateUpdateProjectViewModal } from "@/components/views";
|
||||
import { DeleteProjectViewModal, CreateUpdateProjectViewModal, ViewQuickActions } from "@/components/views";
|
||||
// constants
|
||||
import { EUserProjectRoles } from "@/constants/project";
|
||||
// helpers
|
||||
import { calculateTotalFilters } from "@/helpers/filter.helper";
|
||||
import { copyUrlToClipboard } from "@/helpers/string.helper";
|
||||
// hooks
|
||||
import { useProjectView, useUser } from "@/hooks/store";
|
||||
|
||||
type Props = {
|
||||
parentRef: React.RefObject<HTMLElement>;
|
||||
view: IProjectView;
|
||||
};
|
||||
|
||||
export const ViewListItemAction: FC<Props> = observer((props) => {
|
||||
const { view } = props;
|
||||
const { parentRef, view } = props;
|
||||
// states
|
||||
const [createUpdateViewModal, setCreateUpdateViewModal] = useState(false);
|
||||
const [deleteViewModal, setDeleteViewModal] = useState(false);
|
||||
@ -54,18 +50,6 @@ export const ViewListItemAction: FC<Props> = observer((props) => {
|
||||
removeViewFromFavorites(workspaceSlug.toString(), projectId.toString(), view.id);
|
||||
};
|
||||
|
||||
const handleCopyText = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
copyUrlToClipboard(`${workspaceSlug}/projects/${projectId}/views/${view.id}`).then(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Link Copied!",
|
||||
message: "View link copied to clipboard.",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{workspaceSlug && projectId && view && (
|
||||
@ -92,43 +76,14 @@ export const ViewListItemAction: FC<Props> = observer((props) => {
|
||||
selected={view.is_favorite}
|
||||
/>
|
||||
)}
|
||||
|
||||
<CustomMenu ellipsis>
|
||||
{isEditingAllowed && (
|
||||
<>
|
||||
<CustomMenu.MenuItem
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setCreateUpdateViewModal(true);
|
||||
}}
|
||||
>
|
||||
<span className="flex items-center justify-start gap-2">
|
||||
<PencilIcon size={14} strokeWidth={2} />
|
||||
<span>Edit View</span>
|
||||
</span>
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDeleteViewModal(true);
|
||||
}}
|
||||
>
|
||||
<span className="flex items-center justify-start gap-2">
|
||||
<TrashIcon size={14} strokeWidth={2} />
|
||||
<span>Delete View</span>
|
||||
</span>
|
||||
</CustomMenu.MenuItem>
|
||||
</>
|
||||
)}
|
||||
<CustomMenu.MenuItem onClick={handleCopyText}>
|
||||
<span className="flex items-center justify-start gap-2">
|
||||
<LinkIcon className="h-3 w-3" />
|
||||
<span>Copy view link</span>
|
||||
</span>
|
||||
</CustomMenu.MenuItem>
|
||||
</CustomMenu>
|
||||
{projectId && workspaceSlug && (
|
||||
<ViewQuickActions
|
||||
parentRef={parentRef}
|
||||
projectId={projectId.toString()}
|
||||
view={view}
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { FC } from "react";
|
||||
import { FC, useRef } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useRouter } from "next/router";
|
||||
// types
|
||||
@ -15,6 +15,8 @@ type Props = {
|
||||
|
||||
export const ProjectViewListItem: FC<Props> = observer((props) => {
|
||||
const { view } = props;
|
||||
// refs
|
||||
const parentRef = useRef(null);
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
@ -25,8 +27,9 @@ export const ProjectViewListItem: FC<Props> = observer((props) => {
|
||||
<ListItem
|
||||
title={view.name}
|
||||
itemLink={`/${workspaceSlug}/projects/${projectId}/views/${view.id}`}
|
||||
actionableItems={<ViewListItemAction view={view} />}
|
||||
actionableItems={<ViewListItemAction parentRef={parentRef} view={view} />}
|
||||
isMobile={isMobile}
|
||||
parentRef={parentRef}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
@ -41,6 +41,7 @@ class MyDocument extends Document {
|
||||
)}
|
||||
</Head>
|
||||
<body>
|
||||
<div id="context-menu-portal" />
|
||||
<Main />
|
||||
<NextScript />
|
||||
{process.env.NEXT_PUBLIC_PLAUSIBLE_DOMAIN && (
|
||||
|
Loading…
Reference in New Issue
Block a user