[WEB-3838]feat:sub work items sorting (#6967)

* refactor: sub-work items components, hooks and types

* feat: added orderby and display properties toggle for sub work items

* fix: build errors

* chore: removed issue type from filters

* chore: added null check

* fix: added null check

---------

Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>
This commit is contained in:
Vamsi Krishna 2025-04-29 15:23:10 +05:30 committed by GitHub
parent 55340f9f48
commit 14dc6a56bc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 843 additions and 995 deletions

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

@ -0,0 +1,70 @@
import { FC } from "react";
import isEmpty from "lodash/isEmpty";
import { observer } from "mobx-react";
import { IIssueDisplayFilterOptions, ILayoutDisplayFiltersOptions, IIssueDisplayProperties } from "@plane/types";
import { DisplayPropertiesIcon } from "@plane/ui";
import { FilterDisplayProperties, FilterOrderBy, FiltersDropdown } from "@/components/issues";
type TSubIssueDisplayFiltersProps = {
displayProperties: IIssueDisplayProperties;
displayFilters: IIssueDisplayFilterOptions;
handleDisplayFiltersUpdate: (updatedDisplayFilter: Partial<IIssueDisplayFilterOptions>) => void;
handleDisplayPropertiesUpdate: (updatedDisplayProperties: Partial<IIssueDisplayProperties>) => void;
layoutDisplayFiltersOptions: ILayoutDisplayFiltersOptions | undefined;
isEpic?: boolean;
};
export const SubIssueDisplayFilters: FC<TSubIssueDisplayFiltersProps> = observer((props) => {
const {
isEpic = false,
displayProperties,
layoutDisplayFiltersOptions,
handleDisplayPropertiesUpdate,
handleDisplayFiltersUpdate,
displayFilters,
} = props;
return (
<>
{layoutDisplayFiltersOptions?.display_filters && layoutDisplayFiltersOptions?.display_properties.length > 0 && (
<FiltersDropdown
placement="bottom-end"
menuButton={<DisplayPropertiesIcon className="h-3.5 w-3.5 text-custom-text-100" />}
>
<div
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
}}
className="vertical-scrollbar scrollbar-sm relative h-full w-full divide-y divide-custom-border-200 overflow-hidden overflow-y-auto px-2.5 max-h-[25rem] text-left"
>
{/* display properties */}
<div className="py-2">
<FilterDisplayProperties
displayProperties={displayProperties}
displayPropertiesToRender={layoutDisplayFiltersOptions.display_properties}
handleUpdate={handleDisplayPropertiesUpdate}
isEpic={isEpic}
/>
</div>
{/* order by */}
{!isEmpty(layoutDisplayFiltersOptions?.display_filters?.order_by) && (
<div className="py-2">
<FilterOrderBy
selectedOrderBy={displayFilters?.order_by}
handleUpdate={(val) =>
handleDisplayFiltersUpdate({
order_by: val,
})
}
orderByOptions={layoutDisplayFiltersOptions?.display_filters.order_by ?? []}
/>
</div>
)}
</div>
</FiltersDropdown>
)}
</>
);
});

View file

@ -1,29 +1,23 @@
"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): TSubIssueOperations => {
// router
const { epicId: epicIdParam } = useParams();
const pathname = usePathname();
// translation
const { t } = useTranslation();
// store hooks
const {
@ -46,9 +40,8 @@ export const useSubIssueOperations = (issueServiceType: TIssueServiceType): TSub
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"),
@ -61,7 +54,7 @@ export const useSubIssueOperations = (issueServiceType: TIssueServiceType): TSub
});
});
},
fetchSubIssues: async (workspaceSlug: string, projectId: string, parentIssueId: string) => {
fetchSubIssues: async (workspaceSlug, projectId, parentIssueId) => {
try {
await fetchSubIssues(workspaceSlug, projectId, parentIssueId);
} catch {
@ -77,7 +70,7 @@ export const useSubIssueOperations = (issueServiceType: TIssueServiceType): TSub
});
}
},
addSubIssue: async (workspaceSlug: string, projectId: string, parentIssueId: string, issueIds: string[]) => {
addSubIssue: async (workspaceSlug, projectId, parentIssueId, issueIds) => {
try {
await createSubIssues(workspaceSlug, projectId, parentIssueId, issueIds);
setToast({
@ -94,7 +87,6 @@ export const useSubIssueOperations = (issueServiceType: TIssueServiceType): TSub
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
@ -105,13 +97,13 @@ export const useSubIssueOperations = (issueServiceType: TIssueServiceType): TSub
}
},
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);
@ -172,7 +164,7 @@ export const useSubIssueOperations = (issueServiceType: TIssueServiceType): TSub
});
}
},
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);
@ -218,7 +210,7 @@ export const useSubIssueOperations = (issueServiceType: TIssueServiceType): TSub
});
}
},
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(() => {
@ -244,20 +236,20 @@ export const useSubIssueOperations = (issueServiceType: TIssueServiceType): TSub
},
}),
[
fetchSubIssues,
captureIssueEvent,
createSubIssues,
epicId,
updateSubIssue,
removeSubIssue,
deleteSubIssue,
setSubIssueHelpers,
epicId,
fetchSubIssues,
getIssueById,
getStateById,
updateAnalytics,
captureIssueEvent,
pathname,
t,
issueServiceType,
pathname,
removeSubIssue,
setSubIssueHelpers,
t,
updateAnalytics,
updateSubIssue,
]
);

View file

@ -2,3 +2,4 @@ export * from "./content";
export * from "./title";
export * from "./root";
export * from "./quick-action-button";
export * from "./display-filters";

View file

@ -1,14 +1,15 @@
"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 { useSubIssueOperations } from "@/components/issues/issue-detail-widgets/sub-issues/helper";
import { WithDisplayPropertiesHOC } from "@/components/issues/issue-layouts/properties/with-display-properties-HOC";
import { cn } from "@/helpers/common.helper";
import { generateWorkItemLink } from "@/helpers/issue.helper";
// hooks
@ -18,15 +19,10 @@ import { usePlatformOS } from "@/hooks/use-platform-os";
// plane web components
import { IssueIdentifier } from "@/plane-web/components/issues";
// local components
import { useSubIssueOperations } from "../issue-detail-widgets/sub-issues/helper";
import { IssueList } from "./issues-list";
import { IssueProperty } from "./properties";
// ui
// types
import { TSubIssueOperations } from "./root";
// import { ISubIssuesRootLoaders, ISubIssuesRootLoadersHandler } from "./root";
import { SubIssuesListItemProperties } from "./properties";
import { SubIssuesListRoot } from "./root";
export interface ISubIssues {
type Props = {
workspaceSlug: string;
projectId: string;
parentIssueId: string;
@ -41,9 +37,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,
@ -59,6 +55,9 @@ export const IssueListItem: React.FC<ISubIssues> = observer((props) => {
const { t } = useTranslation();
const {
issue: { getIssueById },
subIssues: {
filters: { getSubIssueFilters },
},
} = useIssueDetail(issueServiceType);
const {
subIssues: { subIssueHelpersByIssueId, setSubIssueHelpers },
@ -80,6 +79,10 @@ export const IssueListItem: React.FC<ISubIssues> = observer((props) => {
const subIssueHelpers = subIssueHelpersByIssueId(parentIssueId);
const subIssueCount = issue?.sub_issues_count ?? 0;
// derived values
const subIssueFilters = getSubIssueFilters(parentIssueId);
const displayProperties = subIssueFilters.displayProperties ?? {};
//
const handleIssuePeekOverview = (issue: TIssue) => handleRedirection(workspaceSlug, issue, isMobile);
@ -150,17 +153,19 @@ export const IssueListItem: React.FC<ISubIssues> = observer((props) => {
backgroundColor: currentIssueStateDetail?.color ?? "#737373",
}}
/>
<div className="flex-shrink-0">
{projectDetail && (
<IssueIdentifier
projectId={projectDetail.id}
issueTypeId={issue.type_id}
projectIdentifier={projectDetail.identifier}
issueSequenceId={issue.sequence_id}
textContainerClassName="text-xs text-custom-text-200"
/>
)}
</div>
<WithDisplayPropertiesHOC displayProperties={displayProperties || {}} displayPropertyKey="key">
<div className="flex-shrink-0">
{projectDetail && (
<IssueIdentifier
projectId={projectDetail.id}
issueTypeId={issue.type_id}
projectIdentifier={projectDetail.identifier}
issueSequenceId={issue.sequence_id}
textContainerClassName="text-xs text-custom-text-200"
/>
)}
</div>
</WithDisplayPropertiesHOC>
<Tooltip tooltipContent={issue.name} isMobile={isMobile}>
<span className="w-full truncate text-sm text-custom-text-100">{issue.name}</span>
</Tooltip>
@ -173,13 +178,14 @@ export const IssueListItem: React.FC<ISubIssues> = observer((props) => {
e.stopPropagation();
}}
>
<IssueProperty
<SubIssuesListItemProperties
workspaceSlug={workspaceSlug}
parentIssueId={parentIssueId}
issueId={issueId}
disabled={disabled}
subIssueOperations={subIssueOperations}
issueServiceType={issueServiceType}
updateSubIssue={subIssueOperations.updateSubIssue}
displayProperties={displayProperties}
issue={issue}
/>
</div>
@ -205,7 +211,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">
@ -258,7 +264,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

@ -0,0 +1,161 @@
// plane imports
import { SyntheticEvent } from "react";
import { observer } from "mobx-react";
import { CalendarClock } from "lucide-react";
import { useTranslation } from "@plane/i18n";
import { IIssueDisplayProperties, TIssue } from "@plane/types";
// components
import { PriorityDropdown, MemberDropdown, StateDropdown, DateDropdown } from "@/components/dropdowns";
// hooks
import { WithDisplayPropertiesHOC } from "@/components/issues/issue-layouts/properties/with-display-properties-HOC";
import { getDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper";
type Props = {
workspaceSlug: string;
parentIssueId: string;
issueId: string;
disabled: boolean;
updateSubIssue: (
workspaceSlug: string,
projectId: string,
parentIssueId: string,
issueId: string,
issueData: Partial<TIssue>,
oldIssue?: Partial<TIssue>
) => Promise<void>;
displayProperties?: IIssueDisplayProperties;
issue: TIssue;
};
export const SubIssuesListItemProperties: React.FC<Props> = observer((props) => {
const { workspaceSlug, parentIssueId, issueId, disabled, updateSubIssue, displayProperties, issue } = props;
// hooks
const { t } = useTranslation();
const handleEventPropagation = (e: SyntheticEvent<HTMLDivElement>) => {
e.stopPropagation();
e.preventDefault();
};
if (!displayProperties) return <></>;
const maxDate = getDate(issue.target_date);
maxDate?.setDate(maxDate.getDate());
return (
<div className="relative flex items-center gap-2">
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="start_date">
<div className="h-5 flex-shrink-0" onFocus={handleEventPropagation} onClick={handleEventPropagation}>
<DateDropdown
value={issue.start_date ?? null}
onChange={(val) =>
issue.project_id &&
updateSubIssue(
workspaceSlug,
issue.project_id,
parentIssueId,
issueId,
{
start_date: val ? renderFormattedPayloadDate(val) : null,
},
{ ...issue }
)
}
maxDate={maxDate}
placeholder={t("common.order_by.start_date")}
icon={<CalendarClock className="h-3 w-3 flex-shrink-0" />}
buttonVariant={issue.start_date ? "border-with-text" : "border-without-text"}
optionsClassName="z-30"
disabled={!disabled}
/>
</div>
</WithDisplayPropertiesHOC>
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="due_date">
<div className="h-5 flex-shrink-0" onFocus={handleEventPropagation} onClick={handleEventPropagation}>
<DateDropdown
value={issue.target_date ?? null}
onChange={(val) =>
issue.project_id &&
updateSubIssue(
workspaceSlug,
issue.project_id,
parentIssueId,
issueId,
{
target_date: val ? renderFormattedPayloadDate(val) : null,
},
{ ...issue }
)
}
maxDate={maxDate}
placeholder={t("common.order_by.due_date")}
icon={<CalendarClock className="h-3 w-3 flex-shrink-0" />}
buttonVariant={issue.target_date ? "border-with-text" : "border-without-text"}
optionsClassName="z-30"
disabled={!disabled}
/>
</div>
</WithDisplayPropertiesHOC>
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="state">
<div className="h-5 flex-shrink-0">
<StateDropdown
value={issue.state_id}
projectId={issue.project_id ?? undefined}
onChange={(val) =>
issue.project_id &&
updateSubIssue(
workspaceSlug,
issue.project_id,
parentIssueId,
issueId,
{
state_id: val,
},
{ ...issue }
)
}
disabled={!disabled}
buttonVariant="border-with-text"
/>
</div>
</WithDisplayPropertiesHOC>
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="priority">
<div className="h-5 flex-shrink-0">
<PriorityDropdown
value={issue.priority}
onChange={(val) =>
issue.project_id &&
updateSubIssue(workspaceSlug, issue.project_id, parentIssueId, issueId, {
priority: val,
})
}
disabled={!disabled}
buttonVariant="border-without-text"
buttonClassName="border"
/>
</div>
</WithDisplayPropertiesHOC>
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="assignee">
<div className="h-5 flex-shrink-0">
<MemberDropdown
value={issue.assignee_ids}
projectId={issue.project_id ?? undefined}
onChange={(val) =>
issue.project_id &&
updateSubIssue(workspaceSlug, issue.project_id, parentIssueId, issueId, {
assignee_ids: val,
})
}
disabled={!disabled}
multiple
buttonVariant={(issue?.assignee_ids || []).length > 0 ? "transparent-without-text" : "border-without-text"}
buttonClassName={(issue?.assignee_ids || []).length > 0 ? "hover:bg-transparent px-0" : ""}
/>
</div>
</WithDisplayPropertiesHOC>
</div>
);
});

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 (
@ -33,7 +33,8 @@ export const SubIssuesCollapsible: FC<Props> = observer((props) => {
isOpen={isCollapsibleOpen}
parentIssueId={issueId}
disabled={disabled}
issueServiceType={issueServiceType}
projectId={projectId}
workspaceSlug={workspaceSlug}
/>
}
buttonClassName="w-full"

View file

@ -0,0 +1,69 @@
import { FC, useCallback } from "react";
import { observer } from "mobx-react";
import { EIssueFilterType, EIssueServiceType, ISSUE_DISPLAY_FILTERS_BY_PAGE } from "@plane/constants";
import { IIssueDisplayFilterOptions, IIssueDisplayProperties, TIssueServiceType } from "@plane/types";
import { useIssueDetail } from "@/hooks/store";
import { SubIssueDisplayFilters } from "./display-filters";
import { SubIssuesActionButton } from "./quick-action-button";
type TSubWorkItemTitleActionsProps = {
disabled: boolean;
issueServiceType?: TIssueServiceType;
parentId: string;
workspaceSlug: string;
projectId: string;
};
export const SubWorkItemTitleActions: FC<TSubWorkItemTitleActionsProps> = observer((props) => {
const { disabled, issueServiceType = EIssueServiceType.ISSUES, parentId, workspaceSlug, projectId } = props;
// store hooks
const {
subIssues: {
filters: { getSubIssueFilters, updateSubIssueFilters },
},
} = useIssueDetail(issueServiceType);
// derived values
const subIssueFilters = getSubIssueFilters(parentId);
const layoutDisplayFiltersOptions = ISSUE_DISPLAY_FILTERS_BY_PAGE["sub_work_items"].list;
const handleDisplayFilters = useCallback(
(updatedDisplayFilter: Partial<IIssueDisplayFilterOptions>) => {
if (!workspaceSlug || !projectId) return;
updateSubIssueFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, updatedDisplayFilter, parentId);
},
[workspaceSlug, projectId, parentId, updateSubIssueFilters]
);
const handleDisplayPropertiesUpdate = useCallback(
(updatedDisplayProperties: Partial<IIssueDisplayProperties>) => {
if (!workspaceSlug || !projectId) return;
updateSubIssueFilters(
workspaceSlug,
projectId,
EIssueFilterType.DISPLAY_PROPERTIES,
updatedDisplayProperties,
parentId
);
},
[workspaceSlug, projectId, parentId, updateSubIssueFilters]
);
return (
<div className="flex items-center gap-2">
<SubIssueDisplayFilters
isEpic={issueServiceType === EIssueServiceType.EPICS}
layoutDisplayFiltersOptions={layoutDisplayFiltersOptions}
displayProperties={subIssueFilters?.displayProperties ?? {}}
displayFilters={subIssueFilters?.displayFilters ?? {}}
handleDisplayPropertiesUpdate={handleDisplayPropertiesUpdate}
handleDisplayFiltersUpdate={handleDisplayFilters}
/>
{!disabled && (
<SubIssuesActionButton issueId={parentId} disabled={disabled} issueServiceType={issueServiceType} />
)}
</div>
);
});

View file

@ -1,34 +1,46 @@
"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";
import { CircularProgressIndicator, CollapsibleButton } from "@plane/ui";
// components
import { SubIssuesActionButton } from "@/components/issues/issue-detail-widgets";
// hooks
import { useIssueDetail } from "@/hooks/store";
import { SubWorkItemTitleActions } from "./title-actions";
type Props = {
isOpen: boolean;
parentIssueId: string;
disabled: boolean;
issueServiceType?: TIssueServiceType;
projectId: string;
workspaceSlug: string;
};
export const SubIssuesCollapsibleTitle: FC<Props> = observer((props) => {
const { isOpen, parentIssueId, disabled, issueServiceType = EIssueServiceType.ISSUES } = props;
const {
isOpen,
parentIssueId,
disabled,
issueServiceType = EIssueServiceType.ISSUES,
projectId,
workspaceSlug,
} = props;
// translation
const { t } = useTranslation();
// store hooks
const {
subIssues: { subIssuesByIssueId, stateDistributionByIssueId },
subIssues: {
subIssuesByIssueId,
stateDistributionByIssueId,
},
} = useIssueDetail(issueServiceType);
// derived data
// derived values
const subIssuesDistribution = stateDistributionByIssueId(parentIssueId);
const subIssues = subIssuesByIssueId(parentIssueId);
// if there are no sub-issues, return null
if (!subIssues) return null;
@ -50,9 +62,13 @@ export const SubIssuesCollapsibleTitle: FC<Props> = observer((props) => {
</div>
}
actionItemElement={
!disabled && (
<SubIssuesActionButton issueId={parentIssueId} disabled={disabled} issueServiceType={issueServiceType} />
)
<SubWorkItemTitleActions
workspaceSlug={workspaceSlug}
projectId={projectId}
parentId={parentIssueId}
disabled={disabled}
issueServiceType={issueServiceType}
/>
}
/>
);

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,86 +0,0 @@
import React from "react";
import { TIssueServiceType } from "@plane/types";
// hooks
import { PriorityDropdown, MemberDropdown, StateDropdown } from "@/components/dropdowns";
import { useIssueDetail } from "@/hooks/store";
// components
// types
import { TSubIssueOperations } from "./root";
export interface IIssueProperty {
workspaceSlug: string;
parentIssueId: string;
issueId: string;
disabled: boolean;
subIssueOperations: TSubIssueOperations;
issueServiceType?: TIssueServiceType;
}
export const IssueProperty: React.FC<IIssueProperty> = (props) => {
const { workspaceSlug, parentIssueId, issueId, disabled, subIssueOperations, issueServiceType } = props;
// hooks
const {
issue: { getIssueById },
} = useIssueDetail(issueServiceType);
const issue = getIssueById(issueId);
if (!issue) return <></>;
return (
<div className="relative flex items-center gap-2">
<div className="h-5 flex-shrink-0">
<StateDropdown
value={issue.state_id}
projectId={issue.project_id ?? undefined}
onChange={(val) =>
issue.project_id &&
subIssueOperations.updateSubIssue(
workspaceSlug,
issue.project_id,
parentIssueId,
issueId,
{
state_id: val,
},
{ ...issue }
)
}
disabled={!disabled}
buttonVariant="border-with-text"
/>
</div>
<div className="h-5 flex-shrink-0">
<PriorityDropdown
value={issue.priority}
onChange={(val) =>
issue.project_id &&
subIssueOperations.updateSubIssue(workspaceSlug, issue.project_id, parentIssueId, issueId, {
priority: val,
})
}
disabled={!disabled}
buttonVariant="border-without-text"
buttonClassName="border"
/>
</div>
<div className="h-5 flex-shrink-0">
<MemberDropdown
value={issue.assignee_ids}
projectId={issue.project_id ?? undefined}
onChange={(val) =>
issue.project_id &&
subIssueOperations.updateSubIssue(workspaceSlug, issue.project_id, parentIssueId, issueId, {
assignee_ids: val,
})
}
disabled={!disabled}
multiple
buttonVariant={(issue?.assignee_ids || []).length > 0 ? "transparent-without-text" : "border-without-text"}
buttonClassName={(issue?.assignee_ids || []).length > 0 ? "hover:bg-transparent px-0" : ""}
/>
</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>
);
});