[WEB-4208]chore: refactored work item quick actions (#7136)
* chore: refactored work item quick actions * chore: update event handling for menu * chore: reverted unwanted changes * fix: update archive copy link * chore: handled undefined function implementation
This commit is contained in:
parent
14d2d69120
commit
6be3f0ea73
21 changed files with 1602 additions and 517 deletions
|
|
@ -1,8 +1,10 @@
|
||||||
import React from "react";
|
import { ChevronRight } from "lucide-react";
|
||||||
|
import React, { useState, useRef, useContext } from "react";
|
||||||
|
import { usePopper } from "react-popper";
|
||||||
// helpers
|
// helpers
|
||||||
import { cn } from "../../../helpers";
|
import { cn } from "../../../helpers";
|
||||||
// types
|
// types
|
||||||
import { TContextMenuItem } from "./root";
|
import { TContextMenuItem, ContextMenuContext, Portal } from "./root";
|
||||||
|
|
||||||
type ContextMenuItemProps = {
|
type ContextMenuItemProps = {
|
||||||
handleActiveItem: () => void;
|
handleActiveItem: () => void;
|
||||||
|
|
@ -14,10 +16,139 @@ type ContextMenuItemProps = {
|
||||||
export const ContextMenuItem: React.FC<ContextMenuItemProps> = (props) => {
|
export const ContextMenuItem: React.FC<ContextMenuItemProps> = (props) => {
|
||||||
const { handleActiveItem, handleClose, isActive, item } = props;
|
const { handleActiveItem, handleClose, isActive, item } = props;
|
||||||
|
|
||||||
|
// Nested menu state
|
||||||
|
const [isNestedOpen, setIsNestedOpen] = useState(false);
|
||||||
|
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
|
||||||
|
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
||||||
|
const [activeNestedIndex, setActiveNestedIndex] = useState<number>(0);
|
||||||
|
const nestedMenuRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
const contextMenuContext = useContext(ContextMenuContext);
|
||||||
|
const hasNestedItems = item.nestedMenuItems && item.nestedMenuItems.length > 0;
|
||||||
|
const renderedNestedItems = item.nestedMenuItems?.filter((nestedItem) => nestedItem.shouldRender !== false) || [];
|
||||||
|
|
||||||
|
const { styles, attributes } = usePopper(referenceElement, popperElement, {
|
||||||
|
placement: "right-start",
|
||||||
|
strategy: "fixed",
|
||||||
|
modifiers: [
|
||||||
|
{
|
||||||
|
name: "offset",
|
||||||
|
options: {
|
||||||
|
offset: [0, 4],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "flip",
|
||||||
|
options: {
|
||||||
|
fallbackPlacements: ["left-start", "right-end", "left-end", "top-start", "bottom-start"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "preventOverflow",
|
||||||
|
options: {
|
||||||
|
padding: 8,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const closeNestedMenu = React.useCallback(() => {
|
||||||
|
setIsNestedOpen(false);
|
||||||
|
setActiveNestedIndex(0);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Register this nested menu with the main context
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (contextMenuContext && hasNestedItems) {
|
||||||
|
return contextMenuContext.registerSubmenu(closeNestedMenu);
|
||||||
|
}
|
||||||
|
}, [contextMenuContext, hasNestedItems, closeNestedMenu]);
|
||||||
|
|
||||||
|
const handleItemClick = (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
if (hasNestedItems) {
|
||||||
|
// Toggle nested menu
|
||||||
|
if (!isNestedOpen && contextMenuContext) {
|
||||||
|
contextMenuContext.closeAllSubmenus();
|
||||||
|
}
|
||||||
|
setIsNestedOpen(!isNestedOpen);
|
||||||
|
} else {
|
||||||
|
// Execute action for regular items
|
||||||
|
item.action();
|
||||||
|
if (item.closeOnClick !== false) handleClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseEnter = () => {
|
||||||
|
handleActiveItem();
|
||||||
|
|
||||||
|
if (hasNestedItems) {
|
||||||
|
// Close other submenus and open this one
|
||||||
|
if (contextMenuContext) {
|
||||||
|
contextMenuContext.closeAllSubmenus();
|
||||||
|
}
|
||||||
|
setIsNestedOpen(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNestedItemClick = (nestedItem: TContextMenuItem, e?: React.MouseEvent) => {
|
||||||
|
if (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
|
||||||
|
nestedItem.action();
|
||||||
|
if (nestedItem.closeOnClick !== false) {
|
||||||
|
handleClose(); // Close the entire context menu
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle keyboard navigation for nested items
|
||||||
|
React.useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (!isNestedOpen || !hasNestedItems) return;
|
||||||
|
|
||||||
|
if (e.key === "ArrowDown") {
|
||||||
|
e.preventDefault();
|
||||||
|
setActiveNestedIndex((prev) => (prev + 1) % renderedNestedItems.length);
|
||||||
|
}
|
||||||
|
if (e.key === "ArrowUp") {
|
||||||
|
e.preventDefault();
|
||||||
|
setActiveNestedIndex((prev) => (prev - 1 + renderedNestedItems.length) % renderedNestedItems.length);
|
||||||
|
}
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
const nestedItem = renderedNestedItems[activeNestedIndex];
|
||||||
|
if (!nestedItem.disabled) {
|
||||||
|
handleNestedItemClick(nestedItem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (e.key === "ArrowLeft") {
|
||||||
|
e.preventDefault();
|
||||||
|
closeNestedMenu();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isNestedOpen && nestedMenuRef.current) {
|
||||||
|
const menuElement = nestedMenuRef.current;
|
||||||
|
menuElement.addEventListener("keydown", handleKeyDown);
|
||||||
|
// Ensure the menu can receive keyboard events
|
||||||
|
menuElement.setAttribute("tabindex", "-1");
|
||||||
|
menuElement.focus();
|
||||||
|
return () => {
|
||||||
|
menuElement.removeEventListener("keydown", handleKeyDown);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [isNestedOpen, activeNestedIndex, renderedNestedItems, hasNestedItems, closeNestedMenu]);
|
||||||
|
|
||||||
if (item.shouldRender === false) return null;
|
if (item.shouldRender === false) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<button
|
<button
|
||||||
|
ref={setReferenceElement}
|
||||||
type="button"
|
type="button"
|
||||||
className={cn(
|
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",
|
"w-full flex items-center gap-2 px-1 py-1.5 text-left text-custom-text-200 rounded text-xs select-none",
|
||||||
|
|
@ -27,19 +158,14 @@ export const ContextMenuItem: React.FC<ContextMenuItemProps> = (props) => {
|
||||||
},
|
},
|
||||||
item.className
|
item.className
|
||||||
)}
|
)}
|
||||||
onClick={(e) => {
|
onClick={handleItemClick}
|
||||||
e.preventDefault();
|
onMouseEnter={handleMouseEnter}
|
||||||
e.stopPropagation();
|
|
||||||
item.action();
|
|
||||||
if (item.closeOnClick !== false) handleClose();
|
|
||||||
}}
|
|
||||||
onMouseEnter={handleActiveItem}
|
|
||||||
disabled={item.disabled}
|
disabled={item.disabled}
|
||||||
>
|
>
|
||||||
{item.customContent ?? (
|
{item.customContent ?? (
|
||||||
<>
|
<>
|
||||||
{item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />}
|
{item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />}
|
||||||
<div>
|
<div className="flex-1">
|
||||||
<h5>{item.title}</h5>
|
<h5>{item.title}</h5>
|
||||||
{item.description && (
|
{item.description && (
|
||||||
<p
|
<p
|
||||||
|
|
@ -51,8 +177,69 @@ export const ContextMenuItem: React.FC<ContextMenuItemProps> = (props) => {
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{hasNestedItems && <ChevronRight className="h-3 w-3 flex-shrink-0" />}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* Nested Menu */}
|
||||||
|
{hasNestedItems && isNestedOpen && (
|
||||||
|
<Portal container={contextMenuContext?.portalContainer}>
|
||||||
|
<div
|
||||||
|
ref={setPopperElement}
|
||||||
|
style={styles.popper}
|
||||||
|
{...attributes.popper}
|
||||||
|
className={cn(
|
||||||
|
"fixed z-[35] min-w-[12rem] overflow-hidden rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-lg",
|
||||||
|
"ring-1 ring-black ring-opacity-5"
|
||||||
|
)}
|
||||||
|
data-context-submenu="true"
|
||||||
|
>
|
||||||
|
<div ref={nestedMenuRef} className="max-h-72 overflow-y-scroll vertical-scrollbar scrollbar-sm">
|
||||||
|
{renderedNestedItems.map((nestedItem, index) => (
|
||||||
|
<button
|
||||||
|
key={nestedItem.key}
|
||||||
|
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": index === activeNestedIndex,
|
||||||
|
"text-custom-text-400": nestedItem.disabled,
|
||||||
|
},
|
||||||
|
nestedItem.className
|
||||||
|
)}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
handleNestedItemClick(nestedItem, e);
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => setActiveNestedIndex(index)}
|
||||||
|
disabled={nestedItem.disabled}
|
||||||
|
data-context-submenu="true"
|
||||||
|
>
|
||||||
|
{nestedItem.customContent ?? (
|
||||||
|
<>
|
||||||
|
{nestedItem.icon && <nestedItem.icon className={cn("h-3 w-3", nestedItem.iconClassName)} />}
|
||||||
|
<div>
|
||||||
|
<h5>{nestedItem.title}</h5>
|
||||||
|
{nestedItem.description && (
|
||||||
|
<p
|
||||||
|
className={cn("text-custom-text-300 whitespace-pre-line", {
|
||||||
|
"text-custom-text-400": nestedItem.disabled,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{nestedItem.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Portal>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -21,15 +21,46 @@ export type TContextMenuItem = {
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
iconClassName?: string;
|
iconClassName?: string;
|
||||||
|
nestedMenuItems?: TContextMenuItem[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Portal component for nested menus
|
||||||
|
interface PortalProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
container?: Element | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Portal: React.FC<PortalProps> = ({ children, container }) => {
|
||||||
|
const [mounted, setMounted] = React.useState(false);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
return () => setMounted(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetContainer = container || document.body;
|
||||||
|
return ReactDOM.createPortal(children, targetContainer);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Context for managing nested menus
|
||||||
|
export const ContextMenuContext = React.createContext<{
|
||||||
|
closeAllSubmenus: () => void;
|
||||||
|
registerSubmenu: (closeSubmenu: () => void) => () => void;
|
||||||
|
portalContainer?: Element | null;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
type ContextMenuProps = {
|
type ContextMenuProps = {
|
||||||
parentRef: React.RefObject<HTMLElement>;
|
parentRef: React.RefObject<HTMLElement>;
|
||||||
items: TContextMenuItem[];
|
items: TContextMenuItem[];
|
||||||
|
portalContainer?: Element | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ContextMenuWithoutPortal: React.FC<ContextMenuProps> = (props) => {
|
const ContextMenuWithoutPortal: React.FC<ContextMenuProps> = (props) => {
|
||||||
const { parentRef, items } = props;
|
const { parentRef, items, portalContainer } = props;
|
||||||
// states
|
// states
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [position, setPosition] = useState({
|
const [position, setPosition] = useState({
|
||||||
|
|
@ -39,11 +70,24 @@ const ContextMenuWithoutPortal: React.FC<ContextMenuProps> = (props) => {
|
||||||
const [activeItemIndex, setActiveItemIndex] = useState<number>(0);
|
const [activeItemIndex, setActiveItemIndex] = useState<number>(0);
|
||||||
// refs
|
// refs
|
||||||
const contextMenuRef = useRef<HTMLDivElement>(null);
|
const contextMenuRef = useRef<HTMLDivElement>(null);
|
||||||
|
const submenuClosersRef = useRef<Set<() => void>>(new Set());
|
||||||
// derived values
|
// derived values
|
||||||
const renderedItems = items.filter((item) => item.shouldRender !== false);
|
const renderedItems = items.filter((item) => item.shouldRender !== false);
|
||||||
const { isMobile } = usePlatformOS();
|
const { isMobile } = usePlatformOS();
|
||||||
|
|
||||||
|
const closeAllSubmenus = React.useCallback(() => {
|
||||||
|
submenuClosersRef.current.forEach((closeSubmenu) => closeSubmenu());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const registerSubmenu = React.useCallback((closeSubmenu: () => void) => {
|
||||||
|
submenuClosersRef.current.add(closeSubmenu);
|
||||||
|
return () => {
|
||||||
|
submenuClosersRef.current.delete(closeSubmenu);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
|
closeAllSubmenus();
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
setActiveItemIndex(0);
|
setActiveItemIndex(0);
|
||||||
};
|
};
|
||||||
|
|
@ -121,13 +165,42 @@ const ContextMenuWithoutPortal: React.FC<ContextMenuProps> = (props) => {
|
||||||
};
|
};
|
||||||
}, [activeItemIndex, isOpen, renderedItems, setIsOpen]);
|
}, [activeItemIndex, isOpen, renderedItems, setIsOpen]);
|
||||||
|
|
||||||
// close on clicking outside
|
// Custom handler for nested menu portal clicks
|
||||||
useOutsideClickDetector(contextMenuRef, handleClose);
|
React.useEffect(() => {
|
||||||
|
const handleDocumentClick = (event: MouseEvent) => {
|
||||||
|
const target = event.target as HTMLElement;
|
||||||
|
|
||||||
|
// Check if the click is on a nested menu element
|
||||||
|
const isNestedMenuClick = target.closest('[data-context-submenu="true"]');
|
||||||
|
const isMainMenuClick = contextMenuRef.current?.contains(target);
|
||||||
|
|
||||||
|
// Also check if the target itself has the data attribute
|
||||||
|
const isNestedMenuElement = target.hasAttribute("data-context-submenu");
|
||||||
|
|
||||||
|
// If it's a nested menu click, main menu click, or nested menu element, don't close
|
||||||
|
if (isNestedMenuClick || isMainMenuClick || isNestedMenuElement) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If menu is open and it's an outside click, close it
|
||||||
|
if (isOpen) {
|
||||||
|
handleClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isOpen) {
|
||||||
|
// Use capture phase to ensure we handle the event before other handlers
|
||||||
|
document.addEventListener("mousedown", handleDocumentClick, true);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", handleDocumentClick, true);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [isOpen, handleClose]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed h-screen w-screen top-0 left-0 cursor-default z-20 opacity-0 pointer-events-none transition-opacity",
|
"fixed h-screen w-screen top-0 left-0 cursor-default z-30 opacity-0 pointer-events-none transition-opacity",
|
||||||
{
|
{
|
||||||
"opacity-100 pointer-events-auto": isOpen,
|
"opacity-100 pointer-events-auto": isOpen,
|
||||||
}
|
}
|
||||||
|
|
@ -140,7 +213,9 @@ const ContextMenuWithoutPortal: React.FC<ContextMenuProps> = (props) => {
|
||||||
top: position.y,
|
top: position.y,
|
||||||
left: position.x,
|
left: position.x,
|
||||||
}}
|
}}
|
||||||
|
data-context-menu="true"
|
||||||
>
|
>
|
||||||
|
<ContextMenuContext.Provider value={{ closeAllSubmenus, registerSubmenu, portalContainer }}>
|
||||||
{renderedItems.map((item, index) => (
|
{renderedItems.map((item, index) => (
|
||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
key={item.key}
|
key={item.key}
|
||||||
|
|
@ -150,6 +225,7 @@ const ContextMenuWithoutPortal: React.FC<ContextMenuProps> = (props) => {
|
||||||
item={item}
|
item={item}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
</ContextMenuContext.Provider>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { Menu } from "@headlessui/react";
|
import { Menu } from "@headlessui/react";
|
||||||
import { ChevronDown, MoreHorizontal } from "lucide-react";
|
import { ChevronDown, ChevronRight, MoreHorizontal } from "lucide-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import ReactDOM from "react-dom";
|
import ReactDOM from "react-dom";
|
||||||
import { usePopper } from "react-popper";
|
import { usePopper } from "react-popper";
|
||||||
|
|
@ -10,7 +10,46 @@ import { cn } from "../../helpers";
|
||||||
// hooks
|
// hooks
|
||||||
import { useDropdownKeyDown } from "../hooks/use-dropdown-key-down";
|
import { useDropdownKeyDown } from "../hooks/use-dropdown-key-down";
|
||||||
// types
|
// types
|
||||||
import { ICustomMenuDropdownProps, ICustomMenuItemProps } from "./helper";
|
import {
|
||||||
|
ICustomMenuDropdownProps,
|
||||||
|
ICustomMenuItemProps,
|
||||||
|
ICustomSubMenuProps,
|
||||||
|
ICustomSubMenuTriggerProps,
|
||||||
|
ICustomSubMenuContentProps,
|
||||||
|
} from "./helper";
|
||||||
|
|
||||||
|
interface PortalProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
container?: Element | null;
|
||||||
|
asChild?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Portal: React.FC<PortalProps> = ({ children, container, asChild = false }) => {
|
||||||
|
const [mounted, setMounted] = React.useState(false);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
return () => setMounted(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetContainer = container || document.body;
|
||||||
|
|
||||||
|
if (asChild) {
|
||||||
|
return ReactDOM.createPortal(children, targetContainer);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ReactDOM.createPortal(<div data-radix-portal="">{children}</div>, targetContainer);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Context for main menu to communicate with submenus
|
||||||
|
const MenuContext = React.createContext<{
|
||||||
|
closeAllSubmenus: () => void;
|
||||||
|
registerSubmenu: (closeSubmenu: () => void) => () => void;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
const CustomMenu = (props: ICustomMenuDropdownProps) => {
|
const CustomMenu = (props: ICustomMenuDropdownProps) => {
|
||||||
const {
|
const {
|
||||||
|
|
@ -45,19 +84,35 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
|
||||||
const [isOpen, setIsOpen] = React.useState(false);
|
const [isOpen, setIsOpen] = React.useState(false);
|
||||||
// refs
|
// refs
|
||||||
const dropdownRef = React.useRef<HTMLDivElement | null>(null);
|
const dropdownRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
|
const submenuClosersRef = React.useRef<Set<() => void>>(new Set());
|
||||||
|
|
||||||
const { styles, attributes } = usePopper(referenceElement, popperElement, {
|
const { styles, attributes } = usePopper(referenceElement, popperElement, {
|
||||||
placement: placement ?? "auto",
|
placement: placement ?? "auto",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const closeAllSubmenus = React.useCallback(() => {
|
||||||
|
submenuClosersRef.current.forEach((closeSubmenu) => closeSubmenu());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const registerSubmenu = React.useCallback((closeSubmenu: () => void) => {
|
||||||
|
submenuClosersRef.current.add(closeSubmenu);
|
||||||
|
return () => {
|
||||||
|
submenuClosersRef.current.delete(closeSubmenu);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const openDropdown = () => {
|
const openDropdown = () => {
|
||||||
setIsOpen(true);
|
setIsOpen(true);
|
||||||
if (referenceElement) referenceElement.focus();
|
if (referenceElement) referenceElement.focus();
|
||||||
};
|
};
|
||||||
const closeDropdown = () => {
|
|
||||||
if (isOpen) onMenuClose?.();
|
const closeDropdown = React.useCallback(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
closeAllSubmenus();
|
||||||
|
onMenuClose?.();
|
||||||
|
}
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
};
|
}, [isOpen, closeAllSubmenus, onMenuClose]);
|
||||||
|
|
||||||
const selectActiveItem = () => {
|
const selectActiveItem = () => {
|
||||||
const activeItem: HTMLElement | undefined | null = dropdownRef.current?.querySelector(
|
const activeItem: HTMLElement | undefined | null = dropdownRef.current?.querySelector(
|
||||||
|
|
@ -75,8 +130,12 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
|
||||||
const handleMenuButtonClick = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
|
const handleMenuButtonClick = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
isOpen ? closeDropdown() : openDropdown();
|
if (isOpen) {
|
||||||
menuButtonOnClick?.();
|
closeDropdown();
|
||||||
|
} else {
|
||||||
|
openDropdown();
|
||||||
|
}
|
||||||
|
if (menuButtonOnClick) menuButtonOnClick();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMouseEnter = () => {
|
const handleMouseEnter = () => {
|
||||||
|
|
@ -86,13 +145,43 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
|
||||||
const handleMouseLeave = () => {
|
const handleMouseLeave = () => {
|
||||||
if (openOnHover && isOpen) {
|
if (openOnHover && isOpen) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
// Only close if menu is still open
|
||||||
|
if (isOpen) {
|
||||||
closeDropdown();
|
closeDropdown();
|
||||||
}, 500);
|
}
|
||||||
|
}, 150); // Small delay to allow moving to submenu
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useOutsideClickDetector(dropdownRef, closeDropdown, useCaptureForOutsideClick);
|
useOutsideClickDetector(dropdownRef, closeDropdown, useCaptureForOutsideClick);
|
||||||
|
|
||||||
|
// Custom handler for submenu portal clicks
|
||||||
|
React.useEffect(() => {
|
||||||
|
const handleDocumentClick = (event: MouseEvent) => {
|
||||||
|
const target = event.target as HTMLElement;
|
||||||
|
const isSubmenuClick = target.closest('[data-prevent-outside-click="true"]');
|
||||||
|
const isMainMenuClick = dropdownRef.current?.contains(target);
|
||||||
|
|
||||||
|
// If it's a submenu click or main menu click, don't close
|
||||||
|
if (isSubmenuClick || isMainMenuClick) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If menu is open and it's an outside click, close it
|
||||||
|
if (isOpen) {
|
||||||
|
closeDropdown();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isOpen) {
|
||||||
|
document.addEventListener("mousedown", handleDocumentClick, useCaptureForOutsideClick);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", handleDocumentClick, useCaptureForOutsideClick);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [isOpen, closeDropdown, useCaptureForOutsideClick]);
|
||||||
|
|
||||||
let menuItems = (
|
let menuItems = (
|
||||||
<Menu.Items
|
<Menu.Items
|
||||||
data-prevent-outside-click={!!portalElement}
|
data-prevent-outside-click={!!portalElement}
|
||||||
|
|
@ -117,7 +206,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
|
||||||
style={styles.popper}
|
style={styles.popper}
|
||||||
{...attributes.popper}
|
{...attributes.popper}
|
||||||
>
|
>
|
||||||
{children}
|
<MenuContext.Provider value={{ closeAllSubmenus, registerSubmenu }}>{children}</MenuContext.Provider>
|
||||||
</div>
|
</div>
|
||||||
</Menu.Items>
|
</Menu.Items>
|
||||||
);
|
);
|
||||||
|
|
@ -136,6 +225,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
|
||||||
onClick={handleOnClick}
|
onClick={handleOnClick}
|
||||||
onMouseEnter={handleMouseEnter}
|
onMouseEnter={handleMouseEnter}
|
||||||
onMouseLeave={handleMouseLeave}
|
onMouseLeave={handleMouseLeave}
|
||||||
|
data-main-menu="true"
|
||||||
>
|
>
|
||||||
{({ open }) => (
|
{({ open }) => (
|
||||||
<>
|
<>
|
||||||
|
|
@ -202,8 +292,161 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// SubMenu context for closing submenu from nested items
|
||||||
|
const SubMenuContext = React.createContext<{ closeSubmenu: () => void } | null>(null);
|
||||||
|
|
||||||
|
// Hook to use submenu context
|
||||||
|
const useSubMenu = () => React.useContext(SubMenuContext);
|
||||||
|
|
||||||
|
// SubMenu implementation
|
||||||
|
const SubMenu: React.FC<ICustomSubMenuProps> = (props) => {
|
||||||
|
const {
|
||||||
|
children,
|
||||||
|
trigger,
|
||||||
|
disabled = false,
|
||||||
|
className = "",
|
||||||
|
contentClassName = "",
|
||||||
|
placement = "right-start",
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const [isOpen, setIsOpen] = React.useState(false);
|
||||||
|
const [referenceElement, setReferenceElement] = React.useState<HTMLSpanElement | null>(null);
|
||||||
|
const [popperElement, setPopperElement] = React.useState<HTMLDivElement | null>(null);
|
||||||
|
const submenuRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
const menuContext = React.useContext(MenuContext);
|
||||||
|
|
||||||
|
const { styles, attributes } = usePopper(referenceElement, popperElement, {
|
||||||
|
placement,
|
||||||
|
strategy: "fixed", // Use fixed positioning to escape overflow constraints
|
||||||
|
modifiers: [
|
||||||
|
{
|
||||||
|
name: "offset",
|
||||||
|
options: {
|
||||||
|
offset: [0, 4],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "flip",
|
||||||
|
options: {
|
||||||
|
fallbackPlacements: ["left-start", "right-end", "left-end", "top-start", "bottom-start"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "preventOverflow",
|
||||||
|
options: {
|
||||||
|
padding: 8,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const closeSubmenu = React.useCallback(() => {
|
||||||
|
setIsOpen(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Register this submenu with the main menu context
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (menuContext) {
|
||||||
|
return menuContext.registerSubmenu(closeSubmenu);
|
||||||
|
}
|
||||||
|
}, [menuContext, closeSubmenu]);
|
||||||
|
|
||||||
|
const toggleSubmenu = () => {
|
||||||
|
if (!disabled) {
|
||||||
|
// Close other submenus when opening this one
|
||||||
|
if (!isOpen && menuContext) {
|
||||||
|
menuContext.closeAllSubmenus();
|
||||||
|
}
|
||||||
|
setIsOpen(!isOpen);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClick = (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
toggleSubmenu();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Close submenu when clicking on other menu items
|
||||||
|
React.useEffect(() => {
|
||||||
|
const handleMenuItemClick = (e: Event) => {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
// Check if the click is on a menu item that's not part of this submenu
|
||||||
|
if (target.closest('[role="menuitem"]') && !submenuRef.current?.contains(target)) {
|
||||||
|
closeSubmenu();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("click", handleMenuItemClick);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("click", handleMenuItemClick);
|
||||||
|
};
|
||||||
|
}, [closeSubmenu]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={submenuRef} className={cn("relative", className)}>
|
||||||
|
<span ref={setReferenceElement} className="w-full">
|
||||||
|
<Menu.Item as="div" disabled={disabled}>
|
||||||
|
{({ active }) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"w-full select-none rounded px-1 py-1.5 text-left text-custom-text-200 flex items-center justify-between cursor-pointer",
|
||||||
|
{
|
||||||
|
"bg-custom-background-80": active && !disabled,
|
||||||
|
"text-custom-text-400": disabled,
|
||||||
|
"cursor-not-allowed": disabled,
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
onClick={handleClick}
|
||||||
|
>
|
||||||
|
<span className="flex-1">{trigger}</span>
|
||||||
|
<ChevronRight className="h-3.5 w-3.5 flex-shrink-0" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Menu.Item>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<Portal>
|
||||||
|
<div
|
||||||
|
ref={setPopperElement}
|
||||||
|
style={styles.popper}
|
||||||
|
{...attributes.popper}
|
||||||
|
className={cn(
|
||||||
|
"fixed z-[20] min-w-[12rem] overflow-hidden rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 p-1 text-xs shadow-custom-shadow-lg",
|
||||||
|
"ring-1 ring-black ring-opacity-5", // Additional styling to make it stand out
|
||||||
|
contentClassName
|
||||||
|
)}
|
||||||
|
data-prevent-outside-click="true"
|
||||||
|
onMouseEnter={() => {
|
||||||
|
// Notify parent menu that we're hovering over submenu
|
||||||
|
const mainMenuElement = document.querySelector('[data-main-menu="true"]');
|
||||||
|
if (mainMenuElement) {
|
||||||
|
const mouseEnterEvent = new MouseEvent("mouseenter", { bubbles: true });
|
||||||
|
mainMenuElement.dispatchEvent(mouseEnterEvent);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={() => {
|
||||||
|
// Notify parent menu that we're leaving submenu
|
||||||
|
const mainMenuElement = document.querySelector('[data-main-menu="true"]');
|
||||||
|
if (mainMenuElement) {
|
||||||
|
const mouseLeaveEvent = new MouseEvent("mouseleave", { bubbles: true });
|
||||||
|
mainMenuElement.dispatchEvent(mouseLeaveEvent);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SubMenuContext.Provider value={{ closeSubmenu }}>{children}</SubMenuContext.Provider>
|
||||||
|
</div>
|
||||||
|
</Portal>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const MenuItem: React.FC<ICustomMenuItemProps> = (props) => {
|
const MenuItem: React.FC<ICustomMenuItemProps> = (props) => {
|
||||||
const { children, disabled = false, onClick, className } = props;
|
const { children, disabled = false, onClick, className } = props;
|
||||||
|
const submenuContext = useSubMenu();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu.Item as="div" disabled={disabled}>
|
<Menu.Item as="div" disabled={disabled}>
|
||||||
|
|
@ -221,6 +464,8 @@ const MenuItem: React.FC<ICustomMenuItemProps> = (props) => {
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
close();
|
close();
|
||||||
onClick?.(e);
|
onClick?.(e);
|
||||||
|
// Close submenu if this item is inside a submenu
|
||||||
|
submenuContext?.closeSubmenu();
|
||||||
}}
|
}}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
|
|
@ -231,6 +476,52 @@ const MenuItem: React.FC<ICustomMenuItemProps> = (props) => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const SubMenuTrigger: React.FC<ICustomSubMenuTriggerProps> = (props) => {
|
||||||
|
const { children, disabled = false, className } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Menu.Item as="div" disabled={disabled}>
|
||||||
|
{({ active }) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"w-full select-none rounded px-1 py-1.5 text-left text-custom-text-200 flex items-center justify-between",
|
||||||
|
{
|
||||||
|
"bg-custom-background-80": active && !disabled,
|
||||||
|
"text-custom-text-400": disabled,
|
||||||
|
"cursor-pointer": !disabled,
|
||||||
|
"cursor-not-allowed": disabled,
|
||||||
|
},
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="flex-1">{children}</span>
|
||||||
|
<ChevronRight className="h-3.5 w-3.5 flex-shrink-0" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Menu.Item>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const SubMenuContent: React.FC<ICustomSubMenuContentProps> = (props) => {
|
||||||
|
const { children, className } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"z-[15] min-w-[12rem] overflow-hidden rounded-md border border-custom-border-300 bg-custom-background-100 p-1 text-xs shadow-custom-shadow-rg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add all components as static properties for external use
|
||||||
|
CustomMenu.Portal = Portal;
|
||||||
CustomMenu.MenuItem = MenuItem;
|
CustomMenu.MenuItem = MenuItem;
|
||||||
|
CustomMenu.SubMenu = SubMenu;
|
||||||
|
CustomMenu.SubMenuTrigger = SubMenuTrigger;
|
||||||
|
CustomMenu.SubMenuContent = SubMenuContent;
|
||||||
|
|
||||||
export { CustomMenu };
|
export { CustomMenu };
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,12 @@ export interface IDropdownProps {
|
||||||
useCaptureForOutsideClick?: boolean;
|
useCaptureForOutsideClick?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IPortalProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
container?: Element | null;
|
||||||
|
asChild?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ICustomMenuDropdownProps extends IDropdownProps {
|
export interface ICustomMenuDropdownProps extends IDropdownProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
ellipsis?: boolean;
|
ellipsis?: boolean;
|
||||||
|
|
@ -75,3 +81,27 @@ export interface ICustomSelectItemProps {
|
||||||
value: any;
|
value: any;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Submenu interfaces
|
||||||
|
export interface ICustomSubMenuProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
trigger: React.ReactNode;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
contentClassName?: string;
|
||||||
|
placement?: Placement;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ICustomSubMenuTriggerProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ICustomSubMenuContentProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
placement?: Placement;
|
||||||
|
sideOffset?: number;
|
||||||
|
alignOffset?: number;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { Copy } from "lucide-react";
|
||||||
|
import { TContextMenuItem } from "@plane/ui";
|
||||||
|
|
||||||
|
export interface CopyMenuHelperProps {
|
||||||
|
baseItem: {
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
icon: typeof Copy;
|
||||||
|
action: () => void;
|
||||||
|
shouldRender: boolean;
|
||||||
|
};
|
||||||
|
activeLayout: string;
|
||||||
|
setTrackElement: (element: string) => void;
|
||||||
|
setCreateUpdateIssueModal: (open: boolean) => void;
|
||||||
|
setDuplicateWorkItemModal?: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createCopyMenuWithDuplication = (props: CopyMenuHelperProps): TContextMenuItem => {
|
||||||
|
const { baseItem } = props;
|
||||||
|
|
||||||
|
return baseItem;
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { FC } from "react";
|
||||||
|
|
||||||
|
type TDuplicateWorkItemModalProps = {
|
||||||
|
workItemId: string;
|
||||||
|
onClose: () => void;
|
||||||
|
isOpen: boolean;
|
||||||
|
workspaceSlug: string;
|
||||||
|
projectId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DuplicateWorkItemModal: FC<TDuplicateWorkItemModalProps> = () => <></>;
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
export * from "./duplicate-modal";
|
||||||
|
export * from "./copy-menu-helper";
|
||||||
18
web/ce/store/issue/issue-details/root.store.ts
Normal file
18
web/ce/store/issue/issue-details/root.store.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { makeObservable } from "mobx";
|
||||||
|
import { TIssueServiceType } from "@plane/types";
|
||||||
|
import {
|
||||||
|
IssueDetail as IssueDetailCore,
|
||||||
|
IIssueDetail as IIssueDetailCore,
|
||||||
|
} from "@/store/issue/issue-details/root.store";
|
||||||
|
import { IIssueRootStore } from "@/store/issue/root.store";
|
||||||
|
|
||||||
|
export type IIssueDetail = IIssueDetailCore;
|
||||||
|
|
||||||
|
export class IssueDetail extends IssueDetailCore {
|
||||||
|
constructor(rootStore: IIssueRootStore, serviceType: TIssueServiceType) {
|
||||||
|
super(rootStore, serviceType);
|
||||||
|
makeObservable(this, {
|
||||||
|
// observables
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -29,6 +29,7 @@ type Props = TDropdownProps & {
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
renderCondition?: (project: TProject) => boolean;
|
renderCondition?: (project: TProject) => boolean;
|
||||||
renderByDefault?: boolean;
|
renderByDefault?: boolean;
|
||||||
|
currentProjectId?: string;
|
||||||
} & (
|
} & (
|
||||||
| {
|
| {
|
||||||
multiple: false;
|
multiple: false;
|
||||||
|
|
@ -63,6 +64,7 @@ export const ProjectDropdown: React.FC<Props> = observer((props) => {
|
||||||
tabIndex,
|
tabIndex,
|
||||||
value,
|
value,
|
||||||
renderByDefault = true,
|
renderByDefault = true,
|
||||||
|
currentProjectId,
|
||||||
} = props;
|
} = props;
|
||||||
// states
|
// states
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
|
|
@ -108,7 +110,9 @@ export const ProjectDropdown: React.FC<Props> = observer((props) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const filteredOptions =
|
const filteredOptions =
|
||||||
query === "" ? options : options?.filter((o) => o?.query.toLowerCase().includes(query.toLowerCase()));
|
query === ""
|
||||||
|
? options?.filter((o) => o?.value !== currentProjectId)
|
||||||
|
: options?.filter((o) => o?.value !== currentProjectId && o?.query.toLowerCase().includes(query.toLowerCase()));
|
||||||
|
|
||||||
const { handleClose, handleKeyDown, handleOnClick, searchInputKeyDown } = useDropdown({
|
const { handleClose, handleKeyDown, handleOnClick, searchInputKeyDown } = useDropdown({
|
||||||
dropdownRef,
|
dropdownRef,
|
||||||
|
|
@ -198,7 +202,7 @@ export const ProjectDropdown: React.FC<Props> = observer((props) => {
|
||||||
>
|
>
|
||||||
{!hideIcon && getProjectIcon(value)}
|
{!hideIcon && getProjectIcon(value)}
|
||||||
{BUTTON_VARIANTS_WITH_TEXT.includes(buttonVariant) && (
|
{BUTTON_VARIANTS_WITH_TEXT.includes(buttonVariant) && (
|
||||||
<span className="flex-grow truncate max-w-40">{getDisplayName(value, placeholder)}</span>
|
<span className="truncate max-w-40">{getDisplayName(value, placeholder)}</span>
|
||||||
)}
|
)}
|
||||||
{dropdownArrow && (
|
{dropdownArrow && (
|
||||||
<ChevronDown className={cn("h-2.5 w-2.5 flex-shrink-0", dropdownArrowClassName)} aria-hidden="true" />
|
<ChevronDown className={cn("h-2.5 w-2.5 flex-shrink-0", dropdownArrowClassName)} aria-hidden="true" />
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-d
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
// plane helpers
|
// plane helpers
|
||||||
|
import { MoreHorizontal } from "lucide-react";
|
||||||
import { EIssueServiceType } from "@plane/constants";
|
import { EIssueServiceType } from "@plane/constants";
|
||||||
import { useOutsideClickDetector } from "@plane/hooks";
|
import { useOutsideClickDetector } from "@plane/hooks";
|
||||||
// types
|
// types
|
||||||
|
|
@ -60,9 +61,25 @@ interface IssueDetailsBlockProps {
|
||||||
|
|
||||||
const KanbanIssueDetailsBlock: React.FC<IssueDetailsBlockProps> = observer((props) => {
|
const KanbanIssueDetailsBlock: React.FC<IssueDetailsBlockProps> = observer((props) => {
|
||||||
const { cardRef, issue, updateIssue, quickActions, isReadOnly, displayProperties, isEpic = false } = props;
|
const { cardRef, issue, updateIssue, quickActions, isReadOnly, displayProperties, isEpic = false } = props;
|
||||||
|
// refs
|
||||||
|
const menuActionRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
// states
|
||||||
|
const [isMenuActive, setIsMenuActive] = useState(false);
|
||||||
// hooks
|
// hooks
|
||||||
const { isMobile } = usePlatformOS();
|
const { isMobile } = usePlatformOS();
|
||||||
|
|
||||||
|
const customActionButton = (
|
||||||
|
<div
|
||||||
|
ref={menuActionRef}
|
||||||
|
className={`flex items-center h-full w-full cursor-pointer rounded p-1 text-custom-sidebar-text-400 hover:bg-custom-background-80 ${
|
||||||
|
isMenuActive ? "bg-custom-background-80 text-custom-text-100" : "text-custom-text-200"
|
||||||
|
}`}
|
||||||
|
onClick={() => setIsMenuActive(!isMenuActive)}
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
// derived values
|
// derived values
|
||||||
const subIssueCount = issue?.sub_issues_count ?? 0;
|
const subIssueCount = issue?.sub_issues_count ?? 0;
|
||||||
|
|
||||||
|
|
@ -71,6 +88,8 @@ const KanbanIssueDetailsBlock: React.FC<IssueDetailsBlockProps> = observer((prop
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useOutsideClickDetector(menuActionRef, () => setIsMenuActive(false));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
|
|
@ -85,12 +104,14 @@ const KanbanIssueDetailsBlock: React.FC<IssueDetailsBlockProps> = observer((prop
|
||||||
<div
|
<div
|
||||||
className={cn("absolute -top-1 right-0", {
|
className={cn("absolute -top-1 right-0", {
|
||||||
"hidden group-hover/kanban-block:block": !isMobile,
|
"hidden group-hover/kanban-block:block": !isMobile,
|
||||||
|
"!block": isMenuActive,
|
||||||
})}
|
})}
|
||||||
onClick={handleEventPropagation}
|
onClick={handleEventPropagation}
|
||||||
>
|
>
|
||||||
{quickActions({
|
{quickActions({
|
||||||
issue,
|
issue,
|
||||||
parentRef: cardRef,
|
parentRef: cardRef,
|
||||||
|
customActionButton,
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -4,21 +4,21 @@ import { useState } from "react";
|
||||||
import omit from "lodash/omit";
|
import omit from "lodash/omit";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import { Copy, ExternalLink, Link, Pencil, Trash2 } from "lucide-react";
|
|
||||||
// plane imports
|
// plane imports
|
||||||
import { ARCHIVABLE_STATE_GROUPS, EIssuesStoreType } from "@plane/constants";
|
import { ARCHIVABLE_STATE_GROUPS, EIssuesStoreType } from "@plane/constants";
|
||||||
import { TIssue } from "@plane/types";
|
import { TIssue } from "@plane/types";
|
||||||
import { ArchiveIcon, ContextMenu, CustomMenu, TContextMenuItem, TOAST_TYPE, setToast } from "@plane/ui";
|
import { ContextMenu, CustomMenu } from "@plane/ui";
|
||||||
import { copyUrlToClipboard } from "@plane/utils";
|
|
||||||
// components
|
// components
|
||||||
import { ArchiveIssueModal, CreateUpdateIssueModal, DeleteIssueModal } from "@/components/issues";
|
import { ArchiveIssueModal, CreateUpdateIssueModal, DeleteIssueModal } from "@/components/issues";
|
||||||
// helpers
|
// helpers
|
||||||
import { cn } from "@/helpers/common.helper";
|
import { cn } from "@/helpers/common.helper";
|
||||||
import { generateWorkItemLink } from "@/helpers/issue.helper";
|
|
||||||
// hooks
|
// hooks
|
||||||
import { useEventTracker, useProject, useProjectState } from "@/hooks/store";
|
import { useEventTracker, useProject, useProjectState } from "@/hooks/store";
|
||||||
// types
|
// plane-web components
|
||||||
|
import { DuplicateWorkItemModal } from "@/plane-web/components/issues/issue-layouts/quick-action-dropdowns";
|
||||||
import { IQuickActionProps } from "../list/list-view-types";
|
import { IQuickActionProps } from "../list/list-view-types";
|
||||||
|
// helper
|
||||||
|
import { useAllIssueMenuItems, MenuItemFactoryProps } from "./helper";
|
||||||
|
|
||||||
export const AllIssueQuickActions: React.FC<IQuickActionProps> = observer((props) => {
|
export const AllIssueQuickActions: React.FC<IQuickActionProps> = observer((props) => {
|
||||||
const {
|
const {
|
||||||
|
|
@ -37,6 +37,7 @@ export const AllIssueQuickActions: React.FC<IQuickActionProps> = observer((props
|
||||||
const [issueToEdit, setIssueToEdit] = useState<TIssue | undefined>(undefined);
|
const [issueToEdit, setIssueToEdit] = useState<TIssue | undefined>(undefined);
|
||||||
const [deleteIssueModal, setDeleteIssueModal] = useState(false);
|
const [deleteIssueModal, setDeleteIssueModal] = useState(false);
|
||||||
const [archiveIssueModal, setArchiveIssueModal] = useState(false);
|
const [archiveIssueModal, setArchiveIssueModal] = useState(false);
|
||||||
|
const [duplicateWorkItemModal, setDuplicateWorkItemModal] = useState(false);
|
||||||
// router
|
// router
|
||||||
const { workspaceSlug } = useParams();
|
const { workspaceSlug } = useParams();
|
||||||
// store hooks
|
// store hooks
|
||||||
|
|
@ -51,24 +52,6 @@ export const AllIssueQuickActions: React.FC<IQuickActionProps> = observer((props
|
||||||
const isArchivingAllowed = handleArchive && isEditingAllowed;
|
const isArchivingAllowed = handleArchive && isEditingAllowed;
|
||||||
const isInArchivableGroup = !!stateDetails && ARCHIVABLE_STATE_GROUPS.includes(stateDetails?.group);
|
const isInArchivableGroup = !!stateDetails && ARCHIVABLE_STATE_GROUPS.includes(stateDetails?.group);
|
||||||
|
|
||||||
const workItemLink = generateWorkItemLink({
|
|
||||||
workspaceSlug: workspaceSlug?.toString(),
|
|
||||||
projectId: issue?.project_id,
|
|
||||||
issueId: issue?.id,
|
|
||||||
projectIdentifier,
|
|
||||||
sequenceId: issue?.sequence_id,
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleOpenInNewTab = () => window.open(workItemLink, "_blank");
|
|
||||||
const handleCopyIssueLink = () =>
|
|
||||||
copyUrlToClipboard(workItemLink).then(() =>
|
|
||||||
setToast({
|
|
||||||
type: TOAST_TYPE.SUCCESS,
|
|
||||||
title: "Link copied",
|
|
||||||
message: "Work item link copied to clipboard",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const duplicateIssuePayload = omit(
|
const duplicateIssuePayload = omit(
|
||||||
{
|
{
|
||||||
...issue,
|
...issue,
|
||||||
|
|
@ -78,65 +61,33 @@ export const AllIssueQuickActions: React.FC<IQuickActionProps> = observer((props
|
||||||
["id"]
|
["id"]
|
||||||
);
|
);
|
||||||
|
|
||||||
const MENU_ITEMS: TContextMenuItem[] = [
|
// Menu items and modals using helper
|
||||||
{
|
const menuItemProps: MenuItemFactoryProps = {
|
||||||
key: "edit",
|
issue,
|
||||||
title: "Edit",
|
workspaceSlug: workspaceSlug?.toString(),
|
||||||
icon: Pencil,
|
projectIdentifier,
|
||||||
action: () => {
|
activeLayout: "Global issues",
|
||||||
setTrackElement("Global issues");
|
isEditingAllowed,
|
||||||
setIssueToEdit(issue);
|
isArchivingAllowed,
|
||||||
setCreateUpdateIssueModal(true);
|
isDeletingAllowed: isEditingAllowed,
|
||||||
},
|
isInArchivableGroup,
|
||||||
shouldRender: isEditingAllowed,
|
setTrackElement,
|
||||||
},
|
setIssueToEdit,
|
||||||
{
|
setCreateUpdateIssueModal,
|
||||||
key: "make-a-copy",
|
setDeleteIssueModal,
|
||||||
title: "Make a copy",
|
setArchiveIssueModal,
|
||||||
icon: Copy,
|
setDuplicateWorkItemModal,
|
||||||
action: () => {
|
handleDelete,
|
||||||
setTrackElement("Global issues");
|
handleUpdate,
|
||||||
setCreateUpdateIssueModal(true);
|
handleArchive,
|
||||||
},
|
storeType: EIssuesStoreType.GLOBAL,
|
||||||
shouldRender: isEditingAllowed,
|
};
|
||||||
},
|
|
||||||
{
|
const MENU_ITEMS = useAllIssueMenuItems(menuItemProps);
|
||||||
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\nwork items 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{/* Modals */}
|
||||||
<ArchiveIssueModal
|
<ArchiveIssueModal
|
||||||
data={issue}
|
data={issue}
|
||||||
isOpen={archiveIssueModal}
|
isOpen={archiveIssueModal}
|
||||||
|
|
@ -160,7 +111,18 @@ export const AllIssueQuickActions: React.FC<IQuickActionProps> = observer((props
|
||||||
if (issueToEdit && handleUpdate) await handleUpdate(data);
|
if (issueToEdit && handleUpdate) await handleUpdate(data);
|
||||||
}}
|
}}
|
||||||
storeType={EIssuesStoreType.GLOBAL}
|
storeType={EIssuesStoreType.GLOBAL}
|
||||||
|
isDraft={false}
|
||||||
/>
|
/>
|
||||||
|
{issue.project_id && workspaceSlug && (
|
||||||
|
<DuplicateWorkItemModal
|
||||||
|
workItemId={issue.id}
|
||||||
|
isOpen={duplicateWorkItemModal}
|
||||||
|
onClose={() => setDuplicateWorkItemModal(false)}
|
||||||
|
workspaceSlug={workspaceSlug.toString()}
|
||||||
|
projectId={issue.project_id}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<ContextMenu parentRef={parentRef} items={MENU_ITEMS} />
|
<ContextMenu parentRef={parentRef} items={MENU_ITEMS} />
|
||||||
<CustomMenu
|
<CustomMenu
|
||||||
ellipsis
|
ellipsis
|
||||||
|
|
@ -174,6 +136,73 @@ export const AllIssueQuickActions: React.FC<IQuickActionProps> = observer((props
|
||||||
>
|
>
|
||||||
{MENU_ITEMS.map((item) => {
|
{MENU_ITEMS.map((item) => {
|
||||||
if (item.shouldRender === false) return null;
|
if (item.shouldRender === false) return null;
|
||||||
|
|
||||||
|
// Render submenu if nestedMenuItems exist
|
||||||
|
if (item.nestedMenuItems && item.nestedMenuItems.length > 0) {
|
||||||
|
return (
|
||||||
|
<CustomMenu.SubMenu
|
||||||
|
key={item.key}
|
||||||
|
trigger={
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />}
|
||||||
|
<h5>{item.title}</h5>
|
||||||
|
{item.description && (
|
||||||
|
<p
|
||||||
|
className={cn("text-custom-text-300 whitespace-pre-line", {
|
||||||
|
"text-custom-text-400": item.disabled,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{item.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
disabled={item.disabled}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2",
|
||||||
|
{
|
||||||
|
"text-custom-text-400": item.disabled,
|
||||||
|
},
|
||||||
|
item.className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.nestedMenuItems.map((nestedItem) => (
|
||||||
|
<CustomMenu.MenuItem
|
||||||
|
key={nestedItem.key}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
nestedItem.action();
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2",
|
||||||
|
{
|
||||||
|
"text-custom-text-400": nestedItem.disabled,
|
||||||
|
},
|
||||||
|
nestedItem.className
|
||||||
|
)}
|
||||||
|
disabled={nestedItem.disabled}
|
||||||
|
>
|
||||||
|
{nestedItem.icon && <nestedItem.icon className={cn("h-3 w-3", nestedItem.iconClassName)} />}
|
||||||
|
<div>
|
||||||
|
<h5>{nestedItem.title}</h5>
|
||||||
|
{nestedItem.description && (
|
||||||
|
<p
|
||||||
|
className={cn("text-custom-text-300 whitespace-pre-line", {
|
||||||
|
"text-custom-text-400": nestedItem.disabled,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{nestedItem.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CustomMenu.MenuItem>
|
||||||
|
))}
|
||||||
|
</CustomMenu.SubMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render regular menu item
|
||||||
return (
|
return (
|
||||||
<CustomMenu.MenuItem
|
<CustomMenu.MenuItem
|
||||||
key={item.key}
|
key={item.key}
|
||||||
|
|
|
||||||
|
|
@ -3,21 +3,19 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
// icons
|
|
||||||
import { ArchiveRestoreIcon, ExternalLink, Link, Trash2 } from "lucide-react";
|
|
||||||
// ui
|
// ui
|
||||||
import { EIssuesStoreType, EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
import { EIssuesStoreType, EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||||
import { ContextMenu, CustomMenu, TContextMenuItem, TOAST_TYPE, setToast } from "@plane/ui";
|
import { ContextMenu, CustomMenu } from "@plane/ui";
|
||||||
import { copyUrlToClipboard } from "@plane/utils";
|
|
||||||
// components
|
// components
|
||||||
import { DeleteIssueModal } from "@/components/issues";
|
import { DeleteIssueModal } from "@/components/issues";
|
||||||
// constants
|
|
||||||
// helpers
|
// helpers
|
||||||
import { cn } from "@/helpers/common.helper";
|
import { cn } from "@/helpers/common.helper";
|
||||||
// hooks
|
// hooks
|
||||||
import { useEventTracker, useIssues, useUserPermissions } from "@/hooks/store";
|
import { useEventTracker, useIssues, useUserPermissions } from "@/hooks/store";
|
||||||
// types
|
// types
|
||||||
import { IQuickActionProps } from "../list/list-view-types";
|
import { IQuickActionProps } from "../list/list-view-types";
|
||||||
|
// helper
|
||||||
|
import { useArchivedIssueMenuItems, MenuItemFactoryProps } from "./helper";
|
||||||
|
|
||||||
export const ArchivedIssueQuickActions: React.FC<IQuickActionProps> = observer((props) => {
|
export const ArchivedIssueQuickActions: React.FC<IQuickActionProps> = observer((props) => {
|
||||||
const {
|
const {
|
||||||
|
|
@ -47,76 +45,34 @@ export const ArchivedIssueQuickActions: React.FC<IQuickActionProps> = observer((
|
||||||
const isRestoringAllowed =
|
const isRestoringAllowed =
|
||||||
handleRestore && allowPermissions([EUserPermissions.ADMIN, EUserPermissions.MEMBER], EUserPermissionsLevel.PROJECT);
|
handleRestore && allowPermissions([EUserPermissions.ADMIN, EUserPermissions.MEMBER], EUserPermissionsLevel.PROJECT);
|
||||||
|
|
||||||
const issueLink = `${workspaceSlug}/projects/${issue.project_id}/archives/issues/${issue.id}`;
|
// Menu items and modals using helper
|
||||||
|
const menuItemProps: MenuItemFactoryProps = {
|
||||||
const handleOpenInNewTab = () => window.open(`/${issueLink}`, "_blank");
|
issue,
|
||||||
const handleCopyIssueLink = () =>
|
workspaceSlug: workspaceSlug?.toString(),
|
||||||
copyUrlToClipboard(issueLink).then(() =>
|
activeLayout,
|
||||||
setToast({
|
isEditingAllowed,
|
||||||
type: TOAST_TYPE.SUCCESS,
|
isDeletingAllowed: isEditingAllowed,
|
||||||
title: "Link copied",
|
isRestoringAllowed: !!isRestoringAllowed,
|
||||||
message: "Work item link copied to clipboard",
|
setTrackElement,
|
||||||
})
|
setIssueToEdit: () => {},
|
||||||
);
|
setCreateUpdateIssueModal: () => {},
|
||||||
const handleIssueRestore = async () => {
|
setDeleteIssueModal,
|
||||||
if (!handleRestore) return;
|
handleRestore,
|
||||||
await handleRestore()
|
handleDelete,
|
||||||
.then(() => {
|
|
||||||
setToast({
|
|
||||||
type: TOAST_TYPE.SUCCESS,
|
|
||||||
title: "Restore success",
|
|
||||||
message: "Your work item can be found in project work items.",
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
setToast({
|
|
||||||
type: TOAST_TYPE.ERROR,
|
|
||||||
title: "Error!",
|
|
||||||
message: "Work item could not be restored. Please try again.",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const MENU_ITEMS: TContextMenuItem[] = [
|
const MENU_ITEMS = useArchivedIssueMenuItems(menuItemProps);
|
||||||
{
|
|
||||||
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{/* Modals */}
|
||||||
<DeleteIssueModal
|
<DeleteIssueModal
|
||||||
data={issue}
|
data={issue}
|
||||||
isOpen={deleteIssueModal}
|
isOpen={deleteIssueModal}
|
||||||
handleClose={() => setDeleteIssueModal(false)}
|
handleClose={() => setDeleteIssueModal(false)}
|
||||||
onSubmit={handleDelete}
|
onSubmit={handleDelete}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ContextMenu parentRef={parentRef} items={MENU_ITEMS} />
|
<ContextMenu parentRef={parentRef} items={MENU_ITEMS} />
|
||||||
<CustomMenu
|
<CustomMenu
|
||||||
ellipsis
|
ellipsis
|
||||||
|
|
|
||||||
|
|
@ -4,21 +4,22 @@ import { useState } from "react";
|
||||||
import omit from "lodash/omit";
|
import omit from "lodash/omit";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import { Copy, ExternalLink, Link, Pencil, Trash2, XCircle } from "lucide-react";
|
|
||||||
// plane imports
|
// plane imports
|
||||||
import { ARCHIVABLE_STATE_GROUPS, EIssuesStoreType, EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
import { ARCHIVABLE_STATE_GROUPS, EIssuesStoreType, EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||||
import { TIssue } from "@plane/types";
|
import { TIssue } from "@plane/types";
|
||||||
import { ArchiveIcon, ContextMenu, CustomMenu, TContextMenuItem, TOAST_TYPE, setToast } from "@plane/ui";
|
import { ContextMenu, CustomMenu } from "@plane/ui";
|
||||||
import { copyUrlToClipboard } from "@plane/utils";
|
|
||||||
// components
|
// components
|
||||||
import { ArchiveIssueModal, CreateUpdateIssueModal, DeleteIssueModal } from "@/components/issues";
|
import { ArchiveIssueModal, CreateUpdateIssueModal, DeleteIssueModal } from "@/components/issues";
|
||||||
// helpers
|
// helpers
|
||||||
import { cn } from "@/helpers/common.helper";
|
import { cn } from "@/helpers/common.helper";
|
||||||
import { generateWorkItemLink } from "@/helpers/issue.helper";
|
|
||||||
// hooks
|
// hooks
|
||||||
import { useEventTracker, useIssues, useProject, useProjectState, useUserPermissions } from "@/hooks/store";
|
import { useEventTracker, useIssues, useProject, useProjectState, useUserPermissions } from "@/hooks/store";
|
||||||
|
// plane-web components
|
||||||
|
import { DuplicateWorkItemModal } from "@/plane-web/components/issues/issue-layouts/quick-action-dropdowns";
|
||||||
// types
|
// types
|
||||||
import { IQuickActionProps } from "../list/list-view-types";
|
import { IQuickActionProps } from "../list/list-view-types";
|
||||||
|
// helper
|
||||||
|
import { useCycleIssueMenuItems, MenuItemFactoryProps } from "./helper";
|
||||||
|
|
||||||
export const CycleIssueQuickActions: React.FC<IQuickActionProps> = observer((props) => {
|
export const CycleIssueQuickActions: React.FC<IQuickActionProps> = observer((props) => {
|
||||||
const {
|
const {
|
||||||
|
|
@ -38,6 +39,7 @@ export const CycleIssueQuickActions: React.FC<IQuickActionProps> = observer((pro
|
||||||
const [issueToEdit, setIssueToEdit] = useState<TIssue | undefined>(undefined);
|
const [issueToEdit, setIssueToEdit] = useState<TIssue | undefined>(undefined);
|
||||||
const [deleteIssueModal, setDeleteIssueModal] = useState(false);
|
const [deleteIssueModal, setDeleteIssueModal] = useState(false);
|
||||||
const [archiveIssueModal, setArchiveIssueModal] = useState(false);
|
const [archiveIssueModal, setArchiveIssueModal] = useState(false);
|
||||||
|
const [duplicateWorkItemModal, setDuplicateWorkItemModal] = useState(false);
|
||||||
// router
|
// router
|
||||||
const { workspaceSlug, cycleId } = useParams();
|
const { workspaceSlug, cycleId } = useParams();
|
||||||
// store hooks
|
// store hooks
|
||||||
|
|
@ -58,25 +60,6 @@ export const CycleIssueQuickActions: React.FC<IQuickActionProps> = observer((pro
|
||||||
|
|
||||||
const activeLayout = `${issuesFilter.issueFilters?.displayFilters?.layout} layout`;
|
const activeLayout = `${issuesFilter.issueFilters?.displayFilters?.layout} layout`;
|
||||||
|
|
||||||
const workItemLink = generateWorkItemLink({
|
|
||||||
workspaceSlug: workspaceSlug?.toString(),
|
|
||||||
projectId: issue?.project_id,
|
|
||||||
issueId: issue?.id,
|
|
||||||
projectIdentifier,
|
|
||||||
sequenceId: issue?.sequence_id,
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleOpenInNewTab = () => window.open(workItemLink, "_blank");
|
|
||||||
|
|
||||||
const handleCopyIssueLink = () =>
|
|
||||||
copyUrlToClipboard(workItemLink).then(() =>
|
|
||||||
setToast({
|
|
||||||
type: TOAST_TYPE.SUCCESS,
|
|
||||||
title: "Link copied",
|
|
||||||
message: "Work item link copied to clipboard",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const duplicateIssuePayload = omit(
|
const duplicateIssuePayload = omit(
|
||||||
{
|
{
|
||||||
...issue,
|
...issue,
|
||||||
|
|
@ -86,75 +69,35 @@ export const CycleIssueQuickActions: React.FC<IQuickActionProps> = observer((pro
|
||||||
["id"]
|
["id"]
|
||||||
);
|
);
|
||||||
|
|
||||||
const MENU_ITEMS: TContextMenuItem[] = [
|
// Menu items and modals using helper
|
||||||
{
|
const menuItemProps: MenuItemFactoryProps = {
|
||||||
key: "edit",
|
issue,
|
||||||
title: "Edit",
|
workspaceSlug: workspaceSlug?.toString(),
|
||||||
icon: Pencil,
|
projectIdentifier,
|
||||||
action: () => {
|
activeLayout,
|
||||||
setIssueToEdit({
|
isEditingAllowed,
|
||||||
...issue,
|
isArchivingAllowed,
|
||||||
cycle_id: cycleId?.toString() ?? null,
|
isDeletingAllowed,
|
||||||
});
|
isInArchivableGroup,
|
||||||
setTrackElement(activeLayout);
|
setTrackElement,
|
||||||
setCreateUpdateIssueModal(true);
|
setIssueToEdit,
|
||||||
},
|
setCreateUpdateIssueModal,
|
||||||
shouldRender: isEditingAllowed,
|
setDeleteIssueModal,
|
||||||
},
|
setArchiveIssueModal,
|
||||||
{
|
setDuplicateWorkItemModal,
|
||||||
key: "make-a-copy",
|
handleRemoveFromView,
|
||||||
title: "Make a copy",
|
cycleId: cycleId?.toString(),
|
||||||
icon: Copy,
|
handleDelete,
|
||||||
action: () => {
|
handleUpdate,
|
||||||
setTrackElement(activeLayout);
|
handleArchive,
|
||||||
setCreateUpdateIssueModal(true);
|
storeType: EIssuesStoreType.CYCLE,
|
||||||
},
|
};
|
||||||
shouldRender: isEditingAllowed,
|
|
||||||
},
|
const MENU_ITEMS = useCycleIssueMenuItems(menuItemProps);
|
||||||
{
|
|
||||||
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\nwork items 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{/* Modals */}
|
||||||
<ArchiveIssueModal
|
<ArchiveIssueModal
|
||||||
data={issue}
|
data={issue}
|
||||||
isOpen={archiveIssueModal}
|
isOpen={archiveIssueModal}
|
||||||
|
|
@ -178,7 +121,18 @@ export const CycleIssueQuickActions: React.FC<IQuickActionProps> = observer((pro
|
||||||
if (issueToEdit && handleUpdate) await handleUpdate(data);
|
if (issueToEdit && handleUpdate) await handleUpdate(data);
|
||||||
}}
|
}}
|
||||||
storeType={EIssuesStoreType.CYCLE}
|
storeType={EIssuesStoreType.CYCLE}
|
||||||
|
isDraft={false}
|
||||||
/>
|
/>
|
||||||
|
{issue.project_id && workspaceSlug && (
|
||||||
|
<DuplicateWorkItemModal
|
||||||
|
workItemId={issue.id}
|
||||||
|
isOpen={duplicateWorkItemModal}
|
||||||
|
onClose={() => setDuplicateWorkItemModal(false)}
|
||||||
|
workspaceSlug={workspaceSlug.toString()}
|
||||||
|
projectId={issue.project_id}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<ContextMenu parentRef={parentRef} items={MENU_ITEMS} />
|
<ContextMenu parentRef={parentRef} items={MENU_ITEMS} />
|
||||||
<CustomMenu
|
<CustomMenu
|
||||||
ellipsis
|
ellipsis
|
||||||
|
|
@ -192,6 +146,73 @@ export const CycleIssueQuickActions: React.FC<IQuickActionProps> = observer((pro
|
||||||
>
|
>
|
||||||
{MENU_ITEMS.map((item) => {
|
{MENU_ITEMS.map((item) => {
|
||||||
if (item.shouldRender === false) return null;
|
if (item.shouldRender === false) return null;
|
||||||
|
|
||||||
|
// Render submenu if nestedMenuItems exist
|
||||||
|
if (item.nestedMenuItems && item.nestedMenuItems.length > 0) {
|
||||||
|
return (
|
||||||
|
<CustomMenu.SubMenu
|
||||||
|
key={item.key}
|
||||||
|
trigger={
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />}
|
||||||
|
<h5>{item.title}</h5>
|
||||||
|
{item.description && (
|
||||||
|
<p
|
||||||
|
className={cn("text-custom-text-300 whitespace-pre-line", {
|
||||||
|
"text-custom-text-400": item.disabled,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{item.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
disabled={item.disabled}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2",
|
||||||
|
{
|
||||||
|
"text-custom-text-400": item.disabled,
|
||||||
|
},
|
||||||
|
item.className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.nestedMenuItems.map((nestedItem) => (
|
||||||
|
<CustomMenu.MenuItem
|
||||||
|
key={nestedItem.key}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
nestedItem.action();
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2",
|
||||||
|
{
|
||||||
|
"text-custom-text-400": nestedItem.disabled,
|
||||||
|
},
|
||||||
|
nestedItem.className
|
||||||
|
)}
|
||||||
|
disabled={nestedItem.disabled}
|
||||||
|
>
|
||||||
|
{nestedItem.icon && <nestedItem.icon className={cn("h-3 w-3", nestedItem.iconClassName)} />}
|
||||||
|
<div>
|
||||||
|
<h5>{nestedItem.title}</h5>
|
||||||
|
{nestedItem.description && (
|
||||||
|
<p
|
||||||
|
className={cn("text-custom-text-300 whitespace-pre-line", {
|
||||||
|
"text-custom-text-400": nestedItem.disabled,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{nestedItem.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CustomMenu.MenuItem>
|
||||||
|
))}
|
||||||
|
</CustomMenu.SubMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render regular menu item
|
||||||
return (
|
return (
|
||||||
<CustomMenu.MenuItem
|
<CustomMenu.MenuItem
|
||||||
key={item.key}
|
key={item.key}
|
||||||
|
|
|
||||||
|
|
@ -3,23 +3,21 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import omit from "lodash/omit";
|
import omit from "lodash/omit";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
// icons
|
import { useParams, usePathname } from "next/navigation";
|
||||||
import { Pencil, Trash2 } from "lucide-react";
|
// plane imports
|
||||||
// types
|
|
||||||
import { EIssuesStoreType, EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
import { EIssuesStoreType, EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||||
import { useTranslation } from "@plane/i18n";
|
|
||||||
import { TIssue } from "@plane/types";
|
import { TIssue } from "@plane/types";
|
||||||
// ui
|
import { ContextMenu, CustomMenu } from "@plane/ui";
|
||||||
import { ContextMenu, CustomMenu, TContextMenuItem } from "@plane/ui";
|
|
||||||
// components
|
// components
|
||||||
import { CreateUpdateIssueModal, DeleteIssueModal } from "@/components/issues";
|
import { CreateUpdateIssueModal, DeleteIssueModal } from "@/components/issues";
|
||||||
// constant
|
|
||||||
// helpers
|
// helpers
|
||||||
import { cn } from "@/helpers/common.helper";
|
import { cn } from "@/helpers/common.helper";
|
||||||
// hooks
|
// hooks
|
||||||
import { useEventTracker, useIssues, useUserPermissions } from "@/hooks/store";
|
import { useEventTracker, useUserPermissions } from "@/hooks/store";
|
||||||
// types
|
// types
|
||||||
import { IQuickActionProps } from "../list/list-view-types";
|
import { IQuickActionProps } from "../list/list-view-types";
|
||||||
|
// helper
|
||||||
|
import { useDraftIssueMenuItems, MenuItemFactoryProps } from "./helper";
|
||||||
|
|
||||||
export const DraftIssueQuickActions: React.FC<IQuickActionProps> = observer((props) => {
|
export const DraftIssueQuickActions: React.FC<IQuickActionProps> = observer((props) => {
|
||||||
const {
|
const {
|
||||||
|
|
@ -32,6 +30,9 @@ export const DraftIssueQuickActions: React.FC<IQuickActionProps> = observer((pro
|
||||||
placements = "bottom-end",
|
placements = "bottom-end",
|
||||||
parentRef,
|
parentRef,
|
||||||
} = props;
|
} = props;
|
||||||
|
// router
|
||||||
|
const { workspaceSlug } = useParams();
|
||||||
|
const pathname = usePathname();
|
||||||
// states
|
// states
|
||||||
const [createUpdateIssueModal, setCreateUpdateIssueModal] = useState(false);
|
const [createUpdateIssueModal, setCreateUpdateIssueModal] = useState(false);
|
||||||
const [issueToEdit, setIssueToEdit] = useState<TIssue | undefined>(undefined);
|
const [issueToEdit, setIssueToEdit] = useState<TIssue | undefined>(undefined);
|
||||||
|
|
@ -39,56 +40,52 @@ export const DraftIssueQuickActions: React.FC<IQuickActionProps> = observer((pro
|
||||||
// store hooks
|
// store hooks
|
||||||
const { allowPermissions } = useUserPermissions();
|
const { allowPermissions } = useUserPermissions();
|
||||||
const { setTrackElement } = useEventTracker();
|
const { setTrackElement } = useEventTracker();
|
||||||
const { issuesFilter } = useIssues(EIssuesStoreType.PROJECT);
|
|
||||||
|
|
||||||
const { t } = useTranslation();
|
|
||||||
// derived values
|
// derived values
|
||||||
const activeLayout = `${issuesFilter.issueFilters?.displayFilters?.layout} layout`;
|
const activeLayout = "Draft Issues";
|
||||||
// auth
|
// auth
|
||||||
const isEditingAllowed =
|
const isEditingAllowed =
|
||||||
allowPermissions([EUserPermissions.ADMIN, EUserPermissions.MEMBER], EUserPermissionsLevel.PROJECT) && !readOnly;
|
allowPermissions(
|
||||||
|
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
|
||||||
|
EUserPermissionsLevel.PROJECT,
|
||||||
|
workspaceSlug?.toString(),
|
||||||
|
issue.project_id ?? undefined
|
||||||
|
) && !readOnly;
|
||||||
const isDeletingAllowed = isEditingAllowed;
|
const isDeletingAllowed = isEditingAllowed;
|
||||||
|
|
||||||
|
const isDraftIssue = pathname?.includes("draft-issues") || false;
|
||||||
|
|
||||||
const duplicateIssuePayload = omit(
|
const duplicateIssuePayload = omit(
|
||||||
{
|
{
|
||||||
...issue,
|
...issue,
|
||||||
name: `${issue.name} (copy)`,
|
name: `${issue.name} (copy)`,
|
||||||
is_draft: true,
|
is_draft: isDraftIssue ? false : issue.is_draft,
|
||||||
|
sourceIssueId: issue.id,
|
||||||
},
|
},
|
||||||
["id"]
|
["id"]
|
||||||
);
|
);
|
||||||
|
|
||||||
const MENU_ITEMS: TContextMenuItem[] = [
|
// Menu items and modals using helper
|
||||||
{
|
const menuItemProps: MenuItemFactoryProps = {
|
||||||
key: "edit",
|
issue,
|
||||||
title: "edit",
|
workspaceSlug: workspaceSlug?.toString(),
|
||||||
icon: Pencil,
|
activeLayout,
|
||||||
action: () => {
|
isEditingAllowed,
|
||||||
setTrackElement(activeLayout);
|
isDeletingAllowed,
|
||||||
setIssueToEdit(issue);
|
isDraftIssue,
|
||||||
setCreateUpdateIssueModal(true);
|
setTrackElement,
|
||||||
},
|
setIssueToEdit,
|
||||||
shouldRender: isEditingAllowed,
|
setCreateUpdateIssueModal,
|
||||||
},
|
setDeleteIssueModal,
|
||||||
{
|
handleDelete,
|
||||||
key: "delete",
|
handleUpdate,
|
||||||
title: "delete",
|
storeType: EIssuesStoreType.DRAFT,
|
||||||
icon: Trash2,
|
};
|
||||||
action: () => {
|
|
||||||
setTrackElement(activeLayout);
|
|
||||||
setDeleteIssueModal(true);
|
|
||||||
},
|
|
||||||
shouldRender: isDeletingAllowed,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// check if any of the menu items should render
|
const MENU_ITEMS = useDraftIssueMenuItems(menuItemProps);
|
||||||
const shouldRenderQuickAction = MENU_ITEMS.some((item) => item.shouldRender);
|
|
||||||
|
|
||||||
if (!shouldRenderQuickAction) return <></>;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{/* Modals */}
|
||||||
<DeleteIssueModal
|
<DeleteIssueModal
|
||||||
data={issue}
|
data={issue}
|
||||||
isOpen={deleteIssueModal}
|
isOpen={deleteIssueModal}
|
||||||
|
|
@ -106,17 +103,17 @@ export const DraftIssueQuickActions: React.FC<IQuickActionProps> = observer((pro
|
||||||
if (issueToEdit && handleUpdate) await handleUpdate(data);
|
if (issueToEdit && handleUpdate) await handleUpdate(data);
|
||||||
}}
|
}}
|
||||||
storeType={EIssuesStoreType.DRAFT}
|
storeType={EIssuesStoreType.DRAFT}
|
||||||
isDraft
|
isDraft={isDraftIssue}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ContextMenu parentRef={parentRef} items={MENU_ITEMS} />
|
<ContextMenu parentRef={parentRef} items={MENU_ITEMS} />
|
||||||
<CustomMenu
|
<CustomMenu
|
||||||
ellipsis
|
ellipsis
|
||||||
|
placement={placements}
|
||||||
customButton={customActionButton}
|
customButton={customActionButton}
|
||||||
portalElement={portalElement}
|
portalElement={portalElement}
|
||||||
placement={placements}
|
|
||||||
menuItemsClassName="z-[14]"
|
menuItemsClassName="z-[14]"
|
||||||
maxHeight="lg"
|
maxHeight="lg"
|
||||||
useCaptureForOutsideClick
|
|
||||||
closeOnSelect
|
closeOnSelect
|
||||||
>
|
>
|
||||||
{MENU_ITEMS.map((item) => {
|
{MENU_ITEMS.map((item) => {
|
||||||
|
|
@ -140,7 +137,7 @@ export const DraftIssueQuickActions: React.FC<IQuickActionProps> = observer((pro
|
||||||
>
|
>
|
||||||
{item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />}
|
{item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />}
|
||||||
<div>
|
<div>
|
||||||
<h5>{t(item.title ?? "")}</h5>
|
<h5>{item.title}</h5>
|
||||||
{item.description && (
|
{item.description && (
|
||||||
<p
|
<p
|
||||||
className={cn("text-custom-text-300 whitespace-pre-line", {
|
className={cn("text-custom-text-300 whitespace-pre-line", {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,372 @@
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { Copy, ExternalLink, Link, Pencil, Trash2, XCircle, ArchiveRestoreIcon } from "lucide-react";
|
||||||
|
// plane imports
|
||||||
|
import { EIssuesStoreType } from "@plane/constants";
|
||||||
|
import { useTranslation } from "@plane/i18n";
|
||||||
|
import { TIssue } from "@plane/types";
|
||||||
|
import { ArchiveIcon, TContextMenuItem, TOAST_TYPE, setToast } from "@plane/ui";
|
||||||
|
import { copyUrlToClipboard } from "@plane/utils";
|
||||||
|
// helpers
|
||||||
|
import { generateWorkItemLink } from "@/helpers/issue.helper";
|
||||||
|
// types
|
||||||
|
import { createCopyMenuWithDuplication } from "@/plane-web/components/issues/issue-layouts/quick-action-dropdowns";
|
||||||
|
|
||||||
|
// Generic helper function to handle optional function calls gracefully
|
||||||
|
// Overload for functions without parameters
|
||||||
|
export function handleOptionalAction(
|
||||||
|
optionalFn: (() => void) | (() => Promise<void>) | undefined,
|
||||||
|
actionName: string
|
||||||
|
): void;
|
||||||
|
|
||||||
|
// Overload for functions with one parameter
|
||||||
|
export function handleOptionalAction<T>(
|
||||||
|
optionalFn: ((param: T) => void) | ((param: T) => Promise<void>) | undefined,
|
||||||
|
actionName: string,
|
||||||
|
param: T
|
||||||
|
): void;
|
||||||
|
|
||||||
|
// Implementation
|
||||||
|
export function handleOptionalAction<T>(
|
||||||
|
optionalFn: (() => void) | (() => Promise<void>) | ((param: T) => void) | ((param: T) => Promise<void>) | undefined,
|
||||||
|
actionName: string,
|
||||||
|
param?: T
|
||||||
|
): void {
|
||||||
|
if (optionalFn) {
|
||||||
|
if (param !== undefined) {
|
||||||
|
(optionalFn as (param: T) => void | Promise<void>)(param);
|
||||||
|
} else {
|
||||||
|
(optionalFn as () => void | Promise<void>)();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setToast({
|
||||||
|
type: TOAST_TYPE.ERROR,
|
||||||
|
title: "Action not available",
|
||||||
|
message: `${actionName} action is not implemented.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MenuItemFactoryProps {
|
||||||
|
issue: TIssue;
|
||||||
|
workspaceSlug?: string;
|
||||||
|
projectIdentifier?: string;
|
||||||
|
activeLayout?: string;
|
||||||
|
isEditingAllowed: boolean;
|
||||||
|
isArchivingAllowed?: boolean;
|
||||||
|
isDeletingAllowed: boolean;
|
||||||
|
isRestoringAllowed?: boolean;
|
||||||
|
isInArchivableGroup?: boolean;
|
||||||
|
issueTypeDetail?: { is_active?: boolean };
|
||||||
|
isDraftIssue?: boolean;
|
||||||
|
// Action handlers
|
||||||
|
setTrackElement: (element: string) => void;
|
||||||
|
setIssueToEdit: (issue: TIssue | undefined) => void;
|
||||||
|
setCreateUpdateIssueModal: (open: boolean) => void;
|
||||||
|
setDeleteIssueModal: (open: boolean) => void;
|
||||||
|
setArchiveIssueModal?: (open: boolean) => void;
|
||||||
|
setDuplicateWorkItemModal?: (open: boolean) => void;
|
||||||
|
handleRemoveFromView?: () => void;
|
||||||
|
handleRestore?: () => Promise<void>;
|
||||||
|
// External handlers
|
||||||
|
handleDelete?: () => Promise<void>;
|
||||||
|
handleUpdate?: (data: TIssue) => Promise<void>;
|
||||||
|
handleArchive?: () => Promise<void>;
|
||||||
|
// Context-specific data
|
||||||
|
cycleId?: string;
|
||||||
|
moduleId?: string;
|
||||||
|
storeType?: EIssuesStoreType;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Common action handlers hook
|
||||||
|
export const useIssueActionHandlers = (props: MenuItemFactoryProps) => {
|
||||||
|
const { issue, workspaceSlug, projectIdentifier, handleRestore } = props;
|
||||||
|
|
||||||
|
const workItemLink = useMemo(
|
||||||
|
() =>
|
||||||
|
generateWorkItemLink({
|
||||||
|
workspaceSlug,
|
||||||
|
projectId: issue?.project_id,
|
||||||
|
issueId: issue?.id,
|
||||||
|
projectIdentifier,
|
||||||
|
sequenceId: issue?.sequence_id,
|
||||||
|
}),
|
||||||
|
[workspaceSlug, projectIdentifier, issue]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCopyIssueLink = () =>
|
||||||
|
copyUrlToClipboard(workItemLink).then(() =>
|
||||||
|
setToast({
|
||||||
|
type: TOAST_TYPE.SUCCESS,
|
||||||
|
title: "Link copied",
|
||||||
|
message: "Work item link copied to clipboard",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleOpenInNewTab = () => window.open(workItemLink, "_blank");
|
||||||
|
|
||||||
|
const handleIssueRestore = async () => {
|
||||||
|
if (!handleRestore) {
|
||||||
|
handleOptionalAction(handleRestore, "Restore");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await handleRestore()
|
||||||
|
.then(() => {
|
||||||
|
setToast({
|
||||||
|
type: TOAST_TYPE.SUCCESS,
|
||||||
|
title: "Restore success",
|
||||||
|
message: "Your work item can be found in project work items.",
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setToast({
|
||||||
|
type: TOAST_TYPE.ERROR,
|
||||||
|
title: "Error!",
|
||||||
|
message: "Work item could not be restored. Please try again.",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
workItemLink,
|
||||||
|
handleCopyIssueLink,
|
||||||
|
handleOpenInNewTab,
|
||||||
|
handleIssueRestore,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useMenuItemFactory = (props: MenuItemFactoryProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const actionHandlers = useIssueActionHandlers(props);
|
||||||
|
|
||||||
|
const {
|
||||||
|
issue,
|
||||||
|
activeLayout = "",
|
||||||
|
isEditingAllowed,
|
||||||
|
isArchivingAllowed = false,
|
||||||
|
isDeletingAllowed,
|
||||||
|
isRestoringAllowed = false,
|
||||||
|
isInArchivableGroup = false,
|
||||||
|
issueTypeDetail,
|
||||||
|
setTrackElement,
|
||||||
|
setIssueToEdit,
|
||||||
|
setCreateUpdateIssueModal,
|
||||||
|
setDeleteIssueModal,
|
||||||
|
setArchiveIssueModal,
|
||||||
|
setDuplicateWorkItemModal,
|
||||||
|
handleRemoveFromView,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const createEditMenuItem = (customEditAction?: () => void): TContextMenuItem => ({
|
||||||
|
key: "edit",
|
||||||
|
title: t("common.actions.edit"),
|
||||||
|
icon: Pencil,
|
||||||
|
action:
|
||||||
|
customEditAction ||
|
||||||
|
(() => {
|
||||||
|
setTrackElement(activeLayout);
|
||||||
|
setIssueToEdit(issue);
|
||||||
|
setCreateUpdateIssueModal(true);
|
||||||
|
}),
|
||||||
|
shouldRender: isEditingAllowed,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createCopyMenuItem = (): TContextMenuItem => {
|
||||||
|
const baseItem = {
|
||||||
|
key: "make-a-copy",
|
||||||
|
title: t("common.actions.make_a_copy"),
|
||||||
|
icon: Copy,
|
||||||
|
action: () => {
|
||||||
|
setTrackElement(activeLayout);
|
||||||
|
setCreateUpdateIssueModal(true);
|
||||||
|
},
|
||||||
|
shouldRender: isEditingAllowed && (issueTypeDetail?.is_active ?? true),
|
||||||
|
};
|
||||||
|
|
||||||
|
return createCopyMenuWithDuplication({
|
||||||
|
baseItem,
|
||||||
|
activeLayout,
|
||||||
|
setTrackElement,
|
||||||
|
setCreateUpdateIssueModal,
|
||||||
|
setDuplicateWorkItemModal,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const createOpenInNewTabMenuItem = (): TContextMenuItem => ({
|
||||||
|
key: "open-in-new-tab",
|
||||||
|
title: t("common.actions.open_in_new_tab"),
|
||||||
|
icon: ExternalLink,
|
||||||
|
action: actionHandlers.handleOpenInNewTab,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createCopyLinkMenuItem = (): TContextMenuItem => ({
|
||||||
|
key: "copy-link",
|
||||||
|
title: t("common.actions.copy_link"),
|
||||||
|
icon: Link,
|
||||||
|
action: actionHandlers.handleCopyIssueLink,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createRemoveFromCycleMenuItem = (): TContextMenuItem => ({
|
||||||
|
key: "remove-from-cycle",
|
||||||
|
title: "Remove from cycle",
|
||||||
|
icon: XCircle,
|
||||||
|
action: () => handleOptionalAction(handleRemoveFromView, "Remove from cycle"),
|
||||||
|
shouldRender: isEditingAllowed,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createRemoveFromModuleMenuItem = (): TContextMenuItem => ({
|
||||||
|
key: "remove-from-module",
|
||||||
|
title: "Remove from module",
|
||||||
|
icon: XCircle,
|
||||||
|
action: () => handleOptionalAction(handleRemoveFromView, "Remove from module"),
|
||||||
|
shouldRender: isEditingAllowed,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createArchiveMenuItem = (): TContextMenuItem => ({
|
||||||
|
key: "archive",
|
||||||
|
title: t("common.actions.archive"),
|
||||||
|
description: isInArchivableGroup ? undefined : t("issue.archive.description"),
|
||||||
|
icon: ArchiveIcon,
|
||||||
|
className: "items-start",
|
||||||
|
iconClassName: "mt-1",
|
||||||
|
action: () => handleOptionalAction(setArchiveIssueModal, "Archive", true),
|
||||||
|
disabled: !isInArchivableGroup,
|
||||||
|
shouldRender: isArchivingAllowed,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createRestoreMenuItem = (): TContextMenuItem => ({
|
||||||
|
key: "restore",
|
||||||
|
title: "Restore",
|
||||||
|
icon: ArchiveRestoreIcon,
|
||||||
|
action: actionHandlers.handleIssueRestore,
|
||||||
|
shouldRender: isRestoringAllowed,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createDeleteMenuItem = (): TContextMenuItem => ({
|
||||||
|
key: "delete",
|
||||||
|
title: t("common.actions.delete"),
|
||||||
|
icon: Trash2,
|
||||||
|
action: () => {
|
||||||
|
setTrackElement(activeLayout);
|
||||||
|
setDeleteIssueModal(true);
|
||||||
|
},
|
||||||
|
shouldRender: isDeletingAllowed,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...actionHandlers,
|
||||||
|
createEditMenuItem,
|
||||||
|
createCopyMenuItem,
|
||||||
|
createOpenInNewTabMenuItem,
|
||||||
|
createCopyLinkMenuItem,
|
||||||
|
createRemoveFromCycleMenuItem,
|
||||||
|
createRemoveFromModuleMenuItem,
|
||||||
|
createArchiveMenuItem,
|
||||||
|
createRestoreMenuItem,
|
||||||
|
createDeleteMenuItem,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Predefined menu item sets for different contexts
|
||||||
|
export const useProjectIssueMenuItems = (props: MenuItemFactoryProps): TContextMenuItem[] => {
|
||||||
|
const factory = useMenuItemFactory(props);
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => [
|
||||||
|
factory.createEditMenuItem(),
|
||||||
|
factory.createCopyMenuItem(),
|
||||||
|
factory.createOpenInNewTabMenuItem(),
|
||||||
|
factory.createCopyLinkMenuItem(),
|
||||||
|
factory.createArchiveMenuItem(),
|
||||||
|
factory.createDeleteMenuItem(),
|
||||||
|
],
|
||||||
|
[factory]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useAllIssueMenuItems = (props: MenuItemFactoryProps): TContextMenuItem[] => {
|
||||||
|
const factory = useMenuItemFactory(props);
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => [
|
||||||
|
factory.createEditMenuItem(),
|
||||||
|
factory.createCopyMenuItem(),
|
||||||
|
factory.createOpenInNewTabMenuItem(),
|
||||||
|
factory.createCopyLinkMenuItem(),
|
||||||
|
factory.createArchiveMenuItem(),
|
||||||
|
factory.createDeleteMenuItem(),
|
||||||
|
],
|
||||||
|
[factory]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useCycleIssueMenuItems = (props: MenuItemFactoryProps): TContextMenuItem[] => {
|
||||||
|
const factory = useMenuItemFactory(props);
|
||||||
|
|
||||||
|
const customEditAction = () => {
|
||||||
|
props.setIssueToEdit({
|
||||||
|
...props.issue,
|
||||||
|
cycle_id: props.cycleId ?? null,
|
||||||
|
});
|
||||||
|
props.setTrackElement(props.activeLayout || "");
|
||||||
|
props.setCreateUpdateIssueModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => [
|
||||||
|
factory.createEditMenuItem(customEditAction),
|
||||||
|
factory.createCopyMenuItem(),
|
||||||
|
factory.createOpenInNewTabMenuItem(),
|
||||||
|
factory.createCopyLinkMenuItem(),
|
||||||
|
factory.createRemoveFromCycleMenuItem(),
|
||||||
|
factory.createArchiveMenuItem(),
|
||||||
|
factory.createDeleteMenuItem(),
|
||||||
|
],
|
||||||
|
[factory, props.cycleId]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useModuleIssueMenuItems = (props: MenuItemFactoryProps): TContextMenuItem[] => {
|
||||||
|
const factory = useMenuItemFactory(props);
|
||||||
|
|
||||||
|
const customEditAction = () => {
|
||||||
|
props.setIssueToEdit({
|
||||||
|
...props.issue,
|
||||||
|
module_ids: props.moduleId ? [props.moduleId] : [],
|
||||||
|
});
|
||||||
|
props.setTrackElement(props.activeLayout || "");
|
||||||
|
props.setCreateUpdateIssueModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => [
|
||||||
|
factory.createEditMenuItem(customEditAction),
|
||||||
|
factory.createCopyMenuItem(),
|
||||||
|
factory.createOpenInNewTabMenuItem(),
|
||||||
|
factory.createCopyLinkMenuItem(),
|
||||||
|
factory.createRemoveFromModuleMenuItem(),
|
||||||
|
factory.createArchiveMenuItem(),
|
||||||
|
factory.createDeleteMenuItem(),
|
||||||
|
],
|
||||||
|
[factory, props.moduleId]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useArchivedIssueMenuItems = (props: MenuItemFactoryProps): TContextMenuItem[] => {
|
||||||
|
const factory = useMenuItemFactory(props);
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => [
|
||||||
|
factory.createRestoreMenuItem(),
|
||||||
|
factory.createOpenInNewTabMenuItem(),
|
||||||
|
factory.createCopyLinkMenuItem(),
|
||||||
|
factory.createDeleteMenuItem(),
|
||||||
|
],
|
||||||
|
[factory]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useDraftIssueMenuItems = (props: MenuItemFactoryProps): TContextMenuItem[] => {
|
||||||
|
const factory = useMenuItemFactory(props);
|
||||||
|
|
||||||
|
return useMemo(() => [factory.createEditMenuItem(), factory.createDeleteMenuItem()], [factory]);
|
||||||
|
};
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
|
export * from "./all-issue";
|
||||||
|
export * from "./archived-issue";
|
||||||
export * from "./cycle-issue";
|
export * from "./cycle-issue";
|
||||||
|
export * from "./draft-issue";
|
||||||
export * from "./module-issue";
|
export * from "./module-issue";
|
||||||
export * from "./project-issue";
|
export * from "./project-issue";
|
||||||
export * from "./archived-issue";
|
export * from "./helper";
|
||||||
export * from "./draft-issue";
|
|
||||||
export * from "./all-issue";
|
|
||||||
export * from "../../workspace-draft/quick-action";
|
export * from "../../workspace-draft/quick-action";
|
||||||
|
|
|
||||||
|
|
@ -4,21 +4,21 @@ import { useState } from "react";
|
||||||
import omit from "lodash/omit";
|
import omit from "lodash/omit";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import { Copy, ExternalLink, Link, Pencil, Trash2, XCircle } from "lucide-react";
|
|
||||||
// plane imports
|
// plane imports
|
||||||
import { ARCHIVABLE_STATE_GROUPS, EIssuesStoreType, EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
import { ARCHIVABLE_STATE_GROUPS, EIssuesStoreType, EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||||
import { TIssue } from "@plane/types";
|
import { TIssue } from "@plane/types";
|
||||||
import { ArchiveIcon, ContextMenu, CustomMenu, TContextMenuItem, TOAST_TYPE, setToast } from "@plane/ui";
|
import { ContextMenu, CustomMenu } from "@plane/ui";
|
||||||
import { copyUrlToClipboard } from "@plane/utils";
|
|
||||||
// components
|
// components
|
||||||
import { ArchiveIssueModal, CreateUpdateIssueModal, DeleteIssueModal } from "@/components/issues";
|
import { ArchiveIssueModal, CreateUpdateIssueModal, DeleteIssueModal } from "@/components/issues";
|
||||||
// helpers
|
// helpers
|
||||||
import { cn } from "@/helpers/common.helper";
|
import { cn } from "@/helpers/common.helper";
|
||||||
import { generateWorkItemLink } from "@/helpers/issue.helper";
|
|
||||||
// hooks
|
// hooks
|
||||||
import { useIssues, useEventTracker, useProjectState, useUserPermissions, useProject } from "@/hooks/store";
|
import { useIssues, useEventTracker, useProjectState, useUserPermissions, useProject } from "@/hooks/store";
|
||||||
// types
|
// plane-web components
|
||||||
|
import { DuplicateWorkItemModal } from "@/plane-web/components/issues/issue-layouts/quick-action-dropdowns";
|
||||||
import { IQuickActionProps } from "../list/list-view-types";
|
import { IQuickActionProps } from "../list/list-view-types";
|
||||||
|
// helper
|
||||||
|
import { useModuleIssueMenuItems, MenuItemFactoryProps } from "./helper";
|
||||||
|
|
||||||
export const ModuleIssueQuickActions: React.FC<IQuickActionProps> = observer((props) => {
|
export const ModuleIssueQuickActions: React.FC<IQuickActionProps> = observer((props) => {
|
||||||
const {
|
const {
|
||||||
|
|
@ -38,6 +38,7 @@ export const ModuleIssueQuickActions: React.FC<IQuickActionProps> = observer((pr
|
||||||
const [issueToEdit, setIssueToEdit] = useState<TIssue | undefined>(undefined);
|
const [issueToEdit, setIssueToEdit] = useState<TIssue | undefined>(undefined);
|
||||||
const [deleteIssueModal, setDeleteIssueModal] = useState(false);
|
const [deleteIssueModal, setDeleteIssueModal] = useState(false);
|
||||||
const [archiveIssueModal, setArchiveIssueModal] = useState(false);
|
const [archiveIssueModal, setArchiveIssueModal] = useState(false);
|
||||||
|
const [duplicateWorkItemModal, setDuplicateWorkItemModal] = useState(false);
|
||||||
// router
|
// router
|
||||||
const { workspaceSlug, moduleId } = useParams();
|
const { workspaceSlug, moduleId } = useParams();
|
||||||
// store hooks
|
// store hooks
|
||||||
|
|
@ -58,25 +59,6 @@ export const ModuleIssueQuickActions: React.FC<IQuickActionProps> = observer((pr
|
||||||
|
|
||||||
const activeLayout = `${issuesFilter.issueFilters?.displayFilters?.layout} layout`;
|
const activeLayout = `${issuesFilter.issueFilters?.displayFilters?.layout} layout`;
|
||||||
|
|
||||||
const workItemLink = generateWorkItemLink({
|
|
||||||
workspaceSlug: workspaceSlug?.toString(),
|
|
||||||
projectId: issue?.project_id,
|
|
||||||
issueId: issue?.id,
|
|
||||||
projectIdentifier,
|
|
||||||
sequenceId: issue?.sequence_id,
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleOpenInNewTab = () => window.open(workItemLink, "_blank");
|
|
||||||
|
|
||||||
const handleCopyIssueLink = () =>
|
|
||||||
copyUrlToClipboard(workItemLink).then(() =>
|
|
||||||
setToast({
|
|
||||||
type: TOAST_TYPE.SUCCESS,
|
|
||||||
title: "Link copied",
|
|
||||||
message: "Work item link copied to clipboard",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const duplicateIssuePayload = omit(
|
const duplicateIssuePayload = omit(
|
||||||
{
|
{
|
||||||
...issue,
|
...issue,
|
||||||
|
|
@ -86,72 +68,35 @@ export const ModuleIssueQuickActions: React.FC<IQuickActionProps> = observer((pr
|
||||||
["id"]
|
["id"]
|
||||||
);
|
);
|
||||||
|
|
||||||
const MENU_ITEMS: TContextMenuItem[] = [
|
// Menu items and modals using helper
|
||||||
{
|
const menuItemProps: MenuItemFactoryProps = {
|
||||||
key: "edit",
|
issue,
|
||||||
title: "Edit",
|
workspaceSlug: workspaceSlug?.toString(),
|
||||||
icon: Pencil,
|
projectIdentifier,
|
||||||
action: () => {
|
activeLayout,
|
||||||
setIssueToEdit({ ...issue, module_ids: moduleId ? [moduleId.toString()] : [] });
|
isEditingAllowed,
|
||||||
setTrackElement(activeLayout);
|
isArchivingAllowed,
|
||||||
setCreateUpdateIssueModal(true);
|
isDeletingAllowed,
|
||||||
},
|
isInArchivableGroup,
|
||||||
shouldRender: isEditingAllowed,
|
setTrackElement,
|
||||||
},
|
setIssueToEdit,
|
||||||
{
|
setCreateUpdateIssueModal,
|
||||||
key: "make-a-copy",
|
setDeleteIssueModal,
|
||||||
title: "Make a copy",
|
setArchiveIssueModal,
|
||||||
icon: Copy,
|
setDuplicateWorkItemModal,
|
||||||
action: () => {
|
handleRemoveFromView,
|
||||||
setTrackElement(activeLayout);
|
moduleId: moduleId?.toString(),
|
||||||
setCreateUpdateIssueModal(true);
|
handleDelete,
|
||||||
},
|
handleUpdate,
|
||||||
shouldRender: isEditingAllowed,
|
handleArchive,
|
||||||
},
|
storeType: EIssuesStoreType.MODULE,
|
||||||
{
|
};
|
||||||
key: "open-in-new-tab",
|
|
||||||
title: "Open in new tab",
|
const MENU_ITEMS = useModuleIssueMenuItems(menuItemProps);
|
||||||
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\nwork items 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{/* Modals */}
|
||||||
<ArchiveIssueModal
|
<ArchiveIssueModal
|
||||||
data={issue}
|
data={issue}
|
||||||
isOpen={archiveIssueModal}
|
isOpen={archiveIssueModal}
|
||||||
|
|
@ -175,7 +120,18 @@ export const ModuleIssueQuickActions: React.FC<IQuickActionProps> = observer((pr
|
||||||
if (issueToEdit && handleUpdate) await handleUpdate(data);
|
if (issueToEdit && handleUpdate) await handleUpdate(data);
|
||||||
}}
|
}}
|
||||||
storeType={EIssuesStoreType.MODULE}
|
storeType={EIssuesStoreType.MODULE}
|
||||||
|
isDraft={false}
|
||||||
/>
|
/>
|
||||||
|
{issue.project_id && workspaceSlug && (
|
||||||
|
<DuplicateWorkItemModal
|
||||||
|
workItemId={issue.id}
|
||||||
|
isOpen={duplicateWorkItemModal}
|
||||||
|
onClose={() => setDuplicateWorkItemModal(false)}
|
||||||
|
workspaceSlug={workspaceSlug.toString()}
|
||||||
|
projectId={issue.project_id}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<ContextMenu parentRef={parentRef} items={MENU_ITEMS} />
|
<ContextMenu parentRef={parentRef} items={MENU_ITEMS} />
|
||||||
<CustomMenu
|
<CustomMenu
|
||||||
ellipsis
|
ellipsis
|
||||||
|
|
@ -189,6 +145,73 @@ export const ModuleIssueQuickActions: React.FC<IQuickActionProps> = observer((pr
|
||||||
>
|
>
|
||||||
{MENU_ITEMS.map((item) => {
|
{MENU_ITEMS.map((item) => {
|
||||||
if (item.shouldRender === false) return null;
|
if (item.shouldRender === false) return null;
|
||||||
|
|
||||||
|
// Render submenu if nestedMenuItems exist
|
||||||
|
if (item.nestedMenuItems && item.nestedMenuItems.length > 0) {
|
||||||
|
return (
|
||||||
|
<CustomMenu.SubMenu
|
||||||
|
key={item.key}
|
||||||
|
trigger={
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />}
|
||||||
|
<h5>{item.title}</h5>
|
||||||
|
{item.description && (
|
||||||
|
<p
|
||||||
|
className={cn("text-custom-text-300 whitespace-pre-line", {
|
||||||
|
"text-custom-text-400": item.disabled,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{item.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
disabled={item.disabled}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2",
|
||||||
|
{
|
||||||
|
"text-custom-text-400": item.disabled,
|
||||||
|
},
|
||||||
|
item.className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.nestedMenuItems.map((nestedItem) => (
|
||||||
|
<CustomMenu.MenuItem
|
||||||
|
key={nestedItem.key}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
nestedItem.action();
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2",
|
||||||
|
{
|
||||||
|
"text-custom-text-400": nestedItem.disabled,
|
||||||
|
},
|
||||||
|
nestedItem.className
|
||||||
|
)}
|
||||||
|
disabled={nestedItem.disabled}
|
||||||
|
>
|
||||||
|
{nestedItem.icon && <nestedItem.icon className={cn("h-3 w-3", nestedItem.iconClassName)} />}
|
||||||
|
<div>
|
||||||
|
<h5>{nestedItem.title}</h5>
|
||||||
|
{nestedItem.description && (
|
||||||
|
<p
|
||||||
|
className={cn("text-custom-text-300 whitespace-pre-line", {
|
||||||
|
"text-custom-text-400": nestedItem.disabled,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{nestedItem.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CustomMenu.MenuItem>
|
||||||
|
))}
|
||||||
|
</CustomMenu.SubMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render regular menu item
|
||||||
return (
|
return (
|
||||||
<CustomMenu.MenuItem
|
<CustomMenu.MenuItem
|
||||||
key={item.key}
|
key={item.key}
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,24 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useState } from "react";
|
||||||
import omit from "lodash/omit";
|
import omit from "lodash/omit";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import { useParams, usePathname } from "next/navigation";
|
import { useParams, usePathname } from "next/navigation";
|
||||||
import { Copy, ExternalLink, Link, Pencil, Trash2 } from "lucide-react";
|
|
||||||
// plane imports
|
// plane imports
|
||||||
import { ARCHIVABLE_STATE_GROUPS, EIssuesStoreType, EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
import { ARCHIVABLE_STATE_GROUPS, EIssuesStoreType, EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||||
import { useTranslation } from "@plane/i18n";
|
|
||||||
import { TIssue } from "@plane/types";
|
import { TIssue } from "@plane/types";
|
||||||
import { ArchiveIcon, ContextMenu, CustomMenu, TContextMenuItem, TOAST_TYPE, setToast } from "@plane/ui";
|
import { ContextMenu, CustomMenu } from "@plane/ui";
|
||||||
import { copyUrlToClipboard } from "@plane/utils";
|
|
||||||
// components
|
// components
|
||||||
import { ArchiveIssueModal, CreateUpdateIssueModal, DeleteIssueModal } from "@/components/issues";
|
import { ArchiveIssueModal, CreateUpdateIssueModal, DeleteIssueModal } from "@/components/issues";
|
||||||
// helpers
|
// helpers
|
||||||
import { cn } from "@/helpers/common.helper";
|
import { cn } from "@/helpers/common.helper";
|
||||||
import { generateWorkItemLink } from "@/helpers/issue.helper";
|
|
||||||
// hooks
|
// hooks
|
||||||
import { useEventTracker, useIssues, useProject, useProjectState, useUserPermissions } from "@/hooks/store";
|
import { useEventTracker, useIssues, useProject, useProjectState, useUserPermissions } from "@/hooks/store";
|
||||||
// types
|
// plane-web components
|
||||||
|
import { DuplicateWorkItemModal } from "@/plane-web/components/issues/issue-layouts/quick-action-dropdowns";
|
||||||
import { IQuickActionProps } from "../list/list-view-types";
|
import { IQuickActionProps } from "../list/list-view-types";
|
||||||
|
// helper
|
||||||
|
import { useProjectIssueMenuItems, MenuItemFactoryProps } from "./helper";
|
||||||
|
|
||||||
export const ProjectIssueQuickActions: React.FC<IQuickActionProps> = observer((props) => {
|
export const ProjectIssueQuickActions: React.FC<IQuickActionProps> = observer((props) => {
|
||||||
const {
|
const {
|
||||||
|
|
@ -33,8 +32,6 @@ export const ProjectIssueQuickActions: React.FC<IQuickActionProps> = observer((p
|
||||||
placements = "bottom-end",
|
placements = "bottom-end",
|
||||||
parentRef,
|
parentRef,
|
||||||
} = props;
|
} = props;
|
||||||
// i18n
|
|
||||||
const { t } = useTranslation();
|
|
||||||
// router
|
// router
|
||||||
const { workspaceSlug } = useParams();
|
const { workspaceSlug } = useParams();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
|
@ -43,6 +40,7 @@ export const ProjectIssueQuickActions: React.FC<IQuickActionProps> = observer((p
|
||||||
const [issueToEdit, setIssueToEdit] = useState<TIssue | undefined>(undefined);
|
const [issueToEdit, setIssueToEdit] = useState<TIssue | undefined>(undefined);
|
||||||
const [deleteIssueModal, setDeleteIssueModal] = useState(false);
|
const [deleteIssueModal, setDeleteIssueModal] = useState(false);
|
||||||
const [archiveIssueModal, setArchiveIssueModal] = useState(false);
|
const [archiveIssueModal, setArchiveIssueModal] = useState(false);
|
||||||
|
const [duplicateWorkItemModal, setDuplicateWorkItemModal] = useState(false);
|
||||||
// store hooks
|
// store hooks
|
||||||
const { allowPermissions } = useUserPermissions();
|
const { allowPermissions } = useUserPermissions();
|
||||||
const { setTrackElement } = useEventTracker();
|
const { setTrackElement } = useEventTracker();
|
||||||
|
|
@ -65,24 +63,6 @@ export const ProjectIssueQuickActions: React.FC<IQuickActionProps> = observer((p
|
||||||
const isInArchivableGroup = !!stateDetails && ARCHIVABLE_STATE_GROUPS.includes(stateDetails?.group);
|
const isInArchivableGroup = !!stateDetails && ARCHIVABLE_STATE_GROUPS.includes(stateDetails?.group);
|
||||||
const isDeletingAllowed = isEditingAllowed;
|
const isDeletingAllowed = isEditingAllowed;
|
||||||
|
|
||||||
const workItemLink = generateWorkItemLink({
|
|
||||||
workspaceSlug: workspaceSlug?.toString(),
|
|
||||||
projectId: issue?.project_id,
|
|
||||||
issueId: issue?.id,
|
|
||||||
projectIdentifier,
|
|
||||||
sequenceId: issue?.sequence_id,
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleCopyIssueLink = () =>
|
|
||||||
copyUrlToClipboard(workItemLink).then(() =>
|
|
||||||
setToast({
|
|
||||||
type: TOAST_TYPE.SUCCESS,
|
|
||||||
title: "Link copied",
|
|
||||||
message: "Work item link copied to clipboard",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
const handleOpenInNewTab = () => window.open(workItemLink, "_blank");
|
|
||||||
|
|
||||||
const isDraftIssue = pathname?.includes("draft-issues") || false;
|
const isDraftIssue = pathname?.includes("draft-issues") || false;
|
||||||
|
|
||||||
const duplicateIssuePayload = omit(
|
const duplicateIssuePayload = omit(
|
||||||
|
|
@ -95,68 +75,34 @@ export const ProjectIssueQuickActions: React.FC<IQuickActionProps> = observer((p
|
||||||
["id"]
|
["id"]
|
||||||
);
|
);
|
||||||
|
|
||||||
const MENU_ITEMS: TContextMenuItem[] = useMemo(
|
// Menu items and modals using helper
|
||||||
() => [
|
const menuItemProps: MenuItemFactoryProps = {
|
||||||
{
|
issue,
|
||||||
key: "edit",
|
workspaceSlug: workspaceSlug?.toString(),
|
||||||
title: t("common.actions.edit"),
|
projectIdentifier,
|
||||||
icon: Pencil,
|
activeLayout,
|
||||||
action: () => {
|
isEditingAllowed,
|
||||||
setTrackElement(activeLayout);
|
isArchivingAllowed,
|
||||||
setIssueToEdit(issue);
|
isDeletingAllowed,
|
||||||
setCreateUpdateIssueModal(true);
|
isInArchivableGroup,
|
||||||
},
|
isDraftIssue,
|
||||||
shouldRender: isEditingAllowed,
|
setTrackElement,
|
||||||
},
|
setIssueToEdit,
|
||||||
{
|
setCreateUpdateIssueModal,
|
||||||
key: "make-a-copy",
|
setDeleteIssueModal,
|
||||||
title: t("common.actions.make_a_copy"),
|
setArchiveIssueModal,
|
||||||
icon: Copy,
|
setDuplicateWorkItemModal,
|
||||||
action: () => {
|
handleDelete,
|
||||||
setTrackElement(activeLayout);
|
handleUpdate,
|
||||||
setCreateUpdateIssueModal(true);
|
handleArchive,
|
||||||
},
|
storeType: EIssuesStoreType.PROJECT,
|
||||||
shouldRender: isEditingAllowed,
|
};
|
||||||
},
|
|
||||||
{
|
const MENU_ITEMS = useProjectIssueMenuItems(menuItemProps);
|
||||||
key: "open-in-new-tab",
|
|
||||||
title: t("common.actions.open_in_new_tab"),
|
|
||||||
icon: ExternalLink,
|
|
||||||
action: handleOpenInNewTab,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "copy-link",
|
|
||||||
title: t("common.actions.copy_link"),
|
|
||||||
icon: Link,
|
|
||||||
action: handleCopyIssueLink,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "archive",
|
|
||||||
title: t("common.actions.archive"),
|
|
||||||
description: isInArchivableGroup ? undefined : t("issue.archive.description"),
|
|
||||||
icon: ArchiveIcon,
|
|
||||||
className: "items-start",
|
|
||||||
iconClassName: "mt-1",
|
|
||||||
action: () => setArchiveIssueModal(true),
|
|
||||||
disabled: !isInArchivableGroup,
|
|
||||||
shouldRender: isArchivingAllowed,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "delete",
|
|
||||||
title: t("common.actions.delete"),
|
|
||||||
icon: Trash2,
|
|
||||||
action: () => {
|
|
||||||
setTrackElement(activeLayout);
|
|
||||||
setDeleteIssueModal(true);
|
|
||||||
},
|
|
||||||
shouldRender: isDeletingAllowed,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[t]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{/* Modals */}
|
||||||
<ArchiveIssueModal
|
<ArchiveIssueModal
|
||||||
data={issue}
|
data={issue}
|
||||||
isOpen={archiveIssueModal}
|
isOpen={archiveIssueModal}
|
||||||
|
|
@ -182,6 +128,16 @@ export const ProjectIssueQuickActions: React.FC<IQuickActionProps> = observer((p
|
||||||
storeType={EIssuesStoreType.PROJECT}
|
storeType={EIssuesStoreType.PROJECT}
|
||||||
isDraft={isDraftIssue}
|
isDraft={isDraftIssue}
|
||||||
/>
|
/>
|
||||||
|
{issue.project_id && workspaceSlug && (
|
||||||
|
<DuplicateWorkItemModal
|
||||||
|
workItemId={issue.id}
|
||||||
|
isOpen={duplicateWorkItemModal}
|
||||||
|
onClose={() => setDuplicateWorkItemModal(false)}
|
||||||
|
workspaceSlug={workspaceSlug.toString()}
|
||||||
|
projectId={issue.project_id}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<ContextMenu parentRef={parentRef} items={MENU_ITEMS} />
|
<ContextMenu parentRef={parentRef} items={MENU_ITEMS} />
|
||||||
<CustomMenu
|
<CustomMenu
|
||||||
ellipsis
|
ellipsis
|
||||||
|
|
@ -190,11 +146,77 @@ export const ProjectIssueQuickActions: React.FC<IQuickActionProps> = observer((p
|
||||||
portalElement={portalElement}
|
portalElement={portalElement}
|
||||||
menuItemsClassName="z-[14]"
|
menuItemsClassName="z-[14]"
|
||||||
maxHeight="lg"
|
maxHeight="lg"
|
||||||
useCaptureForOutsideClick
|
|
||||||
closeOnSelect
|
closeOnSelect
|
||||||
>
|
>
|
||||||
{MENU_ITEMS.map((item) => {
|
{MENU_ITEMS.map((item) => {
|
||||||
if (item.shouldRender === false) return null;
|
if (item.shouldRender === false) return null;
|
||||||
|
|
||||||
|
// Render submenu if nestedMenuItems exist
|
||||||
|
if (item.nestedMenuItems && item.nestedMenuItems.length > 0) {
|
||||||
|
return (
|
||||||
|
<CustomMenu.SubMenu
|
||||||
|
key={item.key}
|
||||||
|
trigger={
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />}
|
||||||
|
<h5>{item.title}</h5>
|
||||||
|
{item.description && (
|
||||||
|
<p
|
||||||
|
className={cn("text-custom-text-300 whitespace-pre-line", {
|
||||||
|
"text-custom-text-400": item.disabled,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{item.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
disabled={item.disabled}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2",
|
||||||
|
{
|
||||||
|
"text-custom-text-400": item.disabled,
|
||||||
|
},
|
||||||
|
item.className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.nestedMenuItems.map((nestedItem) => (
|
||||||
|
<CustomMenu.MenuItem
|
||||||
|
key={nestedItem.key}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
nestedItem.action();
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2",
|
||||||
|
{
|
||||||
|
"text-custom-text-400": nestedItem.disabled,
|
||||||
|
},
|
||||||
|
nestedItem.className
|
||||||
|
)}
|
||||||
|
disabled={nestedItem.disabled}
|
||||||
|
>
|
||||||
|
{nestedItem.icon && <nestedItem.icon className={cn("h-3 w-3", nestedItem.iconClassName)} />}
|
||||||
|
<div>
|
||||||
|
<h5>{nestedItem.title}</h5>
|
||||||
|
{nestedItem.description && (
|
||||||
|
<p
|
||||||
|
className={cn("text-custom-text-300 whitespace-pre-line", {
|
||||||
|
"text-custom-text-400": nestedItem.disabled,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{nestedItem.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CustomMenu.MenuItem>
|
||||||
|
))}
|
||||||
|
</CustomMenu.SubMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render regular menu item
|
||||||
return (
|
return (
|
||||||
<CustomMenu.MenuItem
|
<CustomMenu.MenuItem
|
||||||
key={item.key}
|
key={item.key}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { EIssueServiceType } from "@plane/constants";
|
||||||
import { ICycle, IIssueLabel, IModule, IProject, IState, IUserLite, TIssueServiceType } from "@plane/types";
|
import { ICycle, IIssueLabel, IModule, IProject, IState, IUserLite, TIssueServiceType } from "@plane/types";
|
||||||
// plane web store
|
// plane web store
|
||||||
import { IProjectEpics, IProjectEpicsFilter, ProjectEpics, ProjectEpicsFilter } from "@/plane-web/store/issue/epic";
|
import { IProjectEpics, IProjectEpicsFilter, ProjectEpics, ProjectEpicsFilter } from "@/plane-web/store/issue/epic";
|
||||||
|
import { IIssueDetail, IssueDetail } from "@/plane-web/store/issue/issue-details/root.store";
|
||||||
import { ITeamIssuesFilter, ITeamIssues, TeamIssues, TeamIssuesFilter } from "@/plane-web/store/issue/team";
|
import { ITeamIssuesFilter, ITeamIssues, TeamIssues, TeamIssuesFilter } from "@/plane-web/store/issue/team";
|
||||||
import {
|
import {
|
||||||
ITeamViewIssues,
|
ITeamViewIssues,
|
||||||
|
|
@ -19,7 +20,6 @@ import { IWorkspaceMembership } from "@/store/member/workspace-member.store";
|
||||||
import { IArchivedIssuesFilter, ArchivedIssuesFilter, IArchivedIssues, ArchivedIssues } from "./archived";
|
import { IArchivedIssuesFilter, ArchivedIssuesFilter, IArchivedIssues, ArchivedIssues } from "./archived";
|
||||||
import { ICycleIssuesFilter, CycleIssuesFilter, ICycleIssues, CycleIssues } from "./cycle";
|
import { ICycleIssuesFilter, CycleIssuesFilter, ICycleIssues, CycleIssues } from "./cycle";
|
||||||
import { IDraftIssuesFilter, DraftIssuesFilter, IDraftIssues, DraftIssues } from "./draft";
|
import { IDraftIssuesFilter, DraftIssuesFilter, IDraftIssues, DraftIssues } from "./draft";
|
||||||
import { IIssueDetail, IssueDetail } from "./issue-details/root.store";
|
|
||||||
import { IIssueStore, IssueStore } from "./issue.store";
|
import { IIssueStore, IssueStore } from "./issue.store";
|
||||||
import { ICalendarStore, CalendarStore } from "./issue_calendar_view.store";
|
import { ICalendarStore, CalendarStore } from "./issue_calendar_view.store";
|
||||||
import { IIssueKanBanViewStore, IssueKanBanViewStore } from "./issue_kanban_view.store";
|
import { IIssueKanBanViewStore, IssueKanBanViewStore } from "./issue_kanban_view.store";
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
export * from "ce/components/issues/issue-layouts/quick-action-dropdowns"
|
||||||
1
web/ee/store/issue/issue-details/root.store.ts
Normal file
1
web/ee/store/issue/issue-details/root.store.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export * from "ce/store/issue/issue-details/root.store";
|
||||||
Loading…
Add table
Add a link
Reference in a new issue