diff --git a/packages/ui/src/dropdowns/context-menu/item.tsx b/packages/ui/src/dropdowns/context-menu/item.tsx index 83124392082..8e2050d9dd8 100644 --- a/packages/ui/src/dropdowns/context-menu/item.tsx +++ b/packages/ui/src/dropdowns/context-menu/item.tsx @@ -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 import { cn } from "../../../helpers"; // types -import { TContextMenuItem } from "./root"; +import { TContextMenuItem, ContextMenuContext, Portal } from "./root"; type ContextMenuItemProps = { handleActiveItem: () => void; @@ -14,45 +16,230 @@ type ContextMenuItemProps = { export const ContextMenuItem: React.FC = (props) => { const { handleActiveItem, handleClose, isActive, item } = props; - if (item.shouldRender === false) return null; + // Nested menu state + const [isNestedOpen, setIsNestedOpen] = useState(false); + const [referenceElement, setReferenceElement] = useState(null); + const [popperElement, setPopperElement] = useState(null); + const [activeNestedIndex, setActiveNestedIndex] = useState(0); + const nestedMenuRef = useRef(null); - return ( - + + {/* Nested Menu */} + {hasNestedItems && isNestedOpen && ( + +
+
+ {renderedNestedItems.map((nestedItem, index) => ( + + ))} +
- +
)} - + ); }; diff --git a/packages/ui/src/dropdowns/context-menu/root.tsx b/packages/ui/src/dropdowns/context-menu/root.tsx index 61554d7bd75..480607dbac3 100644 --- a/packages/ui/src/dropdowns/context-menu/root.tsx +++ b/packages/ui/src/dropdowns/context-menu/root.tsx @@ -21,15 +21,46 @@ export type TContextMenuItem = { disabled?: boolean; className?: string; iconClassName?: string; + nestedMenuItems?: TContextMenuItem[]; }; +// Portal component for nested menus +interface PortalProps { + children: React.ReactNode; + container?: Element | null; +} + +export const Portal: React.FC = ({ 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 = { parentRef: React.RefObject; items: TContextMenuItem[]; + portalContainer?: Element | null; }; const ContextMenuWithoutPortal: React.FC = (props) => { - const { parentRef, items } = props; + const { parentRef, items, portalContainer } = props; // states const [isOpen, setIsOpen] = useState(false); const [position, setPosition] = useState({ @@ -39,11 +70,24 @@ const ContextMenuWithoutPortal: React.FC = (props) => { const [activeItemIndex, setActiveItemIndex] = useState(0); // refs const contextMenuRef = useRef(null); + const submenuClosersRef = useRef void>>(new Set()); // derived values const renderedItems = items.filter((item) => item.shouldRender !== false); 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 = () => { + closeAllSubmenus(); setIsOpen(false); setActiveItemIndex(0); }; @@ -121,13 +165,42 @@ const ContextMenuWithoutPortal: React.FC = (props) => { }; }, [activeItemIndex, isOpen, renderedItems, setIsOpen]); - // close on clicking outside - useOutsideClickDetector(contextMenuRef, handleClose); + // Custom handler for nested menu portal clicks + 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 (
= (props) => { top: position.y, left: position.x, }} + data-context-menu="true" > - {renderedItems.map((item, index) => ( - setActiveItemIndex(index)} - handleClose={handleClose} - isActive={index === activeItemIndex} - item={item} - /> - ))} + + {renderedItems.map((item, index) => ( + setActiveItemIndex(index)} + handleClose={handleClose} + isActive={index === activeItemIndex} + item={item} + /> + ))} +
); diff --git a/packages/ui/src/dropdowns/custom-menu.tsx b/packages/ui/src/dropdowns/custom-menu.tsx index 688f1489749..d043ced70cf 100644 --- a/packages/ui/src/dropdowns/custom-menu.tsx +++ b/packages/ui/src/dropdowns/custom-menu.tsx @@ -1,5 +1,5 @@ import { Menu } from "@headlessui/react"; -import { ChevronDown, MoreHorizontal } from "lucide-react"; +import { ChevronDown, ChevronRight, MoreHorizontal } from "lucide-react"; import * as React from "react"; import ReactDOM from "react-dom"; import { usePopper } from "react-popper"; @@ -10,7 +10,46 @@ import { cn } from "../../helpers"; // hooks import { useDropdownKeyDown } from "../hooks/use-dropdown-key-down"; // 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 = ({ 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(
{children}
, 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 { @@ -45,19 +84,35 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => { const [isOpen, setIsOpen] = React.useState(false); // refs const dropdownRef = React.useRef(null); + const submenuClosersRef = React.useRef void>>(new Set()); const { styles, attributes } = usePopper(referenceElement, popperElement, { 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 = () => { setIsOpen(true); if (referenceElement) referenceElement.focus(); }; - const closeDropdown = () => { - if (isOpen) onMenuClose?.(); + + const closeDropdown = React.useCallback(() => { + if (isOpen) { + closeAllSubmenus(); + onMenuClose?.(); + } setIsOpen(false); - }; + }, [isOpen, closeAllSubmenus, onMenuClose]); const selectActiveItem = () => { const activeItem: HTMLElement | undefined | null = dropdownRef.current?.querySelector( @@ -75,8 +130,12 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => { const handleMenuButtonClick = (e: React.MouseEvent) => { e.stopPropagation(); e.preventDefault(); - isOpen ? closeDropdown() : openDropdown(); - menuButtonOnClick?.(); + if (isOpen) { + closeDropdown(); + } else { + openDropdown(); + } + if (menuButtonOnClick) menuButtonOnClick(); }; const handleMouseEnter = () => { @@ -86,13 +145,43 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => { const handleMouseLeave = () => { if (openOnHover && isOpen) { setTimeout(() => { - closeDropdown(); - }, 500); + // Only close if menu is still open + if (isOpen) { + closeDropdown(); + } + }, 150); // Small delay to allow moving to submenu } }; 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 = ( { style={styles.popper} {...attributes.popper} > - {children} + {children} ); @@ -136,6 +225,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => { onClick={handleOnClick} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} + data-main-menu="true" > {({ 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 = (props) => { + const { + children, + trigger, + disabled = false, + className = "", + contentClassName = "", + placement = "right-start", + } = props; + + const [isOpen, setIsOpen] = React.useState(false); + const [referenceElement, setReferenceElement] = React.useState(null); + const [popperElement, setPopperElement] = React.useState(null); + const submenuRef = React.useRef(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 ( +
+ + + {({ active }) => ( +
+ {trigger} + +
+ )} +
+
+ + {isOpen && ( + +
{ + // 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); + } + }} + > + {children} +
+
+ )} +
+ ); +}; + const MenuItem: React.FC = (props) => { const { children, disabled = false, onClick, className } = props; + const submenuContext = useSubMenu(); return ( @@ -221,6 +464,8 @@ const MenuItem: React.FC = (props) => { onClick={(e) => { close(); onClick?.(e); + // Close submenu if this item is inside a submenu + submenuContext?.closeSubmenu(); }} disabled={disabled} > @@ -231,6 +476,52 @@ const MenuItem: React.FC = (props) => { ); }; +const SubMenuTrigger: React.FC = (props) => { + const { children, disabled = false, className } = props; + + return ( + + {({ active }) => ( +
+ {children} + +
+ )} +
+ ); +}; + +const SubMenuContent: React.FC = (props) => { + const { children, className } = props; + + return ( +
+ {children} +
+ ); +}; + +// Add all components as static properties for external use +CustomMenu.Portal = Portal; CustomMenu.MenuItem = MenuItem; +CustomMenu.SubMenu = SubMenu; +CustomMenu.SubMenuTrigger = SubMenuTrigger; +CustomMenu.SubMenuContent = SubMenuContent; export { CustomMenu }; diff --git a/packages/ui/src/dropdowns/helper.tsx b/packages/ui/src/dropdowns/helper.tsx index 1d40acef795..ad6cb4fd5b6 100644 --- a/packages/ui/src/dropdowns/helper.tsx +++ b/packages/ui/src/dropdowns/helper.tsx @@ -21,6 +21,12 @@ export interface IDropdownProps { useCaptureForOutsideClick?: boolean; } +export interface IPortalProps { + children: React.ReactNode; + container?: Element | null; + asChild?: boolean; +} + export interface ICustomMenuDropdownProps extends IDropdownProps { children: React.ReactNode; ellipsis?: boolean; @@ -75,3 +81,27 @@ export interface ICustomSelectItemProps { value: any; 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; +} diff --git a/web/ce/components/issues/issue-layouts/quick-action-dropdowns/copy-menu-helper.tsx b/web/ce/components/issues/issue-layouts/quick-action-dropdowns/copy-menu-helper.tsx new file mode 100644 index 00000000000..f9aed404035 --- /dev/null +++ b/web/ce/components/issues/issue-layouts/quick-action-dropdowns/copy-menu-helper.tsx @@ -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; +}; diff --git a/web/ce/components/issues/issue-layouts/quick-action-dropdowns/duplicate-modal.tsx b/web/ce/components/issues/issue-layouts/quick-action-dropdowns/duplicate-modal.tsx new file mode 100644 index 00000000000..1ea30e26e61 --- /dev/null +++ b/web/ce/components/issues/issue-layouts/quick-action-dropdowns/duplicate-modal.tsx @@ -0,0 +1,11 @@ +import { FC } from "react"; + +type TDuplicateWorkItemModalProps = { + workItemId: string; + onClose: () => void; + isOpen: boolean; + workspaceSlug: string; + projectId: string; +}; + +export const DuplicateWorkItemModal: FC = () => <>; diff --git a/web/ce/components/issues/issue-layouts/quick-action-dropdowns/index.ts b/web/ce/components/issues/issue-layouts/quick-action-dropdowns/index.ts new file mode 100644 index 00000000000..470ae9181ee --- /dev/null +++ b/web/ce/components/issues/issue-layouts/quick-action-dropdowns/index.ts @@ -0,0 +1,2 @@ +export * from "./duplicate-modal"; +export * from "./copy-menu-helper"; diff --git a/web/ce/store/issue/issue-details/root.store.ts b/web/ce/store/issue/issue-details/root.store.ts new file mode 100644 index 00000000000..bbea3f46bf6 --- /dev/null +++ b/web/ce/store/issue/issue-details/root.store.ts @@ -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 + }); + } +} diff --git a/web/core/components/dropdowns/project.tsx b/web/core/components/dropdowns/project.tsx index 88c13bdb91e..39ab86201c6 100644 --- a/web/core/components/dropdowns/project.tsx +++ b/web/core/components/dropdowns/project.tsx @@ -29,6 +29,7 @@ type Props = TDropdownProps & { onClose?: () => void; renderCondition?: (project: TProject) => boolean; renderByDefault?: boolean; + currentProjectId?: string; } & ( | { multiple: false; @@ -63,6 +64,7 @@ export const ProjectDropdown: React.FC = observer((props) => { tabIndex, value, renderByDefault = true, + currentProjectId, } = props; // states const [query, setQuery] = useState(""); @@ -108,7 +110,9 @@ export const ProjectDropdown: React.FC = observer((props) => { }); 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({ dropdownRef, @@ -198,7 +202,7 @@ export const ProjectDropdown: React.FC = observer((props) => { > {!hideIcon && getProjectIcon(value)} {BUTTON_VARIANTS_WITH_TEXT.includes(buttonVariant) && ( - {getDisplayName(value, placeholder)} + {getDisplayName(value, placeholder)} )} {dropdownArrow && (