[WEB-3560] feat: epic to work item and vice versa conversion (#2977)

* feat: conversion of epic to work item and vice versa

* chore: delete related cycles and modules

* chore: delete related cycles, modules and parent

* feat: activitites

* fix: None for requested_data and current_instance

* chore: convertWorkItem method, services and types added

* chore: conversion component added

* chore: epic and work item modal updated

* chore: modal state and mutation changes

* chore: conversion action button peek view implementation

* chore: conversion action button detail page implementation

* chore: mutation

* chore: epic enable check added

* chore: toast message updated

* chore: code refactor

* chore: code refactor

* chore: remove prefetch_related and select_related

* fix: work item type conversion and additional properties

* chore: code refactor

* chore: code refactoring

* chore: code refactor

* chore: feature flag added

* chore: icon updated

---------

Co-authored-by: Anmol Singh Bhatia <[email protected]>
This commit is contained in:
Sangeetha
2025-04-30 15:02:15 +05:30
committed by GitHub
co-authored by Anmol Singh Bhatia
parent 51d7e0608c
commit e55aa6d394
27 changed files with 535 additions and 43 deletions
@@ -1741,6 +1741,54 @@ def delete_customer_activity(
)
def convert_epic_to_work_item_activity(
requested_data,
current_instance,
issue_id,
project_id,
workspace_id,
actor_id,
issue_activities,
epoch,
):
issue_activities.append(
IssueActivity(
issue_id=issue_id,
project_id=project_id,
workspace_id=workspace_id,
comment="converted the epic to work item",
field="epic",
verb="converted",
actor_id=actor_id,
epoch=epoch,
)
)
def convert_work_item_to_epic_activity(
requested_data,
current_instance,
issue_id,
project_id,
workspace_id,
actor_id,
issue_activities,
epoch,
):
issue_activities.append(
IssueActivity(
issue_id=issue_id,
project_id=project_id,
workspace_id=workspace_id,
comment="converted the work item to epic",
field="work_item",
verb="converted",
actor_id=actor_id,
epoch=epoch,
)
)
# Receive message from room group
@shared_task
def issue_activity(
@@ -1811,6 +1859,8 @@ def issue_activity(
"issue_draft.activity.deleted": delete_draft_issue_activity,
"intake.activity.created": create_intake_activity,
"epic.activity.created": create_epic_activity,
"work_item.activity.converted": convert_epic_to_work_item_activity,
"epic.activity.converted": convert_work_item_to_epic_activity,
"customer.activity.created": create_customer_activity,
"customer.activity.deleted": delete_customer_activity,
}
+8
View File
@@ -6,6 +6,7 @@ from plane.ee.views.app import (
BulkSubscribeIssuesEndpoint,
IssueWorkLogsEndpoint,
IssueTotalWorkLogEndpoint,
IssueConvertEndpoint,
)
urlpatterns = [
@@ -39,4 +40,11 @@ urlpatterns = [
IssueTotalWorkLogEndpoint.as_view(),
name="issue-work-logs",
),
# Issue Convertion Endpoint
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/conversion/<uuid:entity_id>/",
IssueConvertEndpoint.as_view(),
name="issue-conversion",
),
# End Issue Convertion Endpoint
]
+1
View File
@@ -6,6 +6,7 @@ from plane.ee.views.app.issue import (
BulkSubscribeIssuesEndpoint,
IssueWorkLogsEndpoint,
IssueTotalWorkLogEndpoint,
IssueConvertEndpoint,
)
from plane.ee.views.app.intake import ProjectInTakePublishViewSet
@@ -4,3 +4,4 @@ from .bulk_operations import (
BulkArchiveIssuesEndpoint,
BulkSubscribeIssuesEndpoint,
)
from .convert import IssueConvertEndpoint
@@ -0,0 +1,75 @@
# Module imports
from plane.ee.views.base import BaseAPIView
from plane.db.models import Issue, IssueType
# Third Party imports
from rest_framework.response import Response
from rest_framework import status
from django.utils import timezone
from plane.bgtasks.issue_activities_task import issue_activity
class IssueConvertEndpoint(BaseAPIView):
def post(self, request, slug, project_id: str, entity_id: str):
conversion_type = request.data.get("conversion_type")
issue = Issue.objects.get(id=entity_id)
if conversion_type == "epic":
issue_type = IssueType.objects.filter(
workspace__slug=slug,
project_issue_types__project_id=project_id,
is_epic=True,
level=1,
is_active=True,
).first()
if issue_type is None:
return Response(
{"error": "Epic is not enabled for this project"},
status=status.HTTP_404_NOT_FOUND,
)
# Set the issue type id to the issue type id of the epic
issue.type_id = issue_type.id
# Delete the issue cycle and issue module of the issue
issue.issue_cycle.all().delete()
issue.issue_module.all().delete()
# Set the parent id to None if it exists
if issue.parent_id is not None:
issue.parent_id = None
issue.save()
# Create the issue activity
issue_activity.delay(
type="epic.activity.converted",
issue_id=issue.id,
project_id=issue.project_id,
actor_id=request.user.id,
requested_data=None,
epoch=int(timezone.now().timestamp()),
current_instance=None,
)
elif conversion_type == "work_item":
issue.type_id = None
issue.save()
# Create the issue activity
issue_activity.delay(
type="work_item.activity.converted",
issue_id=issue.id,
project_id=issue.project_id,
actor_id=request.user.id,
requested_data=None,
epoch=int(timezone.now().timestamp()),
current_instance=None,
)
return Response(
{"message": "Converted Successfully"}, status=status.HTTP_200_OK
)
+1
View File
@@ -8,6 +8,7 @@ export enum E_FEATURE_FLAGS {
ESTIMATE_WITH_TIME = "ESTIMATE_WITH_TIME",
ISSUE_TYPES = "ISSUE_TYPES",
EPICS = "EPICS",
WORK_ITEM_CONVERSION = "WORK_ITEM_CONVERSION",
OIDC_SAML_AUTH = "OIDC_SAML_AUTH",
PAGE_ISSUE_EMBEDS = "PAGE_ISSUE_EMBEDS",
PAGE_PUBLISH = "PAGE_PUBLISH",
+5
View File
@@ -375,3 +375,8 @@ export const SPREADSHEET_PROPERTY_DETAILS: {
},
...ADDITIONAL_SPREADSHEET_PROPERTY_DETAILS,
};
export enum EWorkItemConversionType {
WORK_ITEM = "work_item",
EPIC = "epic",
}
+8 -1
View File
@@ -1,5 +1,5 @@
// types
import { EIssuePropertyType, EWorkItemTypeEntity } from "@plane/constants";
import { EIssuePropertyType, EWorkItemTypeEntity, EWorkItemConversionType } from "@plane/constants";
import {
TLoader,
TIssuePropertyPayload,
@@ -114,4 +114,11 @@ export interface IIssueTypesStore {
) => Promise<void | undefined>;
createType: (typeData: Partial<TIssueType>) => Promise<TIssueType | undefined>;
deleteType: (typeId: string) => Promise<void>;
// convert actions
convertWorkItem: (
workspaceSlug: string,
projectId: string,
issueId: string,
convertTo: EWorkItemConversionType
) => Promise<void>;
}
@@ -4,7 +4,13 @@ import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { Briefcase, Sidebar } from "lucide-react";
// plane imports
import { EIssueServiceType, EIssuesStoreType, EUserPermissionsLevel, EUserProjectRoles } from "@plane/constants";
import {
EIssueServiceType,
EIssuesStoreType,
EUserPermissionsLevel,
EUserProjectRoles,
EWorkItemConversionType,
} from "@plane/constants";
import { TIssue } from "@plane/types";
import { Breadcrumbs, EpicIcon, Header, Logo } from "@plane/ui";
// components
@@ -16,7 +22,8 @@ import { useAppTheme, useIssueDetail, useProject, useUserPermissions } from "@/h
import { useAppRouter } from "@/hooks/use-app-router";
import { useIssuesActions } from "@/hooks/use-issues-actions";
// plane-web components
import { ProjectEpicQuickActions } from "@/plane-web/components/epics";
import { ConvertWorkItemAction, ProjectEpicQuickActions } from "@/plane-web/components/epics";
import { WithFeatureFlagHOC } from "@/plane-web/components/feature-flags";
export const EpicItemDetailsHeader = observer(() => {
// router
@@ -40,7 +47,9 @@ export const EpicItemDetailsHeader = observer(() => {
const isEditingAllowed = allowPermissions(
[EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER],
EUserPermissionsLevel.PROJECT
EUserPermissionsLevel.PROJECT,
workspaceSlug?.toString(),
projectId?.toString()
);
const handleDelete = async () => {
@@ -112,6 +121,13 @@ export const EpicItemDetailsHeader = observer(() => {
<Header.RightItem>
{issueDetails && (
<div ref={parentRef} className="flex items-center gap-2">
<WithFeatureFlagHOC workspaceSlug={workspaceSlug?.toString()} flag="WORK_ITEM_CONVERSION" fallback={<></>}>
<ConvertWorkItemAction
workItemId={issueDetails?.id}
conversionType={EWorkItemConversionType.WORK_ITEM}
disabled={!isEditingAllowed}
/>
</WithFeatureFlagHOC>
<ProjectEpicQuickActions
parentRef={parentRef}
issue={issueDetails}
@@ -11,6 +11,7 @@ import {
EIssuesStoreType,
EUserPermissions,
EUserPermissionsLevel,
EWorkItemConversionType,
} from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { TOAST_TYPE, Tooltip, setToast } from "@plane/ui";
@@ -32,6 +33,8 @@ import {
} from "@/hooks/store";
import { useAppRouter } from "@/hooks/use-app-router";
import { usePlatformOS } from "@/hooks/use-platform-os";
import { ConvertWorkItemAction } from "@/plane-web/components/epics";
import { WithFeatureFlagHOC } from "@/plane-web/components/feature-flags";
type Props = {
workspaceSlug: string;
@@ -204,6 +207,13 @@ export const IssueDetailQuickActions: FC<Props> = observer((props) => {
{currentUser && !issue?.archived_at && (
<IssueSubscription workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} />
)}
<WithFeatureFlagHOC workspaceSlug={workspaceSlug?.toString()} flag="WORK_ITEM_CONVERSION" fallback={<></>}>
<ConvertWorkItemAction
workItemId={issue?.id}
conversionType={EWorkItemConversionType.EPIC}
disabled={!isEditable}
/>
</WithFeatureFlagHOC>
<div className="flex flex-wrap items-center gap-2.5 text-custom-text-300">
<Tooltip tooltipContent={t("common.actions.copy_link")} isMobile={isMobile}>
<button
+26 -14
View File
@@ -39,6 +39,7 @@ export const CreateUpdateIssueModalBase: React.FC<IssuesModalProps> = observer((
modalTitle,
primaryButtonText,
isProjectSelectionDisabled = false,
isConversionOperation = false,
} = props;
const issueStoreType = useIssueStoreType();
@@ -72,7 +73,7 @@ export const CreateUpdateIssueModalBase: React.FC<IssuesModalProps> = observer((
removeSubIssue,
} = useIssueDetail();
const { removeSubIssue: removeEpicSubIssue } = useIssueDetail(EIssueServiceType.EPICS);
const { handleCreateUpdatePropertyValues } = useIssueModal();
const { handleCreateUpdatePropertyValues, handleConvert } = useIssueModal();
const { getProjectByIdentifier } = useProject();
const { getIssueTypeById } = useIssueTypes();
// pathname
@@ -270,7 +271,10 @@ export const CreateUpdateIssueModalBase: React.FC<IssuesModalProps> = observer((
}
};
const handleUpdateIssue = async (payload: Partial<TIssue>): Promise<TIssue | undefined> => {
const handleUpdateIssue = async (
payload: Partial<TIssue>,
showToast: boolean = true
): Promise<TIssue | undefined> => {
if (!workspaceSlug || !payload.project_id || !data?.id) return;
try {
@@ -311,11 +315,13 @@ export const CreateUpdateIssueModalBase: React.FC<IssuesModalProps> = observer((
isDraft: isDraft,
});
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("success"),
message: t("issue_updated_successfully"),
});
if (showToast) {
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("success"),
message: t("issue_updated_successfully"),
});
}
captureIssueEvent({
eventName: ISSUE_UPDATED,
payload: { ...payload, issueId: data.id, state: "SUCCESS" },
@@ -324,11 +330,13 @@ export const CreateUpdateIssueModalBase: React.FC<IssuesModalProps> = observer((
handleClose();
} catch (error: any) {
console.error(error);
setToast({
type: TOAST_TYPE.ERROR,
title: t("error"),
message: error?.error ?? t("issue_could_not_be_updated"),
});
if (showToast) {
setToast({
type: TOAST_TYPE.ERROR,
title: t("error"),
message: error?.error ?? t("issue_could_not_be_updated"),
});
}
captureIssueEvent({
eventName: ISSUE_UPDATED,
payload: { ...payload, state: "FAILED" },
@@ -347,7 +355,11 @@ export const CreateUpdateIssueModalBase: React.FC<IssuesModalProps> = observer((
try {
if (beforeFormSubmit) await beforeFormSubmit();
if (!data?.id) response = await handleCreateIssue(payload, is_draft_issue);
else response = await handleUpdateIssue(payload);
else {
// if the issue is being converted, handle the conversion
if (isConversionOperation) handleConvert(workspaceSlug.toString(), data);
response = await handleUpdateIssue(payload);
}
} catch (error) {
console.error(error);
throw error;
@@ -375,7 +387,7 @@ export const CreateUpdateIssueModalBase: React.FC<IssuesModalProps> = observer((
},
onAssetUpload: handleUpdateUploadedAssetIds,
onClose: handleClose,
onSubmit: (payload) => handleFormSubmit(payload, isDraft),
onSubmit: (payload, is_draft_issue = false) => handleFormSubmit(payload, is_draft_issue),
projectId: activeProjectId,
isCreateMoreToggleEnabled: createMore,
onCreateMoreToggleChange: handleCreateMoreToggleChange,
@@ -29,6 +29,7 @@ export interface IssuesModalProps {
};
isProjectSelectionDisabled?: boolean;
templateId?: string;
isConversionOperation?: boolean;
}
export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = observer((props) => {
@@ -5,7 +5,7 @@ import { observer } from "mobx-react";
import Link from "next/link";
import { ArchiveRestoreIcon, Link2, MoveDiagonal, MoveRight, Trash2 } from "lucide-react";
// plane imports
import { ARCHIVABLE_STATE_GROUPS } from "@plane/constants";
import { ARCHIVABLE_STATE_GROUPS, EWorkItemConversionType } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { TNameDescriptionLoader } from "@plane/types";
import {
@@ -28,6 +28,8 @@ import { generateWorkItemLink } from "@/helpers/issue.helper";
import { useIssueDetail, useProject, useProjectState, useUser } from "@/hooks/store";
// hooks
import { usePlatformOS } from "@/hooks/use-platform-os";
import { ConvertWorkItemAction } from "@/plane-web/components/epics";
import { WithFeatureFlagHOC } from "@/plane-web/components/feature-flags";
export type TPeekModes = "side-peek" | "modal" | "full-screen";
const PEEK_OPTIONS: { key: TPeekModes; icon: any; i18n_title: string }[] = [
@@ -175,6 +177,9 @@ export const IssuePeekOverviewHeader: FC<PeekOverviewHeaderProps> = observer((pr
{currentUser && !isArchived && (
<IssueSubscription workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} />
)}
<WithFeatureFlagHOC workspaceSlug={workspaceSlug?.toString()} flag="WORK_ITEM_CONVERSION" fallback={<></>}>
<ConvertWorkItemAction workItemId={issueId} conversionType={EWorkItemConversionType.EPIC} />
</WithFeatureFlagHOC>
<Tooltip tooltipContent={t("common.actions.copy_link")} isMobile={isMobile}>
<button type="button" onClick={handleCopyText}>
<Link2 className="h-4 w-4 -rotate-45 text-custom-text-300 hover:text-custom-text-200" />
@@ -94,6 +94,8 @@ export interface IBaseIssuesStore {
addCycleToIssue: (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => Promise<void>;
removeCycleFromIssue: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>;
addIssueToList: (issueId: string) => void;
removeIssueFromList: (issueId: string) => void;
addIssuesToModule: (
workspaceSlug: string,
projectId: string,
@@ -80,6 +80,8 @@ export interface IIssueDetail
isRelationModalOpen: TIssueRelationModal | null;
isSubIssuesModalOpen: string | null;
attachmentDeleteModalId: string | null;
isWorkItemToEpicModalOpen: string | null;
isEpicToWorkItemModalOpen: string | null;
// computed
isAnyModalOpen: boolean;
// helper actions
@@ -100,6 +102,8 @@ export interface IIssueDetail
toggleOpenWidget: (state: TIssueDetailWidget) => void;
setRelationKey: (relationKey: TIssueRelationTypes | null) => void;
setIssueCrudOperationState: (state: TIssueCrudOperationState) => void;
toggleWorkItemToEpicModal: (value: string | null) => void;
toggleEpicToWorkItemModal: (value: string | null) => void;
// store
rootIssueStore: IIssueRootStore;
issue: IIssueStore;
@@ -141,6 +145,8 @@ export class IssueDetail implements IIssueDetail {
isRelationModalOpen: TIssueRelationModal | null = null;
isSubIssuesModalOpen: string | null = null;
attachmentDeleteModalId: string | null = null;
isWorkItemToEpicModalOpen: string | null = null;
isEpicToWorkItemModalOpen: string | null = null;
// service type
serviceType: TIssueServiceType;
// store
@@ -173,6 +179,8 @@ export class IssueDetail implements IIssueDetail {
attachmentDeleteModalId: observable.ref,
openWidgets: observable.ref,
lastWidgetAction: observable.ref,
isWorkItemToEpicModalOpen: observable.ref,
isEpicToWorkItemModalOpen: observable.ref,
// computed
isAnyModalOpen: computed,
// action
@@ -186,6 +194,8 @@ export class IssueDetail implements IIssueDetail {
toggleRelationModal: action,
toggleSubIssuesModal: action,
toggleDeleteAttachmentModal: action,
toggleWorkItemToEpicModal: action,
toggleEpicToWorkItemModal: action,
setOpenWidgets: action,
setLastWidgetAction: action,
toggleOpenWidget: action,
@@ -218,7 +228,9 @@ export class IssueDetail implements IIssueDetail {
!!this.isArchiveIssueModalOpen ||
!!this.isRelationModalOpen?.issueId ||
!!this.isSubIssuesModalOpen ||
!!this.attachmentDeleteModalId
!!this.attachmentDeleteModalId ||
!!this.isWorkItemToEpicModalOpen ||
!!this.isEpicToWorkItemModalOpen
);
}
@@ -251,7 +263,8 @@ export class IssueDetail implements IIssueDetail {
else this.openWidgets = [state, ...this.openWidgets];
};
setIssueLinkData = (issueLinkData: TIssueLink | null) => (this.issueLinkData = issueLinkData);
toggleWorkItemToEpicModal = (value: string | null) => (this.isWorkItemToEpicModalOpen = value);
toggleEpicToWorkItemModal = (value: string | null) => (this.isEpicToWorkItemModalOpen = value);
// issue
fetchIssue = async (
workspaceSlug: string,
@@ -0,0 +1,42 @@
"use client";
import React, { FC } from "react";
import { ArrowRight } from "lucide-react";
// plane imports
import { EWorkItemConversionType } from "@plane/constants";
import { EpicIcon, LayersIcon, Tooltip } from "@plane/ui";
import { cn } from "@plane/utils";
interface ConvertWorkItemIconProps {
handleOnClick: () => void;
conversionType: EWorkItemConversionType;
disabled?: boolean;
}
export const ConvertWorkItemIcon: FC<ConvertWorkItemIconProps> = (props) => {
const { handleOnClick, conversionType, disabled = false } = props;
// derived values
const IconComponent = conversionType === EWorkItemConversionType.WORK_ITEM ? LayersIcon : EpicIcon;
const tooltipContent =
conversionType === EWorkItemConversionType.WORK_ITEM ? "Convert to Work item" : "Convert to Epic";
const commonIconClasses = "size-4 text-current";
return (
<Tooltip tooltipContent={tooltipContent}>
<button
type="button"
className={cn(
"flex items-center justify-center gap-[1px]",
"text-custom-text-300 hover:text-custom-text-100",
"hover:cursor-pointer",
disabled && "cursor-not-allowed text-custom-text-400"
)}
onClick={handleOnClick}
disabled={disabled}
aria-label={tooltipContent}
>
<ArrowRight className={commonIconClasses} />
<IconComponent className={commonIconClasses} />
</button>
</Tooltip>
);
};
@@ -0,0 +1,2 @@
export * from "./icon";
export * from "./root";
@@ -0,0 +1,107 @@
"use client";
import { FC } from "react";
import { observer } from "mobx-react";
// plane imports
import { EIssueServiceType, EIssuesStoreType, EWorkItemConversionType } from "@plane/constants";
// components
import { CreateUpdateIssueModal } from "@/components/issues";
// hooks
import { useIssueDetail } from "@/hooks/store";
// local imports
import { useIssueTypes } from "@/plane-web/hooks/store";
import { useProjectAdvanced } from "@/plane-web/hooks/store/projects/use-projects";
import { CreateUpdateEpicModal } from "../epic-modal";
import { ConvertWorkItemIcon } from "./icon";
type Props = {
workItemId: string;
conversionType: EWorkItemConversionType;
disabled?: boolean;
};
export const ConvertWorkItemAction: FC<Props> = observer((props) => {
const { workItemId, conversionType, disabled = false } = props;
// hooks
const {
issue: { getIssueById: getEpicById },
isEpicToWorkItemModalOpen,
toggleEpicToWorkItemModal,
setPeekIssue: setEpicPeek,
} = useIssueDetail(EIssueServiceType.EPICS);
const {
issue: { getIssueById },
isWorkItemToEpicModalOpen,
toggleWorkItemToEpicModal,
setPeekIssue,
} = useIssueDetail();
const { getProjectFeatures } = useProjectAdvanced();
const { getProjectDefaultIssueType, getProjectEpicId } = useIssueTypes();
// derived values
const issue =
conversionType === EWorkItemConversionType.WORK_ITEM ? getEpicById(workItemId) : getIssueById(workItemId);
const projectId = issue?.project_id;
const projectFeatures = projectId ? getProjectFeatures(projectId) : undefined;
const isEpicsEnabled = projectFeatures?.is_epic_enabled;
const defaultIssueType = projectId ? getProjectDefaultIssueType(projectId) : undefined;
const defaultEpicId = projectId ? getProjectEpicId(projectId) : undefined;
const handleClose = () => {
if (conversionType === EWorkItemConversionType.WORK_ITEM) toggleEpicToWorkItemModal(null);
else toggleWorkItemToEpicModal(null);
setPeekIssue(undefined);
setEpicPeek(undefined);
};
const handleClick = () => {
if (conversionType === EWorkItemConversionType.WORK_ITEM) toggleEpicToWorkItemModal(workItemId);
else toggleWorkItemToEpicModal(workItemId);
};
if (!isEpicsEnabled) return null;
return (
<>
<ConvertWorkItemIcon handleOnClick={handleClick} disabled={disabled} conversionType={conversionType} />
{issue && isEpicToWorkItemModalOpen === workItemId && conversionType === EWorkItemConversionType.WORK_ITEM && (
<CreateUpdateIssueModal
isOpen={!!isEpicToWorkItemModalOpen}
onClose={handleClose}
storeType={EIssuesStoreType.PROJECT}
data={{
...issue,
is_epic: false,
type_id: defaultIssueType?.id,
}}
isProjectSelectionDisabled
fetchIssueDetails={false}
primaryButtonText={{
default: "Convert to work item",
loading: "Converting...",
}}
withDraftIssueWrapper={false}
isConversionOperation
/>
)}
{issue && isWorkItemToEpicModalOpen === workItemId && conversionType === EWorkItemConversionType.EPIC && (
<CreateUpdateEpicModal
isOpen={!!isWorkItemToEpicModalOpen}
onClose={handleClose}
data={{
...issue,
is_epic: true,
type_id: defaultEpicId,
}}
isProjectSelectionDisabled
fetchIssueDetails={false}
isConversionOperation
primaryButtonText={{
default: "Convert to epic",
loading: "Converting...",
}}
/>
)}
</>
);
});
+25 -13
View File
@@ -29,6 +29,7 @@ export const CreateUpdateEpicModalBase: React.FC<IssuesModalProps> = observer((p
fetchIssueDetails = true,
primaryButtonText,
isProjectSelectionDisabled = false,
isConversionOperation = false,
} = props;
// ref
const issueTitleRef = useRef<HTMLInputElement>(null);
@@ -41,7 +42,7 @@ export const CreateUpdateEpicModalBase: React.FC<IssuesModalProps> = observer((p
const { workspaceSlug, projectId: routerProjectId } = useParams();
const { projectsWithCreatePermissions } = useUser();
const { fetchIssue } = useIssueDetail(EIssueServiceType.EPICS);
const { handleCreateUpdatePropertyValues } = useIssueModal();
const { handleCreateUpdatePropertyValues, handleConvert } = useIssueModal();
// pathname
const pathname = usePathname();
// current store details
@@ -164,7 +165,10 @@ export const CreateUpdateEpicModalBase: React.FC<IssuesModalProps> = observer((p
}
};
const handleUpdateIssue = async (payload: Partial<TIssue>): Promise<TIssue | undefined> => {
const handleUpdateIssue = async (
payload: Partial<TIssue>,
showToast: boolean = true
): Promise<TIssue | undefined> => {
if (!workspaceSlug || !payload.project_id || !data?.id) return;
try {
@@ -178,11 +182,14 @@ export const CreateUpdateEpicModalBase: React.FC<IssuesModalProps> = observer((p
workspaceSlug: workspaceSlug?.toString(),
});
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Epic updated successfully.",
});
if (showToast) {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Epic updated successfully.",
});
}
captureIssueEvent({
eventName: ISSUE_UPDATED,
payload: { ...payload, issueId: data.id, state: "SUCCESS" },
@@ -191,11 +198,13 @@ export const CreateUpdateEpicModalBase: React.FC<IssuesModalProps> = observer((p
handleClose();
} catch (error) {
console.error(error);
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Epic could not be updated. Please try again.",
});
if (showToast) {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Epic could not be updated. Please try again.",
});
}
captureIssueEvent({
eventName: ISSUE_UPDATED,
payload: { ...payload, state: "FAILED" },
@@ -214,7 +223,10 @@ export const CreateUpdateEpicModalBase: React.FC<IssuesModalProps> = observer((p
try {
if (beforeFormSubmit) await beforeFormSubmit();
if (!data?.id) response = await handleCreateIssue(payload);
else response = await handleUpdateIssue(payload);
else {
if (isConversionOperation) handleConvert(workspaceSlug.toString(), data);
response = await handleUpdateIssue(payload, !isConversionOperation);
}
} catch (error) {
console.error(error);
throw error;
@@ -22,6 +22,7 @@ export interface EpicModalProps {
loading: string;
};
isProjectSelectionDisabled?: boolean;
isConversionOperation?: boolean;
}
export const CreateUpdateEpicModal: React.FC<EpicModalProps> = observer(
@@ -1,9 +1,10 @@
import React, { useState } from "react";
import { observer } from "mobx-react-lite";
import { mutate } from "swr";
import { EIssueServiceType } from "@plane/constants";
import { EIssueServiceType, EWorkItemConversionType } from "@plane/constants";
// plane imports
import { ISearchIssueResponse, TIssuePropertyValueErrors, TIssuePropertyValues } from "@plane/types";
import { useTranslation } from "@plane/i18n";
import { ISearchIssueResponse, TIssue, TIssuePropertyValueErrors, TIssuePropertyValues } from "@plane/types";
import { setToast, TOAST_TYPE } from "@plane/ui";
import { getPropertiesDefaultValues } from "@plane/utils";
// components
@@ -33,8 +34,10 @@ export const EpicModalProvider = observer((props: TEpicModalProviderProps) => {
const [issuePropertyValues, setIssuePropertyValues] = useState<TIssuePropertyValues>({});
const [issuePropertyValueErrors, setIssuePropertyValueErrors] = useState<TIssuePropertyValueErrors>({});
// plane web hooks
const { isEpicEnabledForProject, getIssueTypeById, getIssueTypeProperties, getProjectEpicId } = useIssueTypes();
const { isEpicEnabledForProject, getIssueTypeById, getIssueTypeProperties, getProjectEpicId, convertWorkItem } =
useIssueTypes();
const { fetchPropertyActivities } = useIssuePropertiesActivity();
const { t } = useTranslation();
// helpers
const getIssueTypeIdOnProjectChange = (projectId: string) => {
// get active issue types for the project
@@ -131,6 +134,31 @@ export const EpicModalProvider = observer((props: TEpicModalProviderProps) => {
});
};
/**
* Used to handle work item conversion
*/
const handleConvert = async (workspaceSlug: string, data: Partial<TIssue> | undefined) => {
if (data?.id && data?.project_id) {
await convertWorkItem(workspaceSlug.toString(), data.project_id, data?.id, EWorkItemConversionType.EPIC)
.then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("success"),
message: "Work item converted to epic successfully",
});
})
.catch((error) => {
console.error(error);
setToast({
type: TOAST_TYPE.ERROR,
title: t("error"),
message: "Work item could not be converted to epic. Please try again.",
});
throw error;
});
}
};
return (
<IssueModalContext.Provider
value={{
@@ -150,6 +178,7 @@ export const EpicModalProvider = observer((props: TEpicModalProviderProps) => {
handleCreateUpdatePropertyValues,
handleProjectEntitiesFetch: () => Promise.resolve(),
handleTemplateChange: () => Promise.resolve(),
handleConvert,
}}
>
{children}
+1
View File
@@ -2,3 +2,4 @@ export * from "./epic-modal";
export * from "./epic-quick-action";
export * from "./peek-overview";
export * from "./settings";
export * from "./conversions";
@@ -5,7 +5,13 @@ import { observer } from "mobx-react";
import Link from "next/link";
import { Link2, MoveDiagonal, MoveRight, Sidebar } from "lucide-react";
// plane imports
import { EIssueServiceType, EIssuesStoreType, EUserProjectRoles, EUserPermissionsLevel } from "@plane/constants";
import {
EIssueServiceType,
EIssuesStoreType,
EUserProjectRoles,
EUserPermissionsLevel,
EWorkItemConversionType,
} from "@plane/constants";
import { TIssue } from "@plane/types";
import {
CenterPanelIcon,
@@ -29,6 +35,8 @@ import { useIssuesActions } from "@/hooks/use-issues-actions";
// hooks
import { usePlatformOS } from "@/hooks/use-platform-os";
import { WithFeatureFlagHOC } from "../../feature-flags";
import { ConvertWorkItemAction } from "../conversions";
import { ProjectEpicQuickActions } from "../epic-quick-action";
export type TPeekModes = "side-peek" | "modal" | "full-screen";
@@ -192,6 +200,9 @@ export const EpicPeekOverviewHeader: FC<PeekOverviewHeaderProps> = observer((pro
<div className="flex items-center gap-x-4">
<NameDescriptionUpdateStatus isSubmitting={isSubmitting} />
<div className="flex items-center gap-4">
<WithFeatureFlagHOC workspaceSlug={workspaceSlug?.toString()} flag="WORK_ITEM_CONVERSION" fallback={<></>}>
<ConvertWorkItemAction workItemId={issueId} conversionType={EWorkItemConversionType.WORK_ITEM} />
</WithFeatureFlagHOC>
<Tooltip tooltipContent="Copy link" isMobile={isMobile}>
<button type="button" onClick={handleCopyText}>
<Link2 className="h-4 w-4 -rotate-45 text-custom-text-300 hover:text-custom-text-200" />
@@ -2,8 +2,9 @@ import React, { useCallback, useState } from "react";
import { observer } from "mobx-react-lite";
import { mutate } from "swr";
// plane imports
import { DEFAULT_WORK_ITEM_FORM_VALUES, EWorkItemTypeEntity } from "@plane/constants";
import { ISearchIssueResponse, TIssuePropertyValueErrors, TIssuePropertyValues } from "@plane/types";
import { DEFAULT_WORK_ITEM_FORM_VALUES, EWorkItemConversionType, EWorkItemTypeEntity } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { ISearchIssueResponse, TIssue, TIssuePropertyValueErrors, TIssuePropertyValues } from "@plane/types";
import { setToast, TOAST_TYPE } from "@plane/ui";
import {
extractAndSanitizeCustomPropertyValuesFormData,
@@ -39,6 +40,7 @@ export const IssueModalProvider = observer((props: TIssueModalProviderProps) =>
const [issuePropertyValues, setIssuePropertyValues] = useState<TIssuePropertyValues>({});
const [issuePropertyValueErrors, setIssuePropertyValueErrors] = useState<TIssuePropertyValueErrors>({});
// store hooks
const { t } = useTranslation();
const { getProjectStateIds, getAvailableWorkItemCreationStateIds, fetchProjectStates } = useProjectState();
const { getProjectLabelIds, fetchProjectLabels } = useLabel();
const { getModulesFetchStatusByProjectId, getProjectModuleIds, fetchModules } = useModule();
@@ -49,6 +51,7 @@ export const IssueModalProvider = observer((props: TIssueModalProviderProps) =>
const {
isWorkItemTypeEnabledForProject,
getIssueTypeById,
convertWorkItem,
getIssueTypeProperties,
getProjectIssueTypes,
getProjectDefaultIssueType,
@@ -314,6 +317,31 @@ export const IssueModalProvider = observer((props: TIssueModalProviderProps) =>
]
);
/**
* Used to handle work item conversion
*/
const handleConvert = async (workspaceSlug: string, data: Partial<TIssue> | undefined) => {
if (data?.id && data?.project_id) {
await convertWorkItem(workspaceSlug.toString(), data.project_id, data?.id, EWorkItemConversionType.WORK_ITEM)
.then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("success"),
message: "Epic converted to work item successfully",
});
})
.catch((error) => {
console.error(error);
setToast({
type: TOAST_TYPE.ERROR,
title: t("error"),
message: "Epic could not be converted to work item. Please try again.",
});
throw error;
});
}
};
return (
<IssueModalContext.Provider
value={{
@@ -333,6 +361,7 @@ export const IssueModalProvider = observer((props: TIssueModalProviderProps) =>
handleCreateUpdatePropertyValues,
handleProjectEntitiesFetch,
handleTemplateChange,
handleConvert,
}}
>
{children}
@@ -1,4 +1,5 @@
// plane imports
import { EWorkItemConversionType } from "@plane/constants";
import { TEpicAnalytics, TEpicStats } from "@plane/types";
// helpers
import { API_BASE_URL } from "@/helpers/common.helper";
@@ -40,6 +41,20 @@ export class EpicService extends APIService {
throw error?.response?.data;
});
}
}
async convertWorkItemType(
workspaceSlug: string,
projectId: string,
entityId: string,
entityType: EWorkItemConversionType
): Promise<void> {
return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/conversion/${entityId}/`, {
conversion_type: entityType,
})
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
}
export const epicService = new EpicService();
-1
View File
@@ -44,7 +44,6 @@ export class CommandPaletteStore extends BaseCommandPaletteStore implements ICom
createUpdateTeamspaceViewModal: observable,
createUpdateInitiativeModal: observable,
createUpdateCustomerModal: observable,
// computed
isAnyModalOpen: computed,
// actions
+38 -1
View File
@@ -2,7 +2,7 @@ import set from "lodash/set";
import { action, computed, makeObservable, observable, runInAction } from "mobx";
import { computedFn } from "mobx-utils";
// plane imports
import { EWorkItemTypeEntity } from "@plane/constants";
import { EWorkItemConversionType, EWorkItemTypeEntity } from "@plane/constants";
import {
TLoader,
TIssueType,
@@ -24,6 +24,7 @@ import {
issuePropertyOptionService,
issuePropertyService,
issueTypeService,
epicService,
} from "@/plane-web/services/issue-types";
// plane web stores
import { IssueType } from "@/plane-web/store/issue-types";
@@ -67,6 +68,7 @@ export class IssueTypes implements IIssueTypesStore {
fetchAllEpicPropertiesAndOptions: action,
createType: action,
deleteType: action,
convertWorkItem: action,
});
// root store
this.rootStore = store;
@@ -705,4 +707,39 @@ export class IssueTypes implements IIssueTypesStore {
throw error;
}
};
/**
* @description Convert a work item type to another work item type
* @param workspaceSlug
* @param projectId
* @param workItemId
* @param convertTo
*/
convertWorkItem = async (
workspaceSlug: string,
projectId: string,
workItemId: string,
convertTo: EWorkItemConversionType
) => {
if (!workspaceSlug || !projectId || !workItemId || !convertTo) return;
try {
await epicService.convertWorkItemType(workspaceSlug, projectId, workItemId, convertTo);
runInAction(() => {
if (convertTo === EWorkItemConversionType.WORK_ITEM) {
this.rootStore.issue.projectEpics.removeIssueFromList(workItemId);
this.rootStore.issue.projectIssues.addIssueToList(workItemId);
// update is_epic to false
this.rootStore.issue.issues.updateIssue(workItemId, { is_epic: false });
} else if (convertTo === EWorkItemConversionType.EPIC) {
this.rootStore.issue.projectIssues.removeIssueFromList(workItemId);
this.rootStore.issue.projectEpics.addIssueToList(workItemId);
// update is_epic to true
this.rootStore.issue.issues.updateIssue(workItemId, { is_epic: true });
}
});
} catch (error) {
this.loader = "loaded";
throw error;
}
};
}