[WEB-6464] feat: parallel active cycles (#6159)
This commit is contained in:
@@ -74,7 +74,9 @@ from plane.utils.timezone_converter import (
|
||||
convert_to_utc,
|
||||
user_timezone_converter,
|
||||
)
|
||||
from plane.ee.models import EntityProgress
|
||||
from plane.ee.models import EntityProgress, ProjectFeature
|
||||
from plane.payment.flags.flag_decorator import check_workspace_feature_flag
|
||||
from plane.payment.flags.flag import FeatureFlag
|
||||
|
||||
|
||||
class CycleViewSet(BaseViewSet):
|
||||
@@ -677,6 +679,17 @@ class CycleDateCheckEndpoint(BaseAPIView):
|
||||
project_id=project_id,
|
||||
)
|
||||
|
||||
# If the project has parallel cycles enabled, overlapping dates are allowed
|
||||
is_project_parallel = bool(
|
||||
ProjectFeature.objects.filter(project_id=project_id, workspace__slug=slug)
|
||||
.values_list("is_parallel_cycles_enabled", flat=True)
|
||||
.first()
|
||||
)
|
||||
allow_parallel = is_project_parallel and check_workspace_feature_flag(FeatureFlag.PARALLEL_CYCLES, slug)
|
||||
|
||||
if allow_parallel:
|
||||
return Response({"status": True}, status=status.HTTP_200_OK)
|
||||
|
||||
# Check if any cycle intersects in the given interval
|
||||
cycles = Cycle.objects.filter(
|
||||
Q(workspace__slug=slug)
|
||||
|
||||
@@ -200,33 +200,34 @@ class EstimatePointEndpoint(BaseViewSet):
|
||||
estimate__isnull=False,
|
||||
estimate__type="points",
|
||||
).exists():
|
||||
cycle = Cycle.objects.filter(
|
||||
# Trigger activity for all active cycles — there can be multiple when
|
||||
# parallel cycles are enabled for the project
|
||||
active_cycles = Cycle.objects.filter(
|
||||
start_date__lte=timezone.now(),
|
||||
end_date__gte=timezone.now(),
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
).first()
|
||||
)
|
||||
|
||||
# If cycle exists, proceed with the logic
|
||||
if cycle:
|
||||
cycle_id = str(cycle.id)
|
||||
for cycle in active_cycles:
|
||||
issues = Issue.objects.annotate(cycle_id=F("issue_cycle__cycle_id")).filter(
|
||||
estimate_point_id=estimate_point_id, cycle_id=cycle.id
|
||||
)
|
||||
|
||||
# Trigger the entity issue state activity task
|
||||
entity_issue_state_activity_task.delay(
|
||||
issue_cycle_data=[
|
||||
{
|
||||
"issue_id": str(issue.id),
|
||||
"cycle_id": str(cycle_id),
|
||||
}
|
||||
for issue in issues
|
||||
],
|
||||
user_id=str(request.user.id),
|
||||
slug=slug,
|
||||
action="UPDATED",
|
||||
)
|
||||
if issues.exists():
|
||||
# Trigger the entity issue state activity task
|
||||
entity_issue_state_activity_task.delay(
|
||||
issue_cycle_data=[
|
||||
{
|
||||
"issue_id": str(issue.id),
|
||||
"cycle_id": str(cycle.id),
|
||||
}
|
||||
for issue in issues
|
||||
],
|
||||
user_id=str(request.user.id),
|
||||
slug=slug,
|
||||
action="UPDATED",
|
||||
)
|
||||
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
@@ -291,33 +292,35 @@ class EstimatePointEndpoint(BaseViewSet):
|
||||
if Project.objects.filter(
|
||||
workspace__slug=slug, pk=project_id, estimate__isnull=False, estimate__type="points"
|
||||
).exists():
|
||||
cycle = Cycle.objects.filter(
|
||||
# Trigger activity for all active cycles — there can be multiple when
|
||||
# parallel cycles are enabled for the project
|
||||
active_cycles = Cycle.objects.filter(
|
||||
start_date__lte=timezone.now(),
|
||||
end_date__gte=timezone.now(),
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
).first()
|
||||
if cycle:
|
||||
cycle_id = str(cycle.id)
|
||||
)
|
||||
|
||||
for cycle in active_cycles:
|
||||
issues = Issue.objects.filter(
|
||||
estimate_point_id=(new_estimate_id if new_estimate_id else estimate_point_id),
|
||||
issue_cycle__cycle_id=cycle.id,
|
||||
)
|
||||
|
||||
# Trigger the entity issue state activity task
|
||||
entity_issue_state_activity_task.delay(
|
||||
issue_cycle_data=[
|
||||
{
|
||||
"issue_id": str(issue.id),
|
||||
"cycle_id": str(cycle_id),
|
||||
}
|
||||
for issue in issues
|
||||
],
|
||||
user_id=str(request.user.id),
|
||||
slug=slug,
|
||||
action="UPDATED",
|
||||
)
|
||||
if issues.exists():
|
||||
# Trigger the entity issue state activity task
|
||||
entity_issue_state_activity_task.delay(
|
||||
issue_cycle_data=[
|
||||
{
|
||||
"issue_id": str(issue.id),
|
||||
"cycle_id": str(cycle.id),
|
||||
}
|
||||
for issue in issues
|
||||
],
|
||||
user_id=str(request.user.id),
|
||||
slug=slug,
|
||||
action="UPDATED",
|
||||
)
|
||||
|
||||
old_estimate_point.delete()
|
||||
|
||||
|
||||
@@ -30,7 +30,8 @@ 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
|
||||
|
||||
from plane.payment.flags.flag_decorator import check_workspace_feature_flag
|
||||
from plane.payment.flags.flag import FeatureFlag
|
||||
|
||||
@shared_task
|
||||
def schedule_cycle(automated_cycle_id: str, project_id: str, bot_id: str):
|
||||
@@ -51,6 +52,16 @@ def schedule_cycle(automated_cycle_id: str, project_id: str, bot_id: str):
|
||||
start_date = automated_cycle.start_date
|
||||
workspace_id = automated_cycle.workspace_id
|
||||
|
||||
# Check if the project allows parallel (overlapping) cycles
|
||||
is_project_parallel = bool(
|
||||
ProjectFeature.objects.filter(project_id=project_id)
|
||||
.values_list("is_parallel_cycles_enabled", flat=True)
|
||||
.first()
|
||||
)
|
||||
allow_parallel = is_project_parallel and check_workspace_feature_flag(
|
||||
FeatureFlag.PARALLEL_CYCLES, automated_cycle.workspace.slug
|
||||
)
|
||||
|
||||
desired_windows = []
|
||||
for i in range(number_of_cycles):
|
||||
start_dt = start_date + timedelta(days=i * (cycle_duration + cooldown_period))
|
||||
@@ -68,23 +79,24 @@ def schedule_cycle(automated_cycle_id: str, project_id: str, bot_id: str):
|
||||
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)
|
||||
# When parallel cycles are allowed, skip the overlap check
|
||||
if not allow_parallel:
|
||||
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(),
|
||||
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
|
||||
continue
|
||||
|
||||
start_dt = convert_to_utc(
|
||||
date=str(start_dt.date()),
|
||||
@@ -213,27 +225,65 @@ def maintain_future_cycles():
|
||||
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")
|
||||
# Check if the project allows parallel cycles
|
||||
is_project_parallel = (
|
||||
ProjectFeature.objects.filter(project_id=project_id)
|
||||
.values_list("is_parallel_cycles_enabled", flat=True)
|
||||
.first()
|
||||
)
|
||||
if current_cycle and current_cycle.id != cycle_id:
|
||||
allow_parallel = is_project_parallel and check_workspace_feature_flag(
|
||||
FeatureFlag.PARALLEL_CYCLES, workspace_slug
|
||||
)
|
||||
|
||||
# Find all currently active cycles excluding the one that just ended
|
||||
active_cycles = 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,
|
||||
).exclude(id=cycle_id)
|
||||
|
||||
active_count = active_cycles.count()
|
||||
|
||||
if allow_parallel and active_count > 1:
|
||||
# get the next upcoming cycle created by the automation bot
|
||||
next_cycle = (
|
||||
Cycle.objects.filter(
|
||||
workspace__slug=workspace_slug,
|
||||
project_id=project_id,
|
||||
start_date__gt=current_time_in_utc,
|
||||
deleted_at__isnull=True,
|
||||
owned_by_id=bot_id,
|
||||
)
|
||||
.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),
|
||||
)
|
||||
else:
|
||||
# if no next cycle is found then skip the rollover
|
||||
pass
|
||||
# Multiple active cycles exist — rollover target is ambiguous, skip
|
||||
pass
|
||||
elif active_count == 1:
|
||||
# Exactly one other active cycle — transfer issues into it
|
||||
transfer_cycle_issues(
|
||||
slug=workspace_slug,
|
||||
project_id=project_id,
|
||||
cycle_id=cycle_id,
|
||||
new_cycle_id=current_cycle.id,
|
||||
new_cycle_id=str(active_cycles.first().id),
|
||||
request=None,
|
||||
user_id=str(bot_id),
|
||||
)
|
||||
else:
|
||||
# No other active cycle — fall back to the next upcoming cycle
|
||||
next_cycle = (
|
||||
Cycle.objects.filter(
|
||||
workspace__slug=workspace_slug,
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.29 on 2026-03-11 13:27
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('ee', '0073_backfill_workflow_data'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='projectfeature',
|
||||
name='is_parallel_cycles_enabled',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
@@ -103,6 +103,7 @@ class ProjectFeature(ProjectBaseModel):
|
||||
is_workflow_enabled = models.BooleanField(default=False)
|
||||
is_milestone_enabled = models.BooleanField(default=False)
|
||||
is_automated_cycle_enabled = models.BooleanField(default=False)
|
||||
is_parallel_cycles_enabled = models.BooleanField(default=False)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Project Feature"
|
||||
|
||||
@@ -59,6 +59,7 @@ class ProjectFeatureSerializer(BaseSerializer):
|
||||
"is_workflow_enabled",
|
||||
"is_milestone_enabled",
|
||||
"is_automated_cycle_enabled",
|
||||
"is_parallel_cycles_enabled",
|
||||
"project_id",
|
||||
]
|
||||
read_only_fields = [
|
||||
|
||||
@@ -21,8 +21,9 @@ 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.ee.models import ProjectFeature
|
||||
from plane.payment.flags.flag import FeatureFlag
|
||||
from plane.payment.flags.flag_decorator import check_feature_flag
|
||||
from plane.payment.flags.flag_decorator import check_feature_flag, check_workspace_feature_flag
|
||||
|
||||
|
||||
class CycleStartStopEndpoint(BaseAPIView):
|
||||
@@ -70,28 +71,55 @@ class CycleStartStopEndpoint(BaseAPIView):
|
||||
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,
|
||||
)
|
||||
.filter(start_date__gt=current_datetime)
|
||||
.order_by("start_date")
|
||||
).accessible_to(request.user.id, slug)
|
||||
# Check if the project has parallel cycles enabled
|
||||
is_project_parallel = bool(
|
||||
ProjectFeature.objects.filter(project_id=project_id, workspace__slug=slug)
|
||||
.values_list("is_parallel_cycles_enabled", flat=True)
|
||||
.first()
|
||||
)
|
||||
is_flag_enabled = check_workspace_feature_flag(FeatureFlag.PARALLEL_CYCLES, slug)
|
||||
allow_parallel = is_project_parallel and is_flag_enabled
|
||||
|
||||
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,
|
||||
if allow_parallel:
|
||||
# In parallel mode any upcoming cycle can be started — validate only
|
||||
# that this cycle hasn't already started (start_date is in the future)
|
||||
upcoming_cycles = (
|
||||
Cycle.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
project__archived_at__isnull=True,
|
||||
start_date__gt=current_datetime,
|
||||
).accessible_to(request.user.id, slug)
|
||||
)
|
||||
|
||||
if not upcoming_cycles.filter(id=cycle_id).exists():
|
||||
return Response(
|
||||
{"error": "Cycle is not upcoming."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
else:
|
||||
"""
|
||||
# fetch all the upcoming 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,
|
||||
)
|
||||
.filter(start_date__gt=current_datetime)
|
||||
.order_by("start_date")
|
||||
).accessible_to(request.user.id, slug)
|
||||
|
||||
upcoming_first_cycle = upcoming_cycles.first()
|
||||
|
||||
if upcoming_first_cycle is not None and str(cycle_id) != str(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()
|
||||
|
||||
@@ -104,6 +104,8 @@ class FeatureFlag(Enum):
|
||||
MILESTONES = "MILESTONES"
|
||||
# Automated Cycles
|
||||
AUTO_SCHEDULE_CYCLES = "AUTO_SCHEDULE_CYCLES"
|
||||
# Parallel Cycles
|
||||
PARALLEL_CYCLES = "PARALLEL_CYCLES"
|
||||
# Exports
|
||||
ADVANCED_EXPORTS = "ADVANCED_EXPORTS"
|
||||
# WORKSPACE_MEMBER_ACTIVITY
|
||||
|
||||
+9
-3
@@ -25,7 +25,7 @@ import { ProjectSettingsFeatureControlItem } from "@/components/settings/project
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
// plane web imports
|
||||
import { AutoScheduleCycles } from "@/components/cycles/settings";
|
||||
import { AutoScheduleCycles, ParallelCycles } from "@/components/cycles/settings";
|
||||
// plane web imports
|
||||
import type { Route } from "./+types/page";
|
||||
import { FeaturesCyclesProjectSettingsHeader } from "./header";
|
||||
@@ -55,7 +55,7 @@ function FeaturesCyclesSettingsPage({ params }: Route.ComponentProps) {
|
||||
title={t("project_settings.features.cycles.title")}
|
||||
description={t("project_settings.features.cycles.description")}
|
||||
/>
|
||||
<div className="mt-7">
|
||||
<div className="mt-7 flex flex-col gap-4">
|
||||
<ProjectSettingsFeatureControlItem
|
||||
title={t("project_settings.features.cycles.toggle_title")}
|
||||
description={t("project_settings.features.cycles.toggle_description")}
|
||||
@@ -64,9 +64,15 @@ function FeaturesCyclesSettingsPage({ params }: Route.ComponentProps) {
|
||||
value={!!currentProjectDetails?.cycle_view}
|
||||
workspaceSlug={workspaceSlug}
|
||||
/>
|
||||
{/* Parallel cycles configuration */}
|
||||
<ParallelCycles
|
||||
disabled={!currentProjectDetails?.cycle_view}
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
/>
|
||||
</div>
|
||||
{/* Auto-schedule cycles configuration */}
|
||||
<div className="mt-12">
|
||||
{/* Auto-schedule cycles configuration */}
|
||||
<AutoScheduleCycles disabled={!currentProjectDetails?.cycle_view} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { ArchiveRestoreIcon, StopCircle, Download, LockOpen } from "lucide-react";
|
||||
import { ArchiveRestoreIcon, Star, StopCircle, Download, LockOpen } from "lucide-react";
|
||||
// plane imports
|
||||
import { E_FEATURE_FLAGS, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
@@ -84,6 +84,17 @@ export const useQuickActionsFactory = () => {
|
||||
action: handler,
|
||||
}),
|
||||
|
||||
createFavoriteMenuItem: (
|
||||
handler: () => void,
|
||||
opts: { isFavorite: boolean; shouldRender?: boolean }
|
||||
): TContextMenuItem => ({
|
||||
key: "toggle-favorite",
|
||||
title: opts.isFavorite ? t("remove_from_favorites") : t("add_to_favorites"),
|
||||
icon: Star,
|
||||
action: handler,
|
||||
shouldRender: opts.shouldRender,
|
||||
}),
|
||||
|
||||
createArchiveMenuItem: (
|
||||
handler: () => void,
|
||||
opts: { shouldRender?: boolean; disabled?: boolean; description?: string }
|
||||
|
||||
@@ -21,6 +21,7 @@ import { useQuickActionsFactory } from "@/components/common/quick-actions/factor
|
||||
interface UseCycleMenuItemsProps {
|
||||
cycleDetails: ICycle | undefined;
|
||||
isEditingAllowed: boolean;
|
||||
isFavorite: boolean;
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
cycleId: string;
|
||||
@@ -30,6 +31,7 @@ interface UseCycleMenuItemsProps {
|
||||
handleDelete: () => void;
|
||||
handleCopyLink: () => void;
|
||||
handleOpenInNewTab: () => void;
|
||||
handleFavorite: () => void;
|
||||
}
|
||||
|
||||
interface UseModuleMenuItemsProps {
|
||||
@@ -73,7 +75,7 @@ type MenuResult = {
|
||||
|
||||
export const useCycleMenuItems = (props: UseCycleMenuItemsProps): MenuResult => {
|
||||
const factory = useQuickActionsFactory();
|
||||
const { cycleDetails, isEditingAllowed, workspaceSlug, projectId, cycleId, ...handlers } = props;
|
||||
const { cycleDetails, isEditingAllowed, isFavorite, workspaceSlug, projectId, cycleId, ...handlers } = props;
|
||||
|
||||
const isArchived = !!cycleDetails?.archived_at;
|
||||
const isCompleted = cycleDetails?.status?.toLowerCase() === "completed";
|
||||
@@ -103,6 +105,10 @@ export const useCycleMenuItems = (props: UseCycleMenuItemsProps): MenuResult =>
|
||||
factory.createEditMenuItem(handlers.handleEdit, isEditingAllowed && !isCompleted && !isArchived),
|
||||
factory.createOpenInNewTabMenuItem(handlers.handleOpenInNewTab),
|
||||
factory.createCopyLinkMenuItem(handlers.handleCopyLink),
|
||||
factory.createFavoriteMenuItem(handlers.handleFavorite, {
|
||||
isFavorite,
|
||||
shouldRender: isEditingAllowed && !isArchived,
|
||||
}),
|
||||
...(endCycleFeature?.items ?? []),
|
||||
...(exportFeature?.items ?? []),
|
||||
factory.createArchiveMenuItem(handlers.handleArchive, {
|
||||
|
||||
@@ -11,39 +11,37 @@
|
||||
* NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
|
||||
*/
|
||||
|
||||
import { lazy, Suspense, useMemo } from "react";
|
||||
import { lazy, Suspense } from "react";
|
||||
import { useFlag } from "@/plane-web/hooks/store";
|
||||
|
||||
// Module-level lazy imports so React sees stable component references across renders/remounts.
|
||||
// This prevents the active-cycle collapsible state from resetting.
|
||||
const ActiveCycleV1 = lazy(() => import("./v1/root").then((module) => ({ default: module["ActiveCycleRoot"] })));
|
||||
const ActiveCycleV2 = lazy(() => import("./v2/root").then((module) => ({ default: module["ActiveCycleRoot"] })));
|
||||
|
||||
type ProjectActiveCycleRootProps = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
cycleId?: string;
|
||||
cycleIds?: string[];
|
||||
showHeader?: boolean;
|
||||
};
|
||||
|
||||
export function ProjectActiveCycleRoot(props: ProjectActiveCycleRootProps) {
|
||||
const { workspaceSlug, projectId, cycleId, showHeader = true } = props;
|
||||
const { workspaceSlug, projectId, cycleId, cycleIds, showHeader = true } = props;
|
||||
// derived values
|
||||
const isFeatureEnabled = useFlag(workspaceSlug, "CYCLE_PROGRESS_CHARTS");
|
||||
|
||||
const ActiveCycle = useMemo(
|
||||
function ActiveCycle() {
|
||||
return lazy(() =>
|
||||
isFeatureEnabled
|
||||
? import(`./v2/root`).then((module) => ({
|
||||
default: module["ActiveCycleRoot"],
|
||||
}))
|
||||
: import("./v1/root").then((module) => ({
|
||||
default: module["ActiveCycleRoot"],
|
||||
}))
|
||||
);
|
||||
},
|
||||
[isFeatureEnabled]
|
||||
);
|
||||
const ActiveCycle = isFeatureEnabled ? ActiveCycleV2 : ActiveCycleV1;
|
||||
|
||||
return (
|
||||
<Suspense fallback={<></>}>
|
||||
<ActiveCycle workspaceSlug={workspaceSlug} projectId={projectId} cycleId={cycleId} showHeader={showHeader} />
|
||||
<ActiveCycle
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
cycleId={cycleId}
|
||||
cycleIds={cycleIds}
|
||||
showHeader={showHeader}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ type ActiveCycleRootProps = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
cycleId: string | undefined;
|
||||
cycleIds?: string[];
|
||||
showHeader?: boolean;
|
||||
};
|
||||
|
||||
@@ -101,23 +102,79 @@ const ActiveCyclesComponent = observer(function ActiveCyclesComponent({
|
||||
);
|
||||
});
|
||||
|
||||
export const ActiveCycleRoot = observer(function ActiveCycleRoot(props: ActiveCycleRootProps) {
|
||||
const { workspaceSlug, projectId, cycleId: propsCycleId, showHeader = true } = props;
|
||||
// plane hooks
|
||||
const { t } = useTranslation();
|
||||
// store hooks
|
||||
const { currentProjectActiveCycleId } = useCycle();
|
||||
// derived values
|
||||
const cycleId = propsCycleId ?? currentProjectActiveCycleId ?? undefined;
|
||||
// fetch cycle details
|
||||
// Renders details for a single cycle ID — calls the data-fetching hook internally
|
||||
const ActiveCycleEntry = observer(function ActiveCycleEntry({
|
||||
cycleId,
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
}: {
|
||||
cycleId: string;
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
}) {
|
||||
const {
|
||||
handleFiltersUpdate,
|
||||
cycle: activeCycle,
|
||||
cycleIssueDetails,
|
||||
} = useActiveCycleDetails({ workspaceSlug, projectId, cycleId });
|
||||
} = useActiveCycleDetails({
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
cycleId,
|
||||
});
|
||||
|
||||
return (
|
||||
<ActiveCyclesComponent
|
||||
cycleId={cycleId}
|
||||
activeCycle={activeCycle}
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
handleFiltersUpdate={handleFiltersUpdate}
|
||||
cycleIssueDetails={cycleIssueDetails}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export const ActiveCycleRoot = observer(function ActiveCycleRoot(props: ActiveCycleRootProps) {
|
||||
const { workspaceSlug, projectId, cycleId: propsCycleId, cycleIds: propsCycleIds, showHeader = true } = props;
|
||||
// plane hooks
|
||||
const { t } = useTranslation();
|
||||
// store hooks
|
||||
const { getActiveCycleIds, currentProjectActiveCycleId } = useCycle();
|
||||
const activeCyclesForProject = getActiveCycleIds(projectId);
|
||||
|
||||
// When explicit cycleIds are provided (e.g. from teamspace), use them.
|
||||
// When a single cycleId is provided (e.g. from cycle detail page), use it.
|
||||
// Otherwise render all active cycles (supports parallel cycles).
|
||||
const activeCycleIds =
|
||||
propsCycleIds && propsCycleIds.length > 0
|
||||
? propsCycleIds
|
||||
: propsCycleId
|
||||
? [propsCycleId]
|
||||
: activeCyclesForProject.length > 0
|
||||
? activeCyclesForProject
|
||||
: currentProjectActiveCycleId
|
||||
? [currentProjectActiveCycleId]
|
||||
: [];
|
||||
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
|
||||
const content =
|
||||
activeCycleIds.length === 0 ? (
|
||||
<ActiveCyclesComponent
|
||||
cycleId={undefined}
|
||||
activeCycle={null}
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
handleFiltersUpdate={() => {}}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{activeCycleIds.map((id) => (
|
||||
<ActiveCycleEntry key={id} cycleId={id} workspaceSlug={workspaceSlug} projectId={projectId} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{showHeader ? (
|
||||
@@ -125,26 +182,10 @@ export const ActiveCycleRoot = observer(function ActiveCycleRoot(props: ActiveCy
|
||||
<CollapsibleTrigger className="sticky top-0 z-[2] w-full flex-shrink-0 border-b border-subtle bg-layer-1 cursor-pointer">
|
||||
<CycleListGroupHeader title={t("project_cycles.active_cycle.label")} type="current" isExpanded={isOpen} />
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<ActiveCyclesComponent
|
||||
cycleId={cycleId}
|
||||
activeCycle={activeCycle}
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
handleFiltersUpdate={handleFiltersUpdate}
|
||||
cycleIssueDetails={cycleIssueDetails}
|
||||
/>
|
||||
</CollapsibleContent>
|
||||
<CollapsibleContent>{content}</CollapsibleContent>
|
||||
</Collapsible>
|
||||
) : (
|
||||
<ActiveCyclesComponent
|
||||
cycleId={cycleId}
|
||||
activeCycle={activeCycle}
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
handleFiltersUpdate={handleFiltersUpdate}
|
||||
cycleIssueDetails={cycleIssueDetails}
|
||||
/>
|
||||
content
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2023-present Plane Software, Inc.
|
||||
* SPDX-License-Identifier: LicenseRef-Plane-Commercial
|
||||
*
|
||||
* Licensed under the Plane Commercial License (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
* https://plane.so/legals/eula
|
||||
*
|
||||
* DO NOT remove or modify this notice.
|
||||
* NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
|
||||
*/
|
||||
|
||||
import React, { useMemo, useRef } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useSearchParams } from "react-router";
|
||||
import { CalendarDays, ChevronRight } from "lucide-react";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { IconButton } from "@plane/propel/icon-button";
|
||||
import { CycleGroupIcon, InfoIcon } from "@plane/propel/icons";
|
||||
import { CircularProgressIndicator } from "@plane/ui";
|
||||
import { calculateCycleProgress, renderFormattedPayloadDate } from "@plane/utils";
|
||||
// hooks
|
||||
import { useCycle } from "@/hooks/store/use-cycle";
|
||||
import { useMember } from "@/hooks/store/use-member";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
import { useQueryParams } from "@/hooks/use-query-params";
|
||||
// components
|
||||
import { ListItem } from "@/components/core/list";
|
||||
import { ButtonAvatars } from "@/components/dropdowns/member/avatar";
|
||||
import { MergedDateDisplay } from "@/components/dropdowns/merged-date";
|
||||
import { CycleQuickActions } from "../../quick-actions";
|
||||
// local imports
|
||||
import ScopeDelta from "./scope-delta";
|
||||
import { useActiveCycleDetails } from "./use-active-cycle-details";
|
||||
import { summaryDataFormatter } from "./helper";
|
||||
|
||||
type Props = {
|
||||
cycleId: string;
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
onToggle: () => void;
|
||||
};
|
||||
|
||||
export const ActiveCycleCompactRow = observer(function ActiveCycleCompactRow(props: Props) {
|
||||
const { cycleId, workspaceSlug, projectId, onToggle } = props;
|
||||
// refs
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
// router
|
||||
const router = useAppRouter();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { updateQueryParams } = useQueryParams();
|
||||
// store hooks
|
||||
const { getCycleById } = useCycle();
|
||||
const { getUserDetails } = useMember();
|
||||
const { isMobile } = usePlatformOS();
|
||||
const { t } = useTranslation();
|
||||
// derived values
|
||||
const cycleDetails = getCycleById(cycleId);
|
||||
const createdByDetails = cycleDetails?.created_by ? getUserDetails(cycleDetails.created_by) : undefined;
|
||||
const cycleProgress = useActiveCycleDetails({
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
cycleId,
|
||||
});
|
||||
|
||||
const progress = cycleDetails ? calculateCycleProgress(cycleDetails) : 0;
|
||||
const today = renderFormattedPayloadDate(new Date()) ?? "";
|
||||
const progressData = cycleProgress.cycleProgress;
|
||||
const dataToday = progressData?.find((d) => d.date === today);
|
||||
const estimateTypeFormatter = summaryDataFormatter("issues");
|
||||
|
||||
// Compute scope delta visibility
|
||||
const hasScopeDelta = useMemo(() => {
|
||||
if (!progressData || !dataToday) return false;
|
||||
const prevIndex = progressData.findIndex((d) => d.date === dataToday.date) - 1;
|
||||
if (prevIndex < 0) return false;
|
||||
const prevData = progressData[prevIndex];
|
||||
return prevData.scope !== dataToday.scope && !!prevData.scope;
|
||||
}, [progressData, dataToday]);
|
||||
|
||||
if (!cycleDetails) return null;
|
||||
|
||||
// Compute trailing/leading value
|
||||
const trailingValue =
|
||||
dataToday && dataToday.ideal !== undefined && dataToday.actual !== undefined
|
||||
? Math.abs(dataToday.ideal - dataToday.actual)
|
||||
: null;
|
||||
const isBehind = dataToday ? (dataToday.ideal ?? 0) < (dataToday.actual ?? 0) : false;
|
||||
|
||||
const openCycleOverview = (e: React.MouseEvent<HTMLButtonElement | HTMLAnchorElement>) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
const isPeeking = searchParams.get("peekCycle") === cycleId;
|
||||
const newRoute = isPeeking
|
||||
? updateQueryParams({ paramsToRemove: ["peekCycle"] })
|
||||
: updateQueryParams({ paramsToAdd: { peekCycle: cycleId } });
|
||||
router.push(newRoute);
|
||||
};
|
||||
|
||||
const handleEventPropagation = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
title={cycleDetails.name}
|
||||
itemLink={`/${workspaceSlug}/projects/${projectId}/cycles/${cycleId}`}
|
||||
parentRef={parentRef}
|
||||
isMobile={isMobile}
|
||||
className="py-3 text-13 border-b border-subtle bg-layer-transparent hover:bg-layer-transparent-hover"
|
||||
prependTitleElement={
|
||||
<>
|
||||
<IconButton
|
||||
icon={ChevronRight}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
onToggle();
|
||||
}}
|
||||
aria-label="Toggle cycle details"
|
||||
/>
|
||||
<CycleGroupIcon cycleGroup="current" height="16" width="16" />
|
||||
</>
|
||||
}
|
||||
appendTitleElement={
|
||||
<button onClick={openCycleOverview} className={`z-5 shrink-0 ${isMobile ? "flex" : "hidden group-hover:flex"}`}>
|
||||
<InfoIcon className="h-4 w-4 text-placeholder" />
|
||||
</button>
|
||||
}
|
||||
actionableItems={
|
||||
<div className="flex items-center gap-3 shrink-0" onClick={handleEventPropagation}>
|
||||
{/* Progress fraction */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<CircularProgressIndicator size={16} percentage={progress} strokeWidth={2} />
|
||||
<span className="text-11 text-tertiary font-medium">
|
||||
{progress}% ({cycleDetails.completed_issues}/{cycleDetails.total_issues})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Scope delta */}
|
||||
{hasScopeDelta && (
|
||||
<>
|
||||
<div className="h-3 w-px bg-subtle" />
|
||||
<div className="flex items-center gap-1 text-11">
|
||||
<span className="text-tertiary">Scope</span>
|
||||
<ScopeDelta data={progressData} dataToday={dataToday} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Trailing/Leading */}
|
||||
{trailingValue !== null && trailingValue > 0 && (
|
||||
<>
|
||||
<div className="h-3 w-px bg-subtle" />
|
||||
<div className="flex items-center gap-1 text-11">
|
||||
<span className={isBehind ? "text-danger-primary" : "text-success-primary"}>
|
||||
{isBehind ? t("project_cycles.active_cycle.trailing") : t("project_cycles.active_cycle.leading")}
|
||||
</span>
|
||||
<span className={`font-medium ${isBehind ? "text-danger-primary" : "text-success-primary"}`}>
|
||||
{estimateTypeFormatter(trailingValue)}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Date button */}
|
||||
{cycleDetails.start_date && cycleDetails.end_date && (
|
||||
<Button variant="secondary" size="sm" prependIcon={<CalendarDays className="size-3" />} disabled>
|
||||
<MergedDateDisplay startDate={cycleDetails.start_date} endDate={cycleDetails.end_date} />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Lead avatar */}
|
||||
{createdByDetails && <ButtonAvatars showTooltip={false} userIds={createdByDetails.id} />}
|
||||
|
||||
{/* Quick actions menu */}
|
||||
<div className="hidden md:block">
|
||||
<CycleQuickActions
|
||||
parentRef={parentRef}
|
||||
cycleId={cycleId}
|
||||
projectId={projectId}
|
||||
workspaceSlug={workspaceSlug}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
quickActionElement={
|
||||
<div className="block md:hidden">
|
||||
<CycleQuickActions
|
||||
parentRef={parentRef}
|
||||
cycleId={cycleId}
|
||||
projectId={projectId}
|
||||
workspaceSlug={workspaceSlug}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -11,36 +11,23 @@
|
||||
* NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import type { TCycleProgress } from "@plane/types";
|
||||
import { CircularProgressIndicator } from "@plane/ui";
|
||||
|
||||
type Props = {
|
||||
progress: Partial<TCycleProgress> | null | undefined;
|
||||
days_left: number;
|
||||
};
|
||||
|
||||
function ProgressDonut(props: Props) {
|
||||
const { progress, days_left } = props;
|
||||
const [isHovering, setIsHovering] = useState<boolean>(false);
|
||||
const percentage = progress ? ((progress?.completed ?? 0) * 100) / (progress?.scope ?? 1) : 0;
|
||||
const { progress } = props;
|
||||
const percentage = progress ? ((progress?.completed ?? 0) * 100) / (progress?.scope || 1) : 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group flex items-center justify-between py-1 rounded-full"
|
||||
onMouseEnter={() => setIsHovering(true)}
|
||||
onMouseLeave={() => setIsHovering(false)}
|
||||
>
|
||||
<CircularProgressIndicator size={65} percentage={percentage} strokeWidth={3}>
|
||||
<span className="text-[20px] text-accent-secondary font-bold block text-center">
|
||||
{progress ? (isHovering ? days_left : `${percentage ? percentage.toFixed(0) : 0}%`) : "0%"}
|
||||
<div className="flex items-center justify-between rounded-full shrink-0">
|
||||
<CircularProgressIndicator size={48} percentage={percentage} strokeWidth={3}>
|
||||
<span className="text-xs text-primary font-medium block text-center">
|
||||
{`${percentage ? percentage.toFixed(0) : 0}%`}
|
||||
</span>
|
||||
|
||||
{isHovering && (
|
||||
<div className="text-accent-secondary text-9 uppercase whitespace-nowrap tracking-[0.9px] font-semibold leading-[11px] text-center">
|
||||
{days_left === 1 ? "Day" : "Days"} <br /> left
|
||||
</div>
|
||||
)}
|
||||
</CircularProgressIndicator>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -11,19 +11,25 @@
|
||||
* NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
|
||||
*/
|
||||
|
||||
import type { FC } from "react";
|
||||
import React, { useRef } from "react";
|
||||
import { format, startOfToday } from "date-fns";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { CalendarDays } from "lucide-react";
|
||||
// plane imports
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { CycleGroupIcon, InfoIcon } from "@plane/propel/icons";
|
||||
import { BetaBadge } from "@/components/common/beta";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import type { ICycle, TCycleProgress } from "@plane/types";
|
||||
import { ControlLink, Loader } from "@plane/ui";
|
||||
import { findHowManyDaysLeft } from "@plane/utils";
|
||||
import { CycleListItemAction } from "@/components/cycles/list/cycle-list-item-action";
|
||||
import { generateQueryParams } from "@plane/utils";
|
||||
import { ButtonAvatars } from "@/components/dropdowns/member/avatar";
|
||||
import { MergedDateDisplay } from "@/components/dropdowns/merged-date";
|
||||
import { CycleQuickActions } from "@/components/cycles/quick-actions";
|
||||
// hooks
|
||||
import { useMember } from "@/hooks/store/use-member";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
// local imports
|
||||
import { BetaBadge } from "@/components/common/beta";
|
||||
import ProgressDonut from "./progress-donut";
|
||||
|
||||
type Props = {
|
||||
@@ -39,11 +45,23 @@ export function CycleProgressHeader(props: Props) {
|
||||
|
||||
// router
|
||||
const router = useAppRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const parentRef = useRef(null);
|
||||
const progressToday = progress && progress.find((d) => d.date === format(startOfToday(), "yyyy-MM-dd"));
|
||||
// store hooks
|
||||
const { getUserDetails } = useMember();
|
||||
const createdByDetails = cycleDetails.created_by ? getUserDetails(cycleDetails.created_by) : undefined;
|
||||
// handlers
|
||||
const handleControlLinkClick = () => {
|
||||
router.push(`/${workspaceSlug}/projects/${projectId}/cycles/${cycleDetails.id}`);
|
||||
const openCycleOverview = (e: React.MouseEvent<HTMLButtonElement | HTMLAnchorElement>) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
const query = generateQueryParams(searchParams, ["peekCycle"]);
|
||||
if (searchParams.has("peekCycle") && searchParams.get("peekCycle") === cycleId) {
|
||||
router.push(`${pathname}?${query}`);
|
||||
} else {
|
||||
router.push(`${pathname}?${query && `${query}&`}peekCycle=${cycleId}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEventPropagation = (e: React.MouseEvent) => {
|
||||
@@ -53,35 +71,38 @@ export function CycleProgressHeader(props: Props) {
|
||||
|
||||
return (
|
||||
<ControlLink
|
||||
onClick={handleControlLinkClick}
|
||||
href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycleDetails.id}`}
|
||||
onClick={() => router.push(`/${workspaceSlug}/projects/${projectId}/cycles/${cycleDetails.id}`)}
|
||||
>
|
||||
<div className="px-page-x flex items-center justify-between py-4 bg-surface-1 w-full">
|
||||
<div className="flex gap-6 h-full truncate">
|
||||
{progress === null && <Loader.Item width="65px" height="65px" className="flex-shrink-0 rounded-full" />}
|
||||
{progress && (
|
||||
<ProgressDonut progress={progressToday} days_left={findHowManyDaysLeft(cycleDetails.end_date) ?? 0} />
|
||||
)}
|
||||
<div className="flex flex-col h-full my-auto w-full overflow-hidden">
|
||||
<div ref={parentRef} className="group px-page-x flex items-center justify-between py-4 bg-surface-1 w-full">
|
||||
<div className="flex gap-4 h-full truncate items-center">
|
||||
{progress === null && <Loader.Item width="48px" height="48px" className="shrink-0 rounded-full" />}
|
||||
{progress && <ProgressDonut progress={progressToday} />}
|
||||
<div className="flex flex-col overflow-hidden">
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="text-11 text-accent-secondary font-medium">Currently active cycle</div>
|
||||
<CycleGroupIcon cycleGroup="current" height="16" width="16" />
|
||||
<BetaBadge />
|
||||
</div>
|
||||
<Tooltip tooltipContent={cycleDetails.name} position="bottom-end">
|
||||
<div className="inline-block line-clamp-1 truncate font-bold text-primary my-1 text-[20px] text-left">
|
||||
{cycleDetails.name}
|
||||
</div>
|
||||
<span className="truncate font-bold text-primary text-xl">{cycleDetails.name}</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-4 items-center" onClick={handleEventPropagation}>
|
||||
<CycleListItemAction
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
cycleId={cycleId}
|
||||
cycleDetails={cycleDetails}
|
||||
<div className="flex shrink-0 gap-3 items-center" onClick={handleEventPropagation}>
|
||||
<button onClick={openCycleOverview} className="shrink-0 hidden group-hover:flex">
|
||||
<InfoIcon className="h-4 w-4 text-placeholder" />
|
||||
</button>
|
||||
{cycleDetails.start_date && cycleDetails.end_date && (
|
||||
<Button variant="secondary" size="sm" prependIcon={<CalendarDays className="size-3" />} disabled>
|
||||
<MergedDateDisplay startDate={cycleDetails.start_date} endDate={cycleDetails.end_date} />
|
||||
</Button>
|
||||
)}
|
||||
{createdByDetails && <ButtonAvatars showTooltip={false} userIds={createdByDetails.id} />}
|
||||
<CycleQuickActions
|
||||
parentRef={parentRef}
|
||||
isActive
|
||||
cycleId={cycleId}
|
||||
projectId={projectId}
|
||||
workspaceSlug={workspaceSlug}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,31 +11,136 @@
|
||||
* NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { isEmpty } from "lodash-es";
|
||||
import { observer } from "mobx-react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
// plane imports
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@plane/propel/collapsible";
|
||||
import { EmptyStateDetailed } from "@plane/propel/empty-state";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { CycleProgressHeader } from "@/components/cycles/active-cycles/v2/progress-header";
|
||||
import { CycleListGroupHeader } from "@/components/cycles/list/cycle-list-group-header";
|
||||
import { useCycle } from "@/hooks/store/use-cycle";
|
||||
// local imports
|
||||
import { ActiveCycleDetails } from "./details";
|
||||
import { ActiveCycleCompactRow } from "./compact-row";
|
||||
import { useActiveCycleDetails } from "./use-active-cycle-details";
|
||||
|
||||
type ActiveCycleRootProps = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
cycleId?: string;
|
||||
cycleIds?: string[];
|
||||
showHeader?: boolean;
|
||||
};
|
||||
|
||||
// Renders details for a single explicitly-provided cycle ID
|
||||
const ActiveCycleEntry = observer(function ActiveCycleEntry({
|
||||
cycleId,
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
onCollapse,
|
||||
}: {
|
||||
cycleId: string;
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
onCollapse?: () => void;
|
||||
}) {
|
||||
const cycleDetails = useActiveCycleDetails({
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
cycleId,
|
||||
});
|
||||
|
||||
if (!cycleDetails.cycle || isEmpty(cycleDetails.cycle)) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-shrink-0 flex-col border-b border-subtle-1">
|
||||
<div className="sticky top-0 z-[2] w-full flex-shrink-0 border-b border-subtle-1 bg-layer-1 cursor-pointer">
|
||||
<div className="flex items-center bg-surface-1">
|
||||
{onCollapse && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onCollapse();
|
||||
}}
|
||||
className="flex items-center justify-center flex-shrink-0 bg-surface-1 pl-2"
|
||||
>
|
||||
<ChevronDown className="size-4 text-tertiary" />
|
||||
</button>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<CycleProgressHeader
|
||||
cycleDetails={cycleDetails.cycle}
|
||||
progress={cycleDetails.cycleProgress}
|
||||
projectId={projectId}
|
||||
cycleId={cycleDetails.cycle?.id || ""}
|
||||
workspaceSlug={workspaceSlug}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<ActiveCycleDetails {...cycleDetails} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export const ActiveCycleRoot = observer(function ActiveCycleRoot(props: ActiveCycleRootProps) {
|
||||
const { workspaceSlug, projectId, cycleId } = props;
|
||||
const { workspaceSlug, projectId, cycleId: propsCycleId, cycleIds: propsCycleIds } = props;
|
||||
// plane hooks
|
||||
const { t } = useTranslation();
|
||||
// store hooks
|
||||
const cycleDetails = useActiveCycleDetails({ workspaceSlug, projectId, cycleId });
|
||||
const { getActiveCycleIds, currentProjectActiveCycleId } = useCycle();
|
||||
const activeCyclesForProject = getActiveCycleIds(projectId);
|
||||
|
||||
if (!cycleDetails.cycle || isEmpty(cycleDetails.cycle))
|
||||
// When explicit cycleIds are provided (e.g. from teamspace), use them.
|
||||
// When a single cycleId is provided, render only that cycle.
|
||||
// Otherwise render all active cycles (supports parallel cycles).
|
||||
const activeCycleIds = useMemo(
|
||||
() =>
|
||||
propsCycleIds && propsCycleIds.length > 0
|
||||
? propsCycleIds
|
||||
: propsCycleId
|
||||
? [propsCycleId]
|
||||
: activeCyclesForProject.length > 0
|
||||
? activeCyclesForProject
|
||||
: currentProjectActiveCycleId
|
||||
? [currentProjectActiveCycleId]
|
||||
: [],
|
||||
[propsCycleIds, propsCycleId, activeCyclesForProject, currentProjectActiveCycleId]
|
||||
);
|
||||
|
||||
// Track which cycles are expanded (first one expanded by default, multiple allowed)
|
||||
const [expandedCycleIds, setExpandedCycleIds] = useState<Set<string>>(() => new Set([activeCycleIds[0]]));
|
||||
// Collapsible section state for the "Active N" header
|
||||
const [isSectionOpen, setIsSectionOpen] = useState(true);
|
||||
|
||||
// Sync expandedCycleIds only when the actual set of active cycle IDs changes
|
||||
// (not on every render — activeCycleIds gets a new array reference from MobX each time)
|
||||
const activeCycleIdsKey = activeCycleIds.join(",");
|
||||
useEffect(() => {
|
||||
setExpandedCycleIds((prev) => {
|
||||
const stillValid = new Set([...prev].filter((id) => activeCycleIds.includes(id)));
|
||||
if (stillValid.size > 0) return stillValid;
|
||||
return activeCycleIds.length > 0 ? new Set([activeCycleIds[0]]) : new Set();
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeCycleIdsKey]);
|
||||
|
||||
const handleToggleCycle = (id: string) => {
|
||||
setExpandedCycleIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
if (activeCycleIds.length === 0) {
|
||||
return (
|
||||
<EmptyStateDetailed
|
||||
assetKey="cycle"
|
||||
@@ -44,23 +149,41 @@ export const ActiveCycleRoot = observer(function ActiveCycleRoot(props: ActiveCy
|
||||
rootClassName="py-10 h-auto"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Collapsible "Active N" header with individually expandable/collapsible cycle entries
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-shrink-0 flex-col border-b border-subtle-1">
|
||||
<div className="sticky top-0 z-[2] w-full flex-shrink-0 border-b border-subtle-1 bg-layer-1 cursor-pointer">
|
||||
<CycleProgressHeader
|
||||
cycleDetails={cycleDetails.cycle}
|
||||
progress={cycleDetails.cycleProgress}
|
||||
projectId={projectId}
|
||||
cycleId={cycleDetails.cycle?.id || " "}
|
||||
workspaceSlug={workspaceSlug}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<ActiveCycleDetails {...cycleDetails} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
<Collapsible className="flex shrink-0 flex-col" open={isSectionOpen} onOpenChange={setIsSectionOpen}>
|
||||
<CollapsibleTrigger className="sticky top-0 z-[2] w-full shrink-0 border-b border-subtle bg-layer-1 cursor-pointer">
|
||||
<CycleListGroupHeader
|
||||
title={t("project_cycles.active_cycle.label")}
|
||||
type="current"
|
||||
count={activeCycleIds.length}
|
||||
showCount
|
||||
isExpanded={isSectionOpen}
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
{activeCycleIds.map((id) =>
|
||||
expandedCycleIds.has(id) ? (
|
||||
<ActiveCycleEntry
|
||||
key={id}
|
||||
cycleId={id}
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
onCollapse={() => handleToggleCycle(id)}
|
||||
/>
|
||||
) : (
|
||||
<ActiveCycleCompactRow
|
||||
key={id}
|
||||
cycleId={id}
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
onToggle={() => handleToggleCycle(id)}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -32,11 +32,11 @@ function ScopeDelta(props: Props) {
|
||||
<div className="flex text-indigo-400 font-medium">
|
||||
{prevData.scope < dataToday.scope! ? (
|
||||
<>
|
||||
<TrendingUp className="w-[12px] h-[12px] my-auto mr-1" /> +
|
||||
<TrendingUp className="size-3 my-auto mr-1" /> +
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<TrendingDown className="w-[14px] h-[14px] my-auto mr-1" /> -
|
||||
<TrendingDown className="size-3 my-auto mr-1" /> -
|
||||
</>
|
||||
)}
|
||||
{Math.round(delta)}%
|
||||
|
||||
@@ -33,17 +33,24 @@ export interface ICyclesView {
|
||||
export const CyclesView = observer(function CyclesView(props: ICyclesView) {
|
||||
const { workspaceSlug, projectId } = props;
|
||||
// store hooks
|
||||
const { getFilteredCycleIds, getFilteredCompletedCycleIds, loader, currentProjectActiveCycleId } = useCycle();
|
||||
const { getFilteredCycleIds, getFilteredCompletedCycleIds, getActiveCycleIds } = useCycle();
|
||||
const { searchQuery } = useCycleFilter();
|
||||
const { t } = useTranslation();
|
||||
// derived values
|
||||
const filteredCycleIds = getFilteredCycleIds(projectId, false);
|
||||
const filteredCompletedCycleIds = getFilteredCompletedCycleIds(projectId);
|
||||
const filteredUpcomingCycleIds = (filteredCycleIds ?? []).filter(
|
||||
(cycleId) => cycleId !== currentProjectActiveCycleId
|
||||
);
|
||||
const activeCycleIdSet = new Set(getActiveCycleIds(projectId));
|
||||
// Only show active cycles that pass the current filters (status, date, search)
|
||||
const filteredActiveCycleIds: string[] = [];
|
||||
const filteredUpcomingCycleIds: string[] = [];
|
||||
for (const cycleId of filteredCycleIds ?? []) {
|
||||
if (activeCycleIdSet.has(cycleId)) filteredActiveCycleIds.push(cycleId);
|
||||
else filteredUpcomingCycleIds.push(cycleId);
|
||||
}
|
||||
|
||||
if (loader || !filteredCycleIds) return <CycleModuleListLayoutLoader />;
|
||||
// Only show loader before initial fetch (filteredCycleIds is null).
|
||||
// Avoid showing loader on refetch to prevent unmounting CyclesList and resetting collapsible states.
|
||||
if (!filteredCycleIds) return <CycleModuleListLayoutLoader />;
|
||||
|
||||
if (filteredCycleIds.length === 0 && filteredCompletedCycleIds?.length === 0)
|
||||
return (
|
||||
@@ -66,6 +73,7 @@ export const CyclesView = observer(function CyclesView(props: ICyclesView) {
|
||||
|
||||
return (
|
||||
<CyclesList
|
||||
activeCycleIds={filteredActiveCycleIds}
|
||||
completedCycleIds={filteredCompletedCycleIds ?? []}
|
||||
upcomingCycleIds={filteredUpcomingCycleIds}
|
||||
cycleIds={filteredCycleIds}
|
||||
|
||||
@@ -1,316 +0,0 @@
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2023-present Plane Software, Inc.
|
||||
* SPDX-License-Identifier: LicenseRef-Plane-Commercial
|
||||
*
|
||||
* Licensed under the Plane Commercial License (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
* https://plane.so/legals/eula
|
||||
*
|
||||
* DO NOT remove or modify this notice.
|
||||
* NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
|
||||
*/
|
||||
|
||||
import type { MouseEvent } from "react";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams, usePathname, useSearchParams } from "next/navigation";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Eye, ArrowRight, CalendarDays } from "lucide-react";
|
||||
// plane imports
|
||||
import { EUserPermissions, EUserPermissionsLevel, IS_FAVORITE_MENU_OPEN } from "@plane/constants";
|
||||
import { useLocalStorage } from "@plane/hooks";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { TransferIcon, WorkItemsIcon, MembersPropertyIcon } from "@plane/propel/icons";
|
||||
import { setPromiseToast } from "@plane/propel/toast";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import type { ICycle, TCycleGroups } from "@plane/types";
|
||||
import { Avatar, AvatarGroup } from "@plane/propel/avatar";
|
||||
import { FavoriteStar } from "@plane/ui";
|
||||
import { getDate, getFileURL, generateQueryParams } from "@plane/utils";
|
||||
// components
|
||||
import { DateRangeDropdown } from "@/components/dropdowns/date-range";
|
||||
import { ButtonAvatars } from "@/components/dropdowns/member/avatar";
|
||||
import { MergedDateDisplay } from "@/components/dropdowns/merged-date";
|
||||
// hooks
|
||||
import { useCycle } from "@/hooks/store/use-cycle";
|
||||
import { useMember } from "@/hooks/store/use-member";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
import { useTimeZoneConverter } from "@/hooks/use-timezone-converter";
|
||||
// local imports
|
||||
import { CycleQuickActions } from "../quick-actions";
|
||||
import { StartCycleButton } from "../start-cycle/button";
|
||||
import { TransferIssuesModal } from "../transfer-issues-modal";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
cycleId: string;
|
||||
cycleDetails: ICycle;
|
||||
parentRef: React.RefObject<HTMLDivElement>;
|
||||
isActive?: boolean;
|
||||
};
|
||||
|
||||
const defaultValues: Partial<ICycle> = {
|
||||
start_date: null,
|
||||
end_date: null,
|
||||
};
|
||||
|
||||
export const CycleListItemAction = observer(function CycleListItemAction(props: Props) {
|
||||
const { workspaceSlug, projectId, cycleId, cycleDetails, parentRef, isActive = false } = props;
|
||||
// router
|
||||
const { projectId: routerProjectId } = useParams();
|
||||
//states
|
||||
const [transferIssuesModal, setTransferIssuesModal] = useState(false);
|
||||
// hooks
|
||||
const { isMobile } = usePlatformOS();
|
||||
const { t } = useTranslation();
|
||||
const { isProjectTimeZoneDifferent, getProjectUTCOffset, renderFormattedDateInUserTimezone } =
|
||||
useTimeZoneConverter(projectId);
|
||||
// router
|
||||
const router = useAppRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const pathname = usePathname();
|
||||
// store hooks
|
||||
const { addCycleToFavorites, removeCycleFromFavorites } = useCycle();
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
|
||||
// local storage
|
||||
const { setValue: toggleFavoriteMenu, storedValue: isFavoriteMenuOpen } = useLocalStorage<boolean>(
|
||||
IS_FAVORITE_MENU_OPEN,
|
||||
false
|
||||
);
|
||||
|
||||
const { getUserDetails } = useMember();
|
||||
|
||||
// form
|
||||
const { reset } = useForm({
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
// derived values
|
||||
const cycleStatus = cycleDetails.status ? (cycleDetails.status.toLocaleLowerCase() as TCycleGroups) : "draft";
|
||||
|
||||
const showIssueCount = useMemo(() => cycleStatus === "draft" || cycleStatus === "upcoming", [cycleStatus]);
|
||||
|
||||
const transferableIssuesCount = cycleDetails
|
||||
? cycleDetails.total_issues - (cycleDetails.cancelled_issues + cycleDetails.completed_issues)
|
||||
: 0;
|
||||
|
||||
const showTransferIssues = routerProjectId && transferableIssuesCount > 0 && cycleStatus === "completed";
|
||||
|
||||
const projectUTCOffset = getProjectUTCOffset();
|
||||
|
||||
const isEditingAllowed = allowPermissions(
|
||||
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
|
||||
EUserPermissionsLevel.PROJECT,
|
||||
workspaceSlug,
|
||||
projectId
|
||||
);
|
||||
|
||||
// handlers
|
||||
const handleAddToFavorites = (e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
const addToFavoritePromise = addCycleToFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId);
|
||||
|
||||
setPromiseToast(addToFavoritePromise, {
|
||||
loading: t("project_cycles.action.favorite.loading"),
|
||||
success: {
|
||||
title: t("project_cycles.action.favorite.success.title"),
|
||||
message: () => t("project_cycles.action.favorite.success.description"),
|
||||
},
|
||||
error: {
|
||||
title: t("project_cycles.action.favorite.failed.title"),
|
||||
message: () => t("project_cycles.action.favorite.failed.description"),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveFromFavorites = (e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
const removeFromFavoritePromise = removeCycleFromFavorites(
|
||||
workspaceSlug?.toString(),
|
||||
projectId.toString(),
|
||||
cycleId
|
||||
);
|
||||
|
||||
setPromiseToast(removeFromFavoritePromise, {
|
||||
loading: t("project_cycles.action.unfavorite.loading"),
|
||||
success: {
|
||||
title: t("project_cycles.action.unfavorite.success.title"),
|
||||
message: () => t("project_cycles.action.unfavorite.success.description"),
|
||||
},
|
||||
error: {
|
||||
title: t("project_cycles.action.unfavorite.failed.title"),
|
||||
message: () => t("project_cycles.action.unfavorite.failed.description"),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const createdByDetails = cycleDetails.created_by ? getUserDetails(cycleDetails.created_by) : undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (cycleDetails)
|
||||
reset({
|
||||
...cycleDetails,
|
||||
});
|
||||
}, [cycleDetails, reset]);
|
||||
|
||||
// handlers
|
||||
const openCycleOverview = (e: MouseEvent<HTMLButtonElement | HTMLAnchorElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const query = generateQueryParams(searchParams, ["peekCycle"]);
|
||||
if (searchParams.has("peekCycle") && searchParams.get("peekCycle") === cycleId) {
|
||||
router.push(`${pathname}?${query}`);
|
||||
} else {
|
||||
router.push(`${pathname}?${query && `${query}&`}peekCycle=${cycleId}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<TransferIssuesModal
|
||||
handleClose={() => setTransferIssuesModal(false)}
|
||||
isOpen={transferIssuesModal}
|
||||
cycleId={cycleId.toString()}
|
||||
/>
|
||||
<button
|
||||
onClick={openCycleOverview}
|
||||
className={`z-[1] flex text-accent-secondary text-11 gap-1 flex-shrink-0 ${isMobile || (isActive && !searchParams.has("peekCycle")) ? "flex" : "hidden group-hover:flex"}`}
|
||||
>
|
||||
<Eye className="h-4 w-4 my-auto text-accent-secondary" />
|
||||
<span>{t("project_cycles.more_details")}</span>
|
||||
</button>
|
||||
{showIssueCount && (
|
||||
<div className="flex items-center gap-1">
|
||||
<WorkItemsIcon className="h-4 w-4 text-tertiary" />
|
||||
<span className="text-11 text-tertiary">{cycleDetails.total_issues}</span>
|
||||
</div>
|
||||
)}
|
||||
<StartCycleButton cycleId={cycleId} projectId={projectId} />
|
||||
{showTransferIssues && (
|
||||
<div
|
||||
className="px-2 h-6 text-accent-secondary flex items-center gap-1 cursor-pointer"
|
||||
onClick={() => {
|
||||
setTransferIssuesModal(true);
|
||||
}}
|
||||
>
|
||||
<TransferIcon className="fill-accent-primary w-4" />
|
||||
<span>{t("project_cycles.transfer_work_items", { count: transferableIssuesCount })}</span>
|
||||
</div>
|
||||
)}
|
||||
{isActive ? (
|
||||
<>
|
||||
<div className="flex gap-2">
|
||||
{/* Duration */}
|
||||
<Tooltip
|
||||
tooltipContent={
|
||||
<span className="flex gap-1">
|
||||
{renderFormattedDateInUserTimezone(cycleDetails.start_date ?? "")}
|
||||
<ArrowRight className="h-3 w-3 flex-shrink-0 my-auto" />
|
||||
{renderFormattedDateInUserTimezone(cycleDetails.end_date ?? "")}
|
||||
</span>
|
||||
}
|
||||
disabled={!isProjectTimeZoneDifferent()}
|
||||
tooltipHeading={t("project_cycles.in_your_timezone")}
|
||||
>
|
||||
<div className="flex gap-1 text-11 text-tertiary font-medium items-center">
|
||||
<CalendarDays className="h-3 w-3 flex-shrink-0 my-auto" />
|
||||
<MergedDateDisplay startDate={cycleDetails.start_date} endDate={cycleDetails.end_date} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
{projectUTCOffset && (
|
||||
<span className="rounded-md text-11 px-2 cursor-default py-1 bg-layer-1 text-tertiary">
|
||||
{projectUTCOffset}
|
||||
</span>
|
||||
)}
|
||||
{/* created by */}
|
||||
{createdByDetails && <ButtonAvatars showTooltip={false} userIds={createdByDetails?.id} />}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
cycleDetails.start_date && (
|
||||
<>
|
||||
<DateRangeDropdown
|
||||
buttonVariant={"transparent-with-text"}
|
||||
buttonContainerClassName={`h-6 w-full cursor-auto flex items-center gap-1.5 text-tertiary rounded-sm text-11 [&>div]:hover:bg-transparent`}
|
||||
buttonClassName="p-0"
|
||||
minDate={new Date()}
|
||||
value={{
|
||||
from: getDate(cycleDetails.start_date),
|
||||
to: getDate(cycleDetails.end_date),
|
||||
}}
|
||||
placeholder={{
|
||||
from: t("project_cycles.start_date"),
|
||||
to: t("project_cycles.end_date"),
|
||||
}}
|
||||
showTooltip={isProjectTimeZoneDifferent()}
|
||||
customTooltipHeading={t("project_cycles.in_your_timezone")}
|
||||
customTooltipContent={
|
||||
<span className="flex gap-1">
|
||||
{renderFormattedDateInUserTimezone(cycleDetails.start_date ?? "")}
|
||||
<ArrowRight className="h-3 w-3 flex-shrink-0 my-auto" />
|
||||
{renderFormattedDateInUserTimezone(cycleDetails.end_date ?? "")}
|
||||
</span>
|
||||
}
|
||||
mergeDates
|
||||
required={cycleDetails.status !== "draft"}
|
||||
disabled
|
||||
hideIcon={{
|
||||
from: false,
|
||||
to: false,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
{/* created by */}
|
||||
{createdByDetails && !isActive && <ButtonAvatars showTooltip={false} userIds={createdByDetails?.id} />}
|
||||
{!isActive && (
|
||||
<Tooltip tooltipContent={`${cycleDetails.assignee_ids?.length} Members`} isMobile={isMobile}>
|
||||
<div className="flex w-min cursor-default items-center justify-center">
|
||||
{cycleDetails.assignee_ids && cycleDetails.assignee_ids?.length > 0 ? (
|
||||
<AvatarGroup showTooltip={false}>
|
||||
{cycleDetails.assignee_ids?.map((assignee_id) => {
|
||||
const member = getUserDetails(assignee_id);
|
||||
return (
|
||||
<Avatar key={member?.id} name={member?.display_name} src={getFileURL(member?.avatar_url ?? "")} />
|
||||
);
|
||||
})}
|
||||
</AvatarGroup>
|
||||
) : (
|
||||
<MembersPropertyIcon className="h-4 w-4 text-tertiary" />
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
{isEditingAllowed && !cycleDetails.archived_at && (
|
||||
<FavoriteStar
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (cycleDetails.is_favorite) handleRemoveFromFavorites(e);
|
||||
else handleAddToFavorites(e);
|
||||
}}
|
||||
selected={!!cycleDetails.is_favorite}
|
||||
/>
|
||||
)}
|
||||
<div className="hidden md:block">
|
||||
<CycleQuickActions
|
||||
parentRef={parentRef}
|
||||
cycleId={cycleId}
|
||||
projectId={projectId}
|
||||
workspaceSlug={workspaceSlug}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -11,48 +11,47 @@
|
||||
* NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
|
||||
*/
|
||||
|
||||
import type { MouseEvent } from "react";
|
||||
import { useRef } from "react";
|
||||
import React, { useRef } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { CheckIcon } from "@plane/propel/icons";
|
||||
// plane imports
|
||||
import { CycleGroupIcon, InfoIcon } from "@plane/propel/icons";
|
||||
import type { TCycleGroups } from "@plane/types";
|
||||
import { CircularProgressIndicator } from "@plane/ui";
|
||||
import { generateQueryParams } from "@plane/utils";
|
||||
// components
|
||||
import { generateQueryParams, calculateCycleProgress } from "@plane/utils";
|
||||
import { ButtonAvatars } from "@/components/dropdowns/member/avatar";
|
||||
import { ListItem } from "@/components/core/list";
|
||||
import { MergedDateDisplay } from "@/components/dropdowns/merged-date";
|
||||
// hooks
|
||||
import { useCycle } from "@/hooks/store/use-cycle";
|
||||
import { useMember } from "@/hooks/store/use-member";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// local imports
|
||||
import { CycleQuickActions } from "../quick-actions";
|
||||
import { CycleListItemAction } from "./cycle-list-item-action";
|
||||
import { StartCycleButton } from "../start-cycle/button";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { CalendarDays } from "lucide-react";
|
||||
|
||||
type TCyclesListItem = {
|
||||
cycleId: string;
|
||||
handleEditCycle?: () => void;
|
||||
handleDeleteCycle?: () => void;
|
||||
handleAddToFavorites?: () => void;
|
||||
handleRemoveFromFavorites?: () => void;
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const CyclesListItem = observer(function CyclesListItem(props: TCyclesListItem) {
|
||||
const { cycleId, workspaceSlug, projectId, className = "" } = props;
|
||||
const { cycleId, workspaceSlug, projectId, className } = props;
|
||||
// refs
|
||||
const parentRef = useRef(null);
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
// router
|
||||
const router = useAppRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const pathname = usePathname();
|
||||
// hooks
|
||||
const { isMobile } = usePlatformOS();
|
||||
const searchParams = useSearchParams();
|
||||
// store hooks
|
||||
const { getCycleById } = useCycle();
|
||||
const { getUserDetails } = useMember();
|
||||
const { isMobile } = usePlatformOS();
|
||||
|
||||
// derived values
|
||||
const cycleDetails = getCycleById(cycleId);
|
||||
@@ -60,15 +59,12 @@ export const CyclesListItem = observer(function CyclesListItem(props: TCyclesLis
|
||||
if (!cycleDetails) return null;
|
||||
|
||||
// computed
|
||||
// TODO: change this logic once backend fix the response
|
||||
const cycleStatus = cycleDetails.status ? (cycleDetails.status.toLocaleLowerCase() as TCycleGroups) : "draft";
|
||||
const isActive = cycleStatus === "current";
|
||||
const createdByDetails = cycleDetails.created_by ? getUserDetails(cycleDetails.created_by) : undefined;
|
||||
|
||||
// handlers
|
||||
const openCycleOverview = (e: MouseEvent<HTMLButtonElement | HTMLAnchorElement>) => {
|
||||
e.preventDefault();
|
||||
const openCycleOverview = (e: React.MouseEvent<HTMLButtonElement | HTMLAnchorElement>) => {
|
||||
e.stopPropagation();
|
||||
|
||||
e.preventDefault();
|
||||
const query = generateQueryParams(searchParams, ["peekCycle"]);
|
||||
if (searchParams.has("peekCycle") && searchParams.get("peekCycle") === cycleId) {
|
||||
router.push(`${pathname}?${query}`);
|
||||
@@ -77,39 +73,46 @@ export const CyclesListItem = observer(function CyclesListItem(props: TCyclesLis
|
||||
}
|
||||
};
|
||||
|
||||
// handlers
|
||||
const handleArchivedCycleClick = (e: MouseEvent<HTMLAnchorElement>) => {
|
||||
const handleArchivedCycleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
openCycleOverview(e);
|
||||
};
|
||||
|
||||
const handleItemClick = cycleDetails.archived_at ? handleArchivedCycleClick : undefined;
|
||||
|
||||
const progress = calculateCycleProgress(cycleDetails);
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
title={cycleDetails?.name ?? ""}
|
||||
title={cycleDetails.name}
|
||||
itemLink={`/${workspaceSlug}/projects/${projectId}/cycles/${cycleDetails.id}`}
|
||||
onItemClick={handleItemClick}
|
||||
className={className}
|
||||
prependTitleElement={
|
||||
<CircularProgressIndicator size={30} percentage={progress} strokeWidth={3}>
|
||||
{progress === 100 ? (
|
||||
<CheckIcon className="h-3 w-3 stroke-2" />
|
||||
) : (
|
||||
<span className="text-9 text-primary">{`${progress}%`}</span>
|
||||
)}
|
||||
</CircularProgressIndicator>
|
||||
prependTitleElement={<CycleGroupIcon cycleGroup={cycleStatus} height="16" width="16" />}
|
||||
appendTitleElement={
|
||||
<>
|
||||
<button
|
||||
onClick={openCycleOverview}
|
||||
className={`z-5 shrink-0 ${isMobile ? "flex" : "hidden group-hover:flex"}`}
|
||||
>
|
||||
<InfoIcon className="h-4 w-4 text-placeholder" />
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
actionableItems={
|
||||
<CycleListItemAction
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
cycleId={cycleId}
|
||||
cycleDetails={cycleDetails}
|
||||
parentRef={parentRef}
|
||||
isActive={isActive}
|
||||
/>
|
||||
<>
|
||||
<StartCycleButton cycleId={cycleId} projectId={projectId} />
|
||||
|
||||
{cycleDetails.start_date && cycleDetails.end_date && (
|
||||
<Button variant="secondary" size="sm" prependIcon={<CalendarDays className="size-3" />} disabled>
|
||||
<MergedDateDisplay startDate={cycleDetails.start_date} endDate={cycleDetails.end_date} />
|
||||
</Button>
|
||||
)}
|
||||
{createdByDetails && <ButtonAvatars showTooltip={false} userIds={createdByDetails.id} />}
|
||||
<div className="hidden md:block">
|
||||
<CycleQuickActions
|
||||
parentRef={parentRef}
|
||||
cycleId={cycleId}
|
||||
projectId={projectId}
|
||||
workspaceSlug={workspaceSlug}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
quickActionElement={
|
||||
<div className="block md:hidden">
|
||||
@@ -123,7 +126,7 @@ export const CyclesListItem = observer(function CyclesListItem(props: TCyclesLis
|
||||
}
|
||||
isMobile={isMobile}
|
||||
parentRef={parentRef}
|
||||
isSidebarOpen={searchParams.has("peekCycle")}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -25,6 +25,7 @@ import { CycleListGroupHeader } from "./cycle-list-group-header";
|
||||
import { CyclesListMap } from "./cycles-list-map";
|
||||
|
||||
export interface ICyclesList {
|
||||
activeCycleIds?: string[];
|
||||
completedCycleIds: string[];
|
||||
upcomingCycleIds?: string[] | undefined;
|
||||
cycleIds: string[];
|
||||
@@ -37,16 +38,19 @@ const UpcomingCyclesCollapsible = observer(function UpcomingCyclesCollapsible({
|
||||
upcomingCycleIds,
|
||||
projectId,
|
||||
workspaceSlug,
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
}: {
|
||||
upcomingCycleIds: string[];
|
||||
projectId: string;
|
||||
workspaceSlug: string;
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
|
||||
return (
|
||||
<Collapsible className="flex flex-shrink-0 flex-col" open={isOpen} onOpenChange={setIsOpen}>
|
||||
<Collapsible className="flex flex-shrink-0 flex-col" open={isOpen} onOpenChange={onOpenChange}>
|
||||
<CollapsibleTrigger className="sticky top-0 z-[2] w-full flex-shrink-0 border-b border-subtle bg-layer-1 cursor-pointer">
|
||||
<CycleListGroupHeader
|
||||
title={t("project_cycles.upcoming_cycle.label")}
|
||||
@@ -67,16 +71,19 @@ const CompletedCyclesCollapsible = observer(function CompletedCyclesCollapsible(
|
||||
completedCycleIds,
|
||||
projectId,
|
||||
workspaceSlug,
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
}: {
|
||||
completedCycleIds: string[];
|
||||
projectId: string;
|
||||
workspaceSlug: string;
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Collapsible className="flex flex-shrink-0 flex-col pb-7" open={isOpen} onOpenChange={setIsOpen}>
|
||||
<Collapsible className="flex flex-shrink-0 flex-col pb-7" open={isOpen} onOpenChange={onOpenChange}>
|
||||
<CollapsibleTrigger className="sticky top-0 z-2 w-full flex-shrink-0 border-b border-subtle bg-layer-1 cursor-pointer">
|
||||
<CycleListGroupHeader
|
||||
title={t("project_cycles.completed_cycle.label")}
|
||||
@@ -94,7 +101,18 @@ const CompletedCyclesCollapsible = observer(function CompletedCyclesCollapsible(
|
||||
});
|
||||
|
||||
export const CyclesList = observer(function CyclesList(props: ICyclesList) {
|
||||
const { completedCycleIds, upcomingCycleIds, cycleIds, workspaceSlug, projectId, isArchived = false } = props;
|
||||
const {
|
||||
activeCycleIds,
|
||||
completedCycleIds,
|
||||
upcomingCycleIds,
|
||||
cycleIds,
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
isArchived = false,
|
||||
} = props;
|
||||
// Lift collapsible state here so it persists across child remounts
|
||||
const [isUpcomingOpen, setIsUpcomingOpen] = useState(true);
|
||||
const [isCompletedOpen, setIsCompletedOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<ContentWrapper variant={ERowVariant.HUGGING} className="flex-row">
|
||||
@@ -105,18 +123,24 @@ export const CyclesList = observer(function CyclesList(props: ICyclesList) {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ProjectActiveCycleRoot workspaceSlug={workspaceSlug} projectId={projectId} />
|
||||
{activeCycleIds && activeCycleIds.length > 0 && (
|
||||
<ProjectActiveCycleRoot workspaceSlug={workspaceSlug} projectId={projectId} cycleIds={activeCycleIds} />
|
||||
)}
|
||||
{upcomingCycleIds && (
|
||||
<UpcomingCyclesCollapsible
|
||||
upcomingCycleIds={upcomingCycleIds}
|
||||
projectId={projectId}
|
||||
workspaceSlug={workspaceSlug}
|
||||
isOpen={isUpcomingOpen}
|
||||
onOpenChange={setIsUpcomingOpen}
|
||||
/>
|
||||
)}
|
||||
<CompletedCyclesCollapsible
|
||||
completedCycleIds={completedCycleIds}
|
||||
projectId={projectId}
|
||||
workspaceSlug={workspaceSlug}
|
||||
isOpen={isCompletedOpen}
|
||||
onOpenChange={setIsCompletedOpen}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -18,7 +18,7 @@ import { MoreHorizontal } from "lucide-react";
|
||||
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { IconButton } from "@plane/propel/icon-button";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import { TOAST_TYPE, setToast, setPromiseToast } from "@plane/propel/toast";
|
||||
import type { TContextMenuItem } from "@plane/ui";
|
||||
import { ContextMenu, CustomMenu } from "@plane/ui";
|
||||
import { copyUrlToClipboard, cn } from "@plane/utils";
|
||||
@@ -50,7 +50,7 @@ export const CycleQuickActions = observer(function CycleQuickActions(props: Prop
|
||||
const [deleteModal, setDeleteModal] = useState(false);
|
||||
// store hooks
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
const { getCycleById, restoreCycle } = useCycle();
|
||||
const { getCycleById, restoreCycle, addCycleToFavorites, removeCycleFromFavorites } = useCycle();
|
||||
const { t } = useTranslation();
|
||||
// derived values
|
||||
const cycleDetails = getCycleById(cycleId);
|
||||
@@ -73,6 +73,37 @@ export const CycleQuickActions = observer(function CycleQuickActions(props: Prop
|
||||
});
|
||||
const handleOpenInNewTab = () => window.open(`/${cycleLink}`, "_blank");
|
||||
|
||||
const handleFavoriteToggle = () => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
if (cycleDetails?.is_favorite) {
|
||||
const promise = removeCycleFromFavorites(workspaceSlug, projectId, cycleId);
|
||||
setPromiseToast(promise, {
|
||||
loading: t("project_cycles.action.unfavorite.loading"),
|
||||
success: {
|
||||
title: t("project_cycles.action.unfavorite.success.title"),
|
||||
message: () => t("project_cycles.action.unfavorite.success.description"),
|
||||
},
|
||||
error: {
|
||||
title: t("project_cycles.action.unfavorite.failed.title"),
|
||||
message: () => t("project_cycles.action.unfavorite.failed.description"),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const promise = addCycleToFavorites(workspaceSlug, projectId, cycleId);
|
||||
setPromiseToast(promise, {
|
||||
loading: t("project_cycles.action.favorite.loading"),
|
||||
success: {
|
||||
title: t("project_cycles.action.favorite.success.title"),
|
||||
message: () => t("project_cycles.action.favorite.success.description"),
|
||||
},
|
||||
error: {
|
||||
title: t("project_cycles.action.favorite.failed.title"),
|
||||
message: () => t("project_cycles.action.favorite.failed.description"),
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestoreCycle = async () =>
|
||||
await restoreCycle(workspaceSlug, projectId, cycleId)
|
||||
.then(() => {
|
||||
@@ -97,12 +128,14 @@ export const CycleQuickActions = observer(function CycleQuickActions(props: Prop
|
||||
projectId,
|
||||
cycleId,
|
||||
isEditingAllowed,
|
||||
isFavorite: !!cycleDetails?.is_favorite,
|
||||
handleEdit: () => setUpdateModal(true),
|
||||
handleArchive: () => setArchiveCycleModal(true),
|
||||
handleRestore: handleRestoreCycle,
|
||||
handleDelete: () => setDeleteModal(true),
|
||||
handleCopyLink: handleCopyText,
|
||||
handleOpenInNewTab,
|
||||
handleFavorite: handleFavoriteToggle,
|
||||
});
|
||||
|
||||
const MENU_ITEMS: TContextMenuItem[] = Array.isArray(menuResult) ? menuResult : menuResult.items;
|
||||
@@ -147,8 +180,9 @@ export const CycleQuickActions = observer(function CycleQuickActions(props: Prop
|
||||
)}
|
||||
<ContextMenu parentRef={parentRef} items={CONTEXT_MENU_ITEMS} />
|
||||
<CustomMenu
|
||||
customButton={<IconButton variant="tertiary" size="lg" icon={MoreHorizontal} />}
|
||||
customButton={<IconButton variant="tertiary" size="base" icon={MoreHorizontal} />}
|
||||
placement="bottom-end"
|
||||
portalElement={document.body}
|
||||
closeOnSelect
|
||||
maxHeight="lg"
|
||||
buttonClassName={customClassName}
|
||||
|
||||
@@ -12,3 +12,4 @@
|
||||
*/
|
||||
|
||||
export * from "./auto-schedule-cycles";
|
||||
export * from "./parallel-cycles";
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2023-present Plane Software, Inc.
|
||||
* SPDX-License-Identifier: LicenseRef-Plane-Commercial
|
||||
*
|
||||
* Licensed under the Plane Commercial License (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
* https://plane.so/legals/eula
|
||||
*
|
||||
* DO NOT remove or modify this notice.
|
||||
* NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
|
||||
*/
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { UpgradeIcon } from "@plane/propel/icons";
|
||||
import { setPromiseToast } from "@plane/propel/toast";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { Switch } from "@plane/propel/switch";
|
||||
import { cn } from "@plane/utils";
|
||||
// components
|
||||
import { SettingsBoxedControlItem } from "@/components/settings/boxed-control-item";
|
||||
// plane web imports
|
||||
import { useFlag, useWorkspaceSubscription } from "@/plane-web/hooks/store";
|
||||
import { useProjectAdvanced } from "@/plane-web/hooks/store/projects/use-projects";
|
||||
|
||||
type Props = {
|
||||
disabled: boolean;
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
};
|
||||
|
||||
export const ParallelCycles = observer(function ParallelCycles(props: Props) {
|
||||
const { disabled, workspaceSlug, projectId } = props;
|
||||
// store hooks
|
||||
const { isProjectFeatureEnabled, toggleProjectFeatures } = useProjectAdvanced();
|
||||
const { togglePaidPlanModal } = useWorkspaceSubscription();
|
||||
// derived values
|
||||
const isFeatureFlagEnabled = useFlag(workspaceSlug.toString(), "PARALLEL_CYCLES");
|
||||
const isParallelCyclesEnabled = isProjectFeatureEnabled(projectId.toString(), "is_parallel_cycles_enabled");
|
||||
|
||||
const toggleParallelCycles = async (enabled: boolean) => {
|
||||
const promise = toggleProjectFeatures(workspaceSlug.toString(), projectId.toString(), {
|
||||
is_parallel_cycles_enabled: enabled,
|
||||
});
|
||||
|
||||
setPromiseToast(promise, {
|
||||
loading: enabled ? "Enabling parallel cycles" : "Disabling parallel cycles",
|
||||
success: {
|
||||
title: "Success",
|
||||
message: () => (enabled ? "Parallel cycles enabled." : "Parallel cycles disabled."),
|
||||
},
|
||||
error: {
|
||||
title: "Error",
|
||||
message: () => "Failed to update parallel cycles setting. Please try again.",
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn({
|
||||
"opacity-60 pointer-events-none select-none": disabled && isFeatureFlagEnabled,
|
||||
})}
|
||||
>
|
||||
<SettingsBoxedControlItem
|
||||
title="Parallel cycles"
|
||||
description="Run multiple cycles simultaneously to manage overlapping work streams."
|
||||
control={
|
||||
isFeatureFlagEnabled ? (
|
||||
<Switch value={isParallelCyclesEnabled} onChange={toggleParallelCycles} />
|
||||
) : (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
prependIcon={<UpgradeIcon />}
|
||||
onClick={() => togglePaidPlanModal(true)}
|
||||
>
|
||||
Upgrade
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -27,7 +27,6 @@ export function StartCycleModal(props: Props) {
|
||||
<ModalCore isOpen={isOpen} handleClose={handleClose} width={EModalWidth.MD}>
|
||||
<div className="p-4">
|
||||
<h3 className="text-16 font-medium">Sure you want to start this cycle?</h3>
|
||||
{/* <p className="text-13 text-tertiary mt-1"></p> */}
|
||||
<div className="mt-2 pt-2 flex items-center justify-end gap-2 border-t-[0.5px] border-subtle-1">
|
||||
<Button variant="secondary" onClick={handleClose}>
|
||||
Cancel
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { useParams } from "next/navigation";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { SearchIcon, CycleIcon, TransferIcon, CloseIcon } from "@plane/propel/icons";
|
||||
@@ -34,6 +35,7 @@ export const TransferIssuesModal = observer(function TransferIssuesModal(props:
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
// store hooks
|
||||
const { t } = useTranslation();
|
||||
const { currentProjectIncompleteCycleIds, getCycleById, fetchActiveCycleProgress } = useCycle();
|
||||
const {
|
||||
issues: { transferIssuesFromCycle },
|
||||
@@ -138,9 +140,7 @@ export const TransferIssuesModal = observer(function TransferIssuesModal(props:
|
||||
) : (
|
||||
<div className="flex w-full items-center justify-center gap-4 p-5 text-13">
|
||||
<AlertCircle className="h-3.5 w-3.5 text-secondary" />
|
||||
<span className="text-center text-secondary">
|
||||
You don’t have any current cycle. Please create one to transfer the work items.
|
||||
</span>
|
||||
<span className="text-center text-secondary">{t("project_cycles.transfer.no_cycles_available")}</span>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
|
||||
@@ -35,11 +35,11 @@ type TTeamCurrentCyclesRoot = {
|
||||
|
||||
const ProjectActiveCycleCollapsible = observer(function ProjectActiveCycleCollapsible({
|
||||
projectId,
|
||||
cycleId,
|
||||
cycleIds,
|
||||
workspaceSlug,
|
||||
}: {
|
||||
projectId: string;
|
||||
cycleId: string;
|
||||
cycleIds: string[];
|
||||
workspaceSlug: string;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
@@ -53,7 +53,7 @@ const ProjectActiveCycleCollapsible = observer(function ProjectActiveCycleCollap
|
||||
<ProjectActiveCycleRoot
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
cycleId={cycleId}
|
||||
cycleIds={cycleIds}
|
||||
showHeader={false}
|
||||
/>
|
||||
</CollapsibleContent>
|
||||
@@ -88,11 +88,11 @@ export const TeamCurrentCyclesRoot = observer(function TeamCurrentCyclesRoot(pro
|
||||
|
||||
return (
|
||||
<ContentWrapper variant={ERowVariant.HUGGING} className="relative">
|
||||
{Object.entries(groupedActiveCycleIds).map(([projectId, cycleId]) => (
|
||||
{Object.entries(groupedActiveCycleIds).map(([projectId, cycleIds]) => (
|
||||
<ProjectActiveCycleCollapsible
|
||||
key={projectId}
|
||||
projectId={projectId}
|
||||
cycleId={cycleId}
|
||||
cycleIds={cycleIds}
|
||||
workspaceSlug={workspaceSlug}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -51,6 +51,7 @@ export interface ICycleStore {
|
||||
currentProjectCompletedCycleIds: string[] | null;
|
||||
currentProjectIncompleteCycleIds: string[] | null;
|
||||
currentProjectActiveCycleId: string | null;
|
||||
getActiveCycleIds: (projectId: string) => string[];
|
||||
currentProjectArchivedCycleIds: string[] | null;
|
||||
currentProjectActiveCycle: ICycle | null;
|
||||
|
||||
@@ -218,7 +219,7 @@ export abstract class CycleStore implements ICycleStore {
|
||||
}
|
||||
|
||||
/**
|
||||
* returns active cycle id for a project
|
||||
* returns active cycle id for a project (first one found — kept for backwards compatibility)
|
||||
*/
|
||||
get currentProjectActiveCycleId() {
|
||||
const projectId = this.rootStore.router.projectId;
|
||||
@@ -231,6 +232,17 @@ export abstract class CycleStore implements ICycleStore {
|
||||
return activeCycle || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* returns all active cycle ids for a project — supports parallel cycles, sorted by start_date ascending
|
||||
*/
|
||||
getActiveCycleIds = computedFn((projectId: string): string[] => {
|
||||
if (!projectId) return [];
|
||||
const activeCycles = Object.values(this.cycleMap ?? {}).filter(
|
||||
(cycle) => cycle.project_id === projectId && cycle.status?.toLowerCase() === "current"
|
||||
);
|
||||
return sortBy(activeCycles, [(c) => c.start_date]).map((c) => c.id);
|
||||
});
|
||||
|
||||
/**
|
||||
* returns all archived cycle ids for a project
|
||||
*/
|
||||
|
||||
@@ -114,6 +114,7 @@ export class ProjectDetailsStore implements IProjectDetailsStore {
|
||||
is_milestone_enabled: false,
|
||||
project_id: projectId,
|
||||
is_automated_cycle_enabled: false,
|
||||
is_parallel_cycles_enabled: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
* NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
|
||||
*/
|
||||
|
||||
import { set, update } from "lodash-es";
|
||||
import { set, sortBy, update } from "lodash-es";
|
||||
import { observable, action, makeObservable, runInAction } from "mobx";
|
||||
import { computedFn } from "mobx-utils";
|
||||
// types
|
||||
@@ -32,7 +32,7 @@ export interface ITeamspaceCycleStore {
|
||||
getTeamspaceFilteredActiveCycleIds: (teamspaceId: string) => string[];
|
||||
getTeamspaceFilteredCompletedCycleIds: (teamspaceId: string) => string[];
|
||||
getTeamspaceFilteredUpcomingCycleIds: (teamspaceId: string) => string[];
|
||||
getTeamspaceGroupedActiveCycleIds: (teamspaceId: string) => Record<string, string>; // project_id -> teamspace active cycle ID
|
||||
getTeamspaceGroupedActiveCycleIds: (teamspaceId: string) => Record<string, string[]>; // project_id -> teamspace active cycle IDs
|
||||
getTeamspaceGroupedUpcomingCycleIds: (teamspaceId: string) => Record<string, string[]>; // project_id -> teamspace upcoming cycle IDs
|
||||
getTeamspaceGroupedCompletedCycleIds: (teamspaceId: string) => Record<string, string[]>; // project_id -> teamspace completed cycle IDs
|
||||
// actions
|
||||
@@ -139,19 +139,29 @@ export class TeamspaceCycleStore implements ITeamspaceCycleStore {
|
||||
/**
|
||||
* Returns teamspace grouped active cycle IDs
|
||||
* @param teamspaceId
|
||||
* @returns Record<string, string> project_id -> teamspace active cycle ID
|
||||
* @returns Record<string, string[]> project_id -> teamspace active cycle IDs
|
||||
*/
|
||||
getTeamspaceGroupedActiveCycleIds = computedFn((teamspaceId: string): Record<string, string> => {
|
||||
getTeamspaceGroupedActiveCycleIds = computedFn((teamspaceId: string): Record<string, string[]> => {
|
||||
const teamActiveCycleIds = this.getTeamspaceFilteredActiveCycleIds(teamspaceId);
|
||||
// Group by project_id -> cycle_id, only one active cycle per project
|
||||
const projectGroupedActiveCycleIds: Record<string, string> = {};
|
||||
// Group by project_id -> cycle_ids (supports parallel cycles)
|
||||
const projectGroupedActiveCycleIds: Record<string, string[]> = {};
|
||||
teamActiveCycleIds.forEach((cycleId) => {
|
||||
const cycle = this.cycleStore.getCycleById(cycleId);
|
||||
const projectId = cycle?.project_id;
|
||||
if (projectId) {
|
||||
projectGroupedActiveCycleIds[projectId] = cycleId;
|
||||
if (!projectGroupedActiveCycleIds[projectId]) {
|
||||
projectGroupedActiveCycleIds[projectId] = [];
|
||||
}
|
||||
projectGroupedActiveCycleIds[projectId].push(cycleId);
|
||||
}
|
||||
});
|
||||
// Sort each project's cycles by start_date ascending
|
||||
for (const projectId of Object.keys(projectGroupedActiveCycleIds)) {
|
||||
const cycles = projectGroupedActiveCycleIds[projectId]
|
||||
.map((id) => this.cycleStore.getCycleById(id))
|
||||
.filter((c): c is ICycle => !!c);
|
||||
projectGroupedActiveCycleIds[projectId] = sortBy(cycles, [(c) => c.start_date]).map((c) => c.id);
|
||||
}
|
||||
return projectGroupedActiveCycleIds;
|
||||
});
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ export interface ICycleStore extends ICeCycleStore {
|
||||
|
||||
updateCycleStatus: (workspaceSlug: string, projectId: string, cycleId: string, action: CYCLE_ACTION) => Promise<void>;
|
||||
isNextCycle: (projectId: string, cycleId: string) => boolean;
|
||||
isParallelCyclesEnabled: (workspaceSlug: string, projectId: string) => boolean;
|
||||
}
|
||||
|
||||
export class CycleStore extends CeCycleStore implements ICycleStore {
|
||||
@@ -89,6 +90,15 @@ export class CycleStore extends CeCycleStore implements ICycleStore {
|
||||
this.cycleService = new CycleService();
|
||||
}
|
||||
|
||||
isParallelCyclesEnabled = computedFn((workspaceSlug: string, projectId: string) => {
|
||||
const isFeatureFlagEnabled = this.store.featureFlags.getFeatureFlag(workspaceSlug, "PARALLEL_CYCLES", false);
|
||||
const isProjectFeatureEnabled = this.store.projectDetails.isProjectFeatureEnabled(
|
||||
projectId,
|
||||
"is_parallel_cycles_enabled"
|
||||
);
|
||||
return isProjectFeatureEnabled && isFeatureFlagEnabled;
|
||||
});
|
||||
|
||||
fetchUpdates = async (workspaceSlug: string, projectId: string, cycleId: string) => {
|
||||
const updates = await this.cycleUpdateService.getCycleUpdates(workspaceSlug, projectId, cycleId);
|
||||
runInAction(() => {
|
||||
@@ -229,15 +239,29 @@ export class CycleStore extends CeCycleStore implements ICycleStore {
|
||||
};
|
||||
|
||||
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
|
||||
// When parallel cycles are enabled (feature flag + project setting), any upcoming cycle can be started
|
||||
const workspaceSlug = this.store.router.workspaceSlug;
|
||||
const allowParallel = workspaceSlug ? this.isParallelCyclesEnabled(workspaceSlug, projectId) : false;
|
||||
|
||||
if (!allowParallel) {
|
||||
// Default behaviour: block start if any active cycle already exists
|
||||
const activeCycle = Object.values(this.cycleMap ?? {}).find(
|
||||
(c) => c.project_id === projectId && c.status?.toLowerCase() === "current"
|
||||
);
|
||||
if (activeCycle) return false;
|
||||
}
|
||||
|
||||
// In parallel mode (or when no active cycle exists): return true for any upcoming cycle
|
||||
const upcomingCycles = Object.values(this.cycleMap ?? {}).filter(
|
||||
(c) => c.project_id === projectId && c.status?.toLowerCase() === "upcoming"
|
||||
);
|
||||
|
||||
if (allowParallel) {
|
||||
// Any upcoming cycle can be started — show Start button for all of them
|
||||
return upcomingCycles.some((c) => c.id === cycleId);
|
||||
}
|
||||
|
||||
// Default: only the earliest upcoming cycle can be started
|
||||
const nextCycle = sortBy(upcomingCycles, [(c) => c.start_date])[0];
|
||||
return nextCycle?.id === cycleId;
|
||||
});
|
||||
|
||||
@@ -54,6 +54,7 @@ export enum E_FEATURE_FLAGS {
|
||||
ADVANCED_EXPORTS = "ADVANCED_EXPORTS",
|
||||
MILESTONES = "MILESTONES",
|
||||
AUTO_SCHEDULE_CYCLES = "AUTO_SCHEDULE_CYCLES",
|
||||
PARALLEL_CYCLES = "PARALLEL_CYCLES",
|
||||
WORKSPACE_MEMBERS_IMPORT = "WORKSPACE_MEMBERS_IMPORT",
|
||||
|
||||
// ====== silo importers ======
|
||||
@@ -189,6 +190,7 @@ export const FEATURE_TO_BASE_PLAN_MAP = {
|
||||
[E_FEATURE_FLAGS.PROJECT_AUTOMATIONS]: EProductSubscriptionEnum.BUSINESS,
|
||||
[E_FEATURE_FLAGS.WORKITEM_TYPE_FORMULA_FIELD]: EProductSubscriptionEnum.BUSINESS,
|
||||
[E_FEATURE_FLAGS.IDP_GROUP_SYNC]: EProductSubscriptionEnum.ENTERPRISE,
|
||||
[E_FEATURE_FLAGS.PARALLEL_CYCLES]: EProductSubscriptionEnum.ENTERPRISE,
|
||||
};
|
||||
|
||||
export type TSupportedFlagsForUpgrade = keyof typeof FEATURE_TO_BASE_PLAN_MAP;
|
||||
|
||||
@@ -2707,6 +2707,9 @@ Vytvořte nový.`,
|
||||
end_date: "Konec data",
|
||||
in_your_timezone: "V časovém pásmu",
|
||||
transfer_work_items: "Převést {count} pracovních položek",
|
||||
transfer: {
|
||||
no_cycles_available: "Žádné jiné cykly nejsou k dispozici pro přenos pracovních položek.",
|
||||
},
|
||||
date_range: "Období data",
|
||||
add_date: "Přidat datum",
|
||||
active_cycle: {
|
||||
@@ -2719,6 +2722,8 @@ Vytvořte nový.`,
|
||||
ideal: "Ideální",
|
||||
current: "Aktuální",
|
||||
labels: "Štítky",
|
||||
trailing: "Zpoždění",
|
||||
leading: "Předstih",
|
||||
},
|
||||
upcoming_cycle: {
|
||||
label: "Nadcházející cyklus",
|
||||
|
||||
@@ -2738,6 +2738,9 @@ Erstellen Sie ein neues.`,
|
||||
end_date: "Enddatum",
|
||||
in_your_timezone: "In Ihrer Zeitzone",
|
||||
transfer_work_items: "Übertragen von {count} Arbeitselementen",
|
||||
transfer: {
|
||||
no_cycles_available: "Keine anderen Zyklen verfügbar, um Arbeitselemente zu übertragen.",
|
||||
},
|
||||
date_range: "Datumsbereich",
|
||||
add_date: "Datum hinzufügen",
|
||||
active_cycle: {
|
||||
@@ -2750,6 +2753,8 @@ Erstellen Sie ein neues.`,
|
||||
ideal: "Ideal",
|
||||
current: "Aktuell",
|
||||
labels: "Labels",
|
||||
trailing: "Rückstand",
|
||||
leading: "Vorsprung",
|
||||
},
|
||||
upcoming_cycle: {
|
||||
label: "Bevorstehender Zyklus",
|
||||
|
||||
@@ -2796,6 +2796,9 @@ Create a new project instead`,
|
||||
end_date: "End date",
|
||||
in_your_timezone: "In your timezone",
|
||||
transfer_work_items: "Transfer {count} work items",
|
||||
transfer: {
|
||||
no_cycles_available: "No other cycles available to transfer work items to.",
|
||||
},
|
||||
date_range: "Date range",
|
||||
add_date: "Add date",
|
||||
active_cycle: {
|
||||
@@ -2808,6 +2811,8 @@ Create a new project instead`,
|
||||
ideal: "Ideal",
|
||||
current: "Current",
|
||||
labels: "Labels",
|
||||
trailing: "Trailing",
|
||||
leading: "Leading",
|
||||
},
|
||||
upcoming_cycle: {
|
||||
label: "Upcoming cycle",
|
||||
|
||||
@@ -2749,6 +2749,9 @@ Crea un nuevo proyecto en su lugar`,
|
||||
end_date: "Fecha de finalización",
|
||||
in_your_timezone: "En tu zona horaria",
|
||||
transfer_work_items: "Transferir {count} elementos de trabajo",
|
||||
transfer: {
|
||||
no_cycles_available: "No hay otros ciclos disponibles para transferir elementos de trabajo.",
|
||||
},
|
||||
date_range: "Rango de fechas",
|
||||
add_date: "Agregar fecha",
|
||||
active_cycle: {
|
||||
@@ -2761,6 +2764,8 @@ Crea un nuevo proyecto en su lugar`,
|
||||
ideal: "Ideal",
|
||||
current: "Actual",
|
||||
labels: "Etiquetas",
|
||||
trailing: "Retrasado",
|
||||
leading: "Adelantado",
|
||||
},
|
||||
upcoming_cycle: {
|
||||
label: "Ciclo próximo",
|
||||
|
||||
@@ -2755,6 +2755,9 @@ Créez plutôt un nouveau projet`,
|
||||
end_date: "Date de fin",
|
||||
in_your_timezone: "Dans votre fuseau horaire",
|
||||
transfer_work_items: "Transférer {count} éléments de travail",
|
||||
transfer: {
|
||||
no_cycles_available: "Aucun autre cycle disponible pour transférer les éléments de travail.",
|
||||
},
|
||||
date_range: "Plage de dates",
|
||||
add_date: "Ajouter une date",
|
||||
active_cycle: {
|
||||
@@ -2767,6 +2770,8 @@ Créez plutôt un nouveau projet`,
|
||||
ideal: "Idéal",
|
||||
current: "Actuel",
|
||||
labels: "Étiquettes",
|
||||
trailing: "En retard",
|
||||
leading: "En avance",
|
||||
},
|
||||
upcoming_cycle: {
|
||||
label: "Cycle à venir",
|
||||
|
||||
@@ -2719,6 +2719,10 @@ Buat proyek baru sebagai gantinya`,
|
||||
remove_filters_to_see_all_cycles: "Hapus filter untuk melihat semua siklus",
|
||||
remove_search_criteria_to_see_all_cycles: "Hapus kriteria pencarian untuk melihat semua siklus",
|
||||
only_completed_cycles_can_be_archived: "Hanya siklus yang diselesaikan yang dapat diarsipkan",
|
||||
transfer_work_items: "Transfer {count} item kerja",
|
||||
transfer: {
|
||||
no_cycles_available: "Tidak ada siklus lain yang tersedia untuk mentransfer item pekerjaan.",
|
||||
},
|
||||
active_cycle: {
|
||||
label: "Siklus aktif",
|
||||
progress: "Kemajuan",
|
||||
@@ -2729,6 +2733,8 @@ Buat proyek baru sebagai gantinya`,
|
||||
ideal: "Ideal",
|
||||
current: "Sekarang",
|
||||
labels: "Label",
|
||||
trailing: "Tertinggal",
|
||||
leading: "Memimpin",
|
||||
},
|
||||
upcoming_cycle: {
|
||||
label: "Siklus mendatang",
|
||||
|
||||
@@ -2731,6 +2731,9 @@ Crea un nuovo progetto invece`,
|
||||
end_date: "Data di fine",
|
||||
in_your_timezone: "Nel tuo fuso orario",
|
||||
transfer_work_items: "Trasferisci {count} elementi di lavoro",
|
||||
transfer: {
|
||||
no_cycles_available: "Nessun altro ciclo disponibile per trasferire gli elementi di lavoro.",
|
||||
},
|
||||
date_range: "Intervallo di date",
|
||||
add_date: "Aggiungi data",
|
||||
active_cycle: {
|
||||
@@ -2743,6 +2746,8 @@ Crea un nuovo progetto invece`,
|
||||
ideal: "Ideale",
|
||||
current: "Corrente",
|
||||
labels: "Etichette",
|
||||
trailing: "In ritardo",
|
||||
leading: "In anticipo",
|
||||
},
|
||||
upcoming_cycle: {
|
||||
label: "Ciclo in arrivo",
|
||||
|
||||
@@ -2708,6 +2708,9 @@ export default {
|
||||
end_date: "終了日",
|
||||
in_your_timezone: "あなたのタイムゾーン",
|
||||
transfer_work_items: "作業項目を転送 {count}",
|
||||
transfer: {
|
||||
no_cycles_available: "作業アイテムを転送できる他のサイクルがありません。",
|
||||
},
|
||||
date_range: "日付範囲",
|
||||
add_date: "日付を追加",
|
||||
active_cycle: {
|
||||
@@ -2720,6 +2723,8 @@ export default {
|
||||
ideal: "理想",
|
||||
current: "現在",
|
||||
labels: "ラベル",
|
||||
trailing: "遅れ",
|
||||
leading: "リード",
|
||||
},
|
||||
upcoming_cycle: {
|
||||
label: "今後のサイクル",
|
||||
|
||||
@@ -2689,6 +2689,9 @@ export default {
|
||||
end_date: "종료일",
|
||||
in_your_timezone: "내 시간대",
|
||||
transfer_work_items: "{count}개의 작업 항목 이전",
|
||||
transfer: {
|
||||
no_cycles_available: "작업 항목을 전송할 수 있는 다른 사이클이 없습니다.",
|
||||
},
|
||||
date_range: "날짜 범위",
|
||||
add_date: "날짜 추가",
|
||||
active_cycle: {
|
||||
@@ -2701,6 +2704,8 @@ export default {
|
||||
ideal: "이상적인",
|
||||
current: "현재",
|
||||
labels: "레이블",
|
||||
trailing: "뒤처짐",
|
||||
leading: "앞섬",
|
||||
},
|
||||
upcoming_cycle: {
|
||||
label: "다가오는 주기",
|
||||
|
||||
@@ -2716,6 +2716,9 @@ Utwórz nowy.`,
|
||||
end_date: "Data końca",
|
||||
in_your_timezone: "W Twojej strefie czasowej",
|
||||
transfer_work_items: "Przenieś {count} elementów pracy",
|
||||
transfer: {
|
||||
no_cycles_available: "Brak innych cykli dostępnych do przeniesienia elementów pracy.",
|
||||
},
|
||||
date_range: "Zakres dat",
|
||||
add_date: "Dodaj datę",
|
||||
active_cycle: {
|
||||
@@ -2728,6 +2731,8 @@ Utwórz nowy.`,
|
||||
ideal: "Idealny",
|
||||
current: "Obecny",
|
||||
labels: "Etykiety",
|
||||
trailing: "Opóźnienie",
|
||||
leading: "Wyprzedzenie",
|
||||
},
|
||||
upcoming_cycle: {
|
||||
label: "Nadchodzący cykl",
|
||||
|
||||
@@ -2738,6 +2738,10 @@ Crie um novo projeto em vez disso`,
|
||||
remove_filters_to_see_all_cycles: "Remova os filtros para ver todos os ciclos",
|
||||
remove_search_criteria_to_see_all_cycles: "Remova os critérios de pesquisa para ver todos os ciclos",
|
||||
only_completed_cycles_can_be_archived: "Apenas ciclos concluídos podem ser arquivados",
|
||||
transfer_work_items: "Transferir {count} itens de trabalho",
|
||||
transfer: {
|
||||
no_cycles_available: "Não há outros ciclos disponíveis para transferir itens de trabalho.",
|
||||
},
|
||||
active_cycle: {
|
||||
label: "Ciclo ativo",
|
||||
progress: "Progresso",
|
||||
@@ -2748,6 +2752,8 @@ Crie um novo projeto em vez disso`,
|
||||
ideal: "Ideal",
|
||||
current: "Atual",
|
||||
labels: "Etiquetas",
|
||||
trailing: "Atrasado",
|
||||
leading: "Adiantado",
|
||||
},
|
||||
upcoming_cycle: {
|
||||
label: "Próximo ciclo",
|
||||
|
||||
@@ -2733,6 +2733,10 @@ Creează un proiect nou.`,
|
||||
remove_filters_to_see_all_cycles: "Elimină filtrele pentru a vedea toate ciclurile",
|
||||
remove_search_criteria_to_see_all_cycles: "Elimină criteriile de căutare pentru a vedea toate ciclurile",
|
||||
only_completed_cycles_can_be_archived: "Doar ciclurile finalizate pot fi arhivate",
|
||||
transfer_work_items: "Transferați {count} elemente de lucru",
|
||||
transfer: {
|
||||
no_cycles_available: "Nu există alte cicluri disponibile pentru a transfera elemente de lucru.",
|
||||
},
|
||||
active_cycle: {
|
||||
label: "Ciclu activ",
|
||||
progress: "Progres",
|
||||
@@ -2743,6 +2747,8 @@ Creează un proiect nou.`,
|
||||
ideal: "Ideal",
|
||||
current: "Curent",
|
||||
labels: "Etichete",
|
||||
trailing: "Întârziat",
|
||||
leading: "Avansat",
|
||||
},
|
||||
upcoming_cycle: {
|
||||
label: "Ciclu viitor",
|
||||
|
||||
@@ -2720,6 +2720,9 @@ export default {
|
||||
end_date: "Дата окончания",
|
||||
in_your_timezone: "В вашем часовом поясе",
|
||||
transfer_work_items: "Перенести {count} рабочих элементов",
|
||||
transfer: {
|
||||
no_cycles_available: "Нет других циклов для переноса рабочих элементов.",
|
||||
},
|
||||
date_range: "Диапазон дат",
|
||||
add_date: "Добавить дату",
|
||||
active_cycle: {
|
||||
@@ -2732,6 +2735,8 @@ export default {
|
||||
ideal: "Идеальный",
|
||||
current: "Текущий",
|
||||
labels: "Метки",
|
||||
trailing: "Отставание",
|
||||
leading: "Опережение",
|
||||
},
|
||||
upcoming_cycle: {
|
||||
label: "Предстоящий цикл",
|
||||
|
||||
@@ -2670,6 +2670,9 @@ Vytvorte nový.`,
|
||||
end_date: "Dátum konca",
|
||||
in_your_timezone: "Váš časový pásmo",
|
||||
transfer_work_items: "Presunúť {count} pracovných položiek",
|
||||
transfer: {
|
||||
no_cycles_available: "Nie sú k dispozícii žiadne iné cykly na prenos pracovných položiek.",
|
||||
},
|
||||
date_range: "Dátumový rozsah",
|
||||
add_date: "Pridať dátum",
|
||||
active_cycle: {
|
||||
@@ -2682,6 +2685,8 @@ Vytvorte nový.`,
|
||||
ideal: "Ideálne",
|
||||
current: "Aktuálne",
|
||||
labels: "Štítky",
|
||||
trailing: "Oneskorenie",
|
||||
leading: "Náskok",
|
||||
},
|
||||
upcoming_cycle: {
|
||||
label: "Nadchádzajúci cyklus",
|
||||
|
||||
@@ -2709,6 +2709,9 @@ Bunun yerine yeni bir proje oluşturun`,
|
||||
end_date: "Bitiş tarihi",
|
||||
in_your_timezone: "Saat diliminizde",
|
||||
transfer_work_items: "{count} iş öğesini aktar",
|
||||
transfer: {
|
||||
no_cycles_available: "İş öğelerini aktaracak başka döngü bulunamadı.",
|
||||
},
|
||||
date_range: "Tarih aralığı",
|
||||
add_date: "Tarih ekle",
|
||||
active_cycle: {
|
||||
@@ -2721,6 +2724,8 @@ Bunun yerine yeni bir proje oluşturun`,
|
||||
ideal: "İdeal",
|
||||
current: "Mevcut",
|
||||
labels: "Etiketler",
|
||||
trailing: "Geride",
|
||||
leading: "İlerde",
|
||||
},
|
||||
upcoming_cycle: {
|
||||
label: "Yaklaşan döngü",
|
||||
|
||||
@@ -2711,6 +2711,9 @@ export default {
|
||||
end_date: "Дата завершення",
|
||||
in_your_timezone: "У вашому часовому поясі",
|
||||
transfer_work_items: "Перенести {count} робочих одиниць",
|
||||
transfer: {
|
||||
no_cycles_available: "Немає інших циклів для переміщення робочих елементів.",
|
||||
},
|
||||
date_range: "Діапазон дат",
|
||||
add_date: "Додати дату",
|
||||
active_cycle: {
|
||||
@@ -2723,6 +2726,8 @@ export default {
|
||||
ideal: "Ідеальний",
|
||||
current: "Поточний",
|
||||
labels: "Мітки",
|
||||
trailing: "Відставання",
|
||||
leading: "Випередження",
|
||||
},
|
||||
upcoming_cycle: {
|
||||
label: "Майбутній цикл",
|
||||
|
||||
@@ -2716,6 +2716,9 @@ Tạo dự án mới`,
|
||||
end_date: "Ngày kết thúc",
|
||||
in_your_timezone: "Trong múi giờ của bạn",
|
||||
transfer_work_items: "Chuyển {count} mục công việc",
|
||||
transfer: {
|
||||
no_cycles_available: "Không có chu kỳ nào khác để chuyển mục công việc.",
|
||||
},
|
||||
date_range: "Khoảng thời gian",
|
||||
add_date: "Thêm ngày",
|
||||
active_cycle: {
|
||||
@@ -2728,6 +2731,8 @@ Tạo dự án mới`,
|
||||
ideal: "Lý tưởng",
|
||||
current: "Hiện tại",
|
||||
labels: "Nhãn",
|
||||
trailing: "Chậm tiến độ",
|
||||
leading: "Vượt tiến độ",
|
||||
},
|
||||
upcoming_cycle: {
|
||||
label: "Chu kỳ sắp tới",
|
||||
|
||||
@@ -2640,6 +2640,9 @@ export default {
|
||||
end_date: "结束日期",
|
||||
in_your_timezone: "在您的时区",
|
||||
transfer_work_items: "转移 {count} 工作项",
|
||||
transfer: {
|
||||
no_cycles_available: "没有其他可用的周期来转移工作项。",
|
||||
},
|
||||
date_range: "日期范围",
|
||||
add_date: "添加日期",
|
||||
active_cycle: {
|
||||
@@ -2652,6 +2655,8 @@ export default {
|
||||
ideal: "理想",
|
||||
current: "当前",
|
||||
labels: "标签",
|
||||
trailing: "落后",
|
||||
leading: "领先",
|
||||
},
|
||||
upcoming_cycle: {
|
||||
label: "即将到来的周期",
|
||||
|
||||
@@ -2661,6 +2661,9 @@ export default {
|
||||
end_date: "結束日期",
|
||||
in_your_timezone: "在您的時區",
|
||||
transfer_work_items: "轉移 {count} 工作事項",
|
||||
transfer: {
|
||||
no_cycles_available: "沒有其他可用的週期來轉移工作項目。",
|
||||
},
|
||||
date_range: "日期範圍",
|
||||
add_date: "新增日期",
|
||||
active_cycle: {
|
||||
@@ -2673,6 +2676,8 @@ export default {
|
||||
ideal: "理想",
|
||||
current: "目前",
|
||||
labels: "標籤",
|
||||
trailing: "落後",
|
||||
leading: "領先",
|
||||
},
|
||||
upcoming_cycle: {
|
||||
label: "即將到來的週期",
|
||||
|
||||
@@ -107,6 +107,7 @@ export type TProjectFeaturesList = {
|
||||
is_workflow_enabled: boolean;
|
||||
is_milestone_enabled: boolean;
|
||||
is_automated_cycle_enabled: boolean;
|
||||
is_parallel_cycles_enabled: boolean;
|
||||
};
|
||||
|
||||
export type TProjectFeatures = {
|
||||
|
||||
Reference in New Issue
Block a user