From 2475f4465b58594bad25ec202d7f6c4e2e7de23a Mon Sep 17 00:00:00 2001 From: Anmol Singh Bhatia <anmolsinghbhatia@plane.so> Date: Wed, 1 May 2024 15:14:02 +0530 Subject: [PATCH 1/2] chore: workspace view quick action enhancement --- .../views/default-view-quick-action.tsx | 127 ++++++++++++++ web/components/workspace/views/header.tsx | 79 +++++---- web/components/workspace/views/index.ts | 2 + .../workspace/views/quick-action.tsx | 155 ++++++++++++++++++ 4 files changed, 333 insertions(+), 30 deletions(-) create mode 100644 web/components/workspace/views/default-view-quick-action.tsx create mode 100644 web/components/workspace/views/quick-action.tsx diff --git a/web/components/workspace/views/default-view-quick-action.tsx b/web/components/workspace/views/default-view-quick-action.tsx new file mode 100644 index 00000000000..5a58e1737d3 --- /dev/null +++ b/web/components/workspace/views/default-view-quick-action.tsx @@ -0,0 +1,127 @@ +import { observer } from "mobx-react"; +import Link from "next/link"; +import { ExternalLink, LinkIcon } from "lucide-react"; +// ui +import { TStaticViewTypes } from "@plane/types"; +import { ContextMenu, CustomMenu, TContextMenuItem, TOAST_TYPE, setToast } from "@plane/ui"; +// helpers +import { cn } from "@/helpers/common.helper"; +import { copyUrlToClipboard } from "@/helpers/string.helper"; + +type Props = { + parentRef: React.RefObject<HTMLElement>; + workspaceSlug: string; + globalViewId: string | undefined; + view: { + key: TStaticViewTypes; + label: string; + }; +}; + +export const DefaultWorkspaceViewQuickActions: React.FC<Props> = observer((props) => { + const { parentRef, globalViewId, view, workspaceSlug } = props; + + const viewLink = `${workspaceSlug}/workspace-views/${view.key}`; + 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: "open-new-tab", + action: handleOpenInNewTab, + title: "Open in new tab", + icon: ExternalLink, + }, + { + key: "copy-link", + action: handleCopyText, + title: "Copy link", + icon: LinkIcon, + }, + ]; + + return ( + <> + <ContextMenu parentRef={parentRef} items={MENU_ITEMS} /> + + <CustomMenu + customButton={ + <> + {view.key === globalViewId ? ( + <span + className={`flex min-w-min flex-shrink-0 whitespace-nowrap border-b-2 p-3 text-sm font-medium outline-none ${ + view.key === globalViewId + ? "border-custom-primary-100 text-custom-primary-100" + : "border-transparent hover:border-custom-border-200 hover:text-custom-text-400" + }`} + > + {view.label} + </span> + ) : ( + <Link + key={view.key} + id={`global-view-${view.key}`} + href={`/${workspaceSlug}/workspace-views/${view.key}`} + > + <span + className={`flex min-w-min flex-shrink-0 whitespace-nowrap border-b-2 p-3 text-sm font-medium outline-none ${ + view.key === globalViewId + ? "border-custom-primary-100 text-custom-primary-100" + : "border-transparent hover:border-custom-border-200 hover:text-custom-text-400" + }`} + > + {view.label} + </span> + </Link> + )} + </> + } + placement="bottom-end" + menuItemsClassName="z-20" + closeOnSelect + > + {MENU_ITEMS.map((item) => { + if (item.shouldRender === false) return null; + return ( + <CustomMenu.MenuItem + key={item.key} + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + item.action(); + }} + className={cn( + "flex items-center gap-2", + { + "text-custom-text-400": item.disabled, + }, + item.className + )} + > + {item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />} + <div> + <h5>{item.title}</h5> + {item.description && ( + <p + className={cn("text-custom-text-300 whitespace-pre-line", { + "text-custom-text-400": item.disabled, + })} + > + {item.description} + </p> + )} + </div> + </CustomMenu.MenuItem> + ); + })} + </CustomMenu> + </> + ); +}); diff --git a/web/components/workspace/views/header.tsx b/web/components/workspace/views/header.tsx index 35c01481b2b..2e381052a88 100644 --- a/web/components/workspace/views/header.tsx +++ b/web/components/workspace/views/header.tsx @@ -1,11 +1,16 @@ import React, { useEffect, useRef, useState } from "react"; import { observer } from "mobx-react-lite"; -import Link from "next/link"; import { useRouter } from "next/router"; // icons import { Plus } from "lucide-react"; +// types +import { TStaticViewTypes } from "@plane/types"; // components -import { CreateUpdateWorkspaceViewModal } from "@/components/workspace"; +import { + CreateUpdateWorkspaceViewModal, + DefaultWorkspaceViewQuickActions, + WorkspaceViewQuickActions, +} from "@/components/workspace"; // constants import { GLOBAL_VIEW_OPENED } from "@/constants/event-tracker"; import { DEFAULT_GLOBAL_VIEWS_LIST, EUserWorkspaceRoles } from "@/constants/workspace"; @@ -14,6 +19,8 @@ import { useEventTracker, useGlobalView, useUser } from "@/hooks/store"; const ViewTab = observer((props: { viewId: string }) => { const { viewId } = props; + // refs + const parentRef = useRef<HTMLDivElement>(null); // router const router = useRouter(); const { workspaceSlug, globalViewId } = router.query; @@ -22,30 +29,54 @@ const ViewTab = observer((props: { viewId: string }) => { const view = getViewDetailsById(viewId); - if (!view) return null; + if (!view || !workspaceSlug || !globalViewId) return null; return ( - <Link key={viewId} id={`global-view-${viewId}`} href={`/${workspaceSlug}/workspace-views/${viewId}`}> - <span - className={`flex min-w-min flex-shrink-0 whitespace-nowrap border-b-2 p-3 text-sm font-medium outline-none ${ - viewId === globalViewId - ? "border-custom-primary-100 text-custom-primary-100" - : "border-transparent hover:border-custom-border-200 hover:text-custom-text-400" - }`} - > - {view.name} - </span> - </Link> + <div ref={parentRef} className="relative"> + <WorkspaceViewQuickActions + parentRef={parentRef} + view={view} + viewId={viewId} + globalViewId={globalViewId?.toString()} + workspaceSlug={workspaceSlug?.toString()} + /> + </div> ); }); +const DefaultViewTab = (props: { + tab: { + key: TStaticViewTypes; + label: string; + }; +}) => { + const { tab } = props; + // refs + const parentRef = useRef<HTMLDivElement>(null); + // router + const router = useRouter(); + const { workspaceSlug, globalViewId } = router.query; + + if (!workspaceSlug || !globalViewId) return null; + return ( + <div key={tab.key} ref={parentRef} className="relative"> + <DefaultWorkspaceViewQuickActions + parentRef={parentRef} + globalViewId={globalViewId?.toString()} + workspaceSlug={workspaceSlug?.toString()} + view={tab} + /> + </div> + ); +}; + export const GlobalViewsHeader: React.FC = observer(() => { // states const [createViewModal, setCreateViewModal] = useState(false); const containerRef = useRef<HTMLDivElement>(null); // router const router = useRouter(); - const { workspaceSlug, globalViewId } = router.query; + const { globalViewId } = router.query; // store hooks const { currentWorkspaceViews } = useGlobalView(); const { @@ -82,23 +113,11 @@ export const GlobalViewsHeader: React.FC = observer(() => { ref={containerRef} className="flex w-full items-center overflow-x-auto px-4 horizontal-scrollbar scrollbar-sm" > - {DEFAULT_GLOBAL_VIEWS_LIST.map((tab) => ( - <Link key={tab.key} id={`global-view-${tab.key}`} href={`/${workspaceSlug}/workspace-views/${tab.key}`}> - <span - className={`flex min-w-min flex-shrink-0 whitespace-nowrap border-b-2 p-3 text-sm font-medium outline-none ${ - tab.key === globalViewId - ? "border-custom-primary-100 text-custom-primary-100" - : "border-transparent hover:border-custom-border-200 hover:text-custom-text-400" - }`} - > - {tab.label} - </span> - </Link> + {DEFAULT_GLOBAL_VIEWS_LIST.map((tab, index) => ( + <DefaultViewTab key={`${tab.key}-${index}`} tab={tab} /> ))} - {currentWorkspaceViews?.map((viewId) => ( - <ViewTab key={viewId} viewId={viewId} /> - ))} + {currentWorkspaceViews?.map((viewId) => <ViewTab key={viewId} viewId={viewId} />)} </div> {isAuthorizedUser && ( diff --git a/web/components/workspace/views/index.ts b/web/components/workspace/views/index.ts index 7d0547f649c..c41d7523819 100644 --- a/web/components/workspace/views/index.ts +++ b/web/components/workspace/views/index.ts @@ -5,3 +5,5 @@ export * from "./header"; export * from "./modal"; export * from "./view-list-item"; export * from "./views-list"; +export * from "./quick-action"; +export * from "./default-view-quick-action"; diff --git a/web/components/workspace/views/quick-action.tsx b/web/components/workspace/views/quick-action.tsx new file mode 100644 index 00000000000..3a67a95b919 --- /dev/null +++ b/web/components/workspace/views/quick-action.tsx @@ -0,0 +1,155 @@ +import { useState } from "react"; +import { observer } from "mobx-react"; +import Link from "next/link"; +import { ExternalLink, LinkIcon, Pencil, Trash2 } from "lucide-react"; +// types +import { IWorkspaceView } from "@plane/types"; +// ui +import { ContextMenu, CustomMenu, TContextMenuItem, TOAST_TYPE, setToast } from "@plane/ui"; +// components +import { CreateUpdateWorkspaceViewModal, DeleteGlobalViewModal } from "@/components/workspace"; +// constants +import { EUserProjectRoles } from "@/constants/project"; +// helpers +import { cn } from "@/helpers/common.helper"; +import { copyUrlToClipboard } from "@/helpers/string.helper"; +// hooks +import { useUser } from "@/hooks/store"; + +type Props = { + parentRef: React.RefObject<HTMLElement>; + workspaceSlug: string; + globalViewId: string; + viewId: string; + view: IWorkspaceView; +}; + +export const WorkspaceViewQuickActions: React.FC<Props> = observer((props) => { + const { parentRef, view, globalViewId, viewId, workspaceSlug } = props; + // states + const [updateViewModal, setUpdateViewModal] = useState(false); + const [deleteViewModal, setDeleteViewModal] = useState(false); + // store hooks + const { + membership: { currentWorkspaceRole }, + } = useUser(); + // auth + const isEditingAllowed = !!currentWorkspaceRole && currentWorkspaceRole >= EUserProjectRoles.MEMBER; + + const viewLink = `${workspaceSlug}/workspace-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: () => setUpdateViewModal(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: LinkIcon, + }, + { + key: "delete", + action: () => setDeleteViewModal(true), + title: "Delete", + icon: Trash2, + shouldRender: isEditingAllowed, + }, + ]; + + return ( + <> + <CreateUpdateWorkspaceViewModal data={view} isOpen={updateViewModal} onClose={() => setUpdateViewModal(false)} /> + <DeleteGlobalViewModal data={view} isOpen={deleteViewModal} onClose={() => setDeleteViewModal(false)} /> + + <ContextMenu parentRef={parentRef} items={MENU_ITEMS} /> + + <CustomMenu + customButton={ + <> + {viewId === globalViewId ? ( + <span + className={`flex min-w-min flex-shrink-0 whitespace-nowrap border-b-2 p-3 text-sm font-medium outline-none ${ + viewId === globalViewId + ? "border-custom-primary-100 text-custom-primary-100" + : "border-transparent hover:border-custom-border-200 hover:text-custom-text-400" + }`} + > + {view.name} + </span> + ) : ( + <Link key={viewId} id={`global-view-${viewId}`} href={`/${workspaceSlug}/workspace-views/${viewId}`}> + <span + className={`flex min-w-min flex-shrink-0 whitespace-nowrap border-b-2 p-3 text-sm font-medium outline-none ${ + viewId === globalViewId + ? "border-custom-primary-100 text-custom-primary-100" + : "border-transparent hover:border-custom-border-200 hover:text-custom-text-400" + }`} + > + {view.name} + </span> + </Link> + )} + </> + } + placement="bottom-end" + menuItemsClassName="z-20" + closeOnSelect + > + {MENU_ITEMS.map((item) => { + if (item.shouldRender === false) return null; + return ( + <CustomMenu.MenuItem + key={item.key} + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + item.action(); + }} + className={cn( + "flex items-center gap-2", + { + "text-custom-text-400": item.disabled, + }, + item.className + )} + > + {item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />} + <div> + <h5>{item.title}</h5> + {item.description && ( + <p + className={cn("text-custom-text-300 whitespace-pre-line", { + "text-custom-text-400": item.disabled, + })} + > + {item.description} + </p> + )} + </div> + </CustomMenu.MenuItem> + ); + })} + </CustomMenu> + </> + ); +}); From 4bdaf15cd45332a1cb2d45702e8b700e0e703393 Mon Sep 17 00:00:00 2001 From: Anmol Singh Bhatia <anmolsinghbhatia@plane.so> Date: Wed, 1 May 2024 15:43:12 +0530 Subject: [PATCH 2/2] fix: issue quick action height --- .../issues/issue-layouts/quick-action-dropdowns/all-issue.tsx | 1 + .../issue-layouts/quick-action-dropdowns/archived-issue.tsx | 1 + .../issues/issue-layouts/quick-action-dropdowns/cycle-issue.tsx | 1 + .../issues/issue-layouts/quick-action-dropdowns/draft-issue.tsx | 1 + .../issues/issue-layouts/quick-action-dropdowns/module-issue.tsx | 1 + .../issue-layouts/quick-action-dropdowns/project-issue.tsx | 1 + 6 files changed, 6 insertions(+) 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 7a73a25f9fd..b7825fc577b 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 @@ -161,6 +161,7 @@ export const AllIssueQuickActions: React.FC<IQuickActionProps> = observer((props portalElement={portalElement} placement={placements} menuItemsClassName="z-[14]" + maxHeight="lg" closeOnSelect > {MENU_ITEMS.map((item) => { 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 0327755a9cc..62b808b3f9b 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 @@ -123,6 +123,7 @@ export const ArchivedIssueQuickActions: React.FC<IQuickActionProps> = observer(( portalElement={portalElement} placement={placements} menuItemsClassName="z-[14]" + maxHeight="lg" closeOnSelect > {MENU_ITEMS.map((item) => { 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 503d8258eb9..a35de2735c5 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 @@ -181,6 +181,7 @@ export const CycleIssueQuickActions: React.FC<IQuickActionProps> = observer((pro customButton={customActionButton} portalElement={portalElement} menuItemsClassName="z-[14]" + maxHeight="lg" closeOnSelect > {MENU_ITEMS.map((item) => { 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 18c25910721..bbeda85ce1c 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 @@ -107,6 +107,7 @@ export const DraftIssueQuickActions: React.FC<IQuickActionProps> = observer((pro portalElement={portalElement} placement={placements} menuItemsClassName="z-[14]" + maxHeight="lg" closeOnSelect > {MENU_ITEMS.map((item) => { 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 3cc3343b604..6061c0bee3e 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 @@ -178,6 +178,7 @@ export const ModuleIssueQuickActions: React.FC<IQuickActionProps> = observer((pr customButton={customActionButton} portalElement={portalElement} menuItemsClassName="z-[14]" + maxHeight="lg" closeOnSelect > {MENU_ITEMS.map((item) => { 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 0fbe10da9eb..b74c9c57dd7 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 @@ -171,6 +171,7 @@ export const ProjectIssueQuickActions: React.FC<IQuickActionProps> = observer((p customButton={customActionButton} portalElement={portalElement} menuItemsClassName="z-[14]" + maxHeight="lg" closeOnSelect > {MENU_ITEMS.map((item) => {