[WEB-1964]:feat: cycle manual start and stop (#2191)

* draft: cycles

* dev: cycle start stop

* dev: implemented additional actions

* chore: updated start_date and end_date in cycle create and update

* chore: updated the start-stop cycle

* chore: updated cycle feature flag

* chore: handled validation for start and stop cycle

* *dev: added start cycle modal
*dev: modified end cycle modal

* chore: added current cycle to cycle dropdown

* fix: updated cancelled issues count into pending issues

* chore: code refactor

* dev: added acycle id to igonore current cycle in dropdown

* chore: synced with ce

* fix: added cycle fetch for end cycle transfer issues

* chore: cycle store restructuring

---------

Co-authored-by: gurusinath <gurusainath007@gmail.com>
This commit is contained in:
Vamsi Krishna
2025-01-16 13:21:43 +05:30
committed by GitHub
parent 0060cb95dd
commit d66dba51ea
20 changed files with 516 additions and 26 deletions

View File

@@ -3,6 +3,7 @@ from django.urls import path
from plane.ee.views.app.cycle import (
WorkspaceActiveCycleEndpoint,
CycleUpdatesViewSet,
CycleStartStopEndpoint,
)
from plane.ee.views.app.update import UpdatesReactionViewSet
@@ -34,22 +35,20 @@ urlpatterns = [
# Updates Reactions
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/updates/<uuid:update_id>/reactions/",
UpdatesReactionViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
UpdatesReactionViewSet.as_view({"get": "list", "post": "create"}),
name="project-update-reactions",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/updates/<uuid:update_id>/reactions/<str:reaction_code>/",
UpdatesReactionViewSet.as_view(
{
"delete": "destroy",
}
),
UpdatesReactionViewSet.as_view({"delete": "destroy"}),
name="project-update-reactions",
),
## End Updates Reactions
# cycle start and stop starts
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/<uuid:cycle_id>/start-stop/",
CycleStartStopEndpoint.as_view(),
name="cycle-start-stop",
),
# cycle start and stop ends
]

View File

@@ -1,6 +1,3 @@
from .active_cycle import (
WorkspaceActiveCycleEndpoint,
)
from .update import (
CycleUpdatesViewSet,
)
from .active_cycle import WorkspaceActiveCycleEndpoint
from .update import CycleUpdatesViewSet
from .start_stop import CycleStartStopEndpoint

View File

@@ -0,0 +1,99 @@
# Third Party imports
from rest_framework import status
from rest_framework.response import Response
# Django imports
from django.utils import timezone
# Module imports
from plane.utils.timezone_converter import convert_to_utc_with_timestamp
from plane.ee.views.base import BaseAPIView
from plane.app.permissions import allow_permission, ROLE
from plane.db.models import Cycle
from plane.payment.flags.flag import FeatureFlag
from plane.payment.flags.flag_decorator import check_feature_flag
class CycleStartStopEndpoint(BaseAPIView):
@check_feature_flag(FeatureFlag.CYCLE_MANUAL_START_STOP)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def post(self, request, slug, project_id, cycle_id):
try:
# get the request data
action = request.data.get("action", None)
current_date = request.data.get("date", None)
if action is None or current_date is None:
return Response(
{"error": "action and date are required fields"},
status=status.HTTP_400_BAD_REQUEST,
)
cycle = Cycle.objects.get(
workspace__slug=slug, project_id=project_id, id=cycle_id
)
if cycle is None:
return Response(
{"error": "Cycle not found"}, status=status.HTTP_404_NOT_FOUND
)
current_datetime = convert_to_utc_with_timestamp(project_id, current_date)
if action == "STOP":
"""
# fetch all the active cycles and check if the current cycle is in the
# active cycles
"""
active_cycles = Cycle.objects.filter(
workspace__slug=slug,
project_id=project_id,
project__archived_at__isnull=True,
project__project_projectmember__member=request.user,
project__project_projectmember__is_active=True,
).filter(start_date__lte=timezone.now(), end_date__gte=timezone.now())
if cycle not in active_cycles:
return Response(
{"error": "Cycle is not active."},
status=status.HTTP_400_BAD_REQUEST,
)
cycle.end_date = current_datetime
if action == "START":
"""
# fetch all the upcoming cycles cycles and sort them by start date and
# check if the current cycle is equal to the first cycle in the list
"""
upcoming_cycles = (
Cycle.objects.filter(
workspace__slug=slug,
project_id=project_id,
project__archived_at__isnull=True,
project__project_projectmember__member=request.user,
project__project_projectmember__is_active=True,
)
.filter(start_date__gt=current_datetime)
.order_by("start_date")
)
upcoming_first_cycle = upcoming_cycles.first()
if (
upcoming_first_cycle is not None
and cycle_id != upcoming_first_cycle.id
):
return Response(
{"error": "Cycle is not the next upcoming cycle."},
status=status.HTTP_400_BAD_REQUEST,
)
cycle.start_date = current_datetime
cycle.save()
return Response(status=status.HTTP_204_NO_CONTENT)
except Exception as e:
return Response(
{"error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR
)

View File

@@ -58,6 +58,8 @@ class FeatureFlag(Enum):
SILO_INTEGRATIONS = "SILO_INTEGRATIONS"
# MOVE_PAGES
MOVE_PAGES = "MOVE_PAGES"
# cycle manual start and stop
CYCLE_MANUAL_START_STOP = "CYCLE_MANUAL_START_STOP"
class AdminFeatureFlag(Enum):

View File

@@ -106,3 +106,47 @@ def convert_utc_to_project_timezone(utc_datetime, project_id):
local_datetime = utc_datetime.astimezone(local_tz)
return local_datetime
def get_current_time(timezone_str):
"""
Get current time for a specific timezone
"""
try:
tz = pytz.timezone(timezone_str)
utc_now = datetime.now(pytz.UTC)
current_datetime = utc_now.astimezone(tz)
return current_datetime
except pytz.exceptions.UnknownTimeZoneError as e:
raise ValueError(f"Invalid timezone: {str(e)}")
def convert_to_utc_with_timestamp(project_id, date):
"""
Convert a date to UTC based on project timezone
"""
# Retrieve the project's timezone using the project ID
project = Project.objects.get(id=project_id)
project_timezone = project.timezone
if not date or not project_timezone:
raise ValueError("Both date and timezone must be provided.")
# Parse the string into a date object
current_date = datetime.strptime(date, "%Y-%m-%d").date()
local_datetime = datetime.combine(
current_date, get_current_time(project.timezone).time()
)
# Get the project's timezone
local_tz = pytz.timezone(project_timezone)
# Localize the datetime to the project's timezone
localized_datetime = local_tz.localize(local_datetime)
# Convert the localized datetime to UTC
utc_datetime = localized_datetime.astimezone(pytz.utc)
# Return the UTC datetime for storage
return utc_datetime

View File

@@ -28,6 +28,7 @@ export enum E_FEATURE_FLAGS {
PROJECT_OVERVIEW = "PROJECT_OVERVIEW",
PROJECT_UPDATES = "PROJECT_UPDATES",
STICKIES = "STICKIES",
CYCLE_MANUAL_START_STOP = "CYCLE_MANUAL_START_STOP",
HOME_ADVANCED = "HOME_ADVANCED",
// ====== silo importers ======

View File

@@ -10,7 +10,6 @@ import { ControlLink, Loader, Tooltip } from "@plane/ui";
import { CycleListItemAction } from "@/components/cycles";
// helpers
import { ButtonAvatars } from "@/components/dropdowns/member/avatar";
import { cn } from "@/helpers/common.helper";
import { findHowManyDaysLeft } from "@/helpers/date-time.helper";
import { useMember } from "@/hooks/store";
import { useAppRouter } from "@/hooks/use-app-router";

View File

@@ -0,0 +1,64 @@
import { FC, useState } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { setToast, TOAST_TYPE } from "@plane/ui";
import { useCycle } from "@/hooks/store";
import { StartCycleModal } from "@/plane-web/components/cycles";
import { CYCLE_ACTION } from "@/plane-web/constants/cycle";
import { useFlag } from "@/plane-web/hooks/store";
type Props = {
cycleId: string;
projectId: string;
};
export const CycleAdditionalActions: FC<Props> = observer((props) => {
//props
const { projectId, cycleId } = props;
// router
const { workspaceSlug } = useParams();
// states
const [startCycleModalOpen, setStartCycleModal] = useState(false);
const [startingCycle, setStartingCycle] = useState(false);
// store hooks
const { isNextCycle, updateCycleStatus } = useCycle();
const isEndCycleEnabled = useFlag(workspaceSlug?.toString(), "CYCLE_MANUAL_START_STOP");
const handleStartCycle = async () => {
setStartingCycle(true);
try {
await updateCycleStatus(workspaceSlug.toString(), projectId, cycleId, CYCLE_ACTION.START);
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Cycle started successfully",
});
} catch (error: any) {
setToast({
type: TOAST_TYPE.ERROR,
message: error?.error || "Failed to start cycle",
title: "Error",
});
} finally {
setStartCycleModal(false);
setStartingCycle(false);
}
};
if (!isNextCycle(projectId, cycleId) || !isEndCycleEnabled) return <></>;
return (
<>
<StartCycleModal
handleClose={() => setStartCycleModal(false)}
isOpen={startCycleModalOpen}
handleStartCycle={handleStartCycle}
loading={startingCycle}
/>
<button
className="rounded-md px-2 h-6 text-custom-primary-200 bg-custom-primary-80/30 hover:bg-custom-primary-80/50"
onClick={() => setStartCycleModal(true)}
>
Start cycle
</button>
</>
);
});

View File

@@ -1 +1,2 @@
export * from "ce/components/cycles/end-cycle";
export * from "./modal";
export * from "./use-end-cycle";

View File

@@ -0,0 +1,172 @@
"use client";
import { FC, useMemo, useState } from "react";
import { EIssuesStoreType } from "@plane/constants";
import { Button, EModalPosition, EModalWidth, Input, ModalCore, TOAST_TYPE, setToast } from "@plane/ui";
import { CycleDropdown } from "@/components/dropdowns";
import { useCycle, useIssues } from "@/hooks/store";
import { CYCLE_ACTION } from "@/plane-web/constants/cycle";
type EndCycleModalProps = {
isOpen: boolean;
handleClose: () => void;
cycleId: string;
projectId: string;
workspaceSlug: string;
transferrableIssuesCount: number;
cycleName: string;
};
type LoadingState = "Transferring..." | "Ending Cycle..." | "Completing Cycle ...";
export const EndCycleModal: FC<EndCycleModalProps> = (props) => {
const { isOpen, handleClose, transferrableIssuesCount, projectId, workspaceSlug, cycleId, cycleName } = props;
const [transferIssues, setTransferIssues] = useState<boolean>(false);
const [targetCycle, setTargetCycle] = useState<string | null>(null);
const [loadingState, setLoadingState] = useState<LoadingState | null>(null);
const { updateCycleStatus, fetchActiveCycleProgress } = useCycle();
const completeCycle = useMemo(() => transferrableIssuesCount === 0, [transferrableIssuesCount]);
const {
issues: { transferIssuesFromCycle },
} = useIssues(EIssuesStoreType.CYCLE);
const handleSubmit = async () => {
if (!workspaceSlug || !projectId || !cycleId) return;
setLoadingState(completeCycle ? "Completing Cycle ..." : "Ending Cycle...");
await updateCycleStatus(workspaceSlug, projectId, cycleId, CYCLE_ACTION.STOP)
.then(async () => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: `Cycle ${completeCycle ? "completed" : "ended"} successfully.`,
});
if (transferIssues && targetCycle) await handleTransferIssues(targetCycle);
handleClose();
})
.catch((err) => {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: err.error || `Unable to ${completeCycle ? "complete" : "end"} Cycle. Please try again.`,
});
})
.finally(() => {
setLoadingState(null);
});
};
const handleTransferIssues = async (newCycleId: string) => {
setLoadingState("Transferring...");
await transferIssuesFromCycle(workspaceSlug.toString(), projectId.toString(), cycleId.toString(), {
new_cycle_id: newCycleId,
})
.then(async () => {
await getCycleDetails(newCycleId);
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Issues have been transferred successfully",
});
})
.catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Issues cannot be transfer. Please try again.",
});
})
.finally(() => {
setLoadingState(null);
});
};
/**To update issue counts in target cycle and current cycle */
const getCycleDetails = async (newCycleId: string) => {
const cyclesFetch = [
fetchActiveCycleProgress(workspaceSlug.toString(), projectId.toString(), cycleId),
fetchActiveCycleProgress(workspaceSlug.toString(), projectId.toString(), newCycleId),
];
await Promise.all(cyclesFetch).catch((error) => {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error",
message: error.error || "Unable to fetch cycle details",
});
});
};
return (
<ModalCore isOpen={isOpen} handleClose={handleClose} position={EModalPosition.TOP} width={EModalWidth.LG}>
<div className="p-4">
<h3 className="text-lg font-medium">
{completeCycle ? `Comple cycle ${cycleName}?` : "Choose what happens to incomplete work items."}
</h3>
<p className="text-sm text-custom-text-300 mt-1">
{completeCycle ? (
<>Congrats, Great work! All work issues are done - ready for review.</>
) : (
<>
You have <span className="text-custom-80 font-semibold">{transferrableIssuesCount}</span>
&nbsp;incomplete issues in this cycle that you can move to an upcoming cycle or leave as-is in this one.
</>
)}
</p>
<div className="mt-3 flex flex-col gap-2">
{!completeCycle && (
<>
<div className="flex gap-1 mb-1">
<Input
type="radio"
name="transfer_cycle"
className="cursor-pointer"
checked={!transferIssues}
onChange={() => setTransferIssues(false)}
{...props}
/>
<span className="text-custom-100 text-sm">Leave pending issues in this cycle.</span>
</div>
<div className="flex gap-1">
<Input
type="radio"
name="transfer_cycle"
className="cursor-pointer"
checked={transferIssues}
onChange={() => setTransferIssues(true)}
{...props}
/>
<span className="text-custom-100 text-sm">Transfer pending issues to an upcoming cycle.</span>
</div>
</>
)}
{transferIssues && (
<CycleDropdown
value={targetCycle}
onChange={setTargetCycle}
projectId={projectId}
buttonVariant="transparent-with-text"
className="group"
buttonContainerClassName="w-full border text-left rounded"
buttonClassName={`py-1 text-sm justify-between`}
placeholder="Select Cycle"
hideIcon
dropdownArrow
dropdownArrowClassName="h-3.5 w-3.5"
placement="bottom-end"
currentCycleId={cycleId}
/>
)}
</div>
<div className="mt-2 pt-2 flex items-center justify-end gap-2 border-t-[0.5px] border-custom-border-200">
<Button variant="neutral-primary" size="sm" onClick={handleClose}>
Cancel
</Button>
<Button variant="danger" size="sm" type="submit" disabled={!!loadingState} onClick={handleSubmit}>
{loadingState ? loadingState : completeCycle ? "Complete Cycle" : "End Cycle"}
</Button>
</div>
</div>
</ModalCore>
);
};

View File

@@ -0,0 +1,27 @@
import { useState } from "react";
import { useParams } from "next/navigation";
import { StopCircle } from "lucide-react";
//sto
import { useFlag } from "@/plane-web/hooks/store";
export const useEndCycle = (isCurrentCycle: boolean) => {
const [isEndCycleModalOpen, setEndCycleModalOpen] = useState(false);
// router
const { workspaceSlug } = useParams();
// store hooks
const isEndCycleEnabled = useFlag(workspaceSlug?.toString(), "CYCLE_MANUAL_START_STOP");
const endCycleContextMenu = {
key: "end-cycle",
title: "End Cycle",
icon: StopCircle,
action: () => setEndCycleModalOpen(true),
shouldRender: isCurrentCycle,
};
return {
isEndCycleModalOpen,
setEndCycleModalOpen,
endCycleContextMenu: isEndCycleEnabled ? endCycleContextMenu : undefined,
};
};

View File

@@ -1,4 +1,5 @@
export * from "./active-cycle";
export * from "./analytics-sidebar";
export * from "./end-cycle";
export * from "ce/components/cycles/additional-actions";
export * from "./additional-actions";
export * from "./start-cycle";

View File

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

View File

@@ -0,0 +1,29 @@
import React from "react";
import { Button, EModalWidth, ModalCore } from "@plane/ui";
interface Props {
handleStartCycle: () => void;
isOpen: boolean;
handleClose: () => void;
loading: boolean;
}
export const StartCycleModal: React.FC<Props> = (props) => {
const { isOpen, handleClose, handleStartCycle, loading } = props;
return (
<ModalCore isOpen={isOpen} handleClose={handleClose} width={EModalWidth.MD}>
<div className="p-4">
<h3 className="text-lg font-medium">Sure you want to start this cycle?</h3>
{/* <p className="text-sm text-custom-text-300 mt-1"></p> */}
<div className="mt-2 pt-2 flex items-center justify-end gap-2 border-t-[0.5px] border-custom-border-200">
<Button variant="neutral-primary" size="sm" onClick={handleClose}>
Cancel
</Button>
<Button variant="primary" size="sm" type="submit" disabled={!!loading} onClick={handleStartCycle}>
{loading ? "Starting..." : "Start Cycle"}
</Button>
</div>
</div>
</ModalCore>
);
};

View File

@@ -0,0 +1,4 @@
export enum CYCLE_ACTION {
START = "START",
STOP = "STOP",
}

View File

@@ -1,7 +1,9 @@
// helpers
import { API_BASE_URL } from "@/helpers/common.helper";
// services
import { CYCLE_ACTION } from "@/plane-web/constants/cycle";
import { APIService } from "@/services/api.service";
import { CycleService as CycleServiceCore } from "@/services/cycle.service";
import { TCycleUpdateReaction, TCycleUpdates, TCycleUpdateStatus } from "../types";
export class CycleUpdateService extends APIService {
@@ -91,3 +93,22 @@ export class CycleUpdateService extends APIService {
});
}
}
export class CycleService extends CycleServiceCore {
async updateCycleStatus(
workspaceSlug: string,
projectId: string,
cycleId: string,
date: string,
action: CYCLE_ACTION
): Promise<any> {
return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/cycles/${cycleId}/start-stop/`, {
date,
action,
})
.then((res) => res?.data)
.catch((err) => {
throw err?.response?.data;
});
}
}

View File

@@ -1,10 +1,14 @@
import { set } from "lodash";
import { format } from "date-fns";
import set from "lodash/set";
import sortBy from "lodash/sortBy";
import { action, makeObservable, observable, runInAction } from "mobx";
import { computedFn } from "mobx-utils";
import { ICycle } from "@plane/types";
import { CYCLE_ACTION } from "@/plane-web/constants/cycle";
import { CycleUpdateService, CycleService } from "@/plane-web/services/cycle.service";
import { RootStore } from "@/plane-web/store/root.store";
import { TCycleUpdateReaction, TCycleUpdates } from "@/plane-web/types";
import { ICycleStore as ICeCycleStore, CycleStore as CeCycleStore } from "@/store/cycle.store";
import { CycleUpdateService } from "../services/cycle.service";
import { TCycleUpdateReaction, TCycleUpdates } from "../types";
export interface ICycleStore extends ICeCycleStore {
cycleUpdateIds: string[];
@@ -38,6 +42,9 @@ export interface ICycleStore extends ICeCycleStore {
cycleUpdateId: string,
reaction: string
) => Promise<void>;
updateCycleStatus: (workspaceSlug: string, projectId: string, cycleId: string, action: CYCLE_ACTION) => Promise<void>;
isNextCycle: (projectId: string, cycleId: string) => boolean;
}
export class CycleStore extends CeCycleStore implements ICycleStore {
@@ -47,6 +54,7 @@ export class CycleStore extends CeCycleStore implements ICycleStore {
} = {};
//services
cycleUpdateService;
cycleService;
constructor(public store: RootStore) {
super(store);
@@ -65,6 +73,7 @@ export class CycleStore extends CeCycleStore implements ICycleStore {
//services
this.cycleUpdateService = new CycleUpdateService();
this.cycleService = new CycleService();
}
fetchUpdates = async (workspaceSlug: string, projectId: string, cycleId: string) => {
@@ -202,4 +211,24 @@ export class CycleStore extends CeCycleStore implements ICycleStore {
return response;
});
});
updateCycleStatus = async (workspaceSlug: string, projectId: string, cycleId: string, action: CYCLE_ACTION) => {
const date = format(new Date(), "yyyy-MM-dd");
await this.cycleService.updateCycleStatus(workspaceSlug, projectId, cycleId, date, action);
await this.fetchCycleDetails(workspaceSlug, projectId, cycleId);
};
isNextCycle = computedFn((projectId: string, cycleId: string) => {
//check for an active cycle
const activeCycle = Object.values(this.cycleMap ?? {}).find(
(c) => c.project_id === projectId && c.status?.toLowerCase() === "current"
);
if (activeCycle) return false;
// filter cycles with status "upcoming" return one with the latest start date
const upcomingCycles = Object.values(this.cycleMap ?? {}).filter(
(c) => c.project_id === projectId && c.status?.toLowerCase() === "upcoming"
);
const nextCycle = sortBy(upcomingCycles, [(c) => c.start_date])[0];
return nextCycle?.id === cycleId;
});
}

View File

@@ -1 +1 @@
export * from "@/store/cycle.store";
export * from "./cycle.store";

View File

@@ -1,5 +1,5 @@
// plane web store
import { ICycleStore, CycleStore } from "@/plane-web/store/cycle.store";
import { ICycleStore, CycleStore } from "@/plane-web/store/cycle";
import { FeatureFlagsStore, IFeatureFlagsStore } from "@/plane-web/store/feature-flags/feature-flags.store";
import {
IIssuePropertiesActivityStore,

View File

@@ -6,7 +6,7 @@ import { ICycle, TLoader } from "@plane/types";
// plane web services
import { TeamCycleService } from "@/plane-web/services/teams/team-cycles.service";
// plane web store
import { ICycleStore } from "@/plane-web/store/cycle.store";
import { ICycleStore } from "@/plane-web/store/cycle";
import { RootStore } from "@/plane-web/store/root.store";
export interface ITeamCycleStore {