diff --git a/apiserver/plane/bgtasks/issue_activities_task.py b/apiserver/plane/bgtasks/issue_activities_task.py index 82684768d4..84ad886ff4 100644 --- a/apiserver/plane/bgtasks/issue_activities_task.py +++ b/apiserver/plane/bgtasks/issue_activities_task.py @@ -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, } diff --git a/apiserver/plane/ee/urls/app/issue.py b/apiserver/plane/ee/urls/app/issue.py index 6903c9f8c8..46bc7c7b8c 100644 --- a/apiserver/plane/ee/urls/app/issue.py +++ b/apiserver/plane/ee/urls/app/issue.py @@ -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//projects//conversion//", + IssueConvertEndpoint.as_view(), + name="issue-conversion", + ), + # End Issue Convertion Endpoint ] diff --git a/apiserver/plane/ee/views/app/__init__.py b/apiserver/plane/ee/views/app/__init__.py index 7ed2ed6e42..b53b4b5243 100644 --- a/apiserver/plane/ee/views/app/__init__.py +++ b/apiserver/plane/ee/views/app/__init__.py @@ -6,6 +6,7 @@ from plane.ee.views.app.issue import ( BulkSubscribeIssuesEndpoint, IssueWorkLogsEndpoint, IssueTotalWorkLogEndpoint, + IssueConvertEndpoint, ) from plane.ee.views.app.intake import ProjectInTakePublishViewSet diff --git a/apiserver/plane/ee/views/app/issue/__init__.py b/apiserver/plane/ee/views/app/issue/__init__.py index 42b9589432..c5b76122c4 100644 --- a/apiserver/plane/ee/views/app/issue/__init__.py +++ b/apiserver/plane/ee/views/app/issue/__init__.py @@ -4,3 +4,4 @@ from .bulk_operations import ( BulkArchiveIssuesEndpoint, BulkSubscribeIssuesEndpoint, ) +from .convert import IssueConvertEndpoint diff --git a/apiserver/plane/ee/views/app/issue/convert.py b/apiserver/plane/ee/views/app/issue/convert.py new file mode 100644 index 0000000000..adbfeed553 --- /dev/null +++ b/apiserver/plane/ee/views/app/issue/convert.py @@ -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 + ) diff --git a/packages/constants/src/feature-flag.ts b/packages/constants/src/feature-flag.ts index 35e68d2328..c42d2b4614 100644 --- a/packages/constants/src/feature-flag.ts +++ b/packages/constants/src/feature-flag.ts @@ -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", diff --git a/packages/constants/src/issue/common.ts b/packages/constants/src/issue/common.ts index 8b35f6cabc..77b6f825f3 100644 --- a/packages/constants/src/issue/common.ts +++ b/packages/constants/src/issue/common.ts @@ -375,3 +375,8 @@ export const SPREADSHEET_PROPERTY_DETAILS: { }, ...ADDITIONAL_SPREADSHEET_PROPERTY_DETAILS, }; + +export enum EWorkItemConversionType { + WORK_ITEM = "work_item", + EPIC = "epic", +} diff --git a/packages/types/src/work-item-types/work-item-types.d.ts b/packages/types/src/work-item-types/work-item-types.d.ts index ac5eba2f5b..07a0e5a786 100644 --- a/packages/types/src/work-item-types/work-item-types.d.ts +++ b/packages/types/src/work-item-types/work-item-types.d.ts @@ -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; createType: (typeData: Partial) => Promise; deleteType: (typeId: string) => Promise; + // convert actions + convertWorkItem: ( + workspaceSlug: string, + projectId: string, + issueId: string, + convertTo: EWorkItemConversionType + ) => Promise; } diff --git a/web/app/[workspaceSlug]/(projects)/browse/[workItem]/epic-header.tsx b/web/app/[workspaceSlug]/(projects)/browse/[workItem]/epic-header.tsx index b3d9631948..c13872432d 100644 --- a/web/app/[workspaceSlug]/(projects)/browse/[workItem]/epic-header.tsx +++ b/web/app/[workspaceSlug]/(projects)/browse/[workItem]/epic-header.tsx @@ -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(() => { {issueDetails && (
+ }> + + = observer((props) => { {currentUser && !issue?.archived_at && ( )} + }> + +
+ + ); +}; diff --git a/web/ee/components/epics/conversions/index.ts b/web/ee/components/epics/conversions/index.ts new file mode 100644 index 0000000000..857b405525 --- /dev/null +++ b/web/ee/components/epics/conversions/index.ts @@ -0,0 +1,2 @@ +export * from "./icon"; +export * from "./root"; diff --git a/web/ee/components/epics/conversions/root.tsx b/web/ee/components/epics/conversions/root.tsx new file mode 100644 index 0000000000..25df5766b3 --- /dev/null +++ b/web/ee/components/epics/conversions/root.tsx @@ -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 = 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 ( + <> + + + {issue && isEpicToWorkItemModalOpen === workItemId && conversionType === EWorkItemConversionType.WORK_ITEM && ( + + )} + {issue && isWorkItemToEpicModalOpen === workItemId && conversionType === EWorkItemConversionType.EPIC && ( + + )} + + ); +}); diff --git a/web/ee/components/epics/epic-modal/base.tsx b/web/ee/components/epics/epic-modal/base.tsx index 4a38fbb996..9641dc11d8 100644 --- a/web/ee/components/epics/epic-modal/base.tsx +++ b/web/ee/components/epics/epic-modal/base.tsx @@ -29,6 +29,7 @@ export const CreateUpdateEpicModalBase: React.FC = observer((p fetchIssueDetails = true, primaryButtonText, isProjectSelectionDisabled = false, + isConversionOperation = false, } = props; // ref const issueTitleRef = useRef(null); @@ -41,7 +42,7 @@ export const CreateUpdateEpicModalBase: React.FC = 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 = observer((p } }; - const handleUpdateIssue = async (payload: Partial): Promise => { + const handleUpdateIssue = async ( + payload: Partial, + showToast: boolean = true + ): Promise => { if (!workspaceSlug || !payload.project_id || !data?.id) return; try { @@ -178,11 +182,14 @@ export const CreateUpdateEpicModalBase: React.FC = 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 = 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 = 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; diff --git a/web/ee/components/epics/epic-modal/modal.tsx b/web/ee/components/epics/epic-modal/modal.tsx index 8d2daa706e..8117dcf4ba 100644 --- a/web/ee/components/epics/epic-modal/modal.tsx +++ b/web/ee/components/epics/epic-modal/modal.tsx @@ -22,6 +22,7 @@ export interface EpicModalProps { loading: string; }; isProjectSelectionDisabled?: boolean; + isConversionOperation?: boolean; } export const CreateUpdateEpicModal: React.FC = observer( diff --git a/web/ee/components/epics/epic-modal/provider.tsx b/web/ee/components/epics/epic-modal/provider.tsx index 4953c5dd9a..61706f98ed 100644 --- a/web/ee/components/epics/epic-modal/provider.tsx +++ b/web/ee/components/epics/epic-modal/provider.tsx @@ -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({}); const [issuePropertyValueErrors, setIssuePropertyValueErrors] = useState({}); // 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 | 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 ( { handleCreateUpdatePropertyValues, handleProjectEntitiesFetch: () => Promise.resolve(), handleTemplateChange: () => Promise.resolve(), + handleConvert, }} > {children} diff --git a/web/ee/components/epics/index.ts b/web/ee/components/epics/index.ts index e361e80240..7d47c7b182 100644 --- a/web/ee/components/epics/index.ts +++ b/web/ee/components/epics/index.ts @@ -2,3 +2,4 @@ export * from "./epic-modal"; export * from "./epic-quick-action"; export * from "./peek-overview"; export * from "./settings"; +export * from "./conversions"; diff --git a/web/ee/components/epics/peek-overview/header.tsx b/web/ee/components/epics/peek-overview/header.tsx index 244c245390..e0a6d4e126 100644 --- a/web/ee/components/epics/peek-overview/header.tsx +++ b/web/ee/components/epics/peek-overview/header.tsx @@ -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 = observer((pro
+ }> + +