import { FC, useCallback, useEffect, useMemo, useState } from "react"; import { observer } from "mobx-react-lite"; import { useRouter } from "next/router"; import { Plus, ChevronRight, Loader, Pencil } from "lucide-react"; import { IUser, TIssue } from "@plane/types"; // hooks import { CircularProgressIndicator, CustomMenu, LayersIcon, TOAST_TYPE, setToast } from "@plane/ui"; import { ExistingIssuesListModal } from "@/components/core"; import { CreateUpdateIssueModal, DeleteIssueModal } from "@/components/issues"; import { cn } from "@/helpers/common.helper"; import { copyTextToClipboard } from "@/helpers/string.helper"; import { useEventTracker, useIssueDetail } from "@/hooks/store"; // components import { IssueList } from "./issues-list"; // ui // helpers // types export interface ISubIssuesRoot { workspaceSlug: string; projectId: string; parentIssueId: string; currentUser: IUser; disabled: boolean; } export type TSubIssueOperations = { copyText: (text: string) => void; fetchSubIssues: (workspaceSlug: string, projectId: string, parentIssueId: string) => Promise; addSubIssue: (workspaceSlug: string, projectId: string, parentIssueId: string, issueIds: string[]) => Promise; updateSubIssue: ( workspaceSlug: string, projectId: string, parentIssueId: string, issueId: string, issueData: Partial, oldIssue?: Partial, fromModal?: boolean ) => Promise; removeSubIssue: (workspaceSlug: string, projectId: string, parentIssueId: string, issueId: string) => Promise; deleteSubIssue: (workspaceSlug: string, projectId: string, parentIssueId: string, issueId: string) => Promise; }; export const SubIssuesRoot: FC = observer((props) => { const { workspaceSlug, projectId, parentIssueId, disabled = false } = props; // router const router = useRouter(); const { issue: { getIssueById }, subIssues: { subIssuesByIssueId, stateDistributionByIssueId, subIssueHelpersByIssueId, setSubIssueHelpers }, fetchSubIssues, createSubIssues, updateSubIssue, removeSubIssue, deleteSubIssue, isCreateIssueModalOpen, toggleCreateIssueModal, isSubIssuesModalOpen, toggleSubIssuesModal, } = useIssueDetail(); const { setTrackElement, captureIssueEvent } = useEventTracker(); // state type TIssueCrudState = { toggle: boolean; parentIssueId: string | undefined; issue: TIssue | undefined }; const [issueCrudState, setIssueCrudState] = useState<{ create: TIssueCrudState; existing: TIssueCrudState; update: TIssueCrudState; delete: TIssueCrudState; }>({ create: { toggle: false, parentIssueId: undefined, issue: undefined, }, existing: { toggle: false, parentIssueId: undefined, issue: undefined, }, update: { toggle: false, parentIssueId: undefined, issue: undefined, }, delete: { toggle: false, parentIssueId: undefined, issue: undefined, }, }); const scrollToSubIssuesView = useCallback(() => { if (router.asPath.split("#")[1] === "sub-issues") { setTimeout(() => { const subIssueDiv = document.getElementById(`sub-issues`); if (subIssueDiv) subIssueDiv.scrollIntoView({ behavior: "smooth", block: "start", }); }, 200); } }, [router.asPath]); useEffect(() => { if (router.asPath) { scrollToSubIssuesView(); } }, [router.asPath, scrollToSubIssuesView]); const handleIssueCrudState = ( key: "create" | "existing" | "update" | "delete", _parentIssueId: string | null, issue: TIssue | null = null ) => { setIssueCrudState({ ...issueCrudState, [key]: { toggle: !issueCrudState[key].toggle, parentIssueId: _parentIssueId, issue: issue, }, }); }; const subIssueOperations: TSubIssueOperations = useMemo( () => ({ copyText: (text: string) => { const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : ""; copyTextToClipboard(`${originURL}/${text}`).then(() => { setToast({ type: TOAST_TYPE.SUCCESS, title: "Link Copied!", message: "Issue link copied to clipboard.", }); }); }, fetchSubIssues: async (workspaceSlug: string, projectId: string, parentIssueId: string) => { try { await fetchSubIssues(workspaceSlug, projectId, parentIssueId); } catch (error) { setToast({ type: TOAST_TYPE.ERROR, title: "Error fetching sub-issues", message: "Error fetching sub-issues", }); } }, addSubIssue: async (workspaceSlug: string, projectId: string, parentIssueId: string, issueIds: string[]) => { try { await createSubIssues(workspaceSlug, projectId, parentIssueId, issueIds); setToast({ type: TOAST_TYPE.SUCCESS, title: "Sub-issues added successfully", message: "Sub-issues added successfully", }); } catch (error) { setToast({ type: TOAST_TYPE.ERROR, title: "Error adding sub-issue", message: "Error adding sub-issue", }); } }, updateSubIssue: async ( workspaceSlug: string, projectId: string, parentIssueId: string, issueId: string, issueData: Partial, oldIssue: Partial = {}, fromModal: boolean = false ) => { try { setSubIssueHelpers(parentIssueId, "issue_loader", issueId); await updateSubIssue(workspaceSlug, projectId, parentIssueId, issueId, issueData, oldIssue, fromModal); captureIssueEvent({ eventName: "Sub-issue updated", payload: { ...oldIssue, ...issueData, state: "SUCCESS", element: "Issue detail page" }, updates: { changed_property: Object.keys(issueData).join(","), change_details: Object.values(issueData).join(","), }, path: router.asPath, }); setToast({ type: TOAST_TYPE.SUCCESS, title: "Sub-issue updated successfully", message: "Sub-issue updated successfully", }); setSubIssueHelpers(parentIssueId, "issue_loader", issueId); } catch (error) { captureIssueEvent({ eventName: "Sub-issue updated", payload: { ...oldIssue, ...issueData, state: "FAILED", element: "Issue detail page" }, updates: { changed_property: Object.keys(issueData).join(","), change_details: Object.values(issueData).join(","), }, path: router.asPath, }); setToast({ type: TOAST_TYPE.ERROR, title: "Error updating sub-issue", message: "Error updating sub-issue", }); } }, removeSubIssue: async (workspaceSlug: string, projectId: string, parentIssueId: string, issueId: string) => { try { setSubIssueHelpers(parentIssueId, "issue_loader", issueId); await removeSubIssue(workspaceSlug, projectId, parentIssueId, issueId); setToast({ type: TOAST_TYPE.SUCCESS, title: "Sub-issue removed successfully", message: "Sub-issue removed successfully", }); captureIssueEvent({ eventName: "Sub-issue removed", payload: { id: issueId, state: "SUCCESS", element: "Issue detail page" }, updates: { changed_property: "parent_id", change_details: parentIssueId, }, path: router.asPath, }); setSubIssueHelpers(parentIssueId, "issue_loader", issueId); } catch (error) { captureIssueEvent({ eventName: "Sub-issue removed", payload: { id: issueId, state: "FAILED", element: "Issue detail page" }, updates: { changed_property: "parent_id", change_details: parentIssueId, }, path: router.asPath, }); setToast({ type: TOAST_TYPE.ERROR, title: "Error removing sub-issue", message: "Error removing sub-issue", }); } }, deleteSubIssue: async (workspaceSlug: string, projectId: string, parentIssueId: string, issueId: string) => { try { setSubIssueHelpers(parentIssueId, "issue_loader", issueId); await deleteSubIssue(workspaceSlug, projectId, parentIssueId, issueId); setToast({ type: TOAST_TYPE.SUCCESS, title: "Issue deleted successfully", message: "Issue deleted successfully", }); captureIssueEvent({ eventName: "Sub-issue deleted", payload: { id: issueId, state: "SUCCESS", element: "Issue detail page" }, path: router.asPath, }); setSubIssueHelpers(parentIssueId, "issue_loader", issueId); } catch (error) { captureIssueEvent({ eventName: "Sub-issue removed", payload: { id: issueId, state: "FAILED", element: "Issue detail page" }, path: router.asPath, }); setToast({ type: TOAST_TYPE.ERROR, title: "Error deleting issue", message: "Error deleting issue", }); } }, }), [fetchSubIssues, createSubIssues, updateSubIssue, removeSubIssue, deleteSubIssue, setSubIssueHelpers] ); const issue = getIssueById(parentIssueId); const subIssuesDistribution = stateDistributionByIssueId(parentIssueId); const subIssues = subIssuesByIssueId(parentIssueId); const subIssueHelpers = subIssueHelpersByIssueId(`${parentIssueId}_root`); const handleFetchSubIssues = useCallback(async () => { if (!subIssueHelpers.issue_visibility.includes(parentIssueId)) { setSubIssueHelpers(`${parentIssueId}_root`, "preview_loader", parentIssueId); await subIssueOperations.fetchSubIssues(workspaceSlug, projectId, parentIssueId); setSubIssueHelpers(`${parentIssueId}_root`, "preview_loader", parentIssueId); } setSubIssueHelpers(`${parentIssueId}_root`, "issue_visibility", parentIssueId); }, [ parentIssueId, projectId, setSubIssueHelpers, subIssueHelpers.issue_visibility, subIssueOperations, workspaceSlug, ]); useEffect(() => { handleFetchSubIssues(); return () => { handleFetchSubIssues(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [parentIssueId]); if (!issue) return <>; return (
{!subIssues ? (
Loading...
) : ( <> {subIssues && subIssues?.length > 0 ? ( <>
{subIssuesDistribution?.completed?.length ?? 0}/{subIssues.length} Done
{!disabled && ( Add sub-issue } buttonClassName="whitespace-nowrap" placement="bottom-end" noBorder noChevron > { setTrackElement("Issue detail nested sub-issue"); handleIssueCrudState("create", parentIssueId, null); toggleCreateIssueModal(true); }} >
Create new
{ setTrackElement("Issue detail nested sub-issue"); handleIssueCrudState("existing", parentIssueId, null); toggleSubIssuesModal(true); }} >
Add existing
)}
{subIssueHelpers.issue_visibility.includes(parentIssueId) && (
)} ) : ( !disabled && (
No sub-issues yet
Add sub-issue } buttonClassName="whitespace-nowrap" placement="bottom-end" noBorder noChevron > { setTrackElement("Issue detail nested sub-issue"); handleIssueCrudState("create", parentIssueId, null); toggleCreateIssueModal(true); }} >
Create new
{ setTrackElement("Issue detail nested sub-issue"); handleIssueCrudState("existing", parentIssueId, null); toggleSubIssuesModal(true); }} >
Add existing
) )} {/* issue create, add from existing , update and delete modals */} {issueCrudState?.create?.toggle && issueCrudState?.create?.parentIssueId && isCreateIssueModalOpen && ( { handleIssueCrudState("create", null, null); toggleCreateIssueModal(false); }} onSubmit={async (_issue: TIssue) => { await subIssueOperations.addSubIssue(workspaceSlug, projectId, parentIssueId, [_issue.id]); }} /> )} {issueCrudState?.existing?.toggle && issueCrudState?.existing?.parentIssueId && isSubIssuesModalOpen && ( { handleIssueCrudState("existing", null, null); toggleSubIssuesModal(false); }} searchParams={{ sub_issue: true, issue_id: issueCrudState?.existing?.parentIssueId }} handleOnSubmit={(_issue) => subIssueOperations.addSubIssue( workspaceSlug, projectId, parentIssueId, _issue.map((issue) => issue.id) ) } workspaceLevelToggle /> )} {issueCrudState?.update?.toggle && issueCrudState?.update?.issue && ( <> { handleIssueCrudState("update", null, null); }} data={issueCrudState?.update?.issue ?? undefined} onSubmit={async (_issue: TIssue) => { await subIssueOperations.updateSubIssue( workspaceSlug, projectId, parentIssueId, _issue.id, _issue, issueCrudState?.update?.issue, true ); }} /> )} {issueCrudState?.delete?.toggle && issueCrudState?.delete?.issue && issueCrudState.delete.parentIssueId && issueCrudState.delete.issue.id && ( { handleIssueCrudState("delete", null, null); }} data={issueCrudState?.delete?.issue as TIssue} onSubmit={async () => await subIssueOperations.deleteSubIssue( workspaceSlug, projectId, issueCrudState?.delete?.parentIssueId as string, issueCrudState?.delete?.issue?.id as string ) } /> )} )}
); });