Compare commits

...

1 Commits

Author SHA1 Message Date
Aaryan Khandelwal
1c376aaf0a refactor: sub-work items components, hooks and types 2025-04-15 12:24:21 +05:30
15 changed files with 180 additions and 742 deletions

View File

@@ -20,3 +20,20 @@ export type TIssueSubIssuesStateDistributionMap = {
export type TIssueSubIssuesIdMap = {
[issue_id: string]: string[];
};
export type TSubIssueOperations = {
copyLink: (path: string) => void;
fetchSubIssues: (workspaceSlug: string, projectId: string, parentIssueId: string) => Promise<void>;
addSubIssue: (workspaceSlug: string, projectId: string, parentIssueId: string, issueIds: string[]) => Promise<void>;
updateSubIssue: (
workspaceSlug: string,
projectId: string,
parentIssueId: string,
issueId: string,
issueData: Partial<TIssue>,
oldIssue?: Partial<TIssue>,
fromModal?: boolean
) => Promise<void>;
removeSubIssue: (workspaceSlug: string, projectId: string, parentIssueId: string, issueId: string) => Promise<void>;
deleteSubIssue: (workspaceSlug: string, projectId: string, parentIssueId: string, issueId: string) => Promise<void>;
};

View File

@@ -42,7 +42,7 @@ export const IssueDetailWidgetModals: FC<Props> = observer((props) => {
} = useIssueDetail();
// helper hooks
const subIssueOperations = useSubIssueOperations();
const subIssueOperations = useSubIssueOperations(issueServiceType);
const handleLinkOperations = useLinkOperations(workspaceSlug, projectId, issueId);
// handlers

View File

@@ -1,18 +1,17 @@
"use client";
import { useMemo } from "react";
import { usePathname } from "next/navigation";
// plane imports
import { EIssueServiceType, ISSUE_DELETED, ISSUE_UPDATED } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { TIssue, TIssueServiceType } from "@plane/types";
import { TOAST_TYPE, setToast } from "@plane/ui";
// constants
// helper
import { copyTextToClipboard } from "@/helpers/string.helper";
import { copyUrlToClipboard } from "@plane/utils";
// hooks
import { useEventTracker, useIssueDetail } from "@/hooks/store";
export type TRelationIssueOperations = {
copyText: (text: string) => void;
copyLink: (path: string) => void;
update: (workspaceSlug: string, projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>;
remove: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>;
};
@@ -29,9 +28,8 @@ export const useRelationOperations = (
const issueOperations: TRelationIssueOperations = useMemo(
() => ({
copyText: (text: string) => {
const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
copyTextToClipboard(`${originURL}${text}`).then(() => {
copyLink: (path) => {
copyUrlToClipboard(path).then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("common.link_copied"),
@@ -39,7 +37,7 @@ export const useRelationOperations = (
});
});
},
update: async (workspaceSlug: string, projectId: string, issueId: string, data: Partial<TIssue>) => {
update: async (workspaceSlug, projectId, issueId, data) => {
try {
await updateIssue(workspaceSlug, projectId, issueId, data);
captureIssueEvent({
@@ -56,7 +54,7 @@ export const useRelationOperations = (
type: TOAST_TYPE.SUCCESS,
message: t("entity.update.success", { entity: entityName }),
});
} catch (error) {
} catch {
captureIssueEvent({
eventName: ISSUE_UPDATED,
payload: { state: "FAILED", element: "Issue detail page" },
@@ -73,7 +71,7 @@ export const useRelationOperations = (
});
}
},
remove: async (workspaceSlug: string, projectId: string, issueId: string) => {
remove: async (workspaceSlug, projectId, issueId) => {
try {
return removeIssue(workspaceSlug, projectId, issueId).then(() => {
captureIssueEvent({
@@ -82,7 +80,7 @@ export const useRelationOperations = (
path: pathname,
});
});
} catch (error) {
} catch {
captureIssueEvent({
eventName: ISSUE_DELETED,
payload: { id: issueId, state: "FAILED", element: "Issue detail page" },
@@ -91,7 +89,7 @@ export const useRelationOperations = (
}
},
}),
[pathname, removeIssue, updateIssue]
[captureIssueEvent, entityName, pathname, removeIssue, t, updateIssue]
);
return issueOperations;

View File

@@ -6,11 +6,11 @@ import { TIssue, TIssueServiceType } from "@plane/types";
// components
import { DeleteIssueModal } from "@/components/issues/delete-issue-modal";
import { CreateUpdateIssueModal } from "@/components/issues/issue-modal";
import { IssueList } from "@/components/issues/sub-issues/issues-list";
// hooks
import { useIssueDetail } from "@/hooks/store";
// helper
// local imports
import { useSubIssueOperations } from "./helper";
import { SubIssuesListRoot } from "./issues-list/root";
type Props = {
workspaceSlug: string;
@@ -53,8 +53,9 @@ export const SubIssuesCollapsibleContent: FC<Props> = observer((props) => {
},
});
// store hooks
const { toggleCreateIssueModal, toggleDeleteIssueModal } = useIssueDetail();
const {
toggleCreateIssueModal,
toggleDeleteIssueModal,
subIssues: { subIssueHelpersByIssueId, setSubIssueHelpers },
} = useIssueDetail(issueServiceType);
@@ -63,20 +64,19 @@ export const SubIssuesCollapsibleContent: FC<Props> = observer((props) => {
const subIssueHelpers = subIssueHelpersByIssueId(`${parentIssueId}_root`);
// handler
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 handleIssueCrudState = useCallback(
(key: "create" | "existing" | "update" | "delete", _parentIssueId: string | null, issue: TIssue | null = null) => {
setIssueCrudState({
...issueCrudState,
[key]: {
toggle: !issueCrudState[key].toggle,
parentIssueId: _parentIssueId,
issue,
},
});
},
[issueCrudState]
);
const handleFetchSubIssues = useCallback(async () => {
if (!subIssueHelpers.issue_visibility.includes(parentIssueId)) {
@@ -116,7 +116,7 @@ export const SubIssuesCollapsibleContent: FC<Props> = observer((props) => {
return (
<>
{subIssueHelpers.issue_visibility.includes(parentIssueId) && (
<IssueList
<SubIssuesListRoot
workspaceSlug={workspaceSlug}
projectId={projectId}
parentIssueId={parentIssueId}

View File

@@ -1,46 +1,38 @@
"use client";
import { useMemo } from "react";
import { useParams, usePathname } from "next/navigation";
// plane imports
import { EIssueServiceType } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { TIssue, TIssueServiceType } from "@plane/types";
import { TIssueServiceType, TSubIssueOperations } from "@plane/types";
import { TOAST_TYPE, setToast } from "@plane/ui";
// helper
import { copyTextToClipboard } from "@/helpers/string.helper";
import { copyUrlToClipboard } from "@plane/utils";
// hooks
import { useEventTracker, useIssueDetail, useProjectState } from "@/hooks/store";
// plane-web
// plane web helpers
import { updateEpicAnalytics } from "@/plane-web/helpers/epic-analytics";
// type
import { TSubIssueOperations } from "../../sub-issues";
export type TRelationIssueOperations = {
copyText: (text: string) => void;
update: (workspaceSlug: string, projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>;
remove: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>;
};
export const useSubIssueOperations = (
issueServiceType: TIssueServiceType = EIssueServiceType.ISSUES
): TSubIssueOperations => {
export const useSubIssueOperations = (issueServiceType: TIssueServiceType): TSubIssueOperations => {
// router
const { epicId: epicIdParam } = useParams();
const pathname = usePathname();
// translation
const { t } = useTranslation();
// store hooks
const {
issue: { getIssueById },
subIssues: { setSubIssueHelpers },
fetchSubIssues,
createSubIssues,
updateSubIssue,
deleteSubIssue,
} = useIssueDetail();
removeSubIssue,
} = useIssueDetail(issueServiceType);
const { getStateById } = useProjectState();
const { peekIssue: epicPeekIssue } = useIssueDetail(EIssueServiceType.EPICS);
// const { updateEpicAnalytics } = useIssueTypes();
const { updateAnalytics } = updateEpicAnalytics();
const { fetchSubIssues } = useIssueDetail();
const { removeSubIssue } = useIssueDetail(issueServiceType);
const { captureIssueEvent } = useEventTracker();
// derived values
@@ -48,9 +40,8 @@ export const useSubIssueOperations = (
const subIssueOperations: TSubIssueOperations = useMemo(
() => ({
copyText: (text: string) => {
const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
copyTextToClipboard(`${originURL}/${text}`).then(() => {
copyLink: (path) => {
copyUrlToClipboard(`/${path}`).then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("common.link_copied"),
@@ -63,10 +54,10 @@ export const useSubIssueOperations = (
});
});
},
fetchSubIssues: async (workspaceSlug: string, projectId: string, parentIssueId: string) => {
fetchSubIssues: async (workspaceSlug, projectId, parentIssueId) => {
try {
await fetchSubIssues(workspaceSlug, projectId, parentIssueId);
} catch (error) {
} catch {
setToast({
type: TOAST_TYPE.ERROR,
title: t("toast.error"),
@@ -79,7 +70,7 @@ export const useSubIssueOperations = (
});
}
},
addSubIssue: async (workspaceSlug: string, projectId: string, parentIssueId: string, issueIds: string[]) => {
addSubIssue: async (workspaceSlug, projectId, parentIssueId, issueIds) => {
try {
await createSubIssues(workspaceSlug, projectId, parentIssueId, issueIds);
setToast({
@@ -92,11 +83,10 @@ export const useSubIssueOperations = (
: t("issue.label", { count: 2 }),
}),
});
} catch (error) {
} catch {
setToast({
type: TOAST_TYPE.ERROR,
title: t("toast.error"),
// message: `Error adding ${issueServiceType === EIssueServiceType.ISSUES ? "sub-issues" : "issues"}`,
message: t("entity.add.failed", {
entity:
issueServiceType === EIssueServiceType.ISSUES
@@ -107,13 +97,13 @@ export const useSubIssueOperations = (
}
},
updateSubIssue: async (
workspaceSlug: string,
projectId: string,
parentIssueId: string,
issueId: string,
issueData: Partial<TIssue>,
oldIssue: Partial<TIssue> = {},
fromModal: boolean = false
workspaceSlug,
projectId,
parentIssueId,
issueId,
issueData,
oldIssue = {},
fromModal = false
) => {
try {
setSubIssueHelpers(parentIssueId, "issue_loader", issueId);
@@ -157,7 +147,7 @@ export const useSubIssueOperations = (
message: t("sub_work_item.update.success"),
});
setSubIssueHelpers(parentIssueId, "issue_loader", issueId);
} catch (error) {
} catch {
captureIssueEvent({
eventName: "Sub-issue updated",
payload: { ...oldIssue, ...issueData, state: "FAILED", element: "Issue detail page" },
@@ -174,7 +164,7 @@ export const useSubIssueOperations = (
});
}
},
removeSubIssue: async (workspaceSlug: string, projectId: string, parentIssueId: string, issueId: string) => {
removeSubIssue: async (workspaceSlug, projectId, parentIssueId, issueId) => {
try {
setSubIssueHelpers(parentIssueId, "issue_loader", issueId);
await removeSubIssue(workspaceSlug, projectId, parentIssueId, issueId);
@@ -203,7 +193,7 @@ export const useSubIssueOperations = (
path: pathname,
});
setSubIssueHelpers(parentIssueId, "issue_loader", issueId);
} catch (error) {
} catch {
captureIssueEvent({
eventName: "Sub-issue removed",
payload: { id: issueId, state: "FAILED", element: "Issue detail page" },
@@ -220,7 +210,7 @@ export const useSubIssueOperations = (
});
}
},
deleteSubIssue: async (workspaceSlug: string, projectId: string, parentIssueId: string, issueId: string) => {
deleteSubIssue: async (workspaceSlug, projectId, parentIssueId, issueId) => {
try {
setSubIssueHelpers(parentIssueId, "issue_loader", issueId);
return deleteSubIssue(workspaceSlug, projectId, parentIssueId, issueId).then(() => {
@@ -231,7 +221,7 @@ export const useSubIssueOperations = (
});
setSubIssueHelpers(parentIssueId, "issue_loader", issueId);
});
} catch (error) {
} catch {
captureIssueEvent({
eventName: "Sub-issue removed",
payload: { id: issueId, state: "FAILED", element: "Issue detail page" },
@@ -245,7 +235,22 @@ export const useSubIssueOperations = (
}
},
}),
[fetchSubIssues, createSubIssues, updateSubIssue, removeSubIssue, deleteSubIssue, setSubIssueHelpers]
[
captureIssueEvent,
createSubIssues,
deleteSubIssue,
epicId,
fetchSubIssues,
getIssueById,
getStateById,
issueServiceType,
pathname,
removeSubIssue,
setSubIssueHelpers,
t,
updateAnalytics,
updateSubIssue,
]
);
return subIssueOperations;

View File

@@ -1,12 +1,11 @@
"use client";
import React from "react";
import { observer } from "mobx-react";
import { ChevronRight, X, Pencil, Trash, Link as LinkIcon, Loader } from "lucide-react";
// plane imports
import { EIssueServiceType } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { TIssue, TIssueServiceType } from "@plane/types";
// ui
import { TIssue, TIssueServiceType, TSubIssueOperations } from "@plane/types";
import { ControlLink, CustomMenu, Tooltip } from "@plane/ui";
// helpers
import { cn } from "@/helpers/common.helper";
@@ -17,15 +16,11 @@ import useIssuePeekOverviewRedirection from "@/hooks/use-issue-peek-overview-red
import { usePlatformOS } from "@/hooks/use-platform-os";
// plane web components
import { IssueIdentifier } from "@/plane-web/components/issues";
// local components
import { IssueList } from "./issues-list";
import { IssueProperty } from "./properties";
// ui
// types
import { TSubIssueOperations } from "./root";
// import { ISubIssuesRootLoaders, ISubIssuesRootLoadersHandler } from "./root";
// local imports
import { SubIssuesListItemProperties } from "./properties";
import { SubIssuesListRoot } from "./root";
export interface ISubIssues {
type Props = {
workspaceSlug: string;
projectId: string;
parentIssueId: string;
@@ -40,9 +35,9 @@ export interface ISubIssues {
subIssueOperations: TSubIssueOperations;
issueId: string;
issueServiceType?: TIssueServiceType;
}
};
export const IssueListItem: React.FC<ISubIssues> = observer((props) => {
export const SubIssuesListItem: React.FC<Props> = observer((props) => {
const {
workspaceSlug,
projectId,
@@ -171,7 +166,7 @@ export const IssueListItem: React.FC<ISubIssues> = observer((props) => {
e.stopPropagation();
}}
>
<IssueProperty
<SubIssuesListItemProperties
workspaceSlug={workspaceSlug}
parentIssueId={parentIssueId}
issueId={issueId}
@@ -203,7 +198,7 @@ export const IssueListItem: React.FC<ISubIssues> = observer((props) => {
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
subIssueOperations.copyText(workItemLink);
subIssueOperations.copyLink(workItemLink);
}}
>
<div className="flex items-center gap-2">
@@ -256,7 +251,7 @@ export const IssueListItem: React.FC<ISubIssues> = observer((props) => {
issue.project_id &&
subIssueCount > 0 &&
!isCurrentIssueRoot && (
<IssueList
<SubIssuesListRoot
workspaceSlug={workspaceSlug}
projectId={issue.project_id}
parentIssueId={issue.id}

View File

@@ -1,22 +1,20 @@
import React from "react";
import { TIssueServiceType } from "@plane/types";
// hooks
import { PriorityDropdown, MemberDropdown, StateDropdown } from "@/components/dropdowns";
import { useIssueDetail } from "@/hooks/store";
// plane imports
import { TIssueServiceType, TSubIssueOperations } from "@plane/types";
// components
// types
import { TSubIssueOperations } from "./root";
import { PriorityDropdown, MemberDropdown, StateDropdown } from "@/components/dropdowns";
// hooks
import { useIssueDetail } from "@/hooks/store";
export interface IIssueProperty {
type Props = {
workspaceSlug: string;
parentIssueId: string;
issueId: string;
disabled: boolean;
subIssueOperations: TSubIssueOperations;
issueServiceType?: TIssueServiceType;
}
};
export const IssueProperty: React.FC<IIssueProperty> = (props) => {
export const SubIssuesListItemProperties: React.FC<Props> = (props) => {
const { workspaceSlug, parentIssueId, issueId, disabled, subIssueOperations, issueServiceType } = props;
// hooks
const {

View File

@@ -0,0 +1,64 @@
import { observer } from "mobx-react";
// plane imports
import { EIssueServiceType } from "@plane/constants";
import { TIssue, TIssueServiceType, TSubIssueOperations } from "@plane/types";
// hooks
import { useIssueDetail } from "@/hooks/store";
// local imports
import { SubIssuesListItem } from "./list-item";
type Props = {
workspaceSlug: string;
projectId: string;
parentIssueId: string;
rootIssueId: string;
spacingLeft: number;
disabled: boolean;
handleIssueCrudState: (
key: "create" | "existing" | "update" | "delete",
issueId: string,
issue?: TIssue | null
) => void;
subIssueOperations: TSubIssueOperations;
issueServiceType?: TIssueServiceType;
};
export const SubIssuesListRoot: React.FC<Props> = observer((props) => {
const {
workspaceSlug,
projectId,
parentIssueId,
rootIssueId,
spacingLeft = 10,
disabled,
handleIssueCrudState,
subIssueOperations,
issueServiceType = EIssueServiceType.ISSUES,
} = props;
// store hooks
const {
subIssues: { subIssuesByIssueId },
} = useIssueDetail(issueServiceType);
// derived values
const subIssueIds = subIssuesByIssueId(parentIssueId);
return (
<div className="relative">
{subIssueIds?.map((issueId) => (
<SubIssuesListItem
key={issueId}
workspaceSlug={workspaceSlug}
projectId={projectId}
parentIssueId={parentIssueId}
rootIssueId={rootIssueId}
issueId={issueId}
spacingLeft={spacingLeft}
disabled={disabled}
handleIssueCrudState={handleIssueCrudState}
subIssueOperations={subIssueOperations}
issueServiceType={issueServiceType}
/>
))}
</div>
);
});

View File

@@ -21,7 +21,7 @@ export const SubIssuesCollapsible: FC<Props> = observer((props) => {
const { workspaceSlug, projectId, issueId, disabled = false, issueServiceType } = props;
// store hooks
const { openWidgets, toggleOpenWidget } = useIssueDetail(issueServiceType);
// derived state
// derived values
const isCollapsibleOpen = openWidgets.includes("sub-issues");
return (

View File

@@ -1,6 +1,8 @@
"use client";
import React, { FC } from "react";
import { FC } from "react";
import { observer } from "mobx-react";
// plane imports
import { EIssueServiceType } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { TIssueServiceType } from "@plane/types";
@@ -19,13 +21,13 @@ type Props = {
export const SubIssuesCollapsibleTitle: FC<Props> = observer((props) => {
const { isOpen, parentIssueId, disabled, issueServiceType = EIssueServiceType.ISSUES } = props;
// translation
const { t } = useTranslation();
// store hooks
const {
subIssues: { subIssuesByIssueId, stateDistributionByIssueId },
} = useIssueDetail(issueServiceType);
// derived data
// derived values
const subIssuesDistribution = stateDistributionByIssueId(parentIssueId);
const subIssues = subIssuesByIssueId(parentIssueId);

View File

@@ -109,7 +109,7 @@ export const RelationIssueListItem: FC<Props> = observer((props) => {
const handleCopyIssueLink = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
e.stopPropagation();
e.preventDefault();
issueOperations.copyText(workItemLink);
issueOperations.copyLink(workItemLink);
};
const handleRemoveRelation = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {

View File

@@ -1 +0,0 @@
export * from "./root";

View File

@@ -1,69 +0,0 @@
import { FC, Fragment } from "react";
import { observer } from "mobx-react";
import { EIssueServiceType } from "@plane/constants";
import { TIssue, TIssueServiceType } from "@plane/types";
// hooks
import { useIssueDetail } from "@/hooks/store";
// components
import { IssueListItem } from "./issue-list-item";
// types
import { TSubIssueOperations } from "./root";
export interface IIssueList {
workspaceSlug: string;
projectId: string;
parentIssueId: string;
rootIssueId: string;
spacingLeft: number;
disabled: boolean;
handleIssueCrudState: (
key: "create" | "existing" | "update" | "delete",
issueId: string,
issue?: TIssue | null
) => void;
subIssueOperations: TSubIssueOperations;
issueServiceType?: TIssueServiceType;
}
export const IssueList: FC<IIssueList> = observer((props) => {
const {
workspaceSlug,
projectId,
parentIssueId,
rootIssueId,
spacingLeft = 10,
disabled,
handleIssueCrudState,
subIssueOperations,
issueServiceType = EIssueServiceType.ISSUES,
} = props;
// hooks
const {
subIssues: { subIssuesByIssueId },
} = useIssueDetail(issueServiceType);
const subIssueIds = subIssuesByIssueId(parentIssueId);
return (
<div className="relative">
{subIssueIds &&
subIssueIds.length > 0 &&
subIssueIds.map((issueId) => (
<Fragment key={issueId}>
<IssueListItem
workspaceSlug={workspaceSlug}
projectId={projectId}
parentIssueId={parentIssueId}
rootIssueId={rootIssueId}
issueId={issueId}
spacingLeft={spacingLeft}
disabled={disabled}
handleIssueCrudState={handleIssueCrudState}
subIssueOperations={subIssueOperations}
issueServiceType={issueServiceType}
/>
</Fragment>
))}
</div>
);
});

View File

@@ -1,25 +0,0 @@
export interface IProgressBar {
total: number;
done: number;
}
export const ProgressBar = ({ total = 0, done = 0 }: IProgressBar) => {
const calPercentage = (doneValue: number, totalValue: number): string => {
if (doneValue === 0 || totalValue === 0) return (0).toFixed(0);
return ((100 * doneValue) / totalValue).toFixed(0);
};
return (
<div className="relative flex items-center gap-2">
<div className="w-full">
<div className="w-full overflow-hidden rounded-full bg-custom-background-80 shadow">
<div
className="h-[6px] rounded-full bg-green-500 transition-all"
style={{ width: `${calPercentage(done, total)}%` }}
/>
</div>
</div>
<div className="flex-shrink-0 text-xs font-medium">{calPercentage(done, total)}% Done</div>
</div>
);
};

View File

@@ -1,546 +0,0 @@
"use client";
import { FC, useCallback, useEffect, useMemo, useState } from "react";
import { observer } from "mobx-react";
import { usePathname } from "next/navigation";
// icons
import { Plus, ChevronRight, Loader, Pencil } from "lucide-react";
// types
import { IUser, TIssue } from "@plane/types";
// ui
import { CircularProgressIndicator, CustomMenu, LayersIcon, TOAST_TYPE, setToast } from "@plane/ui";
// components
import { ExistingIssuesListModal } from "@/components/core";
import { CreateUpdateIssueModal, DeleteIssueModal } from "@/components/issues";
// helpers
import { cn } from "@/helpers/common.helper";
import { copyTextToClipboard } from "@/helpers/string.helper";
// hooks
import { useEventTracker, useIssueDetail } from "@/hooks/store";
// local components
import useURLHash from "@/hooks/use-url-hash";
import { IssueList } from "./issues-list";
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<void>;
addSubIssue: (workspaceSlug: string, projectId: string, parentIssueId: string, issueIds: string[]) => Promise<void>;
updateSubIssue: (
workspaceSlug: string,
projectId: string,
parentIssueId: string,
issueId: string,
issueData: Partial<TIssue>,
oldIssue?: Partial<TIssue>,
fromModal?: boolean
) => Promise<void>;
removeSubIssue: (workspaceSlug: string, projectId: string, parentIssueId: string, issueId: string) => Promise<void>;
deleteSubIssue: (workspaceSlug: string, projectId: string, parentIssueId: string, issueId: string) => Promise<void>;
};
export const SubIssuesRoot: FC<ISubIssuesRoot> = observer((props) => {
const { workspaceSlug, projectId, parentIssueId, disabled = false } = props;
// router
const pathname = usePathname();
const hashValue = useURLHash();
const {
issue: { getIssueById },
subIssues: { subIssuesByIssueId, stateDistributionByIssueId, subIssueHelpersByIssueId, setSubIssueHelpers },
fetchSubIssues,
createSubIssues,
updateSubIssue,
removeSubIssue,
deleteSubIssue,
isCreateIssueModalOpen,
toggleCreateIssueModal,
isSubIssuesModalOpen,
toggleSubIssuesModal,
toggleDeleteIssueModal,
} = 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 (hashValue === "sub-issues") {
setTimeout(() => {
const subIssueDiv = document.getElementById(`sub-issues`);
if (subIssueDiv)
subIssueDiv.scrollIntoView({
behavior: "smooth",
block: "start",
});
}, 200);
}
}, [hashValue]);
useEffect(() => {
if (hashValue) {
scrollToSubIssuesView();
}
}, [hashValue, 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: "Work item 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!",
message: "Error fetching sub-work items",
});
}
},
addSubIssue: async (workspaceSlug: string, projectId: string, parentIssueId: string, issueIds: string[]) => {
try {
await createSubIssues(workspaceSlug, projectId, parentIssueId, issueIds);
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Sub-work items added successfully",
});
} catch (error) {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Error adding sub-work item",
});
}
},
updateSubIssue: async (
workspaceSlug: string,
projectId: string,
parentIssueId: string,
issueId: string,
issueData: Partial<TIssue>,
oldIssue: Partial<TIssue> = {},
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: pathname,
});
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Sub-work item 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: pathname,
});
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Error updating sub-work item",
});
}
},
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: "Success!",
message: "Sub-work item 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: pathname,
});
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: pathname,
});
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Error removing sub-work item",
});
}
},
deleteSubIssue: async (workspaceSlug: string, projectId: string, parentIssueId: string, issueId: string) => {
try {
setSubIssueHelpers(parentIssueId, "issue_loader", issueId);
await deleteSubIssue(workspaceSlug, projectId, parentIssueId, issueId);
captureIssueEvent({
eventName: "Sub-issue deleted",
payload: { id: issueId, state: "SUCCESS", element: "Issue detail page" },
path: pathname,
});
setSubIssueHelpers(parentIssueId, "issue_loader", issueId);
} catch (error) {
captureIssueEvent({
eventName: "Sub-issue removed",
payload: { id: issueId, state: "FAILED", element: "Issue detail page" },
path: pathname,
});
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Error deleting work item",
});
}
},
}),
[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 (
<div id="sub-issues" className="h-full w-full space-y-2">
{!subIssues ? (
<div className="py-3 text-center text-sm font-medium text-custom-text-300">Loading...</div>
) : (
<>
{subIssues && subIssues?.length > 0 ? (
<>
<div className="relative flex items-center justify-between gap-2 text-xs">
<div className="flex items-center gap-2">
<button
type="button"
className="flex items-center gap-1 rounded py-1 px-2 transition-all hover:bg-custom-background-80 font-medium"
onClick={handleFetchSubIssues}
>
<div className="flex flex-shrink-0 items-center justify-center">
{subIssueHelpers.preview_loader.includes(parentIssueId) ? (
<Loader strokeWidth={2} className="h-3 w-3 animate-spin" />
) : (
<ChevronRight
className={cn("h-3 w-3 transition-all", {
"rotate-90": subIssueHelpers.issue_visibility.includes(parentIssueId),
})}
strokeWidth={2}
/>
)}
</div>
<div>Sub-work items</div>
</button>
<div className="flex items-center gap-2 text-custom-text-300">
<CircularProgressIndicator
size={16}
percentage={
subIssuesDistribution?.completed?.length && subIssues.length
? (subIssuesDistribution?.completed?.length / subIssues.length) * 100
: 0
}
strokeWidth={3}
/>
<span>
{subIssuesDistribution?.completed?.length ?? 0}/{subIssues.length} Done
</span>
</div>
</div>
{!disabled && (
<CustomMenu
label={
<>
<Plus className="h-3 w-3" />
Add sub-work item
</>
}
buttonClassName="whitespace-nowrap"
placement="bottom-end"
noBorder
noChevron
>
<CustomMenu.MenuItem
onClick={() => {
setTrackElement("Issue detail nested sub-issue");
handleIssueCrudState("create", parentIssueId, null);
toggleCreateIssueModal(true);
}}
>
<div className="flex items-center gap-2">
<Pencil className="h-3 w-3" />
<span>Create new</span>
</div>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem
onClick={() => {
setTrackElement("Issue detail nested sub-issue");
handleIssueCrudState("existing", parentIssueId, null);
toggleSubIssuesModal(issue.id);
}}
>
<div className="flex items-center gap-2">
<LayersIcon className="h-3 w-3" />
<span>Add existing</span>
</div>
</CustomMenu.MenuItem>
</CustomMenu>
)}
</div>
{subIssueHelpers.issue_visibility.includes(parentIssueId) && (
<IssueList
workspaceSlug={workspaceSlug}
projectId={projectId}
parentIssueId={parentIssueId}
rootIssueId={parentIssueId}
spacingLeft={10}
disabled={!disabled}
handleIssueCrudState={handleIssueCrudState}
subIssueOperations={subIssueOperations}
/>
)}
</>
) : (
!disabled && (
<div className="flex items-center justify-between">
<div className="text-xs italic text-custom-text-300">No sub-work items yet</div>
<CustomMenu
label={
<>
<Plus className="h-3 w-3" />
Add sub-work item
</>
}
buttonClassName="whitespace-nowrap"
placement="bottom-end"
noBorder
noChevron
>
<CustomMenu.MenuItem
onClick={() => {
setTrackElement("Issue detail nested sub-issue");
handleIssueCrudState("create", parentIssueId, null);
toggleCreateIssueModal(true);
}}
>
<div className="flex items-center gap-2">
<Pencil className="h-3 w-3" />
<span>Create new</span>
</div>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem
onClick={() => {
setTrackElement("Issue detail nested sub-issue");
handleIssueCrudState("existing", parentIssueId, null);
toggleSubIssuesModal(issue.id);
}}
>
<div className="flex items-center gap-2">
<LayersIcon className="h-3 w-3" />
<span>Add existing</span>
</div>
</CustomMenu.MenuItem>
</CustomMenu>
</div>
)
)}
{/* issue create, add from existing , update and delete modals */}
{issueCrudState?.create?.toggle && issueCrudState?.create?.parentIssueId && isCreateIssueModalOpen && (
<CreateUpdateIssueModal
isOpen={issueCrudState?.create?.toggle}
data={{
parent_id: issueCrudState?.create?.parentIssueId,
}}
onClose={() => {
handleIssueCrudState("create", null, null);
toggleCreateIssueModal(false);
}}
onSubmit={async (_issue: TIssue) => {
if (_issue.parent_id) {
await subIssueOperations.addSubIssue(workspaceSlug, projectId, _issue.parent_id, [_issue.id]);
}
}}
/>
)}
{issueCrudState?.existing?.toggle && issueCrudState?.existing?.parentIssueId && isSubIssuesModalOpen && (
<ExistingIssuesListModal
workspaceSlug={workspaceSlug}
projectId={projectId}
isOpen={issueCrudState?.existing?.toggle}
handleClose={() => {
handleIssueCrudState("existing", null, null);
toggleSubIssuesModal(null);
}}
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 && (
<>
<CreateUpdateIssueModal
isOpen={issueCrudState?.update?.toggle}
onClose={() => {
handleIssueCrudState("update", null, null);
toggleCreateIssueModal(false);
}}
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 && (
<DeleteIssueModal
isOpen={issueCrudState?.delete?.toggle}
handleClose={() => {
handleIssueCrudState("delete", null, null);
toggleDeleteIssueModal(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
)
}
isSubIssue
/>
)}
</>
)}
</div>
);
});