diff --git a/apps/api/plane/app/views/cycle/base.py b/apps/api/plane/app/views/cycle/base.py index 72b6be87e9..0a4e4142cd 100644 --- a/apps/api/plane/app/views/cycle/base.py +++ b/apps/api/plane/app/views/cycle/base.py @@ -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) diff --git a/apps/api/plane/app/views/estimate/base.py b/apps/api/plane/app/views/estimate/base.py index 8a743b0250..3759ae07a5 100644 --- a/apps/api/plane/app/views/estimate/base.py +++ b/apps/api/plane/app/views/estimate/base.py @@ -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() diff --git a/apps/api/plane/ee/bgtasks/cycle_automation_task.py b/apps/api/plane/ee/bgtasks/cycle_automation_task.py index 1718c4d734..a5bdf6ec02 100644 --- a/apps/api/plane/ee/bgtasks/cycle_automation_task.py +++ b/apps/api/plane/ee/bgtasks/cycle_automation_task.py @@ -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, diff --git a/apps/api/plane/ee/migrations/0074_projectfeature_is_parallel_cycles_enabled.py b/apps/api/plane/ee/migrations/0074_projectfeature_is_parallel_cycles_enabled.py new file mode 100644 index 0000000000..76e028a779 --- /dev/null +++ b/apps/api/plane/ee/migrations/0074_projectfeature_is_parallel_cycles_enabled.py @@ -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), + ), + ] diff --git a/apps/api/plane/ee/models/project.py b/apps/api/plane/ee/models/project.py index 3a1ebb26a7..83acd5b9d0 100644 --- a/apps/api/plane/ee/models/project.py +++ b/apps/api/plane/ee/models/project.py @@ -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" diff --git a/apps/api/plane/ee/serializers/app/project.py b/apps/api/plane/ee/serializers/app/project.py index 4dac2ce7b8..99d8fcc831 100644 --- a/apps/api/plane/ee/serializers/app/project.py +++ b/apps/api/plane/ee/serializers/app/project.py @@ -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 = [ diff --git a/apps/api/plane/ee/views/app/cycle/start_stop.py b/apps/api/plane/ee/views/app/cycle/start_stop.py index b7736c923d..634554aaed 100644 --- a/apps/api/plane/ee/views/app/cycle/start_stop.py +++ b/apps/api/plane/ee/views/app/cycle/start_stop.py @@ -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() diff --git a/apps/api/plane/payment/flags/flag.py b/apps/api/plane/payment/flags/flag.py index daf0408dc5..ee5dba0f64 100644 --- a/apps/api/plane/payment/flags/flag.py +++ b/apps/api/plane/payment/flags/flag.py @@ -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 diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/cycles/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/cycles/page.tsx index f310cd2e2d..2a80e9d4b6 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/cycles/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/cycles/page.tsx @@ -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")} /> -
+
+ {/* Parallel cycles configuration */} +
- {/* Auto-schedule cycles configuration */}
+ {/* Auto-schedule cycles configuration */}
diff --git a/apps/web/core/components/common/quick-actions/factory.tsx b/apps/web/core/components/common/quick-actions/factory.tsx index e3f8010dec..e4ff3f7ff7 100644 --- a/apps/web/core/components/common/quick-actions/factory.tsx +++ b/apps/web/core/components/common/quick-actions/factory.tsx @@ -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 } diff --git a/apps/web/core/components/common/quick-actions/helper.tsx b/apps/web/core/components/common/quick-actions/helper.tsx index 6112088adb..21fde7d337 100644 --- a/apps/web/core/components/common/quick-actions/helper.tsx +++ b/apps/web/core/components/common/quick-actions/helper.tsx @@ -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, { diff --git a/apps/web/core/components/cycles/active-cycles/root.tsx b/apps/web/core/components/cycles/active-cycles/root.tsx index 323688adea..da7176b875 100644 --- a/apps/web/core/components/cycles/active-cycles/root.tsx +++ b/apps/web/core/components/cycles/active-cycles/root.tsx @@ -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 ( }> - + ); } diff --git a/apps/web/core/components/cycles/active-cycles/v1/root.tsx b/apps/web/core/components/cycles/active-cycles/v1/root.tsx index 85e4a8b6b6..2b511efade 100644 --- a/apps/web/core/components/cycles/active-cycles/v1/root.tsx +++ b/apps/web/core/components/cycles/active-cycles/v1/root.tsx @@ -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 ( + + ); +}); + +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 ? ( + {}} + /> + ) : ( + <> + {activeCycleIds.map((id) => ( + + ))} + + ); + return ( <> {showHeader ? ( @@ -125,26 +182,10 @@ export const ActiveCycleRoot = observer(function ActiveCycleRoot(props: ActiveCy - - - + {content} ) : ( - + content )} ); diff --git a/apps/web/core/components/cycles/active-cycles/v2/compact-row.tsx b/apps/web/core/components/cycles/active-cycles/v2/compact-row.tsx new file mode 100644 index 0000000000..3652e097c6 --- /dev/null +++ b/apps/web/core/components/cycles/active-cycles/v2/compact-row.tsx @@ -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(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) => { + 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 ( + + { + e.stopPropagation(); + e.preventDefault(); + onToggle(); + }} + aria-label="Toggle cycle details" + /> + + + } + appendTitleElement={ + + } + actionableItems={ +
+ {/* Progress fraction */} +
+ + + {progress}% ({cycleDetails.completed_issues}/{cycleDetails.total_issues}) + +
+ + {/* Scope delta */} + {hasScopeDelta && ( + <> +
+
+ Scope + +
+ + )} + + {/* Trailing/Leading */} + {trailingValue !== null && trailingValue > 0 && ( + <> +
+
+ + {isBehind ? t("project_cycles.active_cycle.trailing") : t("project_cycles.active_cycle.leading")} + + + {estimateTypeFormatter(trailingValue)} + +
+ + )} + + {/* Date button */} + {cycleDetails.start_date && cycleDetails.end_date && ( + + )} + + {/* Lead avatar */} + {createdByDetails && } + + {/* Quick actions menu */} +
+ +
+
+ } + quickActionElement={ +
+ +
+ } + /> + ); +}); diff --git a/apps/web/core/components/cycles/active-cycles/v2/progress-donut.tsx b/apps/web/core/components/cycles/active-cycles/v2/progress-donut.tsx index 465838d308..bb2162c5e9 100644 --- a/apps/web/core/components/cycles/active-cycles/v2/progress-donut.tsx +++ b/apps/web/core/components/cycles/active-cycles/v2/progress-donut.tsx @@ -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 | null | undefined; - days_left: number; }; function ProgressDonut(props: Props) { - const { progress, days_left } = props; - const [isHovering, setIsHovering] = useState(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 ( -
setIsHovering(true)} - onMouseLeave={() => setIsHovering(false)} - > - - - {progress ? (isHovering ? days_left : `${percentage ? percentage.toFixed(0) : 0}%`) : "0%"} +
+ + + {`${percentage ? percentage.toFixed(0) : 0}%`} - - {isHovering && ( -
- {days_left === 1 ? "Day" : "Days"}
left -
- )}
); diff --git a/apps/web/core/components/cycles/active-cycles/v2/progress-header.tsx b/apps/web/core/components/cycles/active-cycles/v2/progress-header.tsx index f3e103306f..6610af088f 100644 --- a/apps/web/core/components/cycles/active-cycles/v2/progress-header.tsx +++ b/apps/web/core/components/cycles/active-cycles/v2/progress-header.tsx @@ -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) => { + 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 ( router.push(`/${workspaceSlug}/projects/${projectId}/cycles/${cycleDetails.id}`)} > -
-
- {progress === null && } - {progress && ( - - )} -
+
+
+ {progress === null && } + {progress && } +
-
Currently active cycle
+
-
- {cycleDetails.name} -
+ {cycleDetails.name}
-
- + + {cycleDetails.start_date && cycleDetails.end_date && ( + + )} + {createdByDetails && } +
diff --git a/apps/web/core/components/cycles/active-cycles/v2/root.tsx b/apps/web/core/components/cycles/active-cycles/v2/root.tsx index 154286cf0c..13b50b9432 100644 --- a/apps/web/core/components/cycles/active-cycles/v2/root.tsx +++ b/apps/web/core/components/cycles/active-cycles/v2/root.tsx @@ -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 ( +
+
+
+ {onCollapse && ( + + )} +
+ +
+
+
+
+ +
+
+ ); +}); + 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>(() => 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 ( ); + } + // Collapsible "Active N" header with individually expandable/collapsible cycle entries return ( - <> -
-
- -
-
- -
-
- + + + + + + {activeCycleIds.map((id) => + expandedCycleIds.has(id) ? ( + handleToggleCycle(id)} + /> + ) : ( + handleToggleCycle(id)} + /> + ) + )} + + ); }); diff --git a/apps/web/core/components/cycles/active-cycles/v2/scope-delta.tsx b/apps/web/core/components/cycles/active-cycles/v2/scope-delta.tsx index d4112803e2..bea1c91a48 100644 --- a/apps/web/core/components/cycles/active-cycles/v2/scope-delta.tsx +++ b/apps/web/core/components/cycles/active-cycles/v2/scope-delta.tsx @@ -32,11 +32,11 @@ function ScopeDelta(props: Props) {
{prevData.scope < dataToday.scope! ? ( <> - + + + ) : ( <> - - + - )} {Math.round(delta)}% diff --git a/apps/web/core/components/cycles/cycles-view.tsx b/apps/web/core/components/cycles/cycles-view.tsx index de42b958f1..053f19b47f 100644 --- a/apps/web/core/components/cycles/cycles-view.tsx +++ b/apps/web/core/components/cycles/cycles-view.tsx @@ -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 ; + // 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 ; if (filteredCycleIds.length === 0 && filteredCompletedCycleIds?.length === 0) return ( @@ -66,6 +73,7 @@ export const CyclesView = observer(function CyclesView(props: ICyclesView) { return ( ; - isActive?: boolean; -}; - -const defaultValues: Partial = { - 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( - 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) => { - 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) => { - 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) => { - 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 ( - <> - setTransferIssuesModal(false)} - isOpen={transferIssuesModal} - cycleId={cycleId.toString()} - /> - - {showIssueCount && ( -
- - {cycleDetails.total_issues} -
- )} - - {showTransferIssues && ( -
{ - setTransferIssuesModal(true); - }} - > - - {t("project_cycles.transfer_work_items", { count: transferableIssuesCount })} -
- )} - {isActive ? ( - <> -
- {/* Duration */} - - {renderFormattedDateInUserTimezone(cycleDetails.start_date ?? "")} - - {renderFormattedDateInUserTimezone(cycleDetails.end_date ?? "")} - - } - disabled={!isProjectTimeZoneDifferent()} - tooltipHeading={t("project_cycles.in_your_timezone")} - > -
- - -
-
- {projectUTCOffset && ( - - {projectUTCOffset} - - )} - {/* created by */} - {createdByDetails && } -
- - ) : ( - cycleDetails.start_date && ( - <> - 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={ - - {renderFormattedDateInUserTimezone(cycleDetails.start_date ?? "")} - - {renderFormattedDateInUserTimezone(cycleDetails.end_date ?? "")} - - } - mergeDates - required={cycleDetails.status !== "draft"} - disabled - hideIcon={{ - from: false, - to: false, - }} - /> - - ) - )} - {/* created by */} - {createdByDetails && !isActive && } - {!isActive && ( - -
- {cycleDetails.assignee_ids && cycleDetails.assignee_ids?.length > 0 ? ( - - {cycleDetails.assignee_ids?.map((assignee_id) => { - const member = getUserDetails(assignee_id); - return ( - - ); - })} - - ) : ( - - )} -
-
- )} - {isEditingAllowed && !cycleDetails.archived_at && ( - { - e.preventDefault(); - e.stopPropagation(); - if (cycleDetails.is_favorite) handleRemoveFromFavorites(e); - else handleAddToFavorites(e); - }} - selected={!!cycleDetails.is_favorite} - /> - )} -
- -
- - ); -}); diff --git a/apps/web/core/components/cycles/list/cycles-list-item.tsx b/apps/web/core/components/cycles/list/cycles-list-item.tsx index 4117c178e1..cc3dc29856 100644 --- a/apps/web/core/components/cycles/list/cycles-list-item.tsx +++ b/apps/web/core/components/cycles/list/cycles-list-item.tsx @@ -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(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) => { - e.preventDefault(); + const openCycleOverview = (e: React.MouseEvent) => { 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) => { + const handleArchivedCycleClick = (e: React.MouseEvent) => { openCycleOverview(e); }; const handleItemClick = cycleDetails.archived_at ? handleArchivedCycleClick : undefined; - - const progress = calculateCycleProgress(cycleDetails); - return ( - {progress === 100 ? ( - - ) : ( - {`${progress}%`} - )} - + prependTitleElement={} + appendTitleElement={ + <> + + } actionableItems={ - + <> + + + {cycleDetails.start_date && cycleDetails.end_date && ( + + )} + {createdByDetails && } +
+ +
+ } quickActionElement={
@@ -123,7 +126,7 @@ export const CyclesListItem = observer(function CyclesListItem(props: TCyclesLis } isMobile={isMobile} parentRef={parentRef} - isSidebarOpen={searchParams.has("peekCycle")} + className={className} /> ); }); diff --git a/apps/web/core/components/cycles/list/root.tsx b/apps/web/core/components/cycles/list/root.tsx index e17bb52888..3ec074b9db 100644 --- a/apps/web/core/components/cycles/list/root.tsx +++ b/apps/web/core/components/cycles/list/root.tsx @@ -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 ( - + void; }) { - const [isOpen, setIsOpen] = useState(false); const { t } = useTranslation(); return ( - + @@ -105,18 +123,24 @@ export const CyclesList = observer(function CyclesList(props: ICyclesList) { ) : ( <> - + {activeCycleIds && activeCycleIds.length > 0 && ( + + )} {upcomingCycleIds && ( )} )} diff --git a/apps/web/core/components/cycles/quick-actions.tsx b/apps/web/core/components/cycles/quick-actions.tsx index c84bb20d01..536e0d4cca 100644 --- a/apps/web/core/components/cycles/quick-actions.tsx +++ b/apps/web/core/components/cycles/quick-actions.tsx @@ -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 )} } + customButton={} placement="bottom-end" + portalElement={document.body} closeOnSelect maxHeight="lg" buttonClassName={customClassName} diff --git a/apps/web/core/components/cycles/settings/index.ts b/apps/web/core/components/cycles/settings/index.ts index a5007eaeb6..00856e83e4 100644 --- a/apps/web/core/components/cycles/settings/index.ts +++ b/apps/web/core/components/cycles/settings/index.ts @@ -12,3 +12,4 @@ */ export * from "./auto-schedule-cycles"; +export * from "./parallel-cycles"; diff --git a/apps/web/core/components/cycles/settings/parallel-cycles.tsx b/apps/web/core/components/cycles/settings/parallel-cycles.tsx new file mode 100644 index 0000000000..087e46d986 --- /dev/null +++ b/apps/web/core/components/cycles/settings/parallel-cycles.tsx @@ -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 ( +
+ + ) : ( + + ) + } + /> +
+ ); +}); diff --git a/apps/web/core/components/cycles/start-cycle/modal.tsx b/apps/web/core/components/cycles/start-cycle/modal.tsx index 12836a0734..872a9ba94d 100644 --- a/apps/web/core/components/cycles/start-cycle/modal.tsx +++ b/apps/web/core/components/cycles/start-cycle/modal.tsx @@ -27,7 +27,6 @@ export function StartCycleModal(props: Props) {

Sure you want to start this cycle?

- {/*

*/}