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

View File

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

View File

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

View File

@ -269,6 +269,15 @@ export const IssuesView: React.FC<Props> = ({
[setCreateIssueModal, setPreloadedData, selectedGroup] [setCreateIssueModal, setPreloadedData, selectedGroup]
); );
const makeIssueCopy = useCallback(
(issue: IIssue) => {
setCreateIssueModal(true);
setPreloadedData({ ...issue, name: `${issue.name} (Copy)`, actionType: "createIssue" });
},
[setCreateIssueModal, setPreloadedData]
);
const handleEditIssue = useCallback( const handleEditIssue = useCallback(
(issue: IIssue) => { (issue: IIssue) => {
setEditIssueModal(true); setEditIssueModal(true);
@ -370,8 +379,8 @@ export const IssuesView: React.FC<Props> = ({
{(provided, snapshot) => ( {(provided, snapshot) => (
<div <div
className={`${ className={`${
trashBox ? "opacity-100 pointer-events-auto" : "opacity-0 pointer-events-none" trashBox ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-0"
} 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 ${ } 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" : "" snapshot.isDraggingOver ? "bg-red-500 text-white" : ""
} duration-200`} } duration-200`}
ref={provided.innerRef} ref={provided.innerRef}
@ -408,6 +417,7 @@ export const IssuesView: React.FC<Props> = ({
states={states} states={states}
members={members} members={members}
addIssueToState={addIssueToState} addIssueToState={addIssueToState}
makeIssueCopy={makeIssueCopy}
handleEditIssue={handleEditIssue} handleEditIssue={handleEditIssue}
openIssuesListModal={type !== "issue" ? openIssuesListModal : null} openIssuesListModal={type !== "issue" ? openIssuesListModal : null}
handleDeleteIssue={handleDeleteIssue} handleDeleteIssue={handleDeleteIssue}

View File

@ -77,17 +77,6 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
const { setToastAlert } = useToast(); 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( const { data: issues } = useSWR(
workspaceSlug && projectId workspaceSlug && projectId
? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string) ? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string)
@ -228,7 +217,7 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
isOpen={deleteIssueModal} isOpen={deleteIssueModal}
data={issueDetail ?? null} 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"> <div className="flex items-center justify-between pb-3">
<h4 className="text-sm font-medium"> <h4 className="text-sm font-medium">
{issueDetail?.project_detail?.identifier}-{issueDetail?.sequence_id} {issueDetail?.project_detail?.identifier}-{issueDetail?.sequence_id}
@ -453,8 +442,8 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
); );
} else } else
return ( return (
<div className="bg-gray-50 border-y border-gray-400"> <div className="border-y border-gray-400 bg-gray-50">
<div className="flex select-none font-medium items-center gap-2 truncate p-2 text-gray-900"> <div className="flex select-none items-center gap-2 truncate p-2 font-medium text-gray-900">
<RectangleGroupIcon className="h-3 w-3" />{" "} <RectangleGroupIcon className="h-3 w-3" />{" "}
{label.name} {label.name}
</div> </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 "./text-area";
export * from "./avatar"; export * from "./avatar";
export * from "./button"; export * from "./button";
export * from "./context-menu";
export * from "./custom-menu"; export * from "./custom-menu";
export * from "./custom-search-select"; export * from "./custom-search-select";
export * from "./custom-select"; export * from "./custom-select";