mirror of
https://github.com/makeplane/plane.git
synced 2026-07-12 21:40:18 +02:00
[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:
@@ -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
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
99
apiserver/plane/ee/views/app/cycle/start_stop.py
Normal file
99
apiserver/plane/ee/views/app/cycle/start_stop.py
Normal 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
|
||||
)
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ======
|
||||
|
||||
@@ -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";
|
||||
|
||||
64
web/ee/components/cycles/additional-actions.tsx
Normal file
64
web/ee/components/cycles/additional-actions.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -1 +1,2 @@
|
||||
export * from "ce/components/cycles/end-cycle";
|
||||
export * from "./modal";
|
||||
export * from "./use-end-cycle";
|
||||
|
||||
172
web/ee/components/cycles/end-cycle/modal.tsx
Normal file
172
web/ee/components/cycles/end-cycle/modal.tsx
Normal 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>
|
||||
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>
|
||||
);
|
||||
};
|
||||
27
web/ee/components/cycles/end-cycle/use-end-cycle.tsx
Normal file
27
web/ee/components/cycles/end-cycle/use-end-cycle.tsx
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
1
web/ee/components/cycles/start-cycle/index.ts
Normal file
1
web/ee/components/cycles/start-cycle/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./modal";
|
||||
29
web/ee/components/cycles/start-cycle/modal.tsx
Normal file
29
web/ee/components/cycles/start-cycle/modal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
4
web/ee/constants/cycle.ts
Normal file
4
web/ee/constants/cycle.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export enum CYCLE_ACTION {
|
||||
START = "START",
|
||||
STOP = "STOP",
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
export * from "@/store/cycle.store";
|
||||
export * from "./cycle.store";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user