[WEB-4985] feat: cycle automation task (#4413)

* feat: created cycles template model

* feat: added forms for automated cycles

* chore: updated the migration file

* chore: milestone migrations

* chore: added milestone models

* chore: removed target_date constraint

* feat: cycle scheduling task

* chore: updated the migration file

* chore: added the missing import

* chore: added the missing import

* chore: added form integration

* chore: project feature migration

* chore: changed the migration file

* chore: added the enableing in automated cycles

* * chore: added feature flag checks
* chore: updated endpoints to not use uuid
* chore: moved feature enable/disable to project feature

* chore: structured the payload of cycle

* chore: updated edit enable/disable flow

* chore: updated the scheduling task

* chore: updated cycle config forms

* chore: added the cycle scheduling task

* chore: updated the automated cycle log model

* chore: refactor for project features

* fix: build errors

* chore: updated enable/disable for features

* chore: resolved merge conflicts

* chore: reused the transfer cycle issue component

* chore: seperated the cycle automation

* chore: updated the formatting

---------

Co-authored-by: vamsikrishnamathala <[email protected]>
This commit is contained in:
Bavisetti Narayan
2025-10-23 21:42:51 +05:30
committed by GitHub
co-authored by vamsikrishnamathala
parent dff8282d76
commit 360192c120
25 changed files with 1244 additions and 41 deletions
+5
View File
@@ -126,6 +126,11 @@ EE_JOBS = {
"task": "plane.event_stream.bgtasks.outbox_cleaner.delete_outbox_records",
"schedule": crontab(hour=0, minute=30), # UTC 00:30
},
# Cycles maintenance
"maintain-future-cycles": {
"task": "plane.ee.bgtasks.cycle_automation_task.maintain_future_cycles",
"schedule": crontab(minute="*/2"), # Every 2 minutes
},
}
+1
View File
@@ -47,6 +47,7 @@ class BotTypeEnum(models.TextChoices):
INTAKE_BOT = "INTAKE_BOT", "Intake Bot"
APP_BOT = "APP_BOT", "App Bot"
AUTOMATION_BOT = "AUTOMATION_BOT", "Automation Bot"
CYCLE_AUTOMATION_BOT = "CYCLE_AUTOMATION_BOT", "Cycle Automation Bot"
class User(AbstractBaseUser, PermissionsMixin):
@@ -0,0 +1,372 @@
from __future__ import annotations
import json
import pytz
# Python imports
from datetime import timedelta
# Django imports
from django.utils import timezone
from django.db.models import Subquery
# Third Party imports
from celery import shared_task
from django.db.models import Count, Q, When, Case, Value, FloatField, Sum, F
from django.db.models.functions import Cast, Concat
from django.db import models, transaction
# Module imports
from plane.ee.models import CycleSettings, ProjectFeature, AutomatedCycleLog
from plane.db.models import Cycle, Project, Issue, CycleIssue, Workspace
from plane.utils.analytics_plot import burndown_plot
from plane.bgtasks.issue_activities_task import issue_activity
from plane.bgtasks.webhook_task import model_activity
from plane.ee.bgtasks.entity_issue_state_progress_task import (
entity_issue_state_activity_task,
)
from plane.utils.host import base_host
from plane.db.models import BotTypeEnum, ProjectMember
from plane.utils.exception_logger import log_exception
from plane.utils.cycle_transfer_issues import transfer_cycle_issues
from plane.utils.timezone_converter import convert_to_utc
@shared_task
def schedule_cycle(automated_cycle_id: str, project_id: str, bot_id: str):
try:
"""
Schedule a cycle for a project based on the CycleSettings configuration.
- Creates future cycles using the duration and cooldown period.
- Skips cycles that overlap with existing ones.
- Logs success or failure for each attempted cycle creation.
"""
automated_cycle = CycleSettings.objects.get(id=automated_cycle_id, project_id=project_id)
number_of_cycles = automated_cycle.number_of_cycles
cycle_duration = automated_cycle.cycle_duration
cooldown_period = automated_cycle.cooldown_period
start_date = automated_cycle.start_date
workspace_id = automated_cycle.workspace_id
desired_windows = [
(
start_date + timedelta(days=i * (cycle_duration + cooldown_period)),
start_date + timedelta(days=i * (cycle_duration + cooldown_period) + cycle_duration),
)
for i in range(number_of_cycles)
]
# Prefetch all existing cycles in the same project/workspace that could possibly overlap
existing_cycles = Cycle.objects.filter(
project_id=project_id,
workspace_id=workspace_id,
deleted_at__isnull=True,
).values("start_date", "end_date")
logs = []
cycles_to_create = []
for start_dt, end_dt in desired_windows:
# Check if there's any overlap
is_clashing = any((ec["start_date"] <= end_dt and ec["end_date"] >= start_dt) for ec in existing_cycles)
if is_clashing:
logs.append(
AutomatedCycleLog(
automated_cycle=automated_cycle,
workspace_id=workspace_id,
project_id=project_id,
cycle_id=None,
action="cycle_creation_failed",
status="failed",
message=f"Cycle window {start_dt.date()} - {end_dt.date()} overlaps with existing cycle.",
scheduled_at=timezone.now(),
)
)
continue
start_dt = convert_to_utc(
date=str(start_dt.date()),
project_id=project_id,
is_start_date=True,
)
end_dt = convert_to_utc(
date=str(end_dt.date()),
project_id=project_id,
)
# Create the cycle object
cycle = Cycle(
project_id=project_id,
workspace_id=workspace_id,
owned_by_id=bot_id,
created_by_id=bot_id,
updated_by_id=bot_id,
name=automated_cycle.title,
start_date=start_dt,
end_date=end_dt,
)
cycles_to_create.append(cycle)
# Bulk create non-conflicting cycles
created_cycles = Cycle.objects.bulk_create(cycles_to_create, batch_size=50, ignore_conflicts=True)
# Add logs for successful creations
for cycle in created_cycles:
logs.append(
AutomatedCycleLog(
automated_cycle=automated_cycle,
workspace_id=workspace_id,
project_id=project_id,
cycle_id=cycle.id,
action="cycle_created",
status="success",
message=f"Cycle created successfully for {cycle.start_date.date()} - {cycle.end_date.date()}",
scheduled_at=timezone.now(),
)
)
# Bulk create logs
if logs:
AutomatedCycleLog.objects.bulk_create(logs, batch_size=50)
return True
except Exception as e:
log_exception(e)
return False
@shared_task
def maintain_future_cycles():
try:
"""
Ensure each active CycleSettings has exactly `number_of_cycles` future cycles.
- If a cycle started within the last 2 minutes, or a cycle will start within the
next 2 minutes, backfill to keep the number of future cycles constant.
- This runs idempotently and safely in concurrent environments.
"""
now = timezone.now()
buffer = timedelta(minutes=2)
# from the project features table get the cycle enabled projects
cycle_enabled_projects = ProjectFeature.objects.filter(
is_automated_cycle_enabled=True, project__cycle_view=True
).values_list("project_id", flat=True)
# check if the cycle is disabled for the projects
scheduled_cycles = CycleSettings.objects.filter(project_id__in=cycle_enabled_projects)
scheduled_cycles_projects = scheduled_cycles.values("project_id")
# get the recently started cycles to create new future cycles
# get the project's timezone time to check if the cycle has started is accurately respected .
recently_started_cycles = Cycle.objects.filter(
project_id__in=Subquery(scheduled_cycles_projects),
deleted_at__isnull=True,
start_date__gte=now - buffer, # started within last 2 mins (inclusive)
start_date__lte=now, # up to now
).values("project_id", "id")
# get the recently ended cycles to transfer work items
recently_ended_cycles = Cycle.objects.filter(
project_id__in=Subquery(scheduled_cycles_projects),
deleted_at__isnull=True,
end_date__gte=now - buffer, # ended within last 2 mins (inclusive)
end_date__lte=now, # up to now
).values("project_id", "id")
# check for auto-rollover enabled (placeholder for future use)
scheduled_cycles.filter(is_auto_rollover_enabled=True).values_list("project_id", flat=True)
# create the new cycles
for cycle in recently_ended_cycles:
project_id = cycle["project_id"]
cycle_id = cycle["id"]
project = Project.objects.get(id=project_id)
# get the bot id
bot_id = (
ProjectMember.objects.filter(
project_id=project_id,
member__bot_type=BotTypeEnum.CYCLE_AUTOMATION_BOT,
)
.first()
.member_id
)
# Fetch project for the specific record or pass project_id dynamically
project_timezone = project.timezone
# Convert the current time (timezone.now()) to the project's timezone
local_tz = pytz.timezone(project_timezone)
current_time_in_project_tz = timezone.now().astimezone(local_tz)
# Convert project local time back to UTC for comparison (start_date is stored in UTC)
current_time_in_utc = current_time_in_project_tz.astimezone(pytz.utc)
scheduled_cycle = scheduled_cycles.filter(project_id=project_id).first()
# transfer the work-items to the next cycle (if the next cycle exists)
if scheduled_cycle.is_auto_rollover_enabled:
workspace_slug = Workspace.objects.get(id=scheduled_cycle.workspace_id).slug
# first check if the current active cycle is not the same as the old cycle
current_cycle = (
Cycle.objects.filter(
Q(start_date__lte=current_time_in_utc) & Q(end_date__gte=current_time_in_utc),
workspace__slug=workspace_slug,
project_id=project_id,
deleted_at__isnull=True,
)
.order_by("start_date")
.first()
)
if current_cycle and current_cycle.id != cycle_id:
transfer_cycle_issues(
slug=workspace_slug,
project_id=project_id,
cycle_id=cycle_id,
new_cycle_id=current_cycle.id,
request=None,
user_id=str(bot_id),
)
else:
next_cycle = (
Cycle.objects.filter(
workspace__slug=workspace_slug,
project_id=project_id,
start_date__gt=current_time_in_utc,
deleted_at__isnull=True,
)
.order_by("start_date")
.first()
)
if next_cycle:
transfer_cycle_issues(
slug=workspace_slug,
project_id=project_id,
cycle_id=cycle_id,
new_cycle_id=next_cycle.id,
request=None,
user_id=str(bot_id),
)
for cycle in recently_started_cycles:
project_id = cycle["project_id"]
cycle_id = cycle["id"]
project = Project.objects.get(id=project_id)
# get the bot id
bot_id = (
ProjectMember.objects.filter(
project_id=project_id,
member__bot_type=BotTypeEnum.CYCLE_AUTOMATION_BOT,
)
.first()
.member_id
)
scheduled_cycle = scheduled_cycles.filter(project_id=project_id).first()
# get the number of cycles to create
number_of_cycles = scheduled_cycle.number_of_cycles
cycle_duration = scheduled_cycle.cycle_duration
cooldown_period = scheduled_cycle.cooldown_period
project_id = scheduled_cycle.project_id
workspace_id = scheduled_cycle.workspace_id
# get the upcoming cycles which are scheduled for the project
upcoming_cycles = Cycle.objects.filter(project_id=project_id, start_date__gt=now, deleted_at__isnull=True)
# get then end date of the last created cycle and then add the cooldown period and create the new cycle.
last_cycle = upcoming_cycles.order_by("-end_date").first()
if last_cycle:
last_cycle_end_date = last_cycle.end_date + timedelta(days=cooldown_period)
else:
# if there are no upcoming cycles then check for the start date and start the cycles from the next day
last_upcoming = upcoming_cycles.order_by("-start_date").first()
last_cycle_end_date = (
last_upcoming.start_date + timedelta(days=cooldown_period) if last_upcoming else now
)
# if the last cycles is not found then check for which every is the upcoming cycle as the end date (if none of them have end date then skip the project)
upcoming_cycles = Cycle.objects.filter(
project_id=project_id, start_date__gt=now, deleted_at__isnull=True
)
if not upcoming_cycles.exists():
# create the new and the future cycles
schedule_cycle(
automated_cycle_id=scheduled_cycle.id,
project_id=project_id,
bot_id=bot_id,
)
continue
# check the difference between the number of cycles and the upcoming cycles
number_of_cycles_diff = number_of_cycles - upcoming_cycles.count()
# if its greater than create the new cycles from the last cycle end date and add the cooldown period bulk create the cycle
# first we need to check if any of the cycles exists with the same dates in the database
if number_of_cycles_diff > 0:
# Build cycles list with proper UTC conversion for each cycle
cycles_to_create = []
for i in range(number_of_cycles_diff):
start_dt = last_cycle_end_date + timedelta(days=i * (cycle_duration + cooldown_period))
end_dt = last_cycle_end_date + timedelta(
days=(i * (cycle_duration + cooldown_period) + cycle_duration)
)
# Convert dates to UTC using the project's timezone
start_date = convert_to_utc(
date=str(start_dt.date()),
project_id=project_id,
is_start_date=True,
)
end_date = convert_to_utc(
date=str(end_dt.date()),
project_id=project_id,
)
cycles_to_create.append(
Cycle(
project_id=project_id,
workspace_id=workspace_id,
owned_by_id=bot_id,
created_by_id=bot_id,
updated_by_id=bot_id,
name=scheduled_cycle.title,
start_date=start_date,
end_date=end_date,
)
)
# bulk create the cycles
cycles = Cycle.objects.bulk_create(
cycles_to_create,
batch_size=50,
ignore_conflicts=True,
)
AutomatedCycleLog.objects.bulk_create(
[
AutomatedCycleLog(
automated_cycle=scheduled_cycle,
cycle=cycle,
workspace_id=workspace_id,
project_id=project_id,
action="cycle_created",
status="success",
message=f"Cycle created successfully for {cycle.start_date.date()} - {cycle.end_date.date()}",
scheduled_at=timezone.now(),
)
for cycle in cycles
]
)
except Exception as e:
log_exception(e)
return False
@@ -139,6 +139,7 @@ from .app.job import ImportReportSerializer, ImportJobSerializer
from .app.workspace.credential import WorkspaceCredentialSerializer
from .app.workspace.connection import WorkspaceConnectionSerializer
from .app.workspace.entity_connection import WorkspaceEntityConnectionSerializer
from .app.cycle_schedule import AutomatedCycleSerializer
# api
from .api.job import ImportReportAPISerializer, ImportJobAPISerializer
@@ -0,0 +1,47 @@
from plane.ee.models import CycleSettings
from plane.ee.serializers import BaseSerializer
from django.utils import timezone
from rest_framework import serializers
class AutomatedCycleSerializer(BaseSerializer):
class Meta:
model = CycleSettings
fields = "__all__"
read_only_fields = [
"workspace",
"project",
"created_by",
"updated_by",
"created_at",
"updated_at",
]
def validate(self, data):
# start date cannot be in the past
if (
data.get("start_date", None) is not None
and data.get("start_date", None).date() < timezone.now().date()
):
raise serializers.ValidationError("Start date cannot be in the past")
# cycle duration will be in multiple of 7 days
if (
data.get("cycle_duration", None) is not None
and data.get("cycle_duration", None) % 7 != 0
):
raise serializers.ValidationError(
"Cycle duration must be in multiple of 7 days"
)
# the number of cycles to be scheduled in the future cannot be greater than 3
if (
data.get("number_of_cycles", None) is not None
and data.get("number_of_cycles", None) > 3
):
raise serializers.ValidationError(
"Number of cycles to be scheduled in the future cannot be greater than 3"
)
return data
@@ -46,6 +46,7 @@ class ProjectFeatureSerializer(BaseSerializer):
"is_issue_type_enabled",
"is_time_tracking_enabled",
"is_workflow_enabled",
"is_automated_cycle_enabled",
"project_id",
]
read_only_fields = [
+7
View File
@@ -5,6 +5,7 @@ from plane.ee.views.app.cycle import (
CycleUpdatesViewSet,
CycleStartStopEndpoint,
CycleIssueStateAnalyticsEndpoint,
AutomatedCycleViewSet,
)
from plane.ee.views.app.update import UpdatesReactionViewSet
@@ -57,4 +58,10 @@ urlpatterns = [
CycleIssueStateAnalyticsEndpoint.as_view(),
name="project-cycle-progress",
),
# Scheduled Cycles
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/automated-cycles/",
AutomatedCycleViewSet.as_view({"get": "list", "post": "create", "patch": "partial_update"}),
name="automated-cycles",
),
]
@@ -2,3 +2,4 @@ from .active_cycle import WorkspaceActiveCycleEndpoint
from .update import CycleUpdatesViewSet
from .start_stop import CycleStartStopEndpoint
from .base import CycleIssueStateAnalyticsEndpoint
from .schedule import AutomatedCycleViewSet
@@ -0,0 +1,111 @@
import uuid
from django.contrib.auth.hashers import make_password
# Third Party imports
from rest_framework import status
from rest_framework.response import Response
# Module imports
from plane.ee.models import CycleSettings
from plane.db.models import BotTypeEnum, User, Workspace, WorkspaceMember, ProjectMember
from plane.ee.views.base import BaseViewSet
from plane.ee.serializers import AutomatedCycleSerializer
from plane.ee.bgtasks.cycle_automation_task import schedule_cycle
from plane.app.permissions import allow_permission, ROLE
from plane.payment.flags.flag import FeatureFlag
from plane.payment.flags.flag_decorator import check_feature_flag
class AutomatedCycleViewSet(BaseViewSet):
serializer_class = AutomatedCycleSerializer
model = CycleSettings
def get_queryset(self):
return self.filter_queryset(
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id"))
)
@allow_permission([ROLE.ADMIN])
@check_feature_flag(FeatureFlag.AUTO_SCHEDULE_CYCLES)
def list(self, request, slug, project_id):
automated_cycle = self.get_queryset().first()
if automated_cycle is None:
return Response({}, status=status.HTTP_200_OK)
serializer = AutomatedCycleSerializer(automated_cycle)
return Response(serializer.data, status=status.HTTP_200_OK)
@allow_permission([ROLE.ADMIN])
@check_feature_flag(FeatureFlag.AUTO_SCHEDULE_CYCLES)
def create(self, request, slug, project_id):
# check if the automation already exists
if CycleSettings.objects.filter(project_id=project_id, workspace__slug=slug).exists():
return Response(
{"error": "Automated cycle already exists"},
status=status.HTTP_400_BAD_REQUEST,
)
cycle_automation_bot = ProjectMember.objects.filter(
project_id=project_id, member__bot_type=BotTypeEnum.CYCLE_AUTOMATION_BOT
).first()
if not cycle_automation_bot:
# Create a automation bot
cycle_automation_bot = User.objects.create(
username=f"cycle-automation-bot-{uuid.uuid4().hex}",
display_name="Cycle Automation Bot",
first_name="Cycle Automation",
last_name="Bot",
is_bot=True,
bot_type=BotTypeEnum.CYCLE_AUTOMATION_BOT,
email=f"cycle-automation-bot-{uuid.uuid4().hex}@plane.so",
password=make_password(uuid.uuid4().hex),
is_password_autoset=True,
)
workspace_id = Workspace.objects.get(slug=slug).id
# Add user to the workspace
WorkspaceMember.objects.create(
member_id=cycle_automation_bot.id,
workspace_id=workspace_id,
role=ROLE.ADMIN.value,
is_active=True,
)
# add the user to the project
ProjectMember.objects.create(
project_id=project_id,
workspace_id=workspace_id,
member_id=cycle_automation_bot.id,
role=ROLE.ADMIN.value,
is_active=True,
)
serializer = AutomatedCycleSerializer(data=request.data)
if serializer.is_valid():
serializer.save(project_id=project_id)
# when ever some one tries to schedule a cycle, we will schedule the first cycle
schedule_cycle.delay(
project_id=project_id,
bot_id=cycle_automation_bot.id,
automated_cycle_id=serializer.instance.id,
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@allow_permission([ROLE.ADMIN])
@check_feature_flag(FeatureFlag.AUTO_SCHEDULE_CYCLES)
def partial_update(self, request, slug, project_id):
automated_cycle = CycleSettings.objects.filter(project_id=project_id, workspace__slug=slug).first()
if not automated_cycle:
return Response(
{"error": "Automated cycle not found"},
status=status.HTTP_400_BAD_REQUEST,
)
serializer = AutomatedCycleSerializer(automated_cycle, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
+2
View File
@@ -88,6 +88,8 @@ class FeatureFlag(Enum):
PROJECT_AUTOMATIONS = "PROJECT_AUTOMATIONS"
# Recurring Work Items
RECURRING_WORKITEMS = "RECURRING_WORKITEMS"
# Automated Cycles
AUTO_SCHEDULE_CYCLES = "AUTO_SCHEDULE_CYCLES"
# Exports
ADVANCED_EXPORTS = "ADVANCED_EXPORTS"
+1
View File
@@ -312,6 +312,7 @@ CELERY_IMPORTS = (
"plane.authentication.bgtasks.send_app_uninstall_webhook",
"plane.ee.bgtasks.batched_search_update_task",
"plane.ee.bgtasks.recurring_work_item_task",
"plane.ee.bgtasks.cycle_automation_task",
# silo tasks
"plane.silo.bgtasks.integration_apps_task",
# event stream tasks
+10 -19
View File
@@ -30,6 +30,7 @@ from plane.ee.bgtasks.entity_issue_state_progress_task import (
entity_issue_state_activity_task,
)
def transfer_cycle_issues(
slug,
project_id,
@@ -53,9 +54,7 @@ def transfer_cycle_issues(
dict: Response data with success or error message
"""
# Get the new cycle
new_cycle = Cycle.objects.filter(
workspace__slug=slug, project_id=project_id, pk=new_cycle_id
).first()
new_cycle = Cycle.objects.filter(workspace__slug=slug, project_id=project_id, pk=new_cycle_id).first()
# Check if new cycle is already completed
if new_cycle.end_date is not None and new_cycle.end_date < timezone.now():
@@ -218,9 +217,7 @@ def transfer_cycle_issues(
assignee_estimate_distribution = [
{
"display_name": item["display_name"],
"assignee_id": (
str(item["assignee_id"]) if item["assignee_id"] else None
),
"assignee_id": (str(item["assignee_id"]) if item["assignee_id"] else None),
"avatar_url": item.get("avatar_url"),
"total_estimates": item["total_estimates"],
"completed_estimates": item["completed_estimates"],
@@ -312,9 +309,7 @@ def transfer_cycle_issues(
)
)
.values("display_name", "assignee_id", "avatar_url")
.annotate(
total_issues=Count("id", filter=Q(archived_at__isnull=True, is_draft=False))
)
.annotate(total_issues=Count("id", filter=Q(archived_at__isnull=True, is_draft=False)))
.annotate(
completed_issues=Count(
"id",
@@ -362,9 +357,7 @@ def transfer_cycle_issues(
.annotate(color=F("labels__color"))
.annotate(label_id=F("labels__id"))
.values("label_name", "color", "label_id")
.annotate(
total_issues=Count("id", filter=Q(archived_at__isnull=True, is_draft=False))
)
.annotate(total_issues=Count("id", filter=Q(archived_at__isnull=True, is_draft=False)))
.annotate(
completed_issues=Count(
"id",
@@ -411,9 +404,7 @@ def transfer_cycle_issues(
)
# Get the current cycle and save progress snapshot
current_cycle = Cycle.objects.filter(
workspace__slug=slug, project_id=project_id, pk=cycle_id
).first()
current_cycle = Cycle.objects.filter(workspace__slug=slug, project_id=project_id, pk=cycle_id).first()
current_cycle.progress_snapshot = {
"total_issues": old_cycle.total_issues,
@@ -463,9 +454,7 @@ def transfer_cycle_issues(
)
# Bulk update cycle issues
cycle_issues = CycleIssue.objects.bulk_update(
updated_cycles, ["cycle_id"], batch_size=100
)
cycle_issues = CycleIssue.objects.bulk_update(updated_cycles, ["cycle_id"], batch_size=100)
# Capture Issue Activity
issue_activity.delay(
@@ -477,7 +466,7 @@ def transfer_cycle_issues(
current_instance=json.dumps(
{
"updated_cycle_issues": update_cycle_issue_activity,
"created_cycle_issues": [],
"created_cycle_issues": "[]",
}
),
epoch=int(timezone.now().timestamp()),
@@ -485,6 +474,8 @@ def transfer_cycle_issues(
origin=base_host(request=request, is_app=True),
)
# EE Code start here
# Trigger the entity issue state activity task for removal of issues
entity_issue_state_activity_task.delay(
issue_cycle_data=[
@@ -0,0 +1,98 @@
"use client";
import { observer } from "mobx-react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { ChevronLeft } from "lucide-react";
// plane imports
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { setPromiseToast } from "@plane/propel/toast";
import { ToggleSwitch } from "@plane/ui";
// components
import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view";
import { PageHead } from "@/components/core/page-title";
import { SettingsContentWrapper } from "@/components/settings/content-wrapper";
import { SettingsHeading } from "@/components/settings/heading";
// hooks
import { useProject } from "@/hooks/store/use-project";
import { useUserPermissions } from "@/hooks/store/user";
import { AutoScheduleCycles } from "@/plane-web/components/cycles/settings";
// plane web imports
import { PROJECT_BASE_FEATURES_LIST } from "@/plane-web/constants/project/settings";
const CyclesFeatureSettingsPage = observer(() => {
const { workspaceSlug, projectId } = useParams();
// permissions
const { workspaceUserInfo, allowPermissions } = useUserPermissions();
const canPerformProjectAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT);
// project store
const { getProjectById, updateProject } = useProject();
const currentProjectDetails = projectId ? getProjectById(projectId.toString()) : undefined;
if (!workspaceSlug || !projectId) return null;
if (workspaceUserInfo && !canPerformProjectAdminActions) {
return <NotAuthorizedView section="settings" isProjectView className="h-auto" />;
}
const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - Cycle Configuration` : undefined;
const handleToggle = async () => {
if (!currentProjectDetails) return;
const payload = { cycle_view: !Boolean(currentProjectDetails.cycle_view) };
const promise = updateProject(workspaceSlug.toString(), projectId.toString(), payload);
setPromiseToast(promise, {
loading: "Updating project feature...",
success: { title: "Success!", message: () => "Project feature updated successfully." },
error: { title: "Error!", message: () => "Something went wrong. Please try again." },
});
};
const cyclesIcon = PROJECT_BASE_FEATURES_LIST.cycles.icon;
return (
<SettingsContentWrapper>
<PageHead title={pageTitle} />
<div className="mb-4">
<Link
href={`/${workspaceSlug}/settings/projects/${projectId}/features`}
className="text-sm text-custom-text-300 hover:text-custom-text-200"
>
<div className="flex items-center gap-2">
<ChevronLeft className="h-4 w-4 text-custom-text-300" />
<span className="text-sm text-custom-text-300 font-bold">Back to features</span>
</div>
</Link>
</div>
<SettingsHeading
title="Cycles"
description="Schedule work in flexible periods that adapt to this project's unique rhythm and pace."
/>
<div className="gap-x-8 gap-y-2 border-b border-custom-border-100 bg-custom-background-100 py-4">
<div className="flex items-center justify-between">
<div className="flex items-start gap-3">
<div className="flex items-center justify-center rounded bg-custom-background-90 p-3">{cyclesIcon}</div>
<div>
<h4 className="text-sm font-medium leading-5">Enable cycles</h4>
<p className="text-sm leading-5 tracking-tight text-custom-text-300">Plan work in focused timeframes.</p>
</div>
</div>
<ToggleSwitch
value={Boolean(currentProjectDetails?.cycle_view)}
onChange={handleToggle}
disabled={!canPerformProjectAdminActions}
size="sm"
/>
</div>
</div>
{/* Auto-schedule cycles configuration */}
{currentProjectDetails?.cycle_view && <AutoScheduleCycles />}
</SettingsContentWrapper>
);
});
export default CyclesFeatureSettingsPage;
@@ -16,11 +16,8 @@ import { captureSuccess } from "@/helpers/event-tracker.helper";
import { useProject } from "@/hooks/store/use-project";
import { useUser } from "@/hooks/store/user";
// plane web imports
import { ProjectFeatureChildren } from "@/plane-web/components/projects/settings/feature-children";
import { UpgradeBadge } from "@/plane-web/components/workspace/upgrade-badge";
import { PROJECT_FEATURES_LIST } from "@/plane-web/constants/project/settings";
import { useProjectAdvanced } from "@/plane-web/hooks/store/projects/use-projects";
import { useFlag } from "@/plane-web/hooks/store/use-flag";
import { ProjectFeatureToggle } from "./helper";
type Props = {
@@ -35,8 +32,6 @@ export const ProjectFeaturesList: FC<Props> = observer((props) => {
const { t } = useTranslation();
const { data: currentUser } = useUser();
const { getProjectById, updateProject } = useProject();
const { toggleProjectFeatures } = useProjectAdvanced();
const isWorklogEnabled = useFlag(workspaceSlug, "ISSUE_WORKLOG");
// derived values
const currentProjectDetails = getProjectById(projectId);
@@ -48,9 +43,7 @@ export const ProjectFeaturesList: FC<Props> = observer((props) => {
[featureProperty]: !currentProjectDetails?.[featureProperty as keyof IProject],
};
const updateProjectPromise = updateProject(workspaceSlug, projectId, settingsPayload);
if (featureProperty === "is_time_tracking_enabled") {
toggleProjectFeatures(workspaceSlug, projectId, settingsPayload, false);
}
setPromiseToast(updateProjectPromise, {
loading: "Updating project feature...",
success: {
@@ -62,7 +55,6 @@ export const ProjectFeaturesList: FC<Props> = observer((props) => {
message: () => "Something went wrong while updating project feature. Please try again.",
},
});
updateProjectPromise.then(() => {
captureSuccess({
eventName: PROJECT_TRACKER_EVENTS.feature_toggled,
@@ -95,10 +87,7 @@ export const ProjectFeaturesList: FC<Props> = observer((props) => {
<h4 className="text-sm font-medium leading-5">{t(featureItem.key)}</h4>
{featureItem.isPro && (
<Tooltip tooltipContent="Pro feature" position="top">
<UpgradeBadge
flag={featureItem.property === "is_time_tracking_enabled" ? "ISSUE_WORKLOG" : undefined}
className="rounded"
/>
<UpgradeBadge className="rounded" />
</Tooltip>
)}
</div>
@@ -117,13 +106,8 @@ export const ProjectFeaturesList: FC<Props> = observer((props) => {
/>
</div>
<div className="pl-14">
{currentProjectDetails && currentProjectDetails?.[featureItem.property as keyof IProject] && (
<ProjectFeatureChildren
feature={featureItemKey}
currentProjectDetails={currentProjectDetails}
workspaceSlug={workspaceSlug}
/>
)}
{currentProjectDetails?.[featureItem.property as keyof IProject] &&
featureItem.renderChildren?.(currentProjectDetails, workspaceSlug)}
</div>
</div>
))}
@@ -0,0 +1,376 @@
"use client";
import React, { useState } from "react";
import { isEmpty } from "lodash-es";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { Controller, useForm } from "react-hook-form";
// plane imports
import useSWR from "swr";
import { InfoIcon } from "@plane/propel/icons";
import { setPromiseToast } from "@plane/propel/toast";
import { Tooltip } from "@plane/propel/tooltip";
import { TCycleConfig } from "@plane/types";
import { Input, ToggleSwitch, CustomSelect, Button } from "@plane/ui";
import { renderFormattedPayloadDate, getDate } from "@plane/utils";
// components
import { DateDropdown } from "@/components/dropdowns/date";
//services
import { useFlag } from "@/plane-web/hooks/store";
import { useProjectAdvanced } from "@/plane-web/hooks/store/projects/use-projects";
import { cycleService } from "@/plane-web/services/cycle.service";
const defaultValues: Partial<TCycleConfig> = {
title: "",
cycle_duration: 0,
cooldown_period: 0,
start_date: null,
number_of_cycles: 0,
is_auto_rollover_enabled: false,
};
const cycleCountOptions = [
{ value: 1, label: "1 cycle" },
{ value: 2, label: "2 cycles" },
{ value: 3, label: "3 cycles" },
];
export const AutoScheduleCycles = observer(() => {
const { workspaceSlug, projectId } = useParams();
const { control, handleSubmit, reset } = useForm<TCycleConfig>({
defaultValues,
});
// states
const [isEdit, setIsEdit] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
//hooks
const { isProjectFeatureEnabled, toggleProjectFeatures } = useProjectAdvanced();
// derived values
const isFeatureFlagEnabled = useFlag(workspaceSlug.toString(), "AUTO_SCHEDULE_CYCLES");
const isAutoScheduleEnabled = isProjectFeatureEnabled(projectId.toString(), "is_automated_cycle_enabled");
const fetchCycleConfig = async () => {
const cycleConfig = await cycleService.getCycleConfig(workspaceSlug.toString(), projectId.toString());
if (isEmpty(cycleConfig)) {
reset(defaultValues);
return;
} else {
handleSetFormData(cycleConfig);
}
};
useSWR(
workspaceSlug && projectId && isFeatureFlagEnabled && isAutoScheduleEnabled
? `cycle-config-${workspaceSlug}-${projectId}`
: null,
workspaceSlug && projectId && isFeatureFlagEnabled && isAutoScheduleEnabled ? fetchCycleConfig : null,
{
revalidateOnFocus: false,
}
);
if (!isFeatureFlagEnabled) return null;
const handleReset = () => {
setIsEdit(false);
fetchCycleConfig();
};
const handleSetFormData = (data: Partial<TCycleConfig>) => {
reset({
...data,
cycle_duration: data.cycle_duration ? data.cycle_duration / 7 : 0,
});
};
const onSubmit = async (data: Partial<TCycleConfig>) => {
setIsSubmitting(true);
const payload: Partial<TCycleConfig> = {
...data,
cycle_duration: data.cycle_duration ? data.cycle_duration * 7 : 0,
};
const promise = data?.id
? cycleService.updateCycleConfig(workspaceSlug.toString(), projectId.toString(), payload)
: cycleService.scheduleCycle(workspaceSlug.toString(), projectId.toString(), payload);
const toastPromise = promise
.then((response) => {
handleSetFormData(response);
setIsEdit(false);
})
.catch((error) => {
throw error;
})
.finally(() => {
setIsSubmitting(false);
});
setPromiseToast(toastPromise, {
loading: "Saving auto-schedule cycles configuration",
success: {
title: "Success!",
message: () =>
data.id
? "Auto-schedule cycles configuration updated successfully."
: "Auto-schedule cycles configuration saved successfully.",
},
error: {
title: "Error!",
message: () =>
data.id
? "Failed to update auto-schedule cycles configuration."
: "Failed to save auto-schedule cycles configuration.",
},
});
};
const toggleScheduleCycle = async (enabled: boolean) => {
const promise = toggleProjectFeatures(
workspaceSlug.toString(),
projectId.toString(),
{
is_automated_cycle_enabled: enabled,
},
enabled
);
setPromiseToast(promise, {
loading: enabled ? "Enabling auto-schedule cycles" : "Disabling auto-schedule cycles",
success: { title: "Success!", message: () => "Auto-schedule cycles toggled successfully." },
error: { title: "Error!", message: () => "Failed to toggle auto-schedule cycles." },
});
if (enabled) {
setIsEdit(true);
}
};
return (
<div className="space-y-2">
{/* Main toggle */}
<div className="gap-x-8 gap-y-2 bg-custom-background-100 py-4">
<div className="flex items-center justify-between">
<div className="space-y-1">
<h4 className="text-sm font-medium leading-5 flex items-center gap-1">
Auto-schedule cycles
<Tooltip tooltipContent="Automatically create new cycles based on your chosen schedule." position="right">
<div>
<InfoIcon className="size-3 text-custom-text-400" />
</div>
</Tooltip>
</h4>
<p className="text-sm leading-5 tracking-tight text-custom-text-300">
Keep cycles moving without manual setup.
</p>
</div>
<div className="flex items-center gap-2">
{isAutoScheduleEnabled && !isEdit && (
<Button type="button" variant="neutral-primary" size="sm" onClick={() => setIsEdit(true)}>
Edit
</Button>
)}
<ToggleSwitch value={isAutoScheduleEnabled} onChange={toggleScheduleCycle} size="sm" />
</div>
</div>
</div>
{/* Configuration form - only show when enabled */}
{isAutoScheduleEnabled && (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="border border-custom-border-200 bg-custom-background-90 p-4 rounded-lg space-y-5">
{/* Cycle Title */}
<div className="flex justify-between items-center">
<div className="w-2/3">
<div className="flex items-center gap-1">
<span className="text-sm font-medium">Cycle Title</span>
<Tooltip
tooltipContent="The title will be appended with numbers for subsequent cycles. For eg: Design - 1/2/3"
position="right"
>
<div>
<InfoIcon className="size-3 text-custom-text-400" />
</div>
</Tooltip>
</div>
</div>
<div className="w-1/3">
<Controller
name="title"
control={control}
render={({ field }) => (
<Input
{...field}
className="bg-custom-background-100 w-full px-3 py-2 rounded-md border-[0.5px] border-custom-border-200 text-sm"
placeholder="Title"
disabled={!isEdit}
/>
)}
/>
</div>
</div>
{/* Cycle Duration */}
<div className="flex justify-between items-center">
<div className="w-2/3">
<div className="flex items-center gap-1">
<span className="text-sm font-medium">Cycle Duration</span>
</div>
</div>
<div className="w-1/3">
<div className="flex items-center gap-1 w-full">
<Controller
name="cycle_duration"
control={control}
render={({ field }) => (
<Input
{...field}
className="bg-custom-background-100 w-1/2 px-3 py-2 rounded-md border-[0.5px] border-custom-border-200 text-sm"
placeholder="1"
type="number"
max={30}
disabled={!isEdit}
/>
)}
/>
<span className="text-sm text-custom-text-200">Weeks</span>
</div>
</div>
</div>
{/* Cool down Period*/}
<div className="flex justify-between items-center">
<div className="w-2/3">
<div className="flex items-center gap-1">
<span className="text-sm font-medium">Cooldown Period</span>
<Tooltip tooltipContent="Pause between cycles before the next begins." position="right">
<div>
<InfoIcon className="size-3 text-custom-text-400" />
</div>
</Tooltip>
</div>
</div>
<div className="w-1/3">
<div className="flex items-center gap-1 w-full">
<Controller
name="cooldown_period"
control={control}
render={({ field }) => (
<Input
{...field}
className="bg-custom-background-100 w-1/2 px-3 py-2 rounded-md border-[0.5px] border-custom-border-200 text-sm"
placeholder="1"
type="number"
min={0}
disabled={!isEdit}
/>
)}
/>
<span className="text-sm text-custom-text-200">days</span>
</div>
</div>
</div>
{/* Cycle Start Date */}
<div className="flex justify-between items-center">
<div className="w-2/3">
<div className="flex items-center gap-1">
<span className="text-sm font-medium">Cycle starts day</span>
</div>
</div>
<div className="w-1/3">
<div className="flex items-center gap-1 w-full">
<Controller
name="start_date"
control={control}
render={({ field }) => (
<DateDropdown
value={getDate(field.value) || null}
onChange={(date) => field.onChange(renderFormattedPayloadDate(date) || "")}
minDate={new Date()}
buttonVariant="border-with-text"
className="w-full"
buttonClassName="bg-custom-background-100 px-3 py-2 rounded-md border-[0.5px] border-custom-border-200 text-left justify-start w-full text-sm"
showTooltip
disabled={!isEdit}
/>
)}
/>
</div>
</div>
</div>
{/* Number of future cycles */}
<div className="flex justify-between items-center">
<div className="w-2/3">
<div className="flex items-center gap-1">
<span className="text-sm font-medium">Number of future cycles</span>
</div>
</div>
<div className="w-1/3">
<Controller
name="number_of_cycles"
control={control}
render={({ field }) => (
<CustomSelect
disabled={!isEdit}
value={field.value}
onChange={field.onChange}
className="w-full"
buttonClassName="bg-custom-background-100 px-3 py-2 rounded-md border-[0.5px] border-custom-border-200 text-left w-full text-sm"
label={cycleCountOptions.find((option) => option.value === field.value)?.label || "1 cycle"}
>
{cycleCountOptions.map((option) => (
<CustomSelect.Option key={option.value} value={option.value}>
{option.label}
</CustomSelect.Option>
))}
</CustomSelect>
)}
/>
</div>
</div>
{/* Auto-rollover work items */}
<div className="flex justify-between items-center">
<div className="w-2/3">
<div className="flex items-center gap-1">
<span className="text-sm font-medium">Auto-rollover work items</span>
<Tooltip
tooltipContent="On the day a cycle completes, move all unfinished work items into the next cycle."
position="right"
>
<div>
<InfoIcon className="size-3 text-custom-text-400" />
</div>
</Tooltip>
</div>
</div>
<div className="w-1/3 flex justify-end">
<Controller
name="is_auto_rollover_enabled"
control={control}
render={({ field }) => (
<ToggleSwitch value={field.value} onChange={field.onChange} disabled={!isEdit} size="sm" />
)}
/>
</div>
</div>
{/* Action buttons */}
{isEdit && (
<div className="flex items-center justify-end gap-2 pt-4 border-t border-custom-border-200">
<Button type="button" variant="neutral-primary" size="sm" onClick={handleReset}>
Discard
</Button>
<Button type="submit" variant="primary" size="sm" disabled={isSubmitting}>
Save
</Button>
</div>
)}
</div>
</form>
)}
</div>
);
});
@@ -0,0 +1 @@
export * from "./auto-schedule-cycles";
@@ -1 +1,149 @@
export { ProjectFeaturesList } from "@/components/project/settings/features-list";
"use client";
import type { FC } from "react";
import { observer } from "mobx-react";
// plane imports
import type { E_FEATURE_FLAGS } from "@plane/constants";
import { PROJECT_TRACKER_EVENTS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { setPromiseToast } from "@plane/propel/toast";
import { Tooltip } from "@plane/propel/tooltip";
import type { IProject, TProjectFeaturesList } from "@plane/types";
// components
import type { TProperties } from "@/ce/constants/project";
import { ProjectFeatureToggle } from "@/components/project/settings/helper";
import { SettingsHeading } from "@/components/settings/heading";
// helpers
import { captureSuccess } from "@/helpers/event-tracker.helper";
// hooks
import { useProject } from "@/hooks/store/use-project";
import { useUser } from "@/hooks/store/user";
// plane web imports
import { ProjectFeatureChildren } from "@/plane-web/components/projects/settings/feature-children";
import { UpgradeBadge } from "@/plane-web/components/workspace/upgrade-badge";
import { PROJECT_FEATURES_LIST } from "@/plane-web/constants/project/settings";
import { useProjectAdvanced } from "@/plane-web/hooks/store/projects/use-projects";
import { useFlag } from "@/plane-web/hooks/store/use-flag";
type Props = {
workspaceSlug: string;
projectId: string;
isAdmin: boolean;
};
export const ProjectFeaturesList: FC<Props> = observer((props) => {
const { workspaceSlug, projectId, isAdmin } = props;
// store hooks
const { t } = useTranslation();
const { data: currentUser } = useUser();
const { getProjectById, updateProject } = useProject();
const { toggleProjectFeatures, isProjectFeatureEnabled } = useProjectAdvanced();
// Feature flag mapping per project feature property
const FEATURE_FLAG_BY_PROPERTY: Record<string, keyof typeof E_FEATURE_FLAGS | undefined> = {
is_time_tracking_enabled: "ISSUE_WORKLOG",
// Add more property-to-flag mappings here as needed
};
// Precompute known flags once
const flagsByKey: Partial<Record<keyof typeof E_FEATURE_FLAGS, boolean>> = {
ISSUE_WORKLOG: useFlag(workspaceSlug, "ISSUE_WORKLOG"),
};
// derived values
const currentProjectDetails = getProjectById(projectId);
const handleSubmit = async (featureKey: string, featureProperty: string) => {
if (!workspaceSlug || !projectId || !currentProjectDetails) return;
// making the request to update the project feature
const settingsPayload = {
[featureProperty]: !currentProjectDetails?.[featureProperty as keyof IProject],
};
const updateProjectPromise = updateProject(workspaceSlug, projectId, settingsPayload);
// Optimistically update features in store for any feature toggle
toggleProjectFeatures(workspaceSlug, projectId, settingsPayload, false);
setPromiseToast(updateProjectPromise, {
loading: "Updating project feature...",
success: {
title: "Success!",
message: () => "Project feature updated successfully.",
},
error: {
title: "Error!",
message: () => "Something went wrong while updating project feature. Please try again.",
},
});
updateProjectPromise.then(() => {
captureSuccess({
eventName: PROJECT_TRACKER_EVENTS.feature_toggled,
payload: {
feature_key: featureKey,
},
});
});
};
const isFeatureEnabled = (featureItem: TProperties): boolean =>
Boolean(currentProjectDetails?.[featureItem.property as keyof IProject]) ||
isProjectFeatureEnabled(projectId, featureItem.property as keyof TProjectFeaturesList);
if (!currentUser) return <></>;
return (
<div className="space-y-6">
{Object.entries(PROJECT_FEATURES_LIST).map(([featureSectionKey, feature]) => (
<div key={featureSectionKey} className="">
<SettingsHeading title={t(feature.key)} description={t(`${feature.key}_description`)} />
{Object.entries(feature.featureList).map(([featureItemKey, featureItem]) => {
const flagKey = FEATURE_FLAG_BY_PROPERTY[featureItem.property];
const isFeatureFlagEnabled = flagKey ? Boolean(flagsByKey[flagKey]) : true;
return (
<div
key={featureItemKey}
className="gap-x-8 gap-y-2 border-b border-custom-border-100 bg-custom-background-100 py-4"
>
<div key={featureItemKey} className="flex items-center justify-between">
<div className="flex items-start gap-3">
<div className="flex items-center justify-center rounded bg-custom-background-90 p-3">
{featureItem.icon}
</div>
<div>
<div className="flex items-center gap-2">
<h4 className="text-sm font-medium leading-5">{t(featureItem.key)}</h4>
{featureItem.isPro && (
<Tooltip tooltipContent="Pro feature" position="top">
<UpgradeBadge flag={flagKey} className="rounded" />
</Tooltip>
)}
</div>
<p className="text-sm leading-5 tracking-tight text-custom-text-300">
{t(`${featureItem.key}_description`)}
</p>
</div>
</div>
<ProjectFeatureToggle
workspaceSlug={workspaceSlug}
projectId={projectId}
featureItem={featureItem}
value={isFeatureEnabled(featureItem)}
handleSubmit={handleSubmit}
disabled={!isFeatureFlagEnabled || !isAdmin}
/>
</div>
<div className="pl-14">
{currentProjectDetails && currentProjectDetails?.[featureItem.property as keyof IProject] && (
<ProjectFeatureChildren
feature={featureItemKey}
currentProjectDetails={currentProjectDetails}
workspaceSlug={workspaceSlug}
/>
)}
</div>
</div>
);
})}
</div>
))}
</div>
);
});
@@ -58,6 +58,10 @@ export const INTAKE_FEATURES_LIST: TIntakeFeatureList = {
export const PROJECT_BASE_FEATURES_LIST = {
...CE_PROJECT_BASE_FEATURES_LIST,
cycles: {
...CE_PROJECT_BASE_FEATURES_LIST.cycles,
href: "/cycles",
},
};
export const PROJECT_OTHER_FEATURES_LIST = {
+35 -1
View File
@@ -1,11 +1,11 @@
// helpers
import { API_BASE_URL } from "@plane/constants";
// services
import type { TCycleConfig } from "@plane/types";
import type { CYCLE_ACTION } from "@/plane-web/constants/cycle";
import { APIService } from "@/services/api.service";
import { CycleService as CycleServiceCore } from "@/services/cycle.service";
import type { TCycleUpdateReaction, TCycleUpdates } from "../types";
import { TCycleUpdateStatus } from "../types";
export class CycleUpdateService extends APIService {
constructor() {
@@ -112,4 +112,38 @@ export class CycleService extends CycleServiceCore {
throw err?.response?.data;
});
}
async getCycleConfig(workspaceSlug: string, projectId: string): Promise<Partial<TCycleConfig>> {
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/automated-cycles/`)
.then((res) => res?.data)
.catch((err) => {
throw err?.response?.data;
});
}
async scheduleCycle(
workspaceSlug: string,
projectId: string,
data: Partial<TCycleConfig>
): Promise<Partial<TCycleConfig>> {
return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/automated-cycles/`, data)
.then((res) => res?.data)
.catch((err) => {
throw err?.response?.data;
});
}
async updateCycleConfig(
workspaceSlug: string,
projectId: string,
data: Partial<TCycleConfig>
): Promise<Partial<TCycleConfig>> {
return this.patch(`/api/workspaces/${workspaceSlug}/projects/${projectId}/automated-cycles/`, data)
.then((res) => res?.data)
.catch((err) => {
throw err?.response?.data;
});
}
}
export const cycleService = new CycleService();
+1
View File
@@ -100,6 +100,7 @@ export class ProjectStore implements IProjectStore {
is_time_tracking_enabled: false,
is_workflow_enabled: false,
project_id: projectId,
is_automated_cycle_enabled: false,
};
}
}
+1
View File
@@ -33,6 +33,7 @@ export enum E_FEATURE_FLAGS {
GLOBAL_VIEWS_TIMELINE = "GLOBAL_VIEWS_TIMELINE",
COPY_WORK_ITEM = "COPY_WORK_ITEM",
LINK_PAGES = "LINK_PAGES",
AUTO_SCHEDULE_CYCLES = "AUTO_SCHEDULE_CYCLES",
// ====== silo importers ======
SILO_IMPORTERS = "SILO_IMPORTERS",
@@ -0,0 +1,14 @@
export type TCycleConfig = {
id: string;
title: string;
cycle_duration: number;
cooldown_period: number;
start_date: string | null;
number_of_cycles: number;
is_auto_rollover_enabled: boolean;
is_active: boolean;
created_by: string;
updated_by: string | null;
created_at: string;
updated_at: string | null;
};
+1
View File
@@ -1,2 +1,3 @@
export * from "./cycle_filters";
export * from "./cycle";
export * from "./cycle-extended";
@@ -91,6 +91,7 @@ export type TProjectFeaturesList = {
is_issue_type_enabled: boolean;
is_time_tracking_enabled: boolean;
is_workflow_enabled: boolean;
is_automated_cycle_enabled: boolean;
};
export type TProjectFeatures = {