diff --git a/packages/ui/src/dropdowns/context-menu/index.ts b/packages/ui/src/dropdowns/context-menu/index.ts new file mode 100644 index 000000000..9665324ca --- /dev/null +++ b/packages/ui/src/dropdowns/context-menu/index.ts @@ -0,0 +1,2 @@ +export * from "./item"; +export * from "./root"; diff --git a/packages/ui/src/dropdowns/context-menu/item.tsx b/packages/ui/src/dropdowns/context-menu/item.tsx new file mode 100644 index 000000000..99ef790e3 --- /dev/null +++ b/packages/ui/src/dropdowns/context-menu/item.tsx @@ -0,0 +1,54 @@ +import React from "react"; +// helpers +import { cn } from "../../../helpers"; +// types +import { TContextMenuItem } from "./root"; + +type ContextMenuItemProps = { + handleActiveItem: () => void; + handleClose: () => void; + isActive: boolean; + item: TContextMenuItem; +}; + +export const ContextMenuItem: React.FC = (props) => { + const { handleActiveItem, handleClose, isActive, item } = props; + + if (item.shouldRender === false) return null; + + return ( + + ); +}; diff --git a/packages/ui/src/dropdowns/context-menu/root.tsx b/packages/ui/src/dropdowns/context-menu/root.tsx new file mode 100644 index 000000000..47a52b8c2 --- /dev/null +++ b/packages/ui/src/dropdowns/context-menu/root.tsx @@ -0,0 +1,157 @@ +import React, { useEffect, useRef, useState } from "react"; +import ReactDOM from "react-dom"; +// components +import { ContextMenuItem } from "./item"; +// helpers +import { cn } from "../../../helpers"; +// hooks +import useOutsideClickDetector from "../../hooks/use-outside-click-detector"; + +export type TContextMenuItem = { + key: string; + title: string; + description?: string; + icon?: React.FC; + action: () => void; + shouldRender?: boolean; + closeOnClick?: boolean; + disabled?: boolean; + className?: string; + iconClassName?: string; +}; + +type ContextMenuProps = { + parentRef: React.RefObject; + items: TContextMenuItem[]; +}; + +const ContextMenuWithoutPortal: React.FC = (props) => { + const { parentRef, items } = props; + // states + const [isOpen, setIsOpen] = useState(false); + const [position, setPosition] = useState({ + x: 0, + y: 0, + }); + const [activeItemIndex, setActiveItemIndex] = useState(0); + // refs + const contextMenuRef = useRef(null); + // derived values + const renderedItems = items.filter((item) => item.shouldRender !== false); + + const handleClose = () => { + setIsOpen(false); + setActiveItemIndex(0); + }; + + // calculate position of context menu + useEffect(() => { + const parentElement = parentRef.current; + const contextMenu = contextMenuRef.current; + if (!parentElement || !contextMenu) return; + + const handleContextMenu = (e: MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + const contextMenuWidth = contextMenu.clientWidth; + const contextMenuHeight = contextMenu.clientHeight; + + const clickX = e?.pageX || 0; + const clickY = e?.pageY || 0; + + // check if there's enough space at the bottom, otherwise show at the top + let top = clickY; + if (clickY + contextMenuHeight > window.innerHeight) top = clickY - contextMenuHeight; + + // check if there's enough space on the right, otherwise show on the left + let left = clickX; + if (clickX + contextMenuWidth > window.innerWidth) left = clickX - contextMenuWidth; + + setPosition({ x: left, y: top }); + setIsOpen(true); + }; + + const hideContextMenu = (e: KeyboardEvent) => { + if (isOpen && e.key === "Escape") handleClose(); + }; + + parentElement.addEventListener("contextmenu", handleContextMenu); + window.addEventListener("keydown", hideContextMenu); + + return () => { + parentElement.removeEventListener("contextmenu", handleContextMenu); + window.removeEventListener("keydown", hideContextMenu); + }; + }, [contextMenuRef, isOpen, parentRef, setIsOpen, setPosition]); + + // handle keyboard navigation + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (!isOpen) return; + + if (e.key === "ArrowDown") { + e.preventDefault(); + setActiveItemIndex((prev) => (prev + 1) % renderedItems.length); + } + if (e.key === "ArrowUp") { + e.preventDefault(); + setActiveItemIndex((prev) => (prev - 1 + renderedItems.length) % renderedItems.length); + } + if (e.key === "Enter") { + e.preventDefault(); + const item = renderedItems[activeItemIndex]; + if (!item.disabled) { + renderedItems[activeItemIndex].action(); + if (item.closeOnClick !== false) handleClose(); + } + } + }; + + window.addEventListener("keydown", handleKeyDown); + + return () => { + window.removeEventListener("keydown", handleKeyDown); + }; + }, [activeItemIndex, isOpen, renderedItems, setIsOpen]); + + // close on clicking outside + useOutsideClickDetector(contextMenuRef, handleClose); + + return ( +
+
+ {renderedItems.map((item, index) => ( + setActiveItemIndex(index)} + handleClose={handleClose} + isActive={index === activeItemIndex} + item={item} + /> + ))} +
+
+ ); +}; + +export const ContextMenu: React.FC = (props) => { + let contextMenu = ; + const portal = document.querySelector("#context-menu-portal"); + if (portal) contextMenu = ReactDOM.createPortal(contextMenu, portal); + return contextMenu; +}; diff --git a/packages/ui/src/dropdowns/index.ts b/packages/ui/src/dropdowns/index.ts index 0ad9cbb22..d77eac129 100644 --- a/packages/ui/src/dropdowns/index.ts +++ b/packages/ui/src/dropdowns/index.ts @@ -1,3 +1,4 @@ +export * from "./context-menu"; export * from "./custom-menu"; export * from "./custom-select"; export * from "./custom-search-select"; diff --git a/space/pages/_document.tsx b/space/pages/_document.tsx index bf83a722c..ae4455438 100644 --- a/space/pages/_document.tsx +++ b/space/pages/_document.tsx @@ -6,6 +6,7 @@ class MyDocument extends Document { +
diff --git a/web/components/core/list/list-item.tsx b/web/components/core/list/list-item.tsx index a9f844f79..ae32c9b31 100644 --- a/web/components/core/list/list-item.tsx +++ b/web/components/core/list/list-item.tsx @@ -11,6 +11,7 @@ interface IListItemProps { appendTitleElement?: JSX.Element; actionableItems?: JSX.Element; isMobile?: boolean; + parentRef: React.RefObject; } export const ListItem: FC = (props) => { @@ -22,9 +23,11 @@ export const ListItem: FC = (props) => { itemLink, onItemClick, isMobile = false, + parentRef, } = props; + return ( -
+
diff --git a/web/components/cycles/active-cycle/upcoming-cycles-list-item.tsx b/web/components/cycles/active-cycle/upcoming-cycles-list-item.tsx index d928b5fbb..9b63b0f6f 100644 --- a/web/components/cycles/active-cycle/upcoming-cycles-list-item.tsx +++ b/web/components/cycles/active-cycle/upcoming-cycles-list-item.tsx @@ -1,3 +1,4 @@ +import { useRef } from "react"; import { observer } from "mobx-react"; import Link from "next/link"; import { useRouter } from "next/router"; @@ -20,6 +21,8 @@ type Props = { export const UpcomingCycleListItem: React.FC = observer((props) => { const { cycleId } = props; + // refs + const parentRef = useRef(null); // router const router = useRouter(); const { workspaceSlug, projectId } = router.query; @@ -90,6 +93,7 @@ export const UpcomingCycleListItem: React.FC = observer((props) => { return ( @@ -123,6 +127,7 @@ export const UpcomingCycleListItem: React.FC = observer((props) => { {workspaceSlug && projectId && ( = observer((props) => { const { cycleId, workspaceSlug, projectId } = props; + // refs + const parentRef = useRef(null); // router const router = useRouter(); // store @@ -150,7 +152,7 @@ export const CyclesBoardCard: FC = observer((props) => { return (
- +
@@ -246,7 +248,12 @@ export const CyclesBoardCard: FC = observer((props) => { /> )} - +
); diff --git a/web/components/cycles/list/cycle-list-item-action.tsx b/web/components/cycles/list/cycle-list-item-action.tsx index 64a712df7..1f3d2ef65 100644 --- a/web/components/cycles/list/cycle-list-item-action.tsx +++ b/web/components/cycles/list/cycle-list-item-action.tsx @@ -23,11 +23,11 @@ type Props = { projectId: string; cycleId: string; cycleDetails: ICycle; - isArchived: boolean; + parentRef: React.RefObject; }; export const CycleListItemAction: FC = observer((props) => { - const { workspaceSlug, projectId, cycleId, cycleDetails, isArchived } = props; + const { workspaceSlug, projectId, cycleId, cycleDetails, parentRef } = props; // hooks const { isMobile } = usePlatformOS(); // store hooks @@ -103,6 +103,7 @@ export const CycleListItemAction: FC = observer((props) => { }, }); }; + return ( <>
@@ -140,7 +141,7 @@ export const CycleListItemAction: FC = observer((props) => {
- {isEditingAllowed && !isArchived && ( + {isEditingAllowed && !cycleDetails.archived_at && ( { if (cycleDetails.is_favorite) handleRemoveFromFavorites(e); @@ -149,12 +150,7 @@ export const CycleListItemAction: FC = observer((props) => { selected={!!cycleDetails.is_favorite} /> )} - + ); }); diff --git a/web/components/cycles/list/cycles-list-item.tsx b/web/components/cycles/list/cycles-list-item.tsx index d02d0951f..1f70d79c2 100644 --- a/web/components/cycles/list/cycles-list-item.tsx +++ b/web/components/cycles/list/cycles-list-item.tsx @@ -1,4 +1,4 @@ -import { FC, MouseEvent } from "react"; +import { FC, MouseEvent, useRef } from "react"; import { observer } from "mobx-react"; import { useRouter } from "next/router"; // icons @@ -22,11 +22,12 @@ type TCyclesListItem = { handleRemoveFromFavorites?: () => void; workspaceSlug: string; projectId: string; - isArchived?: boolean; }; export const CyclesListItem: FC = observer((props) => { - const { cycleId, workspaceSlug, projectId, isArchived = false } = props; + const { cycleId, workspaceSlug, projectId } = props; + // refs + const parentRef = useRef(null); // router const router = useRouter(); // hooks @@ -80,9 +81,7 @@ export const CyclesListItem: FC = observer((props) => { title={cycleDetails?.name ?? ""} itemLink={`/${workspaceSlug}/projects/${projectId}/cycles/${cycleDetails.id}`} onItemClick={(e) => { - if (isArchived) { - openCycleOverview(e); - } + if (cycleDetails.archived_at) openCycleOverview(e); }} prependTitleElement={ @@ -113,10 +112,11 @@ export const CyclesListItem: FC = observer((props) => { projectId={projectId} cycleId={cycleId} cycleDetails={cycleDetails} - isArchived={isArchived} + parentRef={parentRef} /> } isMobile={isMobile} + parentRef={parentRef} /> ); }); diff --git a/web/components/cycles/list/cycles-list-map.tsx b/web/components/cycles/list/cycles-list-map.tsx index 7a99f5ab7..004c66fca 100644 --- a/web/components/cycles/list/cycles-list-map.tsx +++ b/web/components/cycles/list/cycles-list-map.tsx @@ -5,22 +5,15 @@ type Props = { cycleIds: string[]; projectId: string; workspaceSlug: string; - isArchived?: boolean; }; export const CyclesListMap: React.FC = (props) => { - const { cycleIds, projectId, workspaceSlug, isArchived } = props; + const { cycleIds, projectId, workspaceSlug } = props; return ( <> {cycleIds.map((cycleId) => ( - + ))} ); diff --git a/web/components/cycles/list/root.tsx b/web/components/cycles/list/root.tsx index 52a7e569e..34e34acf0 100644 --- a/web/components/cycles/list/root.tsx +++ b/web/components/cycles/list/root.tsx @@ -27,7 +27,6 @@ export const CyclesList: FC = observer((props) => { cycleIds={cycleIds} projectId={projectId} workspaceSlug={workspaceSlug} - isArchived={isArchived} /> {completedCycleIds.length !== 0 && ( @@ -44,12 +43,7 @@ export const CyclesList: FC = observer((props) => { )} - + )} diff --git a/web/components/cycles/quick-actions.tsx b/web/components/cycles/quick-actions.tsx index bcbb84efd..194bdd068 100644 --- a/web/components/cycles/quick-actions.tsx +++ b/web/components/cycles/quick-actions.tsx @@ -2,27 +2,28 @@ import { useState } from "react"; import { observer } from "mobx-react"; import { useRouter } from "next/router"; // icons -import { ArchiveRestoreIcon, LinkIcon, Pencil, Trash2 } from "lucide-react"; +import { ArchiveRestoreIcon, ExternalLink, LinkIcon, Pencil, Trash2 } from "lucide-react"; // ui -import { ArchiveIcon, CustomMenu, TOAST_TYPE, setToast } from "@plane/ui"; +import { ArchiveIcon, ContextMenu, CustomMenu, TContextMenuItem, TOAST_TYPE, setToast } from "@plane/ui"; // components import { ArchiveCycleModal, CycleCreateUpdateModal, CycleDeleteModal } from "@/components/cycles"; // constants import { EUserProjectRoles } from "@/constants/project"; // helpers +import { cn } from "@/helpers/common.helper"; import { copyUrlToClipboard } from "@/helpers/string.helper"; // hooks import { useCycle, useEventTracker, useUser } from "@/hooks/store"; type Props = { + parentRef: React.RefObject; cycleId: string; projectId: string; workspaceSlug: string; - isArchived?: boolean; }; export const CycleQuickActions: React.FC = observer((props) => { - const { cycleId, projectId, workspaceSlug, isArchived } = props; + const { parentRef, cycleId, projectId, workspaceSlug } = props; // router const router = useRouter(); // states @@ -37,40 +38,31 @@ export const CycleQuickActions: React.FC = observer((props) => { const { getCycleById, restoreCycle } = useCycle(); // derived values const cycleDetails = getCycleById(cycleId); + const isArchived = !!cycleDetails?.archived_at; const isCompleted = cycleDetails?.status?.toLowerCase() === "completed"; // auth const isEditingAllowed = !!currentWorkspaceAllProjectsRole && currentWorkspaceAllProjectsRole[projectId] >= EUserProjectRoles.MEMBER; - const handleCopyText = (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - - copyUrlToClipboard(`${workspaceSlug}/projects/${projectId}/cycles/${cycleId}`).then(() => { + const cycleLink = `${workspaceSlug}/projects/${projectId}/cycles/${cycleId}`; + const handleCopyText = () => + copyUrlToClipboard(cycleLink).then(() => { setToast({ type: TOAST_TYPE.SUCCESS, title: "Link Copied!", message: "Cycle link copied to clipboard.", }); }); - }; + const handleOpenInNewTab = () => window.open(`/${cycleLink}`, "_blank"); - const handleEditCycle = (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); + const handleEditCycle = () => { setTrackElement("Cycles page list layout"); setUpdateModal(true); }; - const handleArchiveCycle = (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - setArchiveCycleModal(true); - }; + const handleArchiveCycle = () => setArchiveCycleModal(true); - const handleRestoreCycle = async (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); + const handleRestoreCycle = async () => await restoreCycle(workspaceSlug, projectId, cycleId) .then(() => { setToast({ @@ -87,15 +79,61 @@ export const CycleQuickActions: React.FC = observer((props) => { message: "Cycle could not be restored. Please try again.", }) ); - }; - const handleDeleteCycle = (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); + const handleDeleteCycle = () => { setTrackElement("Cycles page list layout"); setDeleteModal(true); }; + const MENU_ITEMS: TContextMenuItem[] = [ + { + key: "edit", + title: "Edit", + icon: Pencil, + action: handleEditCycle, + shouldRender: isEditingAllowed && !isCompleted && !isArchived, + }, + { + key: "open-new-tab", + action: handleOpenInNewTab, + title: "Open in new tab", + icon: ExternalLink, + shouldRender: !isArchived, + }, + { + key: "copy-link", + action: handleCopyText, + title: "Copy link", + icon: LinkIcon, + shouldRender: !isArchived, + }, + { + key: "archive", + action: handleArchiveCycle, + title: "Archive", + description: isCompleted ? undefined : "Only completed cycle can\nbe archived.", + icon: ArchiveIcon, + className: "items-start", + iconClassName: "mt-1", + shouldRender: isEditingAllowed && !isArchived, + disabled: !isCompleted, + }, + { + key: "restore", + action: handleRestoreCycle, + title: "Restore", + icon: ArchiveRestoreIcon, + shouldRender: isEditingAllowed && isArchived, + }, + { + key: "delete", + action: handleDeleteCycle, + title: "Delete", + icon: Trash2, + shouldRender: isEditingAllowed && !isCompleted && !isArchived, + }, + ]; + return ( <> {cycleDetails && ( @@ -123,63 +161,42 @@ export const CycleQuickActions: React.FC = observer((props) => { />
)} - - {!isCompleted && isEditingAllowed && !isArchived && ( - - - - Edit cycle - - - )} - {isEditingAllowed && !isArchived && ( - - {isCompleted ? ( -
- - Archive cycle -
- ) : ( -
- -
-

Archive cycle

-

- Only completed cycle
can be archived. + + + {MENU_ITEMS.map((item) => { + if (item.shouldRender === false) return null; + return ( + { + e.preventDefault(); + e.stopPropagation(); + item.action(); + }} + className={cn( + "flex items-center gap-2", + { + "text-custom-text-400": item.disabled, + }, + item.className + )} + > + {item.icon && } +

+
{item.title}
+ {item.description && ( +

+ {item.description}

-
+ )}
- )} - - )} - {isEditingAllowed && isArchived && ( - - - - Restore cycle - - - )} - {!isArchived && ( - - - - Copy cycle link - - - )} - - {!isCompleted && isEditingAllowed && ( - <> -
- - - - Delete cycle - - - )} + ); + })} ); diff --git a/web/components/issues/issue-layouts/calendar/base-calendar-root.tsx b/web/components/issues/issue-layouts/calendar/base-calendar-root.tsx index d5b263776..1b8235af8 100644 --- a/web/components/issues/issue-layouts/calendar/base-calendar-root.tsx +++ b/web/components/issues/issue-layouts/calendar/base-calendar-root.tsx @@ -89,8 +89,9 @@ export const BaseCalendarRoot = observer((props: IBaseCalendarRoot) => { groupedIssueIds={groupedIssueIds} layout={displayFilters?.calendar?.layout} showWeekends={displayFilters?.calendar?.show_weekends ?? false} - quickActions={(issue, customActionButton, placement) => ( + quickActions={({ issue, parentRef, customActionButton, placement }) => ( removeIssue(issue.project_id, issue.id)} diff --git a/web/components/issues/issue-layouts/calendar/calendar.tsx b/web/components/issues/issue-layouts/calendar/calendar.tsx index 8c6a7ebe9..2d52cfa96 100644 --- a/web/components/issues/issue-layouts/calendar/calendar.tsx +++ b/web/components/issues/issue-layouts/calendar/calendar.tsx @@ -1,5 +1,4 @@ import { useState } from "react"; -import { Placement } from "@popperjs/core"; import { observer } from "mobx-react-lite"; import type { IIssueDisplayFilterOptions, @@ -28,6 +27,7 @@ import { ICycleIssuesFilter } from "@/store/issue/cycle"; import { IModuleIssuesFilter } from "@/store/issue/module"; import { IProjectIssuesFilter } from "@/store/issue/project"; import { IProjectViewIssuesFilter } from "@/store/issue/project-views"; +import { TRenderQuickActions } from "../list/list-view-types"; import type { ICalendarWeek } from "./types"; // helpers // constants @@ -38,7 +38,7 @@ type Props = { groupedIssueIds: TGroupedIssues; layout: "month" | "week" | undefined; showWeekends: boolean; - quickActions: (issue: TIssue, customActionButton?: React.ReactElement, placement?: Placement) => React.ReactNode; + quickActions: TRenderQuickActions; quickAddCallback?: ( workspaceSlug: string, projectId: string, diff --git a/web/components/issues/issue-layouts/calendar/day-tile.tsx b/web/components/issues/issue-layouts/calendar/day-tile.tsx index 4e472e226..f8d36dfe0 100644 --- a/web/components/issues/issue-layouts/calendar/day-tile.tsx +++ b/web/components/issues/issue-layouts/calendar/day-tile.tsx @@ -1,5 +1,4 @@ import { Droppable } from "@hello-pangea/dnd"; -import { Placement } from "@popperjs/core"; import { observer } from "mobx-react-lite"; import { TGroupedIssues, TIssue, TIssueMap } from "@plane/types"; // components @@ -14,13 +13,14 @@ import { ICycleIssuesFilter } from "@/store/issue/cycle"; import { IModuleIssuesFilter } from "@/store/issue/module"; import { IProjectIssuesFilter } from "@/store/issue/project"; import { IProjectViewIssuesFilter } from "@/store/issue/project-views"; +import { TRenderQuickActions } from "../list/list-view-types"; type Props = { issuesFilterStore: IProjectIssuesFilter | IModuleIssuesFilter | ICycleIssuesFilter | IProjectViewIssuesFilter; date: ICalendarDate; issues: TIssueMap | undefined; groupedIssueIds: TGroupedIssues; - quickActions: (issue: TIssue, customActionButton?: React.ReactElement, placement?: Placement) => React.ReactNode; + quickActions: TRenderQuickActions; enableQuickIssueCreate?: boolean; disableIssueCreation?: boolean; quickAddCallback?: ( diff --git a/web/components/issues/issue-layouts/calendar/issue-block-root.tsx b/web/components/issues/issue-layouts/calendar/issue-block-root.tsx index f7fd7ab52..efedcf50b 100644 --- a/web/components/issues/issue-layouts/calendar/issue-block-root.tsx +++ b/web/components/issues/issue-layouts/calendar/issue-block-root.tsx @@ -1,14 +1,14 @@ import React from "react"; -import { Placement } from "@popperjs/core"; // components -import { TIssue, TIssueMap } from "@plane/types"; +import { TIssueMap } from "@plane/types"; import { CalendarIssueBlock } from "@/components/issues"; +import { TRenderQuickActions } from "../list/list-view-types"; // types type Props = { issues: TIssueMap | undefined; issueId: string; - quickActions: (issue: TIssue, customActionButton?: React.ReactElement, placement?: Placement) => React.ReactNode; + quickActions: TRenderQuickActions; isDragging?: boolean; }; diff --git a/web/components/issues/issue-layouts/calendar/issue-block.tsx b/web/components/issues/issue-layouts/calendar/issue-block.tsx index 6d1fd6c9c..31d7defc6 100644 --- a/web/components/issues/issue-layouts/calendar/issue-block.tsx +++ b/web/components/issues/issue-layouts/calendar/issue-block.tsx @@ -1,5 +1,4 @@ import { useState, useRef } from "react"; -import { Placement } from "@popperjs/core"; import { observer } from "mobx-react"; import { MoreHorizontal } from "lucide-react"; import { TIssue } from "@plane/types"; @@ -12,15 +11,21 @@ import useOutsideClickDetector from "@/hooks/use-outside-click-detector"; // helpers // types import { usePlatformOS } from "@/hooks/use-platform-os"; +import { TRenderQuickActions } from "../list/list-view-types"; type Props = { issue: TIssue; - quickActions: (issue: TIssue, customActionButton?: React.ReactElement, placement?: Placement) => React.ReactNode; + quickActions: TRenderQuickActions; isDragging?: boolean; }; export const CalendarIssueBlock: React.FC = observer((props) => { const { issue, quickActions, isDragging = false } = props; + // states + const [isMenuActive, setIsMenuActive] = useState(false); + // refs + const blockRef = useRef(null); + const menuActionRef = useRef(null); // hooks const { router: { workspaceSlug, projectId }, @@ -29,10 +34,6 @@ export const CalendarIssueBlock: React.FC = observer((props) => { const { getProjectStates } = useProjectState(); const { getIsIssuePeeked, setPeekIssue } = useIssueDetail(); const { isMobile } = usePlatformOS(); - // states - const [isMenuActive, setIsMenuActive] = useState(false); - - const menuActionRef = useRef(null); const stateColor = getProjectStates(issue?.project_id)?.find((state) => state?.id == issue?.state_id)?.color || ""; @@ -78,6 +79,7 @@ export const CalendarIssueBlock: React.FC = observer((props) => { )}
= observer((props) => { e.stopPropagation(); }} > - {quickActions(issue, customActionButton, placement)} + {quickActions({ + issue, + parentRef: blockRef, + customActionButton, + placement, + })}
diff --git a/web/components/issues/issue-layouts/calendar/issue-blocks.tsx b/web/components/issues/issue-layouts/calendar/issue-blocks.tsx index 79cc84ecb..c8c52ce1e 100644 --- a/web/components/issues/issue-layouts/calendar/issue-blocks.tsx +++ b/web/components/issues/issue-layouts/calendar/issue-blocks.tsx @@ -1,19 +1,19 @@ import { useState } from "react"; import { Draggable } from "@hello-pangea/dnd"; -import { Placement } from "@popperjs/core"; import { observer } from "mobx-react-lite"; import { TIssue, TIssueMap } from "@plane/types"; // components import { CalendarQuickAddIssueForm, CalendarIssueBlockRoot } from "@/components/issues"; // helpers import { renderFormattedPayloadDate } from "@/helpers/date-time.helper"; +import { TRenderQuickActions } from "../list/list-view-types"; // types type Props = { date: Date; issues: TIssueMap | undefined; issueIdList: string[] | null; - quickActions: (issue: TIssue, customActionButton?: React.ReactElement, placement?: Placement) => React.ReactNode; + quickActions: TRenderQuickActions; isDragDisabled?: boolean; enableQuickIssueCreate?: boolean; disableIssueCreation?: boolean; diff --git a/web/components/issues/issue-layouts/calendar/week-days.tsx b/web/components/issues/issue-layouts/calendar/week-days.tsx index 429c237e2..c8524adcc 100644 --- a/web/components/issues/issue-layouts/calendar/week-days.tsx +++ b/web/components/issues/issue-layouts/calendar/week-days.tsx @@ -1,4 +1,3 @@ -import { Placement } from "@popperjs/core"; import { observer } from "mobx-react-lite"; import { TGroupedIssues, TIssue, TIssueMap } from "@plane/types"; // components @@ -10,6 +9,7 @@ import { ICycleIssuesFilter } from "@/store/issue/cycle"; import { IModuleIssuesFilter } from "@/store/issue/module"; import { IProjectIssuesFilter } from "@/store/issue/project"; import { IProjectViewIssuesFilter } from "@/store/issue/project-views"; +import { TRenderQuickActions } from "../list/list-view-types"; import { ICalendarDate, ICalendarWeek } from "./types"; type Props = { @@ -17,7 +17,7 @@ type Props = { issues: TIssueMap | undefined; groupedIssueIds: TGroupedIssues; week: ICalendarWeek | undefined; - quickActions: (issue: TIssue, customActionButton?: React.ReactElement, placement?: Placement) => React.ReactNode; + quickActions: TRenderQuickActions; enableQuickIssueCreate?: boolean; disableIssueCreation?: boolean; quickAddCallback?: ( diff --git a/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx b/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx index 8c6cf900b..0d394cbaa 100644 --- a/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx +++ b/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx @@ -4,7 +4,6 @@ import { dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element import { autoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element"; import { observer } from "mobx-react-lite"; import { useRouter } from "next/router"; -import { TIssue } from "@plane/types"; // hooks import { Spinner, TOAST_TYPE, setToast } from "@plane/ui"; import { DeleteIssueModal } from "@/components/issues"; @@ -15,7 +14,7 @@ import { useEventTracker, useIssueDetail, useIssues, useKanbanView, useUser } fr import { useIssuesActions } from "@/hooks/use-issues-actions"; // ui // types -import { IQuickActionProps } from "../list/list-view-types"; +import { IQuickActionProps, TRenderQuickActions } from "../list/list-view-types"; //components import { KanBan } from "./default"; import { KanBanSwimLanes } from "./swimlanes"; @@ -168,9 +167,10 @@ export const BaseKanBanRoot: React.FC = observer((props: IBas }); }; - const renderQuickActions = useCallback( - (issue: TIssue, customActionButton?: React.ReactElement) => ( + const renderQuickActions: TRenderQuickActions = useCallback( + ({ issue, parentRef, customActionButton }) => ( removeIssue(issue.project_id, issue.id)} diff --git a/web/components/issues/issue-layouts/kanban/block.tsx b/web/components/issues/issue-layouts/kanban/block.tsx index 885f3de4d..82e7dc19c 100644 --- a/web/components/issues/issue-layouts/kanban/block.tsx +++ b/web/components/issues/issue-layouts/kanban/block.tsx @@ -10,6 +10,7 @@ import { cn } from "@/helpers/common.helper"; import { useApplication, useIssueDetail, useKanbanView, useProject } from "@/hooks/store"; import { usePlatformOS } from "@/hooks/use-platform-os"; // components +import { TRenderQuickActions } from "../list/list-view-types"; import { IssueProperties } from "../properties/all-properties"; import { WithDisplayPropertiesHOC } from "../properties/with-display-properties-HOC"; // ui @@ -23,22 +24,23 @@ interface IssueBlockProps { isDragDisabled: boolean; draggableId: string; updateIssue: ((projectId: string, issueId: string, data: Partial) => Promise) | undefined; - quickActions: (issue: TIssue) => React.ReactNode; + quickActions: TRenderQuickActions; canEditProperties: (projectId: string | undefined) => boolean; scrollableContainerRef?: MutableRefObject; issueIds: string[]; //DO NOT REMOVE< needed to force render for virtualization } interface IssueDetailsBlockProps { + cardRef: React.RefObject; issue: TIssue; displayProperties: IIssueDisplayProperties | undefined; updateIssue: ((projectId: string, issueId: string, data: Partial) => Promise) | undefined; - quickActions: (issue: TIssue) => React.ReactNode; + quickActions: TRenderQuickActions; isReadOnly: boolean; } -const KanbanIssueDetailsBlock: React.FC = observer((props: IssueDetailsBlockProps) => { - const { issue, updateIssue, quickActions, isReadOnly, displayProperties } = props; +const KanbanIssueDetailsBlock: React.FC = observer((props) => { + const { cardRef, issue, updateIssue, quickActions, isReadOnly, displayProperties } = props; // hooks const { isMobile } = usePlatformOS(); const { getProjectIdentifierById } = useProject(); @@ -59,7 +61,10 @@ const KanbanIssueDetailsBlock: React.FC = observer((prop className="absolute -top-1 right-0 hidden group-hover/kanban-block:block" onClick={handleEventPropagation} > - {quickActions(issue)} + {quickActions({ + issue, + parentRef: cardRef, + })}
@@ -200,6 +205,7 @@ export const KanbanIssueBlock: React.FC = observer((props) => { changingReference={issueIds} > ) => Promise) | undefined; - quickActions: (issue: TIssue, customActionButton?: React.ReactElement) => React.ReactNode; + quickActions: TRenderQuickActions; canEditProperties: (projectId: string | undefined) => boolean; scrollableContainerRef?: MutableRefObject; } diff --git a/web/components/issues/issue-layouts/kanban/default.tsx b/web/components/issues/issue-layouts/kanban/default.tsx index 3e37c8673..82aec5c5a 100644 --- a/web/components/issues/issue-layouts/kanban/default.tsx +++ b/web/components/issues/issue-layouts/kanban/default.tsx @@ -17,6 +17,7 @@ import { import { useCycle, useKanbanView, useLabel, useMember, useModule, useProject, useProjectState } from "@/hooks/store"; // types // parent components +import { TRenderQuickActions } from "../list/list-view-types"; import { getGroupByColumns, isWorkspaceLevel } from "../utils"; // components import { KanbanStoreType } from "./base-kanban-root"; @@ -33,7 +34,7 @@ export interface IGroupByKanBan { sub_group_id: string; isDragDisabled: boolean; updateIssue: ((projectId: string, issueId: string, data: Partial) => Promise) | undefined; - quickActions: (issue: TIssue, customActionButton?: React.ReactElement) => React.ReactNode; + quickActions: TRenderQuickActions; kanbanFilters: TIssueKanbanFilters; handleKanbanFilters: any; enableQuickIssueCreate?: boolean; @@ -197,7 +198,7 @@ export interface IKanBan { group_by: TIssueGroupByOptions | undefined; sub_group_id?: string; updateIssue: ((projectId: string, issueId: string, data: Partial) => Promise) | undefined; - quickActions: (issue: TIssue, customActionButton?: React.ReactElement) => React.ReactNode; + quickActions: TRenderQuickActions; kanbanFilters: TIssueKanbanFilters; handleKanbanFilters: (toggle: "group_by" | "sub_group_by", value: string) => void; showEmptyGroup: boolean; diff --git a/web/components/issues/issue-layouts/kanban/kanban-group.tsx b/web/components/issues/issue-layouts/kanban/kanban-group.tsx index d3bb21114..85f24d90b 100644 --- a/web/components/issues/issue-layouts/kanban/kanban-group.tsx +++ b/web/components/issues/issue-layouts/kanban/kanban-group.tsx @@ -17,6 +17,7 @@ import { cn } from "@/helpers/common.helper"; // hooks import { useProjectState } from "@/hooks/store"; //components +import { TRenderQuickActions } from "../list/list-view-types"; import { KanbanDropLocation, getSourceFromDropPayload, getDestinationFromDropPayload } from "./utils"; import { KanbanIssueBlocksList, KanBanQuickAddIssueForm } from "."; @@ -30,7 +31,7 @@ interface IKanbanGroup { sub_group_id: string; isDragDisabled: boolean; updateIssue: ((projectId: string, issueId: string, data: Partial) => Promise) | undefined; - quickActions: (issue: TIssue, customActionButton?: React.ReactElement) => React.ReactNode; + quickActions: TRenderQuickActions; enableQuickIssueCreate?: boolean; quickAddCallback?: ( workspaceSlug: string, diff --git a/web/components/issues/issue-layouts/kanban/swimlanes.tsx b/web/components/issues/issue-layouts/kanban/swimlanes.tsx index 99ac692f7..ae881b9ed 100644 --- a/web/components/issues/issue-layouts/kanban/swimlanes.tsx +++ b/web/components/issues/issue-layouts/kanban/swimlanes.tsx @@ -14,6 +14,7 @@ import { } from "@plane/types"; // components import { useCycle, useLabel, useMember, useModule, useProject, useProjectState } from "@/hooks/store"; +import { TRenderQuickActions } from "../list/list-view-types"; import { getGroupByColumns, isWorkspaceLevel } from "../utils"; import { KanbanStoreType } from "./base-kanban-root"; import { KanBan } from "./default"; @@ -106,7 +107,7 @@ interface ISubGroupSwimlane extends ISubGroupSwimlaneHeader { showEmptyGroup: boolean; displayProperties: IIssueDisplayProperties | undefined; updateIssue: ((projectId: string, issueId: string, data: Partial) => Promise) | undefined; - quickActions: (issue: TIssue, customActionButton?: React.ReactElement) => React.ReactNode; + quickActions: TRenderQuickActions; kanbanFilters: TIssueKanbanFilters; handleKanbanFilters: (toggle: "group_by" | "sub_group_by", value: string) => void; handleOnDrop: (source: KanbanDropLocation, destination: KanbanDropLocation) => Promise; @@ -235,7 +236,7 @@ export interface IKanBanSwimLanes { sub_group_by: TIssueGroupByOptions | undefined; group_by: TIssueGroupByOptions | undefined; updateIssue: ((projectId: string, issueId: string, data: Partial) => Promise) | undefined; - quickActions: (issue: TIssue, customActionButton?: React.ReactElement) => React.ReactNode; + quickActions: TRenderQuickActions; kanbanFilters: TIssueKanbanFilters; handleKanbanFilters: (toggle: "group_by" | "sub_group_by", value: string) => void; showEmptyGroup: boolean; diff --git a/web/components/issues/issue-layouts/list/base-list-root.tsx b/web/components/issues/issue-layouts/list/base-list-root.tsx index ac9427101..8a5fcf849 100644 --- a/web/components/issues/issue-layouts/list/base-list-root.tsx +++ b/web/components/issues/issue-layouts/list/base-list-root.tsx @@ -1,6 +1,5 @@ import { FC, useCallback } from "react"; import { observer } from "mobx-react-lite"; -import { TIssue } from "@plane/types"; // types import { EIssuesStoreType } from "@/constants/issue"; import { EUserProjectRoles } from "@/constants/project"; @@ -9,7 +8,7 @@ import { useIssues, useUser } from "@/hooks/store"; import { useIssuesActions } from "@/hooks/use-issues-actions"; // components import { List } from "./default"; -import { IQuickActionProps } from "./list-view-types"; +import { IQuickActionProps, TRenderQuickActions } from "./list-view-types"; // constants // hooks @@ -69,9 +68,10 @@ export const BaseListRoot = observer((props: IBaseListRoot) => { const group_by = displayFilters?.group_by || null; const showEmptyGroup = displayFilters?.show_empty_groups ?? false; - const renderQuickActions = useCallback( - (issue: TIssue) => ( + const renderQuickActions: TRenderQuickActions = useCallback( + ({ issue, parentRef }) => ( removeIssue(issue.project_id, issue.id)} handleUpdate={async (data) => updateIssue && updateIssue(issue.project_id, issue.id, data)} diff --git a/web/components/issues/issue-layouts/list/block.tsx b/web/components/issues/issue-layouts/list/block.tsx index a0017dcc9..b34b83473 100644 --- a/web/components/issues/issue-layouts/list/block.tsx +++ b/web/components/issues/issue-layouts/list/block.tsx @@ -1,27 +1,30 @@ +import { useRef } from "react"; import { observer } from "mobx-react-lite"; import { TIssue, IIssueDisplayProperties, TIssueMap } from "@plane/types"; -// components -// hooks // ui import { Spinner, Tooltip, ControlLink } from "@plane/ui"; -// helper +// helpers import { cn } from "@/helpers/common.helper"; +// hooks import { useApplication, useIssueDetail, useProject } from "@/hooks/store"; import { usePlatformOS } from "@/hooks/use-platform-os"; // types import { IssueProperties } from "../properties/all-properties"; +import { TRenderQuickActions } from "./list-view-types"; interface IssueBlockProps { issueId: string; issuesMap: TIssueMap; updateIssue: ((projectId: string, issueId: string, data: Partial) => Promise) | undefined; - quickActions: (issue: TIssue) => React.ReactNode; + quickActions: TRenderQuickActions; displayProperties: IIssueDisplayProperties | undefined; canEditProperties: (projectId: string | undefined) => boolean; } export const IssueBlock: React.FC = observer((props: IssueBlockProps) => { const { issuesMap, issueId, updateIssue, quickActions, displayProperties, canEditProperties } = props; + // refs + const parentRef = useRef(null); // hooks const { router: { workspaceSlug }, @@ -46,6 +49,7 @@ export const IssueBlock: React.FC = observer((props: IssueBlock return (
= observer((props: IssueBlock )}
{!issue?.tempId && ( -
{quickActions(issue)}
+
+ {quickActions({ + issue, + parentRef, + })} +
)}
@@ -102,7 +111,12 @@ export const IssueBlock: React.FC = observer((props: IssueBlock displayProperties={displayProperties} activeLayout="List" /> -
{quickActions(issue)}
+
+ {quickActions({ + issue, + parentRef, + })} +
) : (
diff --git a/web/components/issues/issue-layouts/list/blocks-list.tsx b/web/components/issues/issue-layouts/list/blocks-list.tsx index f5ddda6b5..1e1751b76 100644 --- a/web/components/issues/issue-layouts/list/blocks-list.tsx +++ b/web/components/issues/issue-layouts/list/blocks-list.tsx @@ -3,6 +3,7 @@ import { FC, MutableRefObject } from "react"; import { TGroupedIssues, TIssue, IIssueDisplayProperties, TIssueMap, TUnGroupedIssues } from "@plane/types"; import RenderIfVisible from "@/components/core/render-if-visible-HOC"; import { IssueBlock } from "@/components/issues"; +import { TRenderQuickActions } from "./list-view-types"; // types interface Props { @@ -10,7 +11,7 @@ interface Props { issuesMap: TIssueMap; canEditProperties: (projectId: string | undefined) => boolean; updateIssue: ((projectId: string, issueId: string, data: Partial) => Promise) | undefined; - quickActions: (issue: TIssue) => React.ReactNode; + quickActions: TRenderQuickActions; displayProperties: IIssueDisplayProperties | undefined; containerRef: MutableRefObject; } diff --git a/web/components/issues/issue-layouts/list/default.tsx b/web/components/issues/issue-layouts/list/default.tsx index 2fcadaa13..d34908896 100644 --- a/web/components/issues/issue-layouts/list/default.tsx +++ b/web/components/issues/issue-layouts/list/default.tsx @@ -16,13 +16,14 @@ import { useCycle, useLabel, useMember, useModule, useProject, useProjectState } // utils import { getGroupByColumns, isWorkspaceLevel } from "../utils"; import { HeaderGroupByCard } from "./headers/group-by-card"; +import { TRenderQuickActions } from "./list-view-types"; export interface IGroupByList { issueIds: TGroupedIssues | TUnGroupedIssues | any; issuesMap: TIssueMap; group_by: string | null; updateIssue: ((projectId: string, issueId: string, data: Partial) => Promise) | undefined; - quickActions: (issue: TIssue) => React.ReactNode; + quickActions: TRenderQuickActions; displayProperties: IIssueDisplayProperties | undefined; enableIssueQuickAdd: boolean; showEmptyGroup?: boolean; @@ -177,7 +178,7 @@ export interface IList { issuesMap: TIssueMap; group_by: string | null; updateIssue: ((projectId: string, issueId: string, data: Partial) => Promise) | undefined; - quickActions: (issue: TIssue) => React.ReactNode; + quickActions: TRenderQuickActions; displayProperties: IIssueDisplayProperties | undefined; showEmptyGroup: boolean; enableIssueQuickAdd: boolean; diff --git a/web/components/issues/issue-layouts/list/list-view-types.d.ts b/web/components/issues/issue-layouts/list/list-view-types.d.ts index f38f52b9c..6597855f6 100644 --- a/web/components/issues/issue-layouts/list/list-view-types.d.ts +++ b/web/components/issues/issue-layouts/list/list-view-types.d.ts @@ -2,6 +2,7 @@ import { Placement } from "@popperjs/core"; import { TIssue } from "@plane/types"; export interface IQuickActionProps { + parentRef: React.RefObject; issue: TIssue; handleDelete: () => Promise; handleUpdate?: (data: TIssue) => Promise; @@ -13,3 +14,17 @@ export interface IQuickActionProps { readOnly?: boolean; placements?: Placement; } + +export type TRenderQuickActions = ({ + issue, + parentRef, + customActionButton, + placement, + portalElement, +}: { + issue: TIssue; + parentRef: React.RefObject; + customActionButton?: React.ReactElement; + placement?: Placement; + portalElement?: HTMLDivElement | null; +}) => React.ReactNode; diff --git a/web/components/issues/issue-layouts/quick-action-dropdowns/all-issue.tsx b/web/components/issues/issue-layouts/quick-action-dropdowns/all-issue.tsx index 86dbe760b..7a73a25f9 100644 --- a/web/components/issues/issue-layouts/quick-action-dropdowns/all-issue.tsx +++ b/web/components/issues/issue-layouts/quick-action-dropdowns/all-issue.tsx @@ -3,21 +3,22 @@ import omit from "lodash/omit"; import { observer } from "mobx-react"; import { useRouter } from "next/router"; import { Copy, ExternalLink, Link, Pencil, Trash2 } from "lucide-react"; +// types import { TIssue } from "@plane/types"; -// hooks -import { ArchiveIcon, CustomMenu, TOAST_TYPE, setToast } from "@plane/ui"; -import { ArchiveIssueModal, CreateUpdateIssueModal, DeleteIssueModal } from "@/components/issues"; // ui +import { ArchiveIcon, ContextMenu, CustomMenu, TContextMenuItem, TOAST_TYPE, setToast } from "@plane/ui"; // components +import { ArchiveIssueModal, CreateUpdateIssueModal, DeleteIssueModal } from "@/components/issues"; +// constants import { EIssuesStoreType } from "@/constants/issue"; import { STATE_GROUPS } from "@/constants/state"; -import { copyUrlToClipboard } from "@/helpers/string.helper"; -import { useEventTracker, useProjectState } from "@/hooks/store"; -// components // helpers +import { cn } from "@/helpers/common.helper"; +import { copyUrlToClipboard } from "@/helpers/string.helper"; +// hooks +import { useEventTracker, useProjectState } from "@/hooks/store"; // types import { IQuickActionProps } from "../list/list-view-types"; -// constants export const AllIssueQuickActions: React.FC = observer((props) => { const { @@ -28,6 +29,8 @@ export const AllIssueQuickActions: React.FC = observer((props customActionButton, portalElement, readOnly = false, + placements = "bottom-start", + parentRef, } = props; // states const [createUpdateIssueModal, setCreateUpdateIssueModal] = useState(false); @@ -68,6 +71,63 @@ export const AllIssueQuickActions: React.FC = observer((props ["id"] ); + const MENU_ITEMS: TContextMenuItem[] = [ + { + key: "edit", + title: "Edit", + icon: Pencil, + action: () => { + setTrackElement("Global issues"); + setIssueToEdit(issue); + setCreateUpdateIssueModal(true); + }, + shouldRender: isEditingAllowed, + }, + { + key: "make-a-copy", + title: "Make a copy", + icon: Copy, + action: () => { + setTrackElement("Global issues"); + setCreateUpdateIssueModal(true); + }, + shouldRender: isEditingAllowed, + }, + { + key: "open-in-new-tab", + title: "Open in new tab", + icon: ExternalLink, + action: handleOpenInNewTab, + }, + { + key: "copy-link", + title: "Copy link", + icon: Link, + action: handleCopyIssueLink, + }, + { + key: "archive", + title: "Archive", + description: isInArchivableGroup ? undefined : "Only completed or canceled\nissues can be archived", + icon: ArchiveIcon, + className: "items-start", + iconClassName: "mt-1", + action: () => setArchiveIssueModal(true), + disabled: !isInArchivableGroup, + shouldRender: isArchivingAllowed, + }, + { + key: "delete", + title: "Delete", + icon: Trash2, + action: () => { + setTrackElement("Global issues"); + setDeleteIssueModal(true); + }, + shouldRender: isEditingAllowed, + }, + ]; + return ( <> = observer((props }} storeType={EIssuesStoreType.PROJECT} /> + - {isEditingAllowed && ( - { - setTrackElement("Global issues"); - setIssueToEdit(issue); - setCreateUpdateIssueModal(true); - }} - > -
- - Edit -
-
- )} - -
- - Open in new tab -
-
- -
- - Copy link -
-
- {isEditingAllowed && ( - { - setTrackElement("Global issues"); - setCreateUpdateIssueModal(true); - }} - > -
- - Make a copy -
-
- )} - {isArchivingAllowed && ( - setArchiveIssueModal(true)} disabled={!isInArchivableGroup}> - {isInArchivableGroup ? ( -
- - Archive -
- ) : ( -
- -
-

Archive

-

- Only completed or canceled -
- issues can be archived + {MENU_ITEMS.map((item) => { + if (item.shouldRender === false) return null; + return ( + { + e.preventDefault(); + e.stopPropagation(); + item.action(); + }} + className={cn( + "flex items-center gap-2", + { + "text-custom-text-400": item.disabled, + }, + item.className + )} + > + {item.icon && } +

+
{item.title}
+ {item.description && ( +

+ {item.description}

-
+ )}
- )} - - )} - {isEditingAllowed && ( - { - setTrackElement("Global issues"); - setDeleteIssueModal(true); - }} - > -
- - Delete -
-
- )} + + ); + })} ); diff --git a/web/components/issues/issue-layouts/quick-action-dropdowns/archived-issue.tsx b/web/components/issues/issue-layouts/quick-action-dropdowns/archived-issue.tsx index 8a49ec9b4..0327755a9 100644 --- a/web/components/issues/issue-layouts/quick-action-dropdowns/archived-issue.tsx +++ b/web/components/issues/issue-layouts/quick-action-dropdowns/archived-issue.tsx @@ -4,13 +4,14 @@ import { useRouter } from "next/router"; // icons import { ArchiveRestoreIcon, ExternalLink, Link, Trash2 } from "lucide-react"; // ui -import { CustomMenu, TOAST_TYPE, setToast } from "@plane/ui"; +import { ContextMenu, CustomMenu, TContextMenuItem, TOAST_TYPE, setToast } from "@plane/ui"; // components import { DeleteIssueModal } from "@/components/issues"; // constants import { EIssuesStoreType } from "@/constants/issue"; import { EUserProjectRoles } from "@/constants/project"; // helpers +import { cn } from "@/helpers/common.helper"; import { copyUrlToClipboard } from "@/helpers/string.helper"; // hooks import { useEventTracker, useIssues, useUser } from "@/hooks/store"; @@ -18,7 +19,16 @@ import { useEventTracker, useIssues, useUser } from "@/hooks/store"; import { IQuickActionProps } from "../list/list-view-types"; export const ArchivedIssueQuickActions: React.FC = observer((props) => { - const { issue, handleDelete, handleRestore, customActionButton, portalElement, readOnly = false } = props; + const { + issue, + handleDelete, + handleRestore, + customActionButton, + portalElement, + readOnly = false, + placements = "bottom-end", + parentRef, + } = props; // states const [deleteIssueModal, setDeleteIssueModal] = useState(false); // router @@ -66,6 +76,38 @@ export const ArchivedIssueQuickActions: React.FC = observer(( }); }; + const MENU_ITEMS: TContextMenuItem[] = [ + { + key: "restore", + title: "Restore", + icon: ArchiveRestoreIcon, + action: handleIssueRestore, + shouldRender: isRestoringAllowed, + }, + { + key: "open-in-new-tab", + title: "Open in new tab", + icon: ExternalLink, + action: handleOpenInNewTab, + }, + { + key: "copy-link", + title: "Copy link", + icon: Link, + action: handleCopyIssueLink, + }, + { + key: "delete", + title: "Delete", + icon: Trash2, + action: () => { + setTrackElement(activeLayout); + setDeleteIssueModal(true); + }, + shouldRender: isEditingAllowed, + }, + ]; + return ( <> = observer(( handleClose={() => setDeleteIssueModal(false)} onSubmit={handleDelete} /> + - {isRestoringAllowed && ( - -
- - Restore -
-
- )} - -
- - Open in new tab -
-
- -
- - Copy link -
-
- {isEditingAllowed && ( - { - setTrackElement(activeLayout); - setDeleteIssueModal(true); - }} - > -
- - Delete issue -
-
- )} + {MENU_ITEMS.map((item) => { + if (item.shouldRender === false) return null; + return ( + { + e.preventDefault(); + e.stopPropagation(); + item.action(); + }} + className={cn( + "flex items-center gap-2", + { + "text-custom-text-400": item.disabled, + }, + item.className + )} + > + {item.icon && } +
+
{item.title}
+ {item.description && ( +

+ {item.description} +

+ )} +
+
+ ); + })}
); diff --git a/web/components/issues/issue-layouts/quick-action-dropdowns/cycle-issue.tsx b/web/components/issues/issue-layouts/quick-action-dropdowns/cycle-issue.tsx index eed0e0dc6..503d8258e 100644 --- a/web/components/issues/issue-layouts/quick-action-dropdowns/cycle-issue.tsx +++ b/web/components/issues/issue-layouts/quick-action-dropdowns/cycle-issue.tsx @@ -2,24 +2,24 @@ import { useState } from "react"; import omit from "lodash/omit"; import { observer } from "mobx-react"; import { useRouter } from "next/router"; -// hooks -// ui import { Copy, ExternalLink, Link, Pencil, Trash2, XCircle } from "lucide-react"; +// types import { TIssue } from "@plane/types"; -import { ArchiveIcon, CustomMenu, TOAST_TYPE, setToast } from "@plane/ui"; -// icons +// ui +import { ArchiveIcon, ContextMenu, CustomMenu, TContextMenuItem, TOAST_TYPE, setToast } from "@plane/ui"; // components import { ArchiveIssueModal, CreateUpdateIssueModal, DeleteIssueModal } from "@/components/issues"; +// constants import { EIssuesStoreType } from "@/constants/issue"; import { EUserProjectRoles } from "@/constants/project"; import { STATE_GROUPS } from "@/constants/state"; -import { copyUrlToClipboard } from "@/helpers/string.helper"; -import { useEventTracker, useIssues, useProjectState, useUser } from "@/hooks/store"; -// components // helpers +import { cn } from "@/helpers/common.helper"; +import { copyUrlToClipboard } from "@/helpers/string.helper"; +// hooks +import { useEventTracker, useIssues, useProjectState, useUser } from "@/hooks/store"; // types import { IQuickActionProps } from "../list/list-view-types"; -// constants export const CycleIssueQuickActions: React.FC = observer((props) => { const { @@ -32,6 +32,7 @@ export const CycleIssueQuickActions: React.FC = observer((pro portalElement, readOnly = false, placements = "bottom-start", + parentRef, } = props; // states const [createUpdateIssueModal, setCreateUpdateIssueModal] = useState(false); @@ -80,6 +81,73 @@ export const CycleIssueQuickActions: React.FC = observer((pro ["id"] ); + const MENU_ITEMS: TContextMenuItem[] = [ + { + key: "edit", + title: "Edit", + icon: Pencil, + action: () => { + setIssueToEdit({ + ...issue, + cycle_id: cycleId?.toString() ?? null, + }); + setTrackElement(activeLayout); + setCreateUpdateIssueModal(true); + }, + shouldRender: isEditingAllowed, + }, + { + key: "make-a-copy", + title: "Make a copy", + icon: Copy, + action: () => { + setTrackElement(activeLayout); + setCreateUpdateIssueModal(true); + }, + shouldRender: isEditingAllowed, + }, + { + key: "open-in-new-tab", + title: "Open in new tab", + icon: ExternalLink, + action: handleOpenInNewTab, + }, + { + key: "copy-link", + title: "Copy link", + icon: Link, + action: handleCopyIssueLink, + }, + { + key: "remove-from-cycle", + title: "Remove from cycle", + icon: XCircle, + action: () => handleRemoveFromView?.(), + shouldRender: isEditingAllowed, + }, + { + key: "archive", + title: "Archive", + description: isInArchivableGroup ? undefined : "Only completed or canceled\nissues can be archived", + icon: ArchiveIcon, + className: "items-start", + iconClassName: "mt-1", + action: () => setArchiveIssueModal(true), + disabled: !isInArchivableGroup, + shouldRender: isArchivingAllowed, + }, + { + key: "delete", + title: "Delete", + icon: Trash2, + action: () => { + setTrackElement(activeLayout); + setDeleteIssueModal(true); + }, + shouldRender: isDeletingAllowed, + }, + ]; + return ( <> = observer((pro }} storeType={EIssuesStoreType.CYCLE} /> + - {isEditingAllowed && ( - { - setIssueToEdit({ - ...issue, - cycle_id: cycleId?.toString() ?? null, - }); - setTrackElement(activeLayout); - setCreateUpdateIssueModal(true); - }} - > -
- - Edit -
-
- )} - -
- - Open in new tab -
-
- -
- - Copy link -
-
- {isEditingAllowed && ( - { - setTrackElement(activeLayout); - setCreateUpdateIssueModal(true); - }} - > -
- - Make a copy -
-
- )} - {isEditingAllowed && ( - { - handleRemoveFromView && handleRemoveFromView(); - }} - > -
- - Remove from cycle -
-
- )} - {isArchivingAllowed && ( - setArchiveIssueModal(true)} disabled={!isInArchivableGroup}> - {isInArchivableGroup ? ( -
- - Archive -
- ) : ( -
- -
-

Archive

-

- Only completed or canceled -
- issues can be archived + {MENU_ITEMS.map((item) => { + if (item.shouldRender === false) return null; + return ( + { + e.preventDefault(); + e.stopPropagation(); + item.action(); + }} + className={cn( + "flex items-center gap-2", + { + "text-custom-text-400": item.disabled, + }, + item.className + )} + > + {item.icon && } +

+
{item.title}
+ {item.description && ( +

+ {item.description}

-
+ )}
- )} - - )} - {isDeletingAllowed && ( - { - setTrackElement(activeLayout); - setDeleteIssueModal(true); - }} - > -
- - Delete -
-
- )} + + ); + })} ); diff --git a/web/components/issues/issue-layouts/quick-action-dropdowns/draft-issue.tsx b/web/components/issues/issue-layouts/quick-action-dropdowns/draft-issue.tsx index 9502e7623..18c259107 100644 --- a/web/components/issues/issue-layouts/quick-action-dropdowns/draft-issue.tsx +++ b/web/components/issues/issue-layouts/quick-action-dropdowns/draft-issue.tsx @@ -6,19 +6,30 @@ import { Pencil, Trash2 } from "lucide-react"; // types import { TIssue } from "@plane/types"; // ui -import { CustomMenu } from "@plane/ui"; +import { ContextMenu, CustomMenu, TContextMenuItem } from "@plane/ui"; // components import { CreateUpdateIssueModal, DeleteIssueModal } from "@/components/issues"; // constant import { EIssuesStoreType } from "@/constants/issue"; import { EUserProjectRoles } from "@/constants/project"; +// helpers +import { cn } from "@/helpers/common.helper"; // hooks import { useEventTracker, useIssues, useUser } from "@/hooks/store"; // types import { IQuickActionProps } from "../list/list-view-types"; export const DraftIssueQuickActions: React.FC = observer((props) => { - const { issue, handleDelete, handleUpdate, customActionButton, portalElement, readOnly = false } = props; + const { + issue, + handleDelete, + handleUpdate, + customActionButton, + portalElement, + readOnly = false, + placements = "bottom-end", + parentRef, + } = props; // states const [createUpdateIssueModal, setCreateUpdateIssueModal] = useState(false); const [issueToEdit, setIssueToEdit] = useState(undefined); @@ -44,6 +55,30 @@ export const DraftIssueQuickActions: React.FC = observer((pro ["id"] ); + const MENU_ITEMS: TContextMenuItem[] = [ + { + key: "edit", + title: "Edit", + icon: Pencil, + action: () => { + setTrackElement(activeLayout); + setIssueToEdit(issue); + setCreateUpdateIssueModal(true); + }, + shouldRender: isEditingAllowed, + }, + { + key: "delete", + title: "Delete", + icon: Trash2, + action: () => { + setTrackElement(activeLayout); + setDeleteIssueModal(true); + }, + shouldRender: isDeletingAllowed, + }, + ]; + return ( <> = observer((pro handleClose={() => setDeleteIssueModal(false)} onSubmit={handleDelete} /> - { @@ -66,43 +100,49 @@ export const DraftIssueQuickActions: React.FC = observer((pro storeType={EIssuesStoreType.PROJECT} isDraft /> - + - {isEditingAllowed && ( - { - setTrackElement(activeLayout); - setIssueToEdit(issue); - setCreateUpdateIssueModal(true); - }} - > -
- - Edit -
-
- )} - {isDeletingAllowed && ( - { - setTrackElement(activeLayout); - setDeleteIssueModal(true); - }} - > -
- - Delete -
-
- )} + {MENU_ITEMS.map((item) => { + if (item.shouldRender === false) return null; + return ( + { + e.preventDefault(); + e.stopPropagation(); + item.action(); + }} + className={cn( + "flex items-center gap-2", + { + "text-custom-text-400": item.disabled, + }, + item.className + )} + > + {item.icon && } +
+
{item.title}
+ {item.description && ( +

+ {item.description} +

+ )} +
+
+ ); + })}
); diff --git a/web/components/issues/issue-layouts/quick-action-dropdowns/module-issue.tsx b/web/components/issues/issue-layouts/quick-action-dropdowns/module-issue.tsx index 1f155d066..3cc3343b6 100644 --- a/web/components/issues/issue-layouts/quick-action-dropdowns/module-issue.tsx +++ b/web/components/issues/issue-layouts/quick-action-dropdowns/module-issue.tsx @@ -2,23 +2,24 @@ import { useState } from "react"; import omit from "lodash/omit"; import { observer } from "mobx-react"; import { useRouter } from "next/router"; -// hooks -// ui import { Copy, ExternalLink, Link, Pencil, Trash2, XCircle } from "lucide-react"; +// types import { TIssue } from "@plane/types"; -import { ArchiveIcon, CustomMenu, TOAST_TYPE, setToast } from "@plane/ui"; +// ui +import { ArchiveIcon, ContextMenu, CustomMenu, TContextMenuItem, TOAST_TYPE, setToast } from "@plane/ui"; // components import { ArchiveIssueModal, CreateUpdateIssueModal, DeleteIssueModal } from "@/components/issues"; +// constants import { EIssuesStoreType } from "@/constants/issue"; import { EUserProjectRoles } from "@/constants/project"; import { STATE_GROUPS } from "@/constants/state"; -import { copyUrlToClipboard } from "@/helpers/string.helper"; -import { useIssues, useEventTracker, useUser, useProjectState } from "@/hooks/store"; -// components // helpers +import { cn } from "@/helpers/common.helper"; +import { copyUrlToClipboard } from "@/helpers/string.helper"; +// hooks +import { useIssues, useEventTracker, useUser, useProjectState } from "@/hooks/store"; // types import { IQuickActionProps } from "../list/list-view-types"; -// constants export const ModuleIssueQuickActions: React.FC = observer((props) => { const { @@ -31,6 +32,7 @@ export const ModuleIssueQuickActions: React.FC = observer((pr portalElement, readOnly = false, placements = "bottom-start", + parentRef, } = props; // states const [createUpdateIssueModal, setCreateUpdateIssueModal] = useState(false); @@ -79,6 +81,70 @@ export const ModuleIssueQuickActions: React.FC = observer((pr ["id"] ); + const MENU_ITEMS: TContextMenuItem[] = [ + { + key: "edit", + title: "Edit", + icon: Pencil, + action: () => { + setIssueToEdit({ ...issue, module_ids: moduleId ? [moduleId.toString()] : [] }); + setTrackElement(activeLayout); + setCreateUpdateIssueModal(true); + }, + shouldRender: isEditingAllowed, + }, + { + key: "make-a-copy", + title: "Make a copy", + icon: Copy, + action: () => { + setTrackElement(activeLayout); + setCreateUpdateIssueModal(true); + }, + shouldRender: isEditingAllowed, + }, + { + key: "open-in-new-tab", + title: "Open in new tab", + icon: ExternalLink, + action: handleOpenInNewTab, + }, + { + key: "copy-link", + title: "Copy link", + icon: Link, + action: handleCopyIssueLink, + }, + { + key: "remove-from-module", + title: "Remove from module", + icon: XCircle, + action: () => handleRemoveFromView?.(), + shouldRender: isEditingAllowed, + }, + { + key: "archive", + title: "Archive", + description: isInArchivableGroup ? undefined : "Only completed or canceled\nissues can be archived", + icon: ArchiveIcon, + className: "items-start", + iconClassName: "mt-1", + action: () => setArchiveIssueModal(true), + disabled: !isInArchivableGroup, + shouldRender: isArchivingAllowed, + }, + { + key: "delete", + title: "Delete", + icon: Trash2, + action: () => { + setTrackElement(activeLayout); + setDeleteIssueModal(true); + }, + shouldRender: isDeletingAllowed, + }, + ]; + return ( <> = observer((pr }} storeType={EIssuesStoreType.MODULE} /> + - {isEditingAllowed && ( - { - setIssueToEdit({ ...issue, module_ids: moduleId ? [moduleId.toString()] : [] }); - setTrackElement(activeLayout); - setCreateUpdateIssueModal(true); - }} - > -
- - Edit -
-
- )} - -
- - Open in new tab -
-
- -
- - Copy link -
-
- {isEditingAllowed && ( - { - setTrackElement(activeLayout); - setCreateUpdateIssueModal(true); - }} - > -
- - Make a copy -
-
- )} - {isEditingAllowed && ( - { - handleRemoveFromView && handleRemoveFromView(); - }} - > -
- - Remove from module -
-
- )} - {isArchivingAllowed && ( - setArchiveIssueModal(true)} disabled={!isInArchivableGroup}> - {isInArchivableGroup ? ( -
- - Archive -
- ) : ( -
- -
-

Archive

-

- Only completed or canceled -
- issues can be archived + {MENU_ITEMS.map((item) => { + if (item.shouldRender === false) return null; + return ( + { + e.preventDefault(); + e.stopPropagation(); + item.action(); + }} + className={cn( + "flex items-center gap-2", + { + "text-custom-text-400": item.disabled, + }, + item.className + )} + > + {item.icon && } +

+
{item.title}
+ {item.description && ( +

+ {item.description}

-
+ )}
- )} - - )} - {isDeletingAllowed && ( - { - e.preventDefault(); - e.stopPropagation(); - setTrackElement(activeLayout); - setDeleteIssueModal(true); - }} - > -
- - Delete -
-
- )} + + ); + })} ); diff --git a/web/components/issues/issue-layouts/quick-action-dropdowns/project-issue.tsx b/web/components/issues/issue-layouts/quick-action-dropdowns/project-issue.tsx index 577a3ce99..0fbe10da9 100644 --- a/web/components/issues/issue-layouts/quick-action-dropdowns/project-issue.tsx +++ b/web/components/issues/issue-layouts/quick-action-dropdowns/project-issue.tsx @@ -2,22 +2,24 @@ import { useState } from "react"; import omit from "lodash/omit"; import { observer } from "mobx-react"; import { useRouter } from "next/router"; -// hooks import { Copy, ExternalLink, Link, Pencil, Trash2 } from "lucide-react"; +// types import { TIssue } from "@plane/types"; -import { ArchiveIcon, CustomMenu, TOAST_TYPE, setToast } from "@plane/ui"; +// ui +import { ArchiveIcon, ContextMenu, CustomMenu, TContextMenuItem, TOAST_TYPE, setToast } from "@plane/ui"; +// components import { ArchiveIssueModal, CreateUpdateIssueModal, DeleteIssueModal } from "@/components/issues"; +// constants import { EIssuesStoreType } from "@/constants/issue"; import { EUserProjectRoles } from "@/constants/project"; import { STATE_GROUPS } from "@/constants/state"; -import { copyUrlToClipboard } from "@/helpers/string.helper"; -import { useEventTracker, useIssues, useProjectState, useUser } from "@/hooks/store"; -// ui -// components // helpers +import { cn } from "@/helpers/common.helper"; +import { copyUrlToClipboard } from "@/helpers/string.helper"; +// hooks +import { useEventTracker, useIssues, useProjectState, useUser } from "@/hooks/store"; // types import { IQuickActionProps } from "../list/list-view-types"; -// constant export const ProjectIssueQuickActions: React.FC = observer((props) => { const { @@ -28,7 +30,8 @@ export const ProjectIssueQuickActions: React.FC = observer((p customActionButton, portalElement, readOnly = false, - placements = "bottom-start", + placements = "bottom-end", + parentRef, } = props; // router const router = useRouter(); @@ -56,9 +59,6 @@ export const ProjectIssueQuickActions: React.FC = observer((p const isDeletingAllowed = isEditingAllowed; const issueLink = `${workspaceSlug}/projects/${issue.project_id}/issues/${issue.id}`; - - const handleOpenInNewTab = () => window.open(`/${issueLink}`, "_blank"); - const handleCopyIssueLink = () => copyUrlToClipboard(issueLink).then(() => setToast({ @@ -67,6 +67,7 @@ export const ProjectIssueQuickActions: React.FC = observer((p message: "Issue link copied to clipboard", }) ); + const handleOpenInNewTab = () => window.open(`/${issueLink}`, "_blank"); const isDraftIssue = router?.asPath?.includes("draft-issues") || false; @@ -79,6 +80,63 @@ export const ProjectIssueQuickActions: React.FC = observer((p ["id"] ); + const MENU_ITEMS: TContextMenuItem[] = [ + { + key: "edit", + title: "Edit", + icon: Pencil, + action: () => { + setTrackElement(activeLayout); + setIssueToEdit(issue); + setCreateUpdateIssueModal(true); + }, + shouldRender: isEditingAllowed, + }, + { + key: "make-a-copy", + title: "Make a copy", + icon: Copy, + action: () => { + setTrackElement(activeLayout); + setCreateUpdateIssueModal(true); + }, + shouldRender: isEditingAllowed, + }, + { + key: "open-in-new-tab", + title: "Open in new tab", + icon: ExternalLink, + action: handleOpenInNewTab, + }, + { + key: "copy-link", + title: "Copy link", + icon: Link, + action: handleCopyIssueLink, + }, + { + key: "archive", + title: "Archive", + description: isInArchivableGroup ? undefined : "Only completed or canceled\nissues can be archived", + icon: ArchiveIcon, + className: "items-start", + iconClassName: "mt-1", + action: () => setArchiveIssueModal(true), + disabled: !isInArchivableGroup, + shouldRender: isArchivingAllowed, + }, + { + key: "delete", + title: "Delete", + icon: Trash2, + action: () => { + setTrackElement(activeLayout); + setDeleteIssueModal(true); + }, + shouldRender: isDeletingAllowed, + }, + ]; + return ( <> = observer((p storeType={EIssuesStoreType.PROJECT} isDraft={isDraftIssue} /> + - {isEditingAllowed && ( - { - setTrackElement(activeLayout); - setIssueToEdit(issue); - setCreateUpdateIssueModal(true); - }} - > -
- - Edit -
-
- )} - -
- - Open in new tab -
-
- -
- - Copy link -
-
- {isEditingAllowed && ( - { - setTrackElement(activeLayout); - setCreateUpdateIssueModal(true); - }} - > -
- - Make a copy -
-
- )} - {isArchivingAllowed && ( - setArchiveIssueModal(true)} disabled={!isInArchivableGroup}> - {isInArchivableGroup ? ( -
- - Archive -
- ) : ( -
- -
-

Archive

-

- Only completed or canceled -
- issues can be archived + {MENU_ITEMS.map((item) => { + if (item.shouldRender === false) return null; + return ( + { + e.preventDefault(); + e.stopPropagation(); + item.action(); + }} + className={cn( + "flex items-center gap-2", + { + "text-custom-text-400": item.disabled, + }, + item.className + )} + > + {item.icon && } +

+
{item.title}
+ {item.description && ( +

+ {item.description}

-
+ )}
- )} - - )} - {isDeletingAllowed && ( - { - setTrackElement(activeLayout); - setDeleteIssueModal(true); - }} - > -
- - Delete -
-
- )} + + ); + })} ); diff --git a/web/components/issues/issue-layouts/roots/all-issue-layout-root.tsx b/web/components/issues/issue-layouts/roots/all-issue-layout-root.tsx index af36b4814..8c185811b 100644 --- a/web/components/issues/issue-layouts/roots/all-issue-layout-root.tsx +++ b/web/components/issues/issue-layouts/roots/all-issue-layout-root.tsx @@ -3,7 +3,7 @@ import isEmpty from "lodash/isEmpty"; import { observer } from "mobx-react-lite"; import { useRouter } from "next/router"; import useSWR from "swr"; -import { TIssue, IIssueDisplayFilterOptions } from "@plane/types"; +import { IIssueDisplayFilterOptions } from "@plane/types"; // hooks // components import { EmptyState } from "@/components/empty-state"; @@ -19,6 +19,7 @@ import { EUserProjectRoles } from "@/constants/project"; import { useApplication, useEventTracker, useGlobalView, useIssues, useProject, useUser } from "@/hooks/store"; import { useIssuesActions } from "@/hooks/use-issues-actions"; import { useWorkspaceIssueProperties } from "@/hooks/use-workspace-issue-properties"; +import { TRenderQuickActions } from "../list/list-view-types"; export const AllIssueLayoutRoot: React.FC = observer(() => { // router @@ -127,9 +128,10 @@ export const AllIssueLayoutRoot: React.FC = observer(() => { [updateFilters, workspaceSlug, globalViewId] ); - const renderQuickActions = useCallback( - (issue: TIssue, customActionButton?: React.ReactElement, portalElement?: HTMLDivElement | null) => ( + const renderQuickActions: TRenderQuickActions = useCallback( + ({ issue, parentRef, customActionButton, placement, portalElement }) => ( removeIssue(issue.project_id, issue.id)} @@ -137,6 +139,7 @@ export const AllIssueLayoutRoot: React.FC = observer(() => { handleArchive={async () => archiveIssue && archiveIssue(issue.project_id, issue.id)} portalElement={portalElement} readOnly={!canEditProperties(issue.project_id)} + placements={placement} /> ), [canEditProperties, removeIssue, updateIssue, archiveIssue] diff --git a/web/components/issues/issue-layouts/spreadsheet/base-spreadsheet-root.tsx b/web/components/issues/issue-layouts/spreadsheet/base-spreadsheet-root.tsx index 2d7623ebe..0a9b10bf6 100644 --- a/web/components/issues/issue-layouts/spreadsheet/base-spreadsheet-root.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/base-spreadsheet-root.tsx @@ -1,7 +1,7 @@ import { FC, useCallback } from "react"; import { observer } from "mobx-react-lite"; import { useRouter } from "next/router"; -import { TIssue, IIssueDisplayFilterOptions, TUnGroupedIssues } from "@plane/types"; +import { IIssueDisplayFilterOptions, TUnGroupedIssues } from "@plane/types"; // hooks import { EIssueFilterType, EIssuesStoreType } from "@/constants/issue"; import { EUserProjectRoles } from "@/constants/project"; @@ -10,7 +10,7 @@ import { useIssuesActions } from "@/hooks/use-issues-actions"; // views // types // constants -import { IQuickActionProps } from "../list/list-view-types"; +import { IQuickActionProps, TRenderQuickActions } from "../list/list-view-types"; import { SpreadsheetView } from "./spreadsheet-view"; export type SpreadsheetStoreType = @@ -66,9 +66,10 @@ export const BaseSpreadsheetRoot = observer((props: IBaseSpreadsheetRoot) => { [projectId, updateFilters] ); - const renderQuickActions = useCallback( - (issue: TIssue, customActionButton?: React.ReactElement, portalElement?: HTMLDivElement | null) => ( + const renderQuickActions: TRenderQuickActions = useCallback( + ({ issue, parentRef, customActionButton, placement, portalElement }) => ( removeIssue(issue.project_id, issue.id)} @@ -78,6 +79,7 @@ export const BaseSpreadsheetRoot = observer((props: IBaseSpreadsheetRoot) => { handleRestore={async () => restoreIssue && restoreIssue(issue.project_id, issue.id)} portalElement={portalElement} readOnly={!isEditingAllowed || isCompletedCycle} + placements={placement} /> ), [isEditingAllowed, isCompletedCycle, removeIssue, updateIssue, removeIssueFromView, archiveIssue, restoreIssue] diff --git a/web/components/issues/issue-layouts/spreadsheet/issue-row.tsx b/web/components/issues/issue-layouts/spreadsheet/issue-row.tsx index 27e2c36eb..83dbe57a7 100644 --- a/web/components/issues/issue-layouts/spreadsheet/issue-row.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/issue-row.tsx @@ -16,17 +16,14 @@ import useOutsideClickDetector from "@/hooks/use-outside-click-detector"; import { usePlatformOS } from "@/hooks/use-platform-os"; // types // local components +import { TRenderQuickActions } from "../list/list-view-types"; import { WithDisplayPropertiesHOC } from "../properties/with-display-properties-HOC"; import { IssueColumn } from "./issue-column"; interface Props { displayProperties: IIssueDisplayProperties; isEstimateEnabled: boolean; - quickActions: ( - issue: TIssue, - customActionButton?: React.ReactElement, - portalElement?: HTMLDivElement | null - ) => React.ReactNode; + quickActions: TRenderQuickActions; canEditProperties: (projectId: string | undefined) => boolean; updateIssue: ((projectId: string, issueId: string, data: Partial) => Promise) | undefined; portalElement: React.MutableRefObject; @@ -112,11 +109,7 @@ export const SpreadsheetIssueRow = observer((props: Props) => { interface IssueRowDetailsProps { displayProperties: IIssueDisplayProperties; isEstimateEnabled: boolean; - quickActions: ( - issue: TIssue, - customActionButton?: React.ReactElement, - portalElement?: HTMLDivElement | null - ) => React.ReactNode; + quickActions: TRenderQuickActions; canEditProperties: (projectId: string | undefined) => boolean; updateIssue: ((projectId: string, issueId: string, data: Partial) => Promise) | undefined; portalElement: React.MutableRefObject; @@ -143,16 +136,18 @@ const IssueRowDetails = observer((props: IssueRowDetailsProps) => { setExpanded, spreadsheetColumnsList, } = props; + // states + const [isMenuActive, setIsMenuActive] = useState(false); + // refs + const cellRef = useRef(null); + const menuActionRef = useRef(null); // router const router = useRouter(); const { workspaceSlug } = router.query; - //hooks + // hooks const { getProjectIdentifierById } = useProject(); const { getIsIssuePeeked, setPeekIssue } = useIssueDetail(); const { isMobile } = usePlatformOS(); - // states - const [isMenuActive, setIsMenuActive] = useState(false); - const menuActionRef = useRef(null); const handleIssuePeekOverview = (issue: TIssue) => workspaceSlug && @@ -196,9 +191,10 @@ const IssueRowDetails = observer((props: IssueRowDetailsProps) => { return ( <> { >
@@ -222,7 +218,12 @@ const IssueRowDetails = observer((props: IssueRowDetailsProps) => {
diff --git a/web/components/issues/issue-layouts/spreadsheet/spreadsheet-table.tsx b/web/components/issues/issue-layouts/spreadsheet/spreadsheet-table.tsx index 834a65fd7..f548c69a5 100644 --- a/web/components/issues/issue-layouts/spreadsheet/spreadsheet-table.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/spreadsheet-table.tsx @@ -4,6 +4,7 @@ import { IIssueDisplayFilterOptions, IIssueDisplayProperties, TIssue } from "@pl //types import { useTableKeyboardNavigation } from "@/hooks/use-table-keyboard-navigation"; //components +import { TRenderQuickActions } from "../list/list-view-types"; import { SpreadsheetIssueRow } from "./issue-row"; import { SpreadsheetHeader } from "./spreadsheet-header"; @@ -13,11 +14,7 @@ type Props = { handleDisplayFilterUpdate: (data: Partial) => void; issueIds: string[]; isEstimateEnabled: boolean; - quickActions: ( - issue: TIssue, - customActionButton?: React.ReactElement, - portalElement?: HTMLDivElement | null - ) => React.ReactNode; + quickActions: TRenderQuickActions; updateIssue: ((projectId: string, issueId: string, data: Partial) => Promise) | undefined; canEditProperties: (projectId: string | undefined) => boolean; portalElement: React.MutableRefObject; diff --git a/web/components/issues/issue-layouts/spreadsheet/spreadsheet-view.tsx b/web/components/issues/issue-layouts/spreadsheet/spreadsheet-view.tsx index 5231e427e..f6c0ceadc 100644 --- a/web/components/issues/issue-layouts/spreadsheet/spreadsheet-view.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/spreadsheet-view.tsx @@ -6,6 +6,7 @@ import { Spinner } from "@plane/ui"; import { SpreadsheetQuickAddIssueForm } from "@/components/issues"; import { SPREADSHEET_PROPERTY_LIST } from "@/constants/spreadsheet"; import { useProject } from "@/hooks/store"; +import { TRenderQuickActions } from "../list/list-view-types"; import { SpreadsheetTable } from "./spreadsheet-table"; // types //hooks @@ -15,11 +16,7 @@ type Props = { displayFilters: IIssueDisplayFilterOptions; handleDisplayFilterUpdate: (data: Partial) => void; issueIds: string[] | undefined; - quickActions: ( - issue: TIssue, - customActionButton?: React.ReactElement, - portalElement?: HTMLDivElement | null - ) => React.ReactNode; + quickActions: TRenderQuickActions; updateIssue: ((projectId: string, issueId: string, data: Partial) => Promise) | undefined; openIssuesListModal?: (() => void) | null; quickAddCallback?: ( diff --git a/web/components/modules/archived-modules/view.tsx b/web/components/modules/archived-modules/view.tsx index 56dbd0135..f12bc412a 100644 --- a/web/components/modules/archived-modules/view.tsx +++ b/web/components/modules/archived-modules/view.tsx @@ -50,7 +50,7 @@ export const ArchivedModulesView: FC = observer((props) =>
{filteredArchivedModuleIds.map((moduleId) => ( - + ))}
= observer((props) => { const { moduleId } = props; + // refs + const parentRef = useRef(null); // router const router = useRouter(); const { workspaceSlug, projectId } = router.query; @@ -145,7 +147,7 @@ export const ModuleCardItem: React.FC = observer((props) => { return (
- +
@@ -239,6 +241,7 @@ export const ModuleCardItem: React.FC = observer((props) => { )} {workspaceSlug && projectId && ( ; }; export const ModuleListItemAction: FC = observer((props) => { - const { moduleId, moduleDetails, isArchived } = props; + const { moduleId, moduleDetails, parentRef } = props; // router const router = useRouter(); const { workspaceSlug, projectId } = router.query; @@ -146,7 +146,7 @@ export const ModuleListItemAction: FC = observer((props) => {
- {isEditingAllowed && !isArchived && ( + {isEditingAllowed && !moduleDetails.archived_at && ( { if (moduleDetails.is_favorite) handleRemoveFromFavorites(e); @@ -157,10 +157,10 @@ export const ModuleListItemAction: FC = observer((props) => { )} {workspaceSlug && projectId && ( )} diff --git a/web/components/modules/module-list-item.tsx b/web/components/modules/module-list-item.tsx index 583c0e709..9ad7d2225 100644 --- a/web/components/modules/module-list-item.tsx +++ b/web/components/modules/module-list-item.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useRef } from "react"; import { observer } from "mobx-react-lite"; import { useRouter } from "next/router"; // icons @@ -14,11 +14,12 @@ import { usePlatformOS } from "@/hooks/use-platform-os"; type Props = { moduleId: string; - isArchived?: boolean; }; export const ModuleListItem: React.FC = observer((props) => { - const { moduleId, isArchived = false } = props; + const { moduleId } = props; + // refs + const parentRef = useRef(null); // router const router = useRouter(); const { workspaceSlug } = router.query; @@ -63,9 +64,7 @@ export const ModuleListItem: React.FC = observer((props) => { title={moduleDetails?.name ?? ""} itemLink={`/${workspaceSlug}/projects/${moduleDetails.project_id}/modules/${moduleDetails.id}`} onItemClick={(e) => { - if (isArchived) { - openModuleOverview(e); - } + if (moduleDetails.archived_at) openModuleOverview(e); }} prependTitleElement={ @@ -91,9 +90,10 @@ export const ModuleListItem: React.FC = observer((props) => { } actionableItems={ - + } isMobile={isMobile} + parentRef={parentRef} /> ); }); diff --git a/web/components/modules/quick-actions.tsx b/web/components/modules/quick-actions.tsx index a1805937e..4cefa825f 100644 --- a/web/components/modules/quick-actions.tsx +++ b/web/components/modules/quick-actions.tsx @@ -2,27 +2,28 @@ import { useState } from "react"; import { observer } from "mobx-react"; import { useRouter } from "next/router"; // icons -import { ArchiveRestoreIcon, LinkIcon, Pencil, Trash2 } from "lucide-react"; +import { ArchiveRestoreIcon, ExternalLink, LinkIcon, Pencil, Trash2 } from "lucide-react"; // ui -import { ArchiveIcon, CustomMenu, TOAST_TYPE, setToast } from "@plane/ui"; +import { ArchiveIcon, ContextMenu, CustomMenu, TContextMenuItem, TOAST_TYPE, setToast } from "@plane/ui"; // components import { ArchiveModuleModal, CreateUpdateModuleModal, DeleteModuleModal } from "@/components/modules"; // constants import { EUserProjectRoles } from "@/constants/project"; // helpers +import { cn } from "@/helpers/common.helper"; import { copyUrlToClipboard } from "@/helpers/string.helper"; // hooks import { useModule, useEventTracker, useUser } from "@/hooks/store"; type Props = { + parentRef: React.RefObject; moduleId: string; projectId: string; workspaceSlug: string; - isArchived?: boolean; }; export const ModuleQuickActions: React.FC = observer((props) => { - const { moduleId, projectId, workspaceSlug, isArchived } = props; + const { parentRef, moduleId, projectId, workspaceSlug } = props; // router const router = useRouter(); // states @@ -37,6 +38,7 @@ export const ModuleQuickActions: React.FC = observer((props) => { const { getModuleById, restoreModule } = useModule(); // derived values const moduleDetails = getModuleById(moduleId); + const isArchived = !!moduleDetails?.archived_at; // auth const isEditingAllowed = !!currentWorkspaceAllProjectsRole && currentWorkspaceAllProjectsRole[projectId] >= EUserProjectRoles.MEMBER; @@ -44,34 +46,25 @@ export const ModuleQuickActions: React.FC = observer((props) => { const moduleState = moduleDetails?.status?.toLocaleLowerCase(); const isInArchivableGroup = !!moduleState && ["completed", "cancelled"].includes(moduleState); - const handleCopyText = (e: React.MouseEvent) => { - e.stopPropagation(); - e.preventDefault(); - copyUrlToClipboard(`${workspaceSlug}/projects/${projectId}/modules/${moduleId}`).then(() => { + const moduleLink = `${workspaceSlug}/projects/${projectId}/modules/${moduleId}`; + const handleCopyText = () => + copyUrlToClipboard(moduleLink).then(() => { setToast({ type: TOAST_TYPE.SUCCESS, title: "Link Copied!", message: "Module link copied to clipboard.", }); }); - }; + const handleOpenInNewTab = () => window.open(`/${moduleLink}`, "_blank"); - const handleEditModule = (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); + const handleEditModule = () => { setTrackElement("Modules page list layout"); setEditModal(true); }; - const handleArchiveModule = (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - setArchiveModuleModal(true); - }; + const handleArchiveModule = () => setArchiveModuleModal(true); - const handleRestoreModule = async (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); + const handleRestoreModule = async () => await restoreModule(workspaceSlug, projectId, moduleId) .then(() => { setToast({ @@ -88,15 +81,61 @@ export const ModuleQuickActions: React.FC = observer((props) => { message: "Module could not be restored. Please try again.", }) ); - }; - const handleDeleteModule = (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); + const handleDeleteModule = () => { setTrackElement("Modules page list layout"); setDeleteModal(true); }; + const MENU_ITEMS: TContextMenuItem[] = [ + { + key: "edit", + title: "Edit", + icon: Pencil, + action: handleEditModule, + shouldRender: isEditingAllowed && !isArchived, + }, + { + key: "open-new-tab", + action: handleOpenInNewTab, + title: "Open in new tab", + icon: ExternalLink, + shouldRender: !isArchived, + }, + { + key: "copy-link", + action: handleCopyText, + title: "Copy link", + icon: LinkIcon, + shouldRender: !isArchived, + }, + { + key: "archive", + action: handleArchiveModule, + title: "Archive", + description: isInArchivableGroup ? undefined : "Only completed or canceled\nmodule can be archived.", + icon: ArchiveIcon, + className: "items-start", + iconClassName: "mt-1", + shouldRender: isEditingAllowed && !isArchived, + disabled: !isInArchivableGroup, + }, + { + key: "restore", + action: handleRestoreModule, + title: "Restore", + icon: ArchiveRestoreIcon, + shouldRender: isEditingAllowed && isArchived, + }, + { + key: "delete", + action: handleDeleteModule, + title: "Delete", + icon: Trash2, + shouldRender: isEditingAllowed, + }, + ]; + return ( <> {moduleDetails && ( @@ -118,60 +157,42 @@ export const ModuleQuickActions: React.FC = observer((props) => { setDeleteModal(false)} />
)} - - {isEditingAllowed && !isArchived && ( - - - - Edit module - - - )} - {isEditingAllowed && !isArchived && ( - - {isInArchivableGroup ? ( -
- - Archive module -
- ) : ( -
- -
-

Archive module

-

- Only completed or cancelled
module can be archived. + + + {MENU_ITEMS.map((item) => { + if (item.shouldRender === false) return null; + return ( + { + e.preventDefault(); + e.stopPropagation(); + item.action(); + }} + className={cn( + "flex items-center gap-2", + { + "text-custom-text-400": item.disabled, + }, + item.className + )} + > + {item.icon && } +

+
{item.title}
+ {item.description && ( +

+ {item.description}

-
+ )}
- )} - - )} - {isEditingAllowed && isArchived && ( - - - - Restore module - - - )} - {!isArchived && ( - - - - Copy module link - - - )} -
- {isEditingAllowed && ( - - - - Delete module - - - )} + + ); + })} ); diff --git a/web/components/pages/dropdowns/quick-actions.tsx b/web/components/pages/dropdowns/quick-actions.tsx index f88aa9757..9c311797a 100644 --- a/web/components/pages/dropdowns/quick-actions.tsx +++ b/web/components/pages/dropdowns/quick-actions.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; import { observer } from "mobx-react"; import { ArchiveRestoreIcon, ExternalLink, Link, Lock, Trash2, UsersRound } from "lucide-react"; -import { ArchiveIcon, CustomMenu, TOAST_TYPE, setToast } from "@plane/ui"; +import { ArchiveIcon, ContextMenu, CustomMenu, TContextMenuItem, TOAST_TYPE, setToast } from "@plane/ui"; // components import { DeletePageModal } from "@/components/pages"; // helpers @@ -11,12 +11,13 @@ import { usePage } from "@/hooks/store"; type Props = { pageId: string; + parentRef: React.RefObject; projectId: string; workspaceSlug: string; }; export const PageQuickActions: React.FC = observer((props) => { - const { pageId, projectId, workspaceSlug } = props; + const { pageId, parentRef, projectId, workspaceSlug } = props; // states const [deletePageModal, setDeletePageModal] = useState(false); // store hooks @@ -44,45 +45,39 @@ export const PageQuickActions: React.FC = observer((props) => { const handleOpenInNewTab = () => window.open(`/${pageLink}`, "_blank"); - const MENU_ITEMS: { - key: string; - action: () => void; - label: string; - icon: React.FC; - shouldRender: boolean; - }[] = [ + const MENU_ITEMS: TContextMenuItem[] = [ { - key: "copy-link", - action: handleCopyText, - label: "Copy link", - icon: Link, - shouldRender: true, + key: "make-public-private", + action: access === 0 ? makePrivate : makePublic, + title: access === 0 ? "Make private" : "Make public", + icon: access === 0 ? Lock : UsersRound, + shouldRender: canCurrentUserChangeAccess && !archived_at, }, { key: "open-new-tab", action: handleOpenInNewTab, - label: "Open in new tab", + title: "Open in new tab", icon: ExternalLink, shouldRender: true, }, + { + key: "copy-link", + action: handleCopyText, + title: "Copy link", + icon: Link, + shouldRender: true, + }, { key: "archive-restore", action: archived_at ? restore : archive, - label: archived_at ? "Restore" : "Archive", + title: archived_at ? "Restore" : "Archive", icon: archived_at ? ArchiveRestoreIcon : ArchiveIcon, shouldRender: canCurrentUserArchivePage, }, - { - key: "make-public-private", - action: access === 0 ? makePrivate : makePublic, - label: access === 0 ? "Make private" : "Make public", - icon: access === 0 ? Lock : UsersRound, - shouldRender: canCurrentUserChangeAccess && !archived_at, - }, { key: "delete", action: () => setDeletePageModal(true), - label: "Delete", + title: "Delete", icon: Trash2, shouldRender: canCurrentUserDeletePage && !!archived_at, }, @@ -96,6 +91,7 @@ export const PageQuickActions: React.FC = observer((props) => { pageId={pageId} projectId={projectId} /> + {MENU_ITEMS.map((item) => { if (!item.shouldRender) return null; @@ -109,8 +105,8 @@ export const PageQuickActions: React.FC = observer((props) => { }} className="flex items-center gap-2" > - - {item.label} + {item.icon && } + {item.title} ); })} diff --git a/web/components/pages/list/block-item-action.tsx b/web/components/pages/list/block-item-action.tsx index caa7cb4eb..f9fb57f0f 100644 --- a/web/components/pages/list/block-item-action.tsx +++ b/web/components/pages/list/block-item-action.tsx @@ -15,10 +15,11 @@ type Props = { workspaceSlug: string; projectId: string; pageId: string; + parentRef: React.RefObject; }; export const BlockItemAction: FC = observer((props) => { - const { workspaceSlug, projectId, pageId } = props; + const { workspaceSlug, projectId, pageId, parentRef } = props; // store hooks const { access, created_at, is_favorite, owned_by, addToFavorites, removeFromFavorites } = usePage(pageId); @@ -88,7 +89,7 @@ export const BlockItemAction: FC = observer((props) => { /> {/* quick actions dropdown */} - + ); }); diff --git a/web/components/pages/list/block.tsx b/web/components/pages/list/block.tsx index 70d88df13..69f667377 100644 --- a/web/components/pages/list/block.tsx +++ b/web/components/pages/list/block.tsx @@ -1,4 +1,4 @@ -import { FC } from "react"; +import { FC, useRef } from "react"; import { observer } from "mobx-react"; // components import { ListItem } from "@/components/core/list"; @@ -15,6 +15,8 @@ type TPageListBlock = { export const PageListBlock: FC = observer((props) => { const { workspaceSlug, projectId, pageId } = props; + // refs + const parentRef = useRef(null); // hooks const { name } = usePage(pageId); const { isMobile } = usePlatformOS(); @@ -23,8 +25,9 @@ export const PageListBlock: FC = observer((props) => { } + actionableItems={} isMobile={isMobile} + parentRef={parentRef} /> ); }); diff --git a/web/components/project/card.tsx b/web/components/project/card.tsx index 046c0f716..3d536a7c0 100644 --- a/web/components/project/card.tsx +++ b/web/components/project/card.tsx @@ -1,12 +1,22 @@ -import React, { useState } from "react"; +import React, { useRef, useState } from "react"; import { observer } from "mobx-react-lite"; import Link from "next/link"; import { useRouter } from "next/router"; -import { ArchiveRestoreIcon, Check, LinkIcon, Lock, Pencil, Trash2 } from "lucide-react"; +import { ArchiveRestoreIcon, Check, ExternalLink, LinkIcon, Lock, Pencil, Trash2, UserPlus } from "lucide-react"; // types import type { IProject } from "@plane/types"; // ui -import { Avatar, AvatarGroup, Button, Tooltip, TOAST_TYPE, setToast, setPromiseToast } from "@plane/ui"; +import { + Avatar, + AvatarGroup, + Button, + Tooltip, + TOAST_TYPE, + setToast, + setPromiseToast, + ContextMenu, + TContextMenuItem, +} from "@plane/ui"; // components import { FavoriteStar } from "@/components/core"; import { ArchiveRestoreProjectModal, DeleteProjectModal, JoinProjectModal, ProjectLogo } from "@/components/project"; @@ -30,6 +40,8 @@ export const ProjectCard: React.FC = observer((props) => { const [deleteProjectModalOpen, setDeleteProjectModal] = useState(false); const [joinProjectModalOpen, setJoinProjectModal] = useState(false); const [restoreProject, setRestoreProject] = useState(false); + // refs + const projectCardRef = useRef(null); // router const router = useRouter(); const { workspaceSlug } = router.query; @@ -80,14 +92,61 @@ export const ProjectCard: React.FC = observer((props) => { }); }; + const projectLink = `${workspaceSlug}/projects/${project.id}/issues`; const handleCopyText = () => - copyUrlToClipboard(`${workspaceSlug}/projects/${project.id}/issues`).then(() => + copyUrlToClipboard(projectLink).then(() => setToast({ type: TOAST_TYPE.SUCCESS, title: "Link Copied!", message: "Project link copied to clipboard.", }) ); + const handleOpenInNewTab = () => window.open(`/${projectLink}`, "_blank"); + + const MENU_ITEMS: TContextMenuItem[] = [ + { + key: "edit", + action: () => router.push(`/${workspaceSlug}/projects/${project.id}/settings`), + title: "Edit", + icon: Pencil, + shouldRender: !isArchived && (isOwner || isMember), + }, + { + key: "join", + action: () => setJoinProjectModal(true), + title: "Join", + icon: UserPlus, + shouldRender: !project.is_member && !isArchived, + }, + { + key: "open-new-tab", + action: handleOpenInNewTab, + title: "Open in new tab", + icon: ExternalLink, + shouldRender: project.is_member, + }, + { + key: "copy-link", + action: handleCopyText, + title: "Copy link", + icon: LinkIcon, + shouldRender: true, + }, + { + key: "restore", + action: () => setRestoreProject(true), + title: "Restore", + icon: ArchiveRestoreIcon, + shouldRender: isArchived && isOwner, + }, + { + key: "delete", + action: () => setDeleteProjectModal(true), + title: "Delete", + icon: Trash2, + shouldRender: isArchived && isOwner, + }, + ]; return ( <> @@ -117,6 +176,7 @@ export const ProjectCard: React.FC = observer((props) => { /> )} { if (!project.is_member || isArchived) { @@ -127,6 +187,7 @@ export const ProjectCard: React.FC = observer((props) => { }} className="flex flex-col rounded border border-custom-border-200 bg-custom-background-100" > +
diff --git a/web/components/views/index.ts b/web/components/views/index.ts index 396f07506..2a2a02dc7 100644 --- a/web/components/views/index.ts +++ b/web/components/views/index.ts @@ -1,6 +1,7 @@ export * from "./delete-view-modal"; export * from "./form"; export * from "./modal"; +export * from "./quick-actions"; export * from "./view-list-item"; export * from "./views-list"; export * from "./view-list-item-action"; diff --git a/web/components/views/quick-actions.tsx b/web/components/views/quick-actions.tsx new file mode 100644 index 000000000..c9993169b --- /dev/null +++ b/web/components/views/quick-actions.tsx @@ -0,0 +1,126 @@ +import { useState } from "react"; +import { observer } from "mobx-react"; +import { ExternalLink, Link, Pencil, Trash2 } from "lucide-react"; +// types +import { IProjectView } from "@plane/types"; +// ui +import { ContextMenu, CustomMenu, TContextMenuItem, TOAST_TYPE, setToast } from "@plane/ui"; +// components +import { CreateUpdateProjectViewModal, DeleteProjectViewModal } from "@/components/views"; +// constants +import { EUserProjectRoles } from "@/constants/project"; +// helpers +import { cn } from "@/helpers/common.helper"; +import { copyUrlToClipboard } from "@/helpers/string.helper"; +// hooks +import { useUser } from "@/hooks/store"; + +type Props = { + parentRef: React.RefObject; + projectId: string; + view: IProjectView; + workspaceSlug: string; +}; + +export const ViewQuickActions: React.FC = observer((props) => { + const { parentRef, projectId, view, workspaceSlug } = props; + // states + const [createUpdateViewModal, setCreateUpdateViewModal] = useState(false); + const [deleteViewModal, setDeleteViewModal] = useState(false); + // store hooks + const { + membership: { currentProjectRole }, + } = useUser(); + // auth + const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; + + const viewLink = `${workspaceSlug}/projects/${projectId}/views/${view.id}`; + const handleCopyText = () => + copyUrlToClipboard(viewLink).then(() => { + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Link Copied!", + message: "View link copied to clipboard.", + }); + }); + const handleOpenInNewTab = () => window.open(`/${viewLink}`, "_blank"); + + const MENU_ITEMS: TContextMenuItem[] = [ + { + key: "edit", + action: () => setCreateUpdateViewModal(true), + title: "Edit", + icon: Pencil, + shouldRender: isEditingAllowed, + }, + { + key: "open-new-tab", + action: handleOpenInNewTab, + title: "Open in new tab", + icon: ExternalLink, + }, + { + key: "copy-link", + action: handleCopyText, + title: "Copy link", + icon: Link, + }, + { + key: "delete", + action: () => setDeleteViewModal(true), + title: "Delete", + icon: Trash2, + shouldRender: isEditingAllowed, + }, + ]; + + return ( + <> + setCreateUpdateViewModal(false)} + workspaceSlug={workspaceSlug} + projectId={projectId} + data={view} + /> + setDeleteViewModal(false)} /> + + + {MENU_ITEMS.map((item) => { + if (item.shouldRender === false) return null; + return ( + { + e.preventDefault(); + e.stopPropagation(); + item.action(); + }} + className={cn( + "flex items-center gap-2", + { + "text-custom-text-400": item.disabled, + }, + item.className + )} + > + {item.icon && } +
+
{item.title}
+ {item.description && ( +

+ {item.description} +

+ )} +
+
+ ); + })} +
+ + ); +}); diff --git a/web/components/views/view-list-item-action.tsx b/web/components/views/view-list-item-action.tsx index 9827ba79d..0c72d0dca 100644 --- a/web/components/views/view-list-item-action.tsx +++ b/web/components/views/view-list-item-action.tsx @@ -1,29 +1,25 @@ import React, { FC, useState } from "react"; import { observer } from "mobx-react"; import { useRouter } from "next/router"; -// icons -import { LinkIcon, PencilIcon, TrashIcon } from "lucide-react"; // types import { IProjectView } from "@plane/types"; -// ui -import { CustomMenu, TOAST_TYPE, setToast } from "@plane/ui"; // components import { FavoriteStar } from "@/components/core"; -import { DeleteProjectViewModal, CreateUpdateProjectViewModal } from "@/components/views"; +import { DeleteProjectViewModal, CreateUpdateProjectViewModal, ViewQuickActions } from "@/components/views"; // constants import { EUserProjectRoles } from "@/constants/project"; // helpers import { calculateTotalFilters } from "@/helpers/filter.helper"; -import { copyUrlToClipboard } from "@/helpers/string.helper"; // hooks import { useProjectView, useUser } from "@/hooks/store"; type Props = { + parentRef: React.RefObject; view: IProjectView; }; export const ViewListItemAction: FC = observer((props) => { - const { view } = props; + const { parentRef, view } = props; // states const [createUpdateViewModal, setCreateUpdateViewModal] = useState(false); const [deleteViewModal, setDeleteViewModal] = useState(false); @@ -54,18 +50,6 @@ export const ViewListItemAction: FC = observer((props) => { removeViewFromFavorites(workspaceSlug.toString(), projectId.toString(), view.id); }; - const handleCopyText = (e: React.MouseEvent) => { - e.stopPropagation(); - e.preventDefault(); - copyUrlToClipboard(`${workspaceSlug}/projects/${projectId}/views/${view.id}`).then(() => { - setToast({ - type: TOAST_TYPE.SUCCESS, - title: "Link Copied!", - message: "View link copied to clipboard.", - }); - }); - }; - return ( <> {workspaceSlug && projectId && view && ( @@ -92,43 +76,14 @@ export const ViewListItemAction: FC = observer((props) => { selected={view.is_favorite} /> )} - - - {isEditingAllowed && ( - <> - { - e.preventDefault(); - e.stopPropagation(); - setCreateUpdateViewModal(true); - }} - > - - - Edit View - - - { - e.preventDefault(); - e.stopPropagation(); - setDeleteViewModal(true); - }} - > - - - Delete View - - - - )} - - - - Copy view link - - - + {projectId && workspaceSlug && ( + + )} ); }); diff --git a/web/components/views/view-list-item.tsx b/web/components/views/view-list-item.tsx index 5cab840f6..e7e36c92e 100644 --- a/web/components/views/view-list-item.tsx +++ b/web/components/views/view-list-item.tsx @@ -1,4 +1,4 @@ -import { FC } from "react"; +import { FC, useRef } from "react"; import { observer } from "mobx-react-lite"; import { useRouter } from "next/router"; // types @@ -15,6 +15,8 @@ type Props = { export const ProjectViewListItem: FC = observer((props) => { const { view } = props; + // refs + const parentRef = useRef(null); // router const router = useRouter(); const { workspaceSlug, projectId } = router.query; @@ -25,8 +27,9 @@ export const ProjectViewListItem: FC = observer((props) => { } + actionableItems={} isMobile={isMobile} + parentRef={parentRef} /> ); }); diff --git a/web/pages/_document.tsx b/web/pages/_document.tsx index 1b54e3f66..234f70469 100644 --- a/web/pages/_document.tsx +++ b/web/pages/_document.tsx @@ -41,6 +41,7 @@ class MyDocument extends Document { )} +
{process.env.NEXT_PUBLIC_PLAUSIBLE_DOMAIN && (