refactor: global context menu component

This commit is contained in:
Aaryan Khandelwal 2023-03-05 03:24:24 +05:30
parent 0a681937fd
commit a4da4bf889
7 changed files with 243 additions and 156 deletions

View File

@ -11,6 +11,7 @@ type Props = {
states: IState[] | undefined;
members: IProjectMember[] | undefined;
addIssueToState: (groupTitle: string, stateId: string | null) => void;
makeIssueCopy: (issue: IIssue) => void;
handleEditIssue: (issue: IIssue) => void;
openIssuesListModal?: (() => void) | null;
handleDeleteIssue: (issue: IIssue) => void;
@ -25,6 +26,7 @@ export const AllBoards: React.FC<Props> = ({
states,
members,
addIssueToState,
makeIssueCopy,
handleEditIssue,
openIssuesListModal,
handleDeleteIssue,
@ -66,6 +68,7 @@ export const AllBoards: React.FC<Props> = ({
selectedGroup={selectedGroup}
members={members}
handleEditIssue={handleEditIssue}
makeIssueCopy={makeIssueCopy}
addIssueToState={() => addIssueToState(singleGroup, stateId)}
handleDeleteIssue={handleDeleteIssue}
openIssuesListModal={openIssuesListModal ?? null}

View File

@ -29,6 +29,7 @@ type Props = {
selectedGroup: NestedKeyOf<IIssue> | null;
members: IProjectMember[] | undefined;
handleEditIssue: (issue: IIssue) => void;
makeIssueCopy: (issue: IIssue) => void;
addIssueToState: () => void;
handleDeleteIssue: (issue: IIssue) => void;
openIssuesListModal?: (() => void) | null;
@ -47,6 +48,7 @@ export const SingleBoard: React.FC<Props> = ({
selectedGroup,
members,
handleEditIssue,
makeIssueCopy,
addIssueToState,
handleDeleteIssue,
openIssuesListModal,
@ -132,6 +134,7 @@ export const SingleBoard: React.FC<Props> = ({
selectedGroup={selectedGroup}
properties={properties}
editIssue={() => handleEditIssue(issue)}
makeIssueCopy={() => makeIssueCopy(issue)}
handleDeleteIssue={handleDeleteIssue}
orderBy={orderBy}
handleTrashBox={handleTrashBox}

View File

@ -24,7 +24,14 @@ import {
ViewStateSelect,
} from "components/issues/view-select";
// ui
import { CustomMenu } from "components/ui";
import { ContextMenu, CustomMenu } from "components/ui";
// icons
import {
ClipboardDocumentCheckIcon,
LinkIcon,
PencilIcon,
TrashIcon,
} from "@heroicons/react/24/outline";
// helpers
import { copyTextToClipboard } from "helpers/string.helper";
// types
@ -47,6 +54,7 @@ type Props = {
selectedGroup: NestedKeyOf<IIssue> | null;
properties: Properties;
editIssue: () => void;
makeIssueCopy: () => void;
removeIssue?: (() => void) | null;
handleDeleteIssue: (issue: IIssue) => void;
orderBy: NestedKeyOf<IIssue> | null;
@ -62,12 +70,14 @@ export const SingleBoardIssue: React.FC<Props> = ({
selectedGroup,
properties,
editIssue,
makeIssueCopy,
removeIssue,
handleDeleteIssue,
orderBy,
handleTrashBox,
userAuth,
}) => {
// context menu
const [contextMenu, setContextMenu] = useState(false);
const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 });
@ -183,154 +193,138 @@ export const SingleBoardIssue: React.FC<Props> = ({
if (snapshot.isDragging) handleTrashBox(snapshot.isDragging);
}, [snapshot, handleTrashBox]);
useEffect(() => {
const hideContextMenu = () => setContextMenu(false);
window.addEventListener("click", hideContextMenu);
return () => {
window.removeEventListener("click", hideContextMenu);
};
}, []);
const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
return (
<div
className={`mb-3 rounded bg-white shadow ${
snapshot.isDragging ? "border-2 border-theme shadow-lg" : ""
}`}
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
style={getStyle(provided.draggableProps.style, snapshot)}
onContextMenu={(e) => {
e.preventDefault();
setContextMenu(true);
setContextMenuPosition({ x: e.pageX, y: e.pageY });
}}
>
{contextMenu && (
<div className="fixed z-20 h-full w-full">
<div
className={`fixed z-20 flex flex-col items-stretch gap-1 rounded-md border bg-white p-2 text-xs shadow-lg`}
style={{
top: `${contextMenuPosition.y}px`,
left: `${contextMenuPosition.x}px`,
}}
>
<Link href={`/${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`}>
<a className="w-full rounded px-1 py-1.5 text-left hover:bg-hover-gray">Open issue</a>
</Link>
<button
type="button"
className="rounded px-1 py-1.5 text-left hover:bg-hover-gray"
onClick={() => handleDeleteIssue(issue)}
>
Delete issue
</button>
<button
type="button"
className="rounded px-1 py-1.5 text-left hover:bg-hover-gray"
onClick={handleCopyText}
>
Copy issue link
</button>
</div>
</div>
)}
<div className="group/card relative select-none p-4">
{!isNotAllowed && (
<div className="absolute top-1.5 right-1.5 z-10 opacity-0 group-hover/card:opacity-100">
{type && !isNotAllowed && (
<CustomMenu width="auto" ellipsis>
<CustomMenu.MenuItem onClick={editIssue}>Edit issue</CustomMenu.MenuItem>
{type !== "issue" && removeIssue && (
<CustomMenu.MenuItem onClick={removeIssue}>
<>Remove from {type}</>
<>
<ContextMenu
position={contextMenuPosition}
title="Quick actions"
isOpen={contextMenu}
setIsOpen={setContextMenu}
>
<ContextMenu.Item Icon={PencilIcon} onClick={editIssue}>
Edit issue
</ContextMenu.Item>
<ContextMenu.Item Icon={ClipboardDocumentCheckIcon} onClick={makeIssueCopy}>
Make a copy...
</ContextMenu.Item>
<ContextMenu.Item Icon={TrashIcon} onClick={() => handleDeleteIssue(issue)}>
Delete issue
</ContextMenu.Item>
<ContextMenu.Item Icon={LinkIcon} onClick={handleCopyText}>
Copy issue link
</ContextMenu.Item>
</ContextMenu>
<div
className={`mb-3 rounded bg-white shadow ${
snapshot.isDragging ? "border-2 border-theme shadow-lg" : ""
}`}
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
style={getStyle(provided.draggableProps.style, snapshot)}
onContextMenu={(e) => {
e.preventDefault();
setContextMenu(true);
setContextMenuPosition({ x: e.pageX, y: e.pageY });
}}
>
<div className="group/card relative select-none p-4">
{!isNotAllowed && (
<div className="absolute top-1.5 right-1.5 z-10 opacity-0 group-hover/card:opacity-100">
{type && !isNotAllowed && (
<CustomMenu width="auto" ellipsis>
<CustomMenu.MenuItem onClick={editIssue}>Edit issue</CustomMenu.MenuItem>
{type !== "issue" && removeIssue && (
<CustomMenu.MenuItem onClick={removeIssue}>
<>Remove from {type}</>
</CustomMenu.MenuItem>
)}
<CustomMenu.MenuItem onClick={() => handleDeleteIssue(issue)}>
Delete issue
</CustomMenu.MenuItem>
)}
<CustomMenu.MenuItem onClick={() => handleDeleteIssue(issue)}>
Delete issue
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={handleCopyText}>Copy issue link</CustomMenu.MenuItem>
</CustomMenu>
<CustomMenu.MenuItem onClick={handleCopyText}>
Copy issue link
</CustomMenu.MenuItem>
</CustomMenu>
)}
</div>
)}
<Link href={`/${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`}>
<a>
{properties.key && (
<div className="mb-2.5 text-xs font-medium text-gray-700">
{issue.project_detail.identifier}-{issue.sequence_id}
</div>
)}
<h5
className="text-sm group-hover:text-theme"
style={{ lineClamp: 3, WebkitLineClamp: 3 }}
>
{issue.name}
</h5>
</a>
</Link>
<div className="relative mt-2.5 flex flex-wrap items-center gap-2 text-xs">
{properties.priority && selectedGroup !== "priority" && (
<ViewPrioritySelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
isNotAllowed={isNotAllowed}
selfPositioned
/>
)}
</div>
)}
<Link href={`/${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`}>
<a>
{properties.key && (
<div className="mb-2.5 text-xs font-medium text-gray-700">
{issue.project_detail.identifier}-{issue.sequence_id}
{properties.state && selectedGroup !== "state_detail.name" && (
<ViewStateSelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
isNotAllowed={isNotAllowed}
selfPositioned
/>
)}
{properties.due_date && (
<ViewDueDateSelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
isNotAllowed={isNotAllowed}
/>
)}
{properties.sub_issue_count && (
<div className="flex flex-shrink-0 items-center gap-1 rounded border px-2 py-1 text-xs shadow-sm duration-300 hover:bg-gray-100 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500">
{issue.sub_issues_count} {issue.sub_issues_count === 1 ? "sub-issue" : "sub-issues"}
</div>
)}
<h5
className="text-sm group-hover:text-theme"
style={{ lineClamp: 3, WebkitLineClamp: 3 }}
>
{issue.name}
</h5>
</a>
</Link>
<div className="relative mt-2.5 flex flex-wrap items-center gap-2 text-xs">
{properties.priority && selectedGroup !== "priority" && (
<ViewPrioritySelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
isNotAllowed={isNotAllowed}
selfPositioned
/>
)}
{properties.state && selectedGroup !== "state_detail.name" && (
<ViewStateSelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
isNotAllowed={isNotAllowed}
selfPositioned
/>
)}
{properties.due_date && (
<ViewDueDateSelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
isNotAllowed={isNotAllowed}
/>
)}
{properties.sub_issue_count && (
<div className="flex flex-shrink-0 items-center gap-1 rounded border px-2 py-1 text-xs shadow-sm duration-300 hover:bg-gray-100 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500">
{issue.sub_issues_count} {issue.sub_issues_count === 1 ? "sub-issue" : "sub-issues"}
</div>
)}
{properties.labels && (
<div className="flex flex-wrap gap-1">
{issue.label_details.map((label) => (
<span
key={label.id}
className="group flex items-center gap-1 rounded-2xl border px-2 py-0.5 text-xs"
>
{properties.labels && (
<div className="flex flex-wrap gap-1">
{issue.label_details.map((label) => (
<span
className="h-1.5 w-1.5 flex-shrink-0 rounded-full"
style={{
backgroundColor: label?.color && label.color !== "" ? label.color : "#000",
}}
/>
{label.name}
</span>
))}
</div>
)}
{properties.assignee && (
<ViewAssigneeSelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
isNotAllowed={isNotAllowed}
tooltipPosition="left"
selfPositioned
/>
)}
key={label.id}
className="group flex items-center gap-1 rounded-2xl border px-2 py-0.5 text-xs"
>
<span
className="h-1.5 w-1.5 flex-shrink-0 rounded-full"
style={{
backgroundColor: label?.color && label.color !== "" ? label.color : "#000",
}}
/>
{label.name}
</span>
))}
</div>
)}
{properties.assignee && (
<ViewAssigneeSelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
isNotAllowed={isNotAllowed}
tooltipPosition="left"
selfPositioned
/>
)}
</div>
</div>
</div>
</div>
</>
);
};

View File

@ -269,6 +269,15 @@ export const IssuesView: React.FC<Props> = ({
[setCreateIssueModal, setPreloadedData, selectedGroup]
);
const makeIssueCopy = useCallback(
(issue: IIssue) => {
setCreateIssueModal(true);
setPreloadedData({ ...issue, name: `${issue.name} (Copy)`, actionType: "createIssue" });
},
[setCreateIssueModal, setPreloadedData]
);
const handleEditIssue = useCallback(
(issue: IIssue) => {
setEditIssueModal(true);
@ -370,8 +379,8 @@ export const IssuesView: React.FC<Props> = ({
{(provided, snapshot) => (
<div
className={`${
trashBox ? "opacity-100 pointer-events-auto" : "opacity-0 pointer-events-none"
} fixed z-20 top-9 right-9 flex justify-center items-center gap-2 bg-red-100 border-2 border-red-500 p-3 w-96 h-28 text-xs italic text-red-500 font-medium rounded ${
trashBox ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-0"
} fixed top-9 right-9 z-20 flex h-28 w-96 items-center justify-center gap-2 rounded border-2 border-red-500 bg-red-100 p-3 text-xs font-medium italic text-red-500 ${
snapshot.isDraggingOver ? "bg-red-500 text-white" : ""
} duration-200`}
ref={provided.innerRef}
@ -408,6 +417,7 @@ export const IssuesView: React.FC<Props> = ({
states={states}
members={members}
addIssueToState={addIssueToState}
makeIssueCopy={makeIssueCopy}
handleEditIssue={handleEditIssue}
openIssuesListModal={type !== "issue" ? openIssuesListModal : null}
handleDeleteIssue={handleDeleteIssue}

View File

@ -77,17 +77,6 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
const { setToastAlert } = useToast();
console.log("isseu details: ", issueDetail);
// const { data: issueLinks } = useSWR(
// workspaceSlug && projectId
// ? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string)
// : null,
// workspaceSlug && projectId
// ? () => issuesService.getIssues(workspaceSlug as string, projectId as string)
// : null
// );
const { data: issues } = useSWR(
workspaceSlug && projectId
? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string)
@ -228,7 +217,7 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
isOpen={deleteIssueModal}
data={issueDetail ?? null}
/>
<div className="w-full divide-y-2 divide-gray-100 sticky top-5">
<div className="sticky top-5 w-full divide-y-2 divide-gray-100">
<div className="flex items-center justify-between pb-3">
<h4 className="text-sm font-medium">
{issueDetail?.project_detail?.identifier}-{issueDetail?.sequence_id}
@ -453,8 +442,8 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
);
} else
return (
<div className="bg-gray-50 border-y border-gray-400">
<div className="flex select-none font-medium items-center gap-2 truncate p-2 text-gray-900">
<div className="border-y border-gray-400 bg-gray-50">
<div className="flex select-none items-center gap-2 truncate p-2 font-medium text-gray-900">
<RectangleGroupIcon className="h-3 w-3" />{" "}
{label.name}
</div>

View File

@ -0,0 +1,87 @@
import React, { useEffect } from "react";
import Link from "next/link";
type Props = {
position: {
x: number;
y: number;
};
children: React.ReactNode;
title?: string | JSX.Element;
isOpen: boolean;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
};
const ContextMenu = ({ position, children, title, isOpen, setIsOpen }: Props) => {
useEffect(() => {
const hideContextMenu = () => setIsOpen(false);
window.addEventListener("click", hideContextMenu);
return () => {
window.removeEventListener("click", hideContextMenu);
};
}, [setIsOpen]);
return (
<div
className={`fixed z-20 h-full w-full ${
isOpen ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-0"
}`}
>
<div
className={`fixed z-20 flex min-w-[8rem] flex-col items-stretch gap-1 rounded-md border bg-white p-2 text-xs shadow-lg`}
style={{
left: `${position.x}px`,
top: `${position.y}px`,
}}
>
{title && <h4 className="border-b px-1 py-1 pb-2 text-[0.8rem] font-medium">{title}</h4>}
{children}
</div>
</div>
);
};
type MenuItemProps = {
children: JSX.Element | string;
renderAs?: "button" | "a";
href?: string;
onClick?: () => void;
className?: string;
Icon?: any;
};
const MenuItem: React.FC<MenuItemProps> = ({
children,
renderAs,
href = "",
onClick,
className = "",
Icon,
}) => (
<div className={`${className} w-full rounded px-1 py-1.5 text-left hover:bg-hover-gray`}>
{renderAs === "a" ? (
<Link href={href}>
<a className="flex items-center gap-2">
<>
{Icon && <Icon />}
{children}
</>
</a>
</Link>
) : (
<button type="button" className="flex items-center gap-2" onClick={onClick}>
<>
{Icon && <Icon height={12} width={12} />}
{children}
</>
</button>
)}
</div>
);
ContextMenu.Item = MenuItem;
export { ContextMenu };

View File

@ -2,6 +2,7 @@ export * from "./input";
export * from "./text-area";
export * from "./avatar";
export * from "./button";
export * from "./context-menu";
export * from "./custom-menu";
export * from "./custom-search-select";
export * from "./custom-select";