import React, { useCallback, useEffect, useRef, useState } from "react"; import { useRouter } from "next/router"; import { mutate } from "swr"; // react-beautiful-dnd import { DraggableProvided, DraggableStateSnapshot, DraggingStyle, NotDraggingStyle, } from "react-beautiful-dnd"; // services import issuesService from "services/issues.service"; import trackEventServices from "services/track-event.service"; // hooks import useToast from "hooks/use-toast"; import useOutsideClickDetector from "hooks/use-outside-click-detector"; // components import { ViewDueDateSelect, ViewEstimateSelect, ViewStartDateSelect } from "components/issues"; import { MembersSelect, LabelSelect, PrioritySelect } from "components/project"; import { StateSelect } from "components/states"; // ui import { ContextMenu, CustomMenu, Tooltip } from "components/ui"; // icons import { ClipboardDocumentCheckIcon, LinkIcon, PencilIcon, TrashIcon, XMarkIcon, ArrowTopRightOnSquareIcon, PaperClipIcon, EllipsisHorizontalIcon, } from "@heroicons/react/24/outline"; import { LayerDiagonalIcon } from "components/icons"; // helpers import { handleIssuesMutation } from "constants/issue"; import { copyTextToClipboard } from "helpers/string.helper"; // types import { ICurrentUserResponse, IIssue, IIssueViewProps, IState, ISubIssueResponse, TIssuePriorities, UserAuth, } from "types"; // fetch-keys import { CYCLE_DETAILS, MODULE_DETAILS, SUB_ISSUES } from "constants/fetch-keys"; type Props = { type?: string; provided: DraggableProvided; snapshot: DraggableStateSnapshot; issue: IIssue; groupTitle?: string; index: number; editIssue: () => void; makeIssueCopy: () => void; handleMyIssueOpen?: (issue: IIssue) => void; removeIssue?: (() => void) | null; handleDeleteIssue: (issue: IIssue) => void; handleDraftIssueEdit?: () => void; handleDraftIssueDelete?: () => void; handleTrashBox: (isDragging: boolean) => void; disableUserActions: boolean; user: ICurrentUserResponse | undefined; userAuth: UserAuth; viewProps: IIssueViewProps; }; export const SingleBoardIssue: React.FC<Props> = ({ type, provided, snapshot, issue, index, editIssue, makeIssueCopy, handleMyIssueOpen, removeIssue, groupTitle, handleDeleteIssue, handleDraftIssueEdit, handleDraftIssueDelete, handleTrashBox, disableUserActions, user, userAuth, viewProps, }) => { // context menu const [contextMenu, setContextMenu] = useState(false); const [contextMenuPosition, setContextMenuPosition] = useState<React.MouseEvent | null>(null); const [isMenuActive, setIsMenuActive] = useState(false); const [isDropdownActive, setIsDropdownActive] = useState(false); const actionSectionRef = useRef<HTMLDivElement | null>(null); const { displayFilters, properties, mutateIssues } = viewProps; const router = useRouter(); const { workspaceSlug, projectId, cycleId, moduleId } = router.query; const isDraftIssue = router.pathname.includes("draft-issues"); const { setToastAlert } = useToast(); const partialUpdateIssue = useCallback( (formData: Partial<IIssue>, issue: IIssue) => { if (!workspaceSlug || !issue) return; if (issue.parent) { mutate<ISubIssueResponse>( SUB_ISSUES(issue.parent.toString()), (prevData) => { if (!prevData) return prevData; return { ...prevData, sub_issues: (prevData.sub_issues ?? []).map((i) => { if (i.id === issue.id) { return { ...i, ...formData, }; } return i; }), }; }, false ); } else { mutateIssues( (prevData: any) => handleIssuesMutation( formData, groupTitle ?? "", displayFilters?.group_by ?? null, index, displayFilters?.order_by ?? "-created_at", prevData ), false ); } issuesService .patchIssue(workspaceSlug as string, issue.project, issue.id, formData, user) .then(() => { mutateIssues(); if (cycleId) mutate(CYCLE_DETAILS(cycleId as string)); if (moduleId) mutate(MODULE_DETAILS(moduleId as string)); }); }, [displayFilters, workspaceSlug, cycleId, moduleId, groupTitle, index, mutateIssues, user] ); const getStyle = ( style: DraggingStyle | NotDraggingStyle | undefined, snapshot: DraggableStateSnapshot ) => { if (displayFilters?.order_by === "sort_order") return style; if (!snapshot.isDragging) return {}; if (!snapshot.isDropAnimating) return style; return { ...style, transitionDuration: `0.001s`, }; }; const handleCopyText = () => { const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : ""; copyTextToClipboard( `${originURL}/${workspaceSlug}/projects/${projectId}/issues/${issue.id}` ).then(() => { setToastAlert({ type: "success", title: "Link Copied!", message: "Issue link copied to clipboard.", }); setIsMenuActive(false); }); }; const handleStateChange = (data: string, states: IState[] | undefined) => { const oldState = states?.find((s) => s.id === issue.state); const newState = states?.find((s) => s.id === data); partialUpdateIssue( { state: data, state_detail: newState, }, issue ); trackEventServices.trackIssuePartialPropertyUpdateEvent( { workspaceSlug, workspaceId: issue.workspace, projectId: issue.project_detail.id, projectIdentifier: issue.project_detail.identifier, projectName: issue.project_detail.name, issueId: issue.id, }, "ISSUE_PROPERTY_UPDATE_STATE", user ); if (oldState?.group !== "completed" && newState?.group !== "completed") { trackEventServices.trackIssueMarkedAsDoneEvent( { workspaceSlug: issue.workspace_detail.slug, workspaceId: issue.workspace_detail.id, projectId: issue.project_detail.id, projectIdentifier: issue.project_detail.identifier, projectName: issue.project_detail.name, issueId: issue.id, }, user ); } }; const handleAssigneeChange = (data: any) => { const newData = issue.assignees ?? []; if (newData.includes(data)) newData.splice(newData.indexOf(data), 1); else newData.push(data); partialUpdateIssue({ assignees_list: data }, issue); trackEventServices.trackIssuePartialPropertyUpdateEvent( { workspaceSlug, workspaceId: issue.workspace, projectId: issue.project_detail.id, projectIdentifier: issue.project_detail.identifier, projectName: issue.project_detail.name, issueId: issue.id, }, "ISSUE_PROPERTY_UPDATE_ASSIGNEE", user ); }; const handleLabelChange = (data: any) => { partialUpdateIssue({ labels_list: data }, issue); }; const handlePriorityChange = (data: TIssuePriorities) => { partialUpdateIssue({ priority: data }, issue); trackEventServices.trackIssuePartialPropertyUpdateEvent( { workspaceSlug, workspaceId: issue.workspace, projectId: issue.project_detail.id, projectIdentifier: issue.project_detail.identifier, projectName: issue.project_detail.name, issueId: issue.id, }, "ISSUE_PROPERTY_UPDATE_PRIORITY", user ); }; useEffect(() => { if (snapshot.isDragging) handleTrashBox(snapshot.isDragging); }, [snapshot, handleTrashBox]); useOutsideClickDetector(actionSectionRef, () => setIsMenuActive(false)); const openPeekOverview = () => { const { query } = router; if (handleMyIssueOpen) handleMyIssueOpen(issue); router.push({ pathname: router.pathname, query: { ...query, peekIssue: issue.id }, }); }; const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disableUserActions; return ( <> <ContextMenu clickEvent={contextMenuPosition} title="Quick actions" isOpen={contextMenu} setIsOpen={setContextMenu} > {!isNotAllowed && ( <> <ContextMenu.Item Icon={PencilIcon} onClick={() => { if (isDraftIssue && handleDraftIssueEdit) handleDraftIssueEdit(); else editIssue(); }} > Edit issue </ContextMenu.Item> {!isDraftIssue && ( <ContextMenu.Item Icon={ClipboardDocumentCheckIcon} onClick={makeIssueCopy}> Make a copy... </ContextMenu.Item> )} <ContextMenu.Item Icon={TrashIcon} onClick={() => { if (isDraftIssue && handleDraftIssueDelete) handleDraftIssueDelete(); else handleDeleteIssue(issue); }} > Delete issue </ContextMenu.Item> </> )} {!isDraftIssue && ( <ContextMenu.Item Icon={LinkIcon} onClick={handleCopyText}> Copy issue link </ContextMenu.Item> )} {!isDraftIssue && ( <a href={`/${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`} target="_blank" rel="noreferrer noopener" > <ContextMenu.Item Icon={ArrowTopRightOnSquareIcon}> Open issue in new tab </ContextMenu.Item> </a> )} </ContextMenu> <div className={`mb-3 rounded bg-custom-background-100 shadow ${ snapshot.isDragging ? "border-2 border-custom-primary shadow-lg" : "" }`} ref={provided.innerRef} {...provided.draggableProps} {...provided.dragHandleProps} style={getStyle(provided.draggableProps.style, snapshot)} onContextMenu={(e) => { e.preventDefault(); setContextMenu(true); setContextMenuPosition(e); }} > <div className="flex flex-col justify-between gap-1.5 group/card relative select-none px-3.5 py-3 h-[118px]"> {!isNotAllowed && ( <div ref={actionSectionRef} className={`z-1 absolute top-1.5 right-1.5 hidden group-hover/card:!flex ${ isMenuActive ? "!flex" : "" }`} > {type && !isNotAllowed && ( <CustomMenu customButton={ <button className="flex w-full cursor-pointer items-center justify-between gap-1 rounded p-1 text-left text-xs duration-300 hover:bg-custom-background-80" onClick={() => setIsMenuActive(!isMenuActive)} > <EllipsisHorizontalIcon className="h-4 w-4" /> </button> } > <CustomMenu.MenuItem onClick={() => { if (isDraftIssue && handleDraftIssueEdit) handleDraftIssueEdit(); else editIssue(); }} > <div className="flex items-center justify-start gap-2"> <PencilIcon className="h-4 w-4" /> <span>Edit issue</span> </div> </CustomMenu.MenuItem> {type !== "issue" && removeIssue && !isDraftIssue && ( <CustomMenu.MenuItem onClick={removeIssue}> <div className="flex items-center justify-start gap-2"> <XMarkIcon className="h-4 w-4" /> <span>Remove from {type}</span> </div> </CustomMenu.MenuItem> )} <CustomMenu.MenuItem onClick={() => { if (isDraftIssue && handleDraftIssueDelete) handleDraftIssueDelete(); else handleDeleteIssue(issue); }} > <div className="flex items-center justify-start gap-2"> <TrashIcon className="h-4 w-4" /> <span>Delete issue</span> </div> </CustomMenu.MenuItem> {!isDraftIssue && ( <CustomMenu.MenuItem onClick={handleCopyText}> <div className="flex items-center justify-start gap-2"> <LinkIcon className="h-4 w-4" /> <span>Copy issue Link</span> </div> </CustomMenu.MenuItem> )} </CustomMenu> )} </div> )} <div className="flex flex-col gap-1.5"> {properties.key && ( <div className="text-xs font-medium text-custom-text-200"> {issue.project_detail.identifier}-{issue.sequence_id} </div> )} <button type="button" onClick={() => { if (isDraftIssue && handleDraftIssueEdit) handleDraftIssueEdit(); else openPeekOverview(); }} > <span className="text-sm text-left break-words line-clamp-2">{issue.name}</span> </button> </div> <div className={`flex items-center gap-2 text-xs ${ isDropdownActive ? "" : "overflow-x-scroll" }`} > {properties.priority && ( <PrioritySelect value={issue.priority} onChange={handlePriorityChange} hideDropdownArrow disabled={isNotAllowed} /> )} {properties.state && ( <StateSelect value={issue.state_detail} onChange={handleStateChange} hideDropdownArrow disabled={isNotAllowed} /> )} {properties.start_date && issue.start_date && ( <ViewStartDateSelect issue={issue} partialUpdateIssue={partialUpdateIssue} handleOnOpen={() => setIsDropdownActive(true)} handleOnClose={() => setIsDropdownActive(false)} user={user} isNotAllowed={isNotAllowed} /> )} {properties.due_date && issue.target_date && ( <ViewDueDateSelect issue={issue} partialUpdateIssue={partialUpdateIssue} handleOnOpen={() => setIsDropdownActive(true)} handleOnClose={() => setIsDropdownActive(false)} user={user} isNotAllowed={isNotAllowed} /> )} {properties.labels && issue.labels.length > 0 && ( <LabelSelect value={issue.labels} onChange={handleLabelChange} labelsDetails={issue.label_details} hideDropdownArrow user={user} disabled={isNotAllowed} /> )} {properties.assignee && ( <MembersSelect value={issue.assignees} onChange={handleAssigneeChange} membersDetails={issue.assignee_details} hideDropdownArrow disabled={isNotAllowed} /> )} {properties.estimate && issue.estimate_point !== null && ( <ViewEstimateSelect issue={issue} partialUpdateIssue={partialUpdateIssue} isNotAllowed={isNotAllowed} user={user} selfPositioned /> )} {properties.sub_issue_count && issue.sub_issues_count > 0 && ( <div className="flex cursor-default items-center rounded-md border border-custom-border-200 px-2.5 py-1 text-xs shadow-sm"> <Tooltip tooltipHeading="Sub-issue" tooltipContent={`${issue.sub_issues_count}`}> <div className="flex items-center gap-1 text-custom-text-200"> <LayerDiagonalIcon className="h-3.5 w-3.5" /> {issue.sub_issues_count} </div> </Tooltip> </div> )} {properties.link && issue.link_count > 0 && ( <div className="flex cursor-default items-center rounded-md border border-custom-border-200 px-2.5 py-1 text-xs shadow-sm"> <Tooltip tooltipHeading="Link" tooltipContent={`${issue.link_count}`}> <div className="flex items-center gap-1 text-custom-text-200"> <LinkIcon className="h-3.5 w-3.5" /> {issue.link_count} </div> </Tooltip> </div> )} {properties.attachment_count && issue.attachment_count > 0 && ( <div className="flex cursor-default items-center rounded-md border border-custom-border-200 px-2.5 py-1 text-xs shadow-sm"> <Tooltip tooltipHeading="Attachment" tooltipContent={`${issue.attachment_count}`}> <div className="flex items-center gap-1 text-custom-text-200"> <PaperClipIcon className="h-3.5 w-3.5 -rotate-45" /> {issue.attachment_count} </div> </Tooltip> </div> )} </div> </div> </div> </> ); };