From d66dba51ea7506a16681e2fbd1324aebbbeaad05 Mon Sep 17 00:00:00 2001 From: Vamsi Krishna <46787868+mathalav55@users.noreply.github.com> Date: Thu, 16 Jan 2025 13:21:43 +0530 Subject: [PATCH] [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 --- apiserver/plane/ee/urls/app/cycle.py | 21 +-- .../plane/ee/views/app/cycle/__init__.py | 9 +- .../plane/ee/views/app/cycle/start_stop.py | 99 ++++++++++ apiserver/plane/payment/flags/flag.py | 2 + apiserver/plane/utils/timezone_converter.py | 44 +++++ packages/constants/src/feature-flag.ts | 1 + .../cycles/active-cycle/progress-header.tsx | 1 - .../components/cycles/additional-actions.tsx | 64 +++++++ web/ee/components/cycles/end-cycle/index.ts | 3 +- web/ee/components/cycles/end-cycle/modal.tsx | 172 ++++++++++++++++++ .../cycles/end-cycle/use-end-cycle.tsx | 27 +++ web/ee/components/cycles/index.ts | 3 +- web/ee/components/cycles/start-cycle/index.ts | 1 + .../components/cycles/start-cycle/modal.tsx | 29 +++ web/ee/constants/cycle.ts | 4 + web/ee/services/cycle.service.ts | 21 +++ web/ee/store/{ => cycle}/cycle.store.ts | 35 +++- web/ee/store/cycle/index.ts | 2 +- web/ee/store/root.store.ts | 2 +- web/ee/store/team/team-cycle.store.ts | 2 +- 20 files changed, 516 insertions(+), 26 deletions(-) create mode 100644 apiserver/plane/ee/views/app/cycle/start_stop.py create mode 100644 web/ee/components/cycles/additional-actions.tsx create mode 100644 web/ee/components/cycles/end-cycle/modal.tsx create mode 100644 web/ee/components/cycles/end-cycle/use-end-cycle.tsx create mode 100644 web/ee/components/cycles/start-cycle/index.ts create mode 100644 web/ee/components/cycles/start-cycle/modal.tsx create mode 100644 web/ee/constants/cycle.ts rename web/ee/store/{ => cycle}/cycle.store.ts (79%) diff --git a/apiserver/plane/ee/urls/app/cycle.py b/apiserver/plane/ee/urls/app/cycle.py index c0ff296ab2..dead96e607 100644 --- a/apiserver/plane/ee/urls/app/cycle.py +++ b/apiserver/plane/ee/urls/app/cycle.py @@ -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//projects//updates//reactions/", - UpdatesReactionViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), + UpdatesReactionViewSet.as_view({"get": "list", "post": "create"}), name="project-update-reactions", ), path( "workspaces//projects//updates//reactions//", - UpdatesReactionViewSet.as_view( - { - "delete": "destroy", - } - ), + UpdatesReactionViewSet.as_view({"delete": "destroy"}), name="project-update-reactions", ), ## End Updates Reactions + # cycle start and stop starts + path( + "workspaces//projects//cycles//start-stop/", + CycleStartStopEndpoint.as_view(), + name="cycle-start-stop", + ), + # cycle start and stop ends ] diff --git a/apiserver/plane/ee/views/app/cycle/__init__.py b/apiserver/plane/ee/views/app/cycle/__init__.py index d9821dea78..923a7bf0db 100644 --- a/apiserver/plane/ee/views/app/cycle/__init__.py +++ b/apiserver/plane/ee/views/app/cycle/__init__.py @@ -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 diff --git a/apiserver/plane/ee/views/app/cycle/start_stop.py b/apiserver/plane/ee/views/app/cycle/start_stop.py new file mode 100644 index 0000000000..fb9ce1dfed --- /dev/null +++ b/apiserver/plane/ee/views/app/cycle/start_stop.py @@ -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 + ) diff --git a/apiserver/plane/payment/flags/flag.py b/apiserver/plane/payment/flags/flag.py index 9156ea3e97..70c0bc9839 100644 --- a/apiserver/plane/payment/flags/flag.py +++ b/apiserver/plane/payment/flags/flag.py @@ -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): diff --git a/apiserver/plane/utils/timezone_converter.py b/apiserver/plane/utils/timezone_converter.py index 3e14d0bf60..8a739ff3fe 100644 --- a/apiserver/plane/utils/timezone_converter.py +++ b/apiserver/plane/utils/timezone_converter.py @@ -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 diff --git a/packages/constants/src/feature-flag.ts b/packages/constants/src/feature-flag.ts index f8c451c022..6f1d4496a1 100644 --- a/packages/constants/src/feature-flag.ts +++ b/packages/constants/src/feature-flag.ts @@ -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 ====== diff --git a/web/ee/components/cycles/active-cycle/progress-header.tsx b/web/ee/components/cycles/active-cycle/progress-header.tsx index 87d9413dfa..5ee8e02125 100644 --- a/web/ee/components/cycles/active-cycle/progress-header.tsx +++ b/web/ee/components/cycles/active-cycle/progress-header.tsx @@ -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"; diff --git a/web/ee/components/cycles/additional-actions.tsx b/web/ee/components/cycles/additional-actions.tsx new file mode 100644 index 0000000000..946201821e --- /dev/null +++ b/web/ee/components/cycles/additional-actions.tsx @@ -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 = 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 ( + <> + setStartCycleModal(false)} + isOpen={startCycleModalOpen} + handleStartCycle={handleStartCycle} + loading={startingCycle} + /> + + + ); +}); diff --git a/web/ee/components/cycles/end-cycle/index.ts b/web/ee/components/cycles/end-cycle/index.ts index 3d772e91cf..2e60c45619 100644 --- a/web/ee/components/cycles/end-cycle/index.ts +++ b/web/ee/components/cycles/end-cycle/index.ts @@ -1 +1,2 @@ -export * from "ce/components/cycles/end-cycle"; +export * from "./modal"; +export * from "./use-end-cycle"; diff --git a/web/ee/components/cycles/end-cycle/modal.tsx b/web/ee/components/cycles/end-cycle/modal.tsx new file mode 100644 index 0000000000..ae90086f7e --- /dev/null +++ b/web/ee/components/cycles/end-cycle/modal.tsx @@ -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 = (props) => { + const { isOpen, handleClose, transferrableIssuesCount, projectId, workspaceSlug, cycleId, cycleName } = props; + const [transferIssues, setTransferIssues] = useState(false); + const [targetCycle, setTargetCycle] = useState(null); + const [loadingState, setLoadingState] = useState(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 ( + +
+

+ {completeCycle ? `Comple cycle ${cycleName}?` : "Choose what happens to incomplete work items."} +

+

+ {completeCycle ? ( + <>Congrats, Great work! All work issues are done - ready for review. + ) : ( + <> + You have {transferrableIssuesCount} +  incomplete issues in this cycle that you can move to an upcoming cycle or leave as-is in this one. + + )} +

+
+ {!completeCycle && ( + <> +
+ setTransferIssues(false)} + {...props} + /> + Leave pending issues in this cycle. +
+
+ setTransferIssues(true)} + {...props} + /> + Transfer pending issues to an upcoming cycle. +
+ + )} + {transferIssues && ( + + )} +
+
+ + +
+
+
+ ); +}; diff --git a/web/ee/components/cycles/end-cycle/use-end-cycle.tsx b/web/ee/components/cycles/end-cycle/use-end-cycle.tsx new file mode 100644 index 0000000000..a783f4b883 --- /dev/null +++ b/web/ee/components/cycles/end-cycle/use-end-cycle.tsx @@ -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, + }; +}; diff --git a/web/ee/components/cycles/index.ts b/web/ee/components/cycles/index.ts index 30d8e85dd0..e315e326b0 100644 --- a/web/ee/components/cycles/index.ts +++ b/web/ee/components/cycles/index.ts @@ -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"; diff --git a/web/ee/components/cycles/start-cycle/index.ts b/web/ee/components/cycles/start-cycle/index.ts new file mode 100644 index 0000000000..031608e25f --- /dev/null +++ b/web/ee/components/cycles/start-cycle/index.ts @@ -0,0 +1 @@ +export * from "./modal"; diff --git a/web/ee/components/cycles/start-cycle/modal.tsx b/web/ee/components/cycles/start-cycle/modal.tsx new file mode 100644 index 0000000000..ea93b59b1b --- /dev/null +++ b/web/ee/components/cycles/start-cycle/modal.tsx @@ -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) => { + const { isOpen, handleClose, handleStartCycle, loading } = props; + return ( + +
+

Sure you want to start this cycle?

+ {/*

*/} +
+ + +
+
+
+ ); +}; diff --git a/web/ee/constants/cycle.ts b/web/ee/constants/cycle.ts new file mode 100644 index 0000000000..b37a066f6d --- /dev/null +++ b/web/ee/constants/cycle.ts @@ -0,0 +1,4 @@ +export enum CYCLE_ACTION { + START = "START", + STOP = "STOP", +} diff --git a/web/ee/services/cycle.service.ts b/web/ee/services/cycle.service.ts index 237b5a4038..d566746a32 100644 --- a/web/ee/services/cycle.service.ts +++ b/web/ee/services/cycle.service.ts @@ -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 { + return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/cycles/${cycleId}/start-stop/`, { + date, + action, + }) + .then((res) => res?.data) + .catch((err) => { + throw err?.response?.data; + }); + } +} diff --git a/web/ee/store/cycle.store.ts b/web/ee/store/cycle/cycle.store.ts similarity index 79% rename from web/ee/store/cycle.store.ts rename to web/ee/store/cycle/cycle.store.ts index d5e66485e2..4cc466f554 100644 --- a/web/ee/store/cycle.store.ts +++ b/web/ee/store/cycle/cycle.store.ts @@ -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; + + updateCycleStatus: (workspaceSlug: string, projectId: string, cycleId: string, action: CYCLE_ACTION) => Promise; + 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; + }); } diff --git a/web/ee/store/cycle/index.ts b/web/ee/store/cycle/index.ts index 3614d60c00..c75b761065 100644 --- a/web/ee/store/cycle/index.ts +++ b/web/ee/store/cycle/index.ts @@ -1 +1 @@ -export * from "@/store/cycle.store"; +export * from "./cycle.store"; diff --git a/web/ee/store/root.store.ts b/web/ee/store/root.store.ts index ded792d45d..51f2768f3d 100644 --- a/web/ee/store/root.store.ts +++ b/web/ee/store/root.store.ts @@ -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, diff --git a/web/ee/store/team/team-cycle.store.ts b/web/ee/store/team/team-cycle.store.ts index d7cbb16a58..e50f6b2b5e 100644 --- a/web/ee/store/team/team-cycle.store.ts +++ b/web/ee/store/team/team-cycle.store.ts @@ -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 {