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) => {