chore: workspace active cycle revamp, project active cycle bug fixes and improvement. (#3407)

* chore:progress component improvement

* fix: completed at for done state

* fix: urgent and high issues in cycle

* fix: added pagination for active cycles

* fix: load more pagination

* chore: cycle types, constant and service updated

* chore: linear progress indicator component improvement

* chore: project cycle bug fixes and improvement

* chore: workspace active cycles revamp

* fix: module and cycle modal start date validation

* chore: workspace active cycle improvement

* chore: workspace active cycles imrprovement

* chore: workspace active cycles imrprovement

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
This commit is contained in:
Anmol Singh Bhatia 2024-01-20 16:33:10 +05:30 committed by GitHub
parent 017a8e422e
commit 31a5211d2c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 546 additions and 600 deletions

View File

@ -916,6 +916,115 @@ class ActiveCycleEndpoint(BaseAPIView):
permission_classes = [ permission_classes = [
WorkspaceUserPermission, WorkspaceUserPermission,
] ]
def get_results_controller(self, results, active_cycles=None):
for cycle in results:
assignee_distribution = (
Issue.objects.filter(
issue_cycle__cycle_id=cycle["id"],
project_id=cycle["project"],
workspace__slug=self.kwargs.get("slug"),
)
.annotate(display_name=F("assignees__display_name"))
.annotate(assignee_id=F("assignees__id"))
.annotate(avatar=F("assignees__avatar"))
.values("display_name", "assignee_id", "avatar")
.annotate(
total_issues=Count(
"assignee_id",
filter=Q(archived_at__isnull=True, is_draft=False),
),
)
.annotate(
completed_issues=Count(
"assignee_id",
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_issues=Count(
"assignee_id",
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("display_name")
)
label_distribution = (
Issue.objects.filter(
issue_cycle__cycle_id=cycle["id"],
project_id=cycle["project"],
workspace__slug=self.kwargs.get("slug"),
)
.annotate(label_name=F("labels__name"))
.annotate(color=F("labels__color"))
.annotate(label_id=F("labels__id"))
.values("label_name", "color", "label_id")
.annotate(
total_issues=Count(
"label_id",
filter=Q(archived_at__isnull=True, is_draft=False),
)
)
.annotate(
completed_issues=Count(
"label_id",
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_issues=Count(
"label_id",
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("label_name")
)
cycle["distribution"] = {
"assignees": assignee_distribution,
"labels": label_distribution,
"completion_chart": {},
}
if cycle["start_date"] and cycle["end_date"]:
cycle["distribution"][
"completion_chart"
] = burndown_plot(
queryset=active_cycles.get(pk=cycle["id"]),
slug=self.kwargs.get("slug"),
project_id=cycle["project"],
cycle_id=cycle["id"],
)
priority_issues = Issue.objects.filter(issue_cycle__cycle_id=cycle["id"], priority__in=["urgent", "high"])
# Priority Ordering
priority_order = ["urgent", "high"]
priority_issues = priority_issues.annotate(
priority_order=Case(
*[When(priority=p, then=Value(i)) for i, p in enumerate(priority_order)],
output_field=CharField(),
)
).order_by("priority_order")[:5]
cycle["issues"] = IssueSerializer(priority_issues, many=True).data
return results
def get(self, request, slug): def get(self, request, slug):
subquery = CycleFavorite.objects.filter( subquery = CycleFavorite.objects.filter(
user=self.request.user, user=self.request.user,
@ -1045,100 +1154,11 @@ class ActiveCycleEndpoint(BaseAPIView):
) )
.order_by("-created_at") .order_by("-created_at")
) )
cycles = CycleSerializer(active_cycles, many=True).data return self.paginate(
request=request,
for cycle in cycles: queryset=active_cycles,
assignee_distribution = ( on_results=lambda active_cycles: CycleSerializer(active_cycles, many=True).data,
Issue.objects.filter( controller=lambda results: self.get_results_controller(results, active_cycles),
issue_cycle__cycle_id=cycle["id"], default_per_page=int(request.GET.get("per_page", 3))
project_id=cycle["project"], )
workspace__slug=slug,
)
.annotate(display_name=F("assignees__display_name"))
.annotate(assignee_id=F("assignees__id"))
.annotate(avatar=F("assignees__avatar"))
.values("display_name", "assignee_id", "avatar")
.annotate(
total_issues=Count(
"assignee_id",
filter=Q(archived_at__isnull=True, is_draft=False),
),
)
.annotate(
completed_issues=Count(
"assignee_id",
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_issues=Count(
"assignee_id",
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("display_name")
)
label_distribution = (
Issue.objects.filter(
issue_cycle__cycle_id=cycle["id"],
project_id=cycle["project"],
workspace__slug=slug,
)
.annotate(label_name=F("labels__name"))
.annotate(color=F("labels__color"))
.annotate(label_id=F("labels__id"))
.values("label_name", "color", "label_id")
.annotate(
total_issues=Count(
"label_id",
filter=Q(archived_at__isnull=True, is_draft=False),
)
)
.annotate(
completed_issues=Count(
"label_id",
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_issues=Count(
"label_id",
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("label_name")
)
cycle["distribution"] = {
"assignees": assignee_distribution,
"labels": label_distribution,
"completion_chart": {},
}
if cycle["start_date"] and cycle["end_date"]:
cycle["distribution"][
"completion_chart"
] = burndown_plot(
queryset=active_cycles.get(pk=cycle["id"]),
slug=slug,
project_id=cycle["project"],
cycle_id=cycle["id"],
)
return Response(cycles, status=status.HTTP_200_OK)

View File

@ -9,6 +9,7 @@ from django.db.models.signals import post_save
from django.dispatch import receiver from django.dispatch import receiver
from django.core.validators import MinValueValidator, MaxValueValidator from django.core.validators import MinValueValidator, MaxValueValidator
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.utils import timezone
# Module imports # Module imports
from . import ProjectBaseModel from . import ProjectBaseModel
@ -183,6 +184,17 @@ class Issue(ProjectBaseModel):
self.state = default_state self.state = default_state
except ImportError: except ImportError:
pass pass
else:
try:
from plane.db.models import State
# Check if the current issue state group is completed or not
if self.state.group == "completed":
self.completed_at = timezone.now()
else:
self.completed_at = None
except ImportError:
pass
if self._state.adding: if self._state.adding:
# Get the maximum display_id value from the database # Get the maximum display_id value from the database

View File

@ -12,6 +12,7 @@ from django.db.models.functions import (
ExtractYear, ExtractYear,
Concat, Concat,
) )
from django.utils import timezone
# Module imports # Module imports
from plane.db.models import Issue from plane.db.models import Issue
@ -168,6 +169,9 @@ def burndown_plot(queryset, slug, project_id, cycle_id=None, module_id=None):
if item["date"] is not None and item["date"] <= date if item["date"] is not None and item["date"] <= date
) )
cumulative_pending_issues -= total_completed cumulative_pending_issues -= total_completed
chart_data[str(date)] = cumulative_pending_issues if date > timezone.now().date():
chart_data[str(date)] = None
else:
chart_data[str(date)] = cumulative_pending_issues
return chart_data return chart_data

View File

@ -62,7 +62,7 @@ export type TAssigneesDistribution = {
}; };
export type TCompletionChartDistribution = { export type TCompletionChartDistribution = {
[key: string]: number; [key: string]: number | null;
}; };
export type TLabelsDistribution = { export type TLabelsDistribution = {

View File

@ -1,5 +1,6 @@
import { EUserWorkspaceRoles } from "constants/workspace"; import { EUserWorkspaceRoles } from "constants/workspace";
import type { import type {
ICycle,
IProjectMember, IProjectMember,
IUser, IUser,
IUserLite, IUserLite,
@ -182,3 +183,14 @@ export interface IProductUpdateResponse {
eyes: number; eyes: number;
}; };
} }
export interface IWorkspaceActiveCyclesResponse {
count: number;
extra_stats: null;
next_cursor: string;
next_page_results: boolean;
prev_cursor: string;
prev_page_results: boolean;
results: ICycle[];
total_pages: number;
}

View File

@ -4,31 +4,37 @@ import { Tooltip } from "../tooltip";
type Props = { type Props = {
data: any; data: any;
noTooltip?: boolean; noTooltip?: boolean;
inPercentage?: boolean;
}; };
export const LinearProgressIndicator: React.FC<Props> = ({ data, noTooltip = false }) => { export const LinearProgressIndicator: React.FC<Props> = ({ data, noTooltip = false, inPercentage = false }) => {
const total = data.reduce((acc: any, cur: any) => acc + cur.value, 0); const total = data.reduce((acc: any, cur: any) => acc + cur.value, 0);
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
let progress = 0; let progress = 0;
const bars = data.map((item: any) => { const bars = data.map((item: any, index: Number) => {
const width = `${(item.value / total) * 100}%`; const width = `${(item.value / total) * 100}%`;
const style = { const style = {
width, width,
backgroundColor: item.color, backgroundColor: item.color,
borderTopLeftRadius: index === 0 ? "99px" : 0,
borderBottomLeftRadius: index === 0 ? "99px" : 0,
borderTopRightRadius: index === data.length - 1 ? "99px" : 0,
borderBottomRightRadius: index === data.length - 1 ? "99px" : 0,
}; };
progress += item.value; progress += item.value;
if (noTooltip) return <div style={style} />; if (noTooltip) return <div style={style} />;
if (width === "0%") return <></>;
else else
return ( return (
<Tooltip key={item.id} tooltipContent={`${item.name} ${Math.round(item.value)}%`}> <Tooltip key={item.id} tooltipContent={`${item.name} ${Math.round(item.value)}${inPercentage ? "%" : ""}`}>
<div style={style} /> <div style={style} />
</Tooltip> </Tooltip>
); );
}); });
return ( return (
<div className="flex h-1 w-full items-center justify-between gap-1"> <div className="flex h-1.5 w-full items-center justify-between gap-1 rounded-l-full rounded-r-full">
{total === 0 ? ( {total === 0 ? (
<div className="flex h-full w-full gap-1 bg-neutral-500">{bars}</div> <div className="flex h-full w-full gap-1 bg-neutral-500">{bars}</div>
) : ( ) : (

View File

@ -30,7 +30,7 @@ export const SingleProgressStats: React.FC<TSingleProgressStatsProps> = ({
<CircularProgressIndicator percentage={(completed / total) * 100} size={14} strokeWidth={2} /> <CircularProgressIndicator percentage={(completed / total) * 100} size={14} strokeWidth={2} />
</span> </span>
<span className="w-8 text-right"> <span className="w-8 text-right">
{isNaN(Math.floor((completed / total) * 100)) ? "0" : Math.floor((completed / total) * 100)}% {isNaN(Math.round((completed / total) * 100)) ? "0" : Math.round((completed / total) * 100)}%
</span> </span>
</div> </div>
<span>of {total}</span> <span>of {total}</span>

View File

@ -3,7 +3,7 @@ import Link from "next/link";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import useSWR from "swr"; import useSWR from "swr";
// hooks // hooks
import { useApplication, useCycle, useIssues, useProjectState } from "hooks/store"; import { useApplication, useCycle, useIssues, useProject } from "hooks/store";
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
// ui // ui
import { SingleProgressStats } from "components/core"; import { SingleProgressStats } from "components/core";
@ -12,55 +12,27 @@ import {
Loader, Loader,
Tooltip, Tooltip,
LinearProgressIndicator, LinearProgressIndicator,
ContrastIcon,
RunningIcon,
LayersIcon, LayersIcon,
StateGroupIcon, StateGroupIcon,
PriorityIcon, PriorityIcon,
Avatar, Avatar,
CycleGroupIcon,
} from "@plane/ui"; } from "@plane/ui";
// components // components
import ProgressChart from "components/core/sidebar/progress-chart"; import ProgressChart from "components/core/sidebar/progress-chart";
import { ActiveCycleProgressStats } from "components/cycles"; import { ActiveCycleProgressStats } from "components/cycles";
import { ViewIssueLabel } from "components/issues"; import { StateDropdown } from "components/dropdowns";
// icons // icons
import { AlarmClock, AlertTriangle, ArrowRight, CalendarDays, Star, Target } from "lucide-react"; import { ArrowRight, CalendarCheck, CalendarDays, Star, Target } from "lucide-react";
// helpers // helpers
import { renderFormattedDate, findHowManyDaysLeft } from "helpers/date-time.helper"; import { renderFormattedDate, findHowManyDaysLeft, renderFormattedDateWithoutYear } from "helpers/date-time.helper";
import { truncateText } from "helpers/string.helper"; import { truncateText } from "helpers/string.helper";
// types // types
import { ICycle } from "@plane/types"; import { ICycle, TCycleGroups } from "@plane/types";
// constants
import { EIssuesStoreType } from "constants/issue"; import { EIssuesStoreType } from "constants/issue";
import { ACTIVE_CYCLE_ISSUES } from "store/issue/cycle";
import { CYCLE_ISSUES_WITH_PARAMS } from "constants/fetch-keys"; import { CYCLE_ISSUES_WITH_PARAMS } from "constants/fetch-keys";
import { STATE_GROUPS_DETAILS } from "constants/cycle";
const stateGroups = [
{
key: "backlog_issues",
title: "Backlog",
color: "#dee2e6",
},
{
key: "unstarted_issues",
title: "Unstarted",
color: "#26b5ce",
},
{
key: "started_issues",
title: "Started",
color: "#f7ae59",
},
{
key: "cancelled_issues",
title: "Cancelled",
color: "#d687ff",
},
{
key: "completed_issues",
title: "Completed",
color: "#09a953",
},
];
interface IActiveCycleDetails { interface IActiveCycleDetails {
workspaceSlug: string; workspaceSlug: string;
@ -72,8 +44,7 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
const { workspaceSlug, projectId } = props; const { workspaceSlug, projectId } = props;
// store hooks // store hooks
const { const {
issues: { issues, fetchActiveCycleIssues }, issues: { fetchActiveCycleIssues },
issueMap,
} = useIssues(EIssuesStoreType.CYCLE); } = useIssues(EIssuesStoreType.CYCLE);
const { const {
commandPalette: { toggleCreateCycleModal }, commandPalette: { toggleCreateCycleModal },
@ -85,7 +56,7 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
addCycleToFavorites, addCycleToFavorites,
removeCycleFromFavorites, removeCycleFromFavorites,
} = useCycle(); } = useCycle();
const { getProjectStates } = useProjectState(); const { currentProjectDetails } = useProject();
// toast alert // toast alert
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
@ -95,9 +66,8 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
); );
const activeCycle = currentProjectActiveCycleId ? getActiveCycleById(currentProjectActiveCycleId) : null; const activeCycle = currentProjectActiveCycleId ? getActiveCycleById(currentProjectActiveCycleId) : null;
const issueIds = issues?.[ACTIVE_CYCLE_ISSUES];
useSWR( const { data: activeCycleIssues } = useSWR(
workspaceSlug && projectId && currentProjectActiveCycleId workspaceSlug && projectId && currentProjectActiveCycleId
? CYCLE_ISSUES_WITH_PARAMS(currentProjectActiveCycleId, { priority: "urgent,high" }) ? CYCLE_ISSUES_WITH_PARAMS(currentProjectActiveCycleId, { priority: "urgent,high" })
: null, : null,
@ -142,14 +112,13 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
const startDate = new Date(activeCycle.start_date ?? ""); const startDate = new Date(activeCycle.start_date ?? "");
const groupedIssues: any = { const groupedIssues: any = {
backlog: activeCycle.backlog_issues,
unstarted: activeCycle.unstarted_issues,
started: activeCycle.started_issues,
completed: activeCycle.completed_issues, completed: activeCycle.completed_issues,
cancelled: activeCycle.cancelled_issues, started: activeCycle.started_issues,
unstarted: activeCycle.unstarted_issues,
backlog: activeCycle.backlog_issues,
}; };
const cycleStatus = activeCycle.status.toLocaleLowerCase(); const cycleStatus = activeCycle.status.toLowerCase() as TCycleGroups;
const handleAddToFavorites = (e: MouseEvent<HTMLButtonElement>) => { const handleAddToFavorites = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault(); e.preventDefault();
@ -177,7 +146,7 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
}); });
}; };
const progressIndicatorData = stateGroups.map((group, index) => ({ const progressIndicatorData = STATE_GROUPS_DETAILS.map((group, index) => ({
id: index, id: index,
name: group.title, name: group.title,
value: value:
@ -187,6 +156,8 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
color: group.color, color: group.color,
})); }));
const daysLeft = findHowManyDaysLeft(activeCycle.end_date ?? new Date());
return ( return (
<div className="grid-row-2 grid divide-y rounded-[10px] border border-custom-border-200 bg-custom-background-100 shadow"> <div className="grid-row-2 grid divide-y rounded-[10px] border border-custom-border-200 bg-custom-background-100 shadow">
<div className="grid grid-cols-1 divide-y border-custom-border-200 lg:grid-cols-3 lg:divide-x lg:divide-y-0"> <div className="grid grid-cols-1 divide-y border-custom-border-200 lg:grid-cols-3 lg:divide-x lg:divide-y-0">
@ -196,68 +167,15 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
<div className="flex items-center justify-between gap-1"> <div className="flex items-center justify-between gap-1">
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<span className="h-5 w-5"> <span className="h-5 w-5">
<ContrastIcon <CycleGroupIcon cycleGroup={cycleStatus} className="h-4 w-4" />
className="h-5 w-5"
color={`${
cycleStatus === "current"
? "#09A953"
: cycleStatus === "upcoming"
? "#F7AE59"
: cycleStatus === "completed"
? "#3F76FF"
: cycleStatus === "draft"
? "rgb(var(--color-text-200))"
: ""
}`}
/>
</span> </span>
<Tooltip tooltipContent={activeCycle.name} position="top-left"> <Tooltip tooltipContent={activeCycle.name} position="top-left">
<h3 className="break-words text-lg font-semibold">{truncateText(activeCycle.name, 70)}</h3> <h3 className="break-words text-lg font-semibold">{truncateText(activeCycle.name, 70)}</h3>
</Tooltip> </Tooltip>
</span> </span>
<span className="flex items-center gap-1 capitalize"> <span className="flex items-center gap-1 capitalize">
<span <span className="flex gap-1 whitespace-nowrap rounded-sm text-sm px-3 py-0.5 bg-amber-500/10 text-amber-500">
className={`rounded-full px-1.5 py-0.5 {`${daysLeft} ${daysLeft > 1 ? "Days" : "Day"} Left`}
${
cycleStatus === "current"
? "bg-green-600/5 text-green-600"
: cycleStatus === "upcoming"
? "bg-orange-300/5 text-orange-300"
: cycleStatus === "completed"
? "bg-blue-500/5 text-blue-500"
: cycleStatus === "draft"
? "bg-neutral-400/5 text-neutral-400"
: ""
}`}
>
{cycleStatus === "current" ? (
<span className="flex gap-1 whitespace-nowrap">
<RunningIcon className="h-4 w-4" />
{findHowManyDaysLeft(activeCycle.end_date ?? new Date())} Days Left
</span>
) : cycleStatus === "upcoming" ? (
<span className="flex gap-1 whitespace-nowrap">
<AlarmClock className="h-4 w-4" />
{findHowManyDaysLeft(activeCycle.start_date ?? new Date())} Days Left
</span>
) : cycleStatus === "completed" ? (
<span className="flex gap-1 whitespace-nowrap">
{activeCycle.total_issues - activeCycle.completed_issues > 0 && (
<Tooltip
tooltipContent={`${activeCycle.total_issues - activeCycle.completed_issues} more pending ${
activeCycle.total_issues - activeCycle.completed_issues === 1 ? "issue" : "issues"
}`}
>
<span>
<AlertTriangle className="h-3.5 w-3.5" />
</span>
</Tooltip>
)}{" "}
Completed
</span>
) : (
cycleStatus
)}
</span> </span>
{activeCycle.is_favorite ? ( {activeCycle.is_favorite ? (
<button <button
@ -344,7 +262,7 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
<div className="flex h-full w-full flex-col p-4 text-custom-text-200"> <div className="flex h-full w-full flex-col p-4 text-custom-text-200">
<div className="flex w-full items-center gap-2 py-1"> <div className="flex w-full items-center gap-2 py-1">
<span>Progress</span> <span>Progress</span>
<LinearProgressIndicator data={progressIndicatorData} /> <LinearProgressIndicator data={progressIndicatorData} inPercentage />
</div> </div>
<div className="mt-2 flex flex-col items-center gap-1"> <div className="mt-2 flex flex-col items-center gap-1">
{Object.keys(groupedIssues).map((group, index) => ( {Object.keys(groupedIssues).map((group, index) => (
@ -355,7 +273,7 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
<span <span
className="block h-3 w-3 rounded-full " className="block h-3 w-3 rounded-full "
style={{ style={{
backgroundColor: stateGroups[index].color, backgroundColor: STATE_GROUPS_DETAILS[index].color,
}} }}
/> />
<span className="text-xs capitalize">{group}</span> <span className="text-xs capitalize">{group}</span>
@ -374,107 +292,66 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
</div> </div>
</div> </div>
<div className="grid grid-cols-1 divide-y border-custom-border-200 lg:grid-cols-2 lg:divide-x lg:divide-y-0"> <div className="grid grid-cols-1 divide-y border-custom-border-200 lg:grid-cols-2 lg:divide-x lg:divide-y-0">
<div className="flex flex-col justify-between p-4"> <div className="flex flex-col gap-3 p-4 max-h-60 overflow-hidden">
<div> <div className="text-custom-primary">High Priority Issues</div>
<div className="text-custom-primary">High Priority Issues</div> <div className="flex flex-col h-full gap-2.5 overflow-y-scroll rounded-md">
<div className="my-3 flex max-h-[240px] min-h-[240px] flex-col gap-2.5 overflow-y-scroll rounded-md"> {activeCycleIssues ? (
{issueIds ? ( activeCycleIssues.length > 0 ? (
issueIds.length > 0 ? ( activeCycleIssues.map((issue: any) => (
issueIds.map((issue: any) => ( <Link
<Link key={issue.id}
key={issue.id} href={`/${workspaceSlug}/projects/${projectId}/issues/${issue.id}`}
href={`/${workspaceSlug}/projects/${projectId}/issues/${issue.id}`} className="flex cursor-pointer flex-wrap items-center justify-between gap-2 rounded-md border border-custom-border-200 px-3 py-1.5"
className="flex cursor-pointer flex-wrap items-center justify-between gap-2 rounded-md border border-custom-border-200 bg-custom-background-90 px-3 py-1.5" >
> <div className="flex items-center gap-1.5">
<div className="flex flex-col gap-1"> <PriorityIcon priority={issue.priority} withContainer size={12} />
<div>
<Tooltip
tooltipHeading="Issue ID"
tooltipContent={`${issue.project_detail?.identifier}-${issue.sequence_id}`}
>
<span className="flex-shrink-0 text-xs text-custom-text-200">
{issue.project_detail?.identifier}-{issue.sequence_id}
</span>
</Tooltip>
</div>
<Tooltip position="top-left" tooltipHeading="Title" tooltipContent={issue.name}>
<span className="text-[0.825rem] text-custom-text-100">{truncateText(issue.name, 30)}</span>
</Tooltip>
</div>
<div className="flex items-center gap-1.5">
<div
className={`grid h-6 w-6 flex-shrink-0 place-items-center items-center rounded border shadow-sm ${
issue.priority === "urgent"
? "border-red-500/20 bg-red-500/20 text-red-500"
: "border-orange-500/20 bg-orange-500/20 text-orange-500"
}`}
>
<PriorityIcon priority={issue.priority} className="text-sm" />
</div>
<ViewIssueLabel labelDetails={issue.label_details} maxRender={2} />
<div className={`flex items-center gap-2 text-custom-text-200`}>
{issue.assignees && issue.assignees.length > 0 && Array.isArray(issue.assignees) ? (
<div className="-my-0.5 flex items-center justify-center gap-2">
<AvatarGroup showTooltip={false}>
{issue.assignee_details.map((assignee: any) => (
<Avatar key={assignee.id} name={assignee.display_name} src={assignee.avatar} />
))}
</AvatarGroup>
</div>
) : (
""
)}
</div>
</div>
</Link>
))
) : (
<div className="grid place-items-center text-center text-sm text-custom-text-200">
No issues present in the cycle.
</div>
)
) : (
<Loader className="space-y-3">
<Loader.Item height="50px" />
<Loader.Item height="50px" />
<Loader.Item height="50px" />
</Loader>
)}
</div>
</div>
{issueIds && issueIds.length > 0 && ( <Tooltip
<div className="flex items-center justify-between gap-2"> tooltipHeading="Issue ID"
<div className="h-1 w-full rounded-full bg-custom-background-80"> tooltipContent={`${currentProjectDetails?.identifier}-${issue.sequence_id}`}
<div >
className="h-1 rounded-full bg-green-600" <span className="flex-shrink-0 text-xs text-custom-text-200">
style={{ {currentProjectDetails?.identifier}-{issue.sequence_id}
width: </span>
issueIds && </Tooltip>
`${ <Tooltip position="top-left" tooltipHeading="Title" tooltipContent={issue.name}>
(issueIds.filter((issue: any) => issue?.state_detail?.group === "completed")?.length / <span className="text-[0.825rem] text-custom-text-100">{truncateText(issue.name, 30)}</span>
issueIds.length) * </Tooltip>
100 ?? 0 </div>
}%`, <div className="flex items-center gap-1.5 flex-shrink-0">
}} <StateDropdown
/> value={issue.state_id ?? undefined}
</div> onChange={() => {}}
<div className="w-16 text-end text-xs text-custom-text-200"> projectId={projectId?.toString() ?? ""}
of{" "} disabled={true}
{ buttonVariant="background-with-text"
issueIds?.filter( />
(issueId) => {issue.target_date && (
getProjectStates(issueMap[issueId]?.project_id)?.find( <Tooltip tooltipHeading="Target Date" tooltipContent={renderFormattedDate(issue.target_date)}>
(issue) => issue.id === issueMap[issueId]?.state_id <div className="h-full flex items-center gap-1.5 rounded text-xs px-2 py-0.5 bg-custom-background-80 cursor-not-allowed">
)?.group === "completed" <CalendarCheck className="h-3 w-3 flex-shrink-0" />
)?.length <span className="text-xs">{renderFormattedDateWithoutYear(issue.target_date)}</span>
}{" "} </div>
of {issueIds?.length} </Tooltip>
</div> )}
</div> </div>
)} </Link>
))
) : (
<div className="flex items-center justify-center h-full text-sm text-custom-text-200">
There are no high priority issues present in this cycle.
</div>
)
) : (
<Loader className="space-y-3">
<Loader.Item height="50px" />
<Loader.Item height="50px" />
<Loader.Item height="50px" />
</Loader>
)}
</div>
</div> </div>
<div className="flex flex-col justify-between border-custom-border-200 p-4"> <div className="flex flex-col border-custom-border-200 p-4 max-h-60">
<div className="flex items-start justify-between gap-4 py-1.5 text-xs"> <div className="flex items-start justify-between gap-4 py-1.5 text-xs">
<div className="flex items-center gap-3 text-custom-text-100"> <div className="flex items-center gap-3 text-custom-text-100">
<div className="flex items-center justify-center gap-1"> <div className="flex items-center justify-center gap-1">
@ -496,7 +373,7 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
</span> </span>
</div> </div>
</div> </div>
<div className="relative h-64"> <div className="relative h-full">
<ProgressChart <ProgressChart
distribution={activeCycle.distribution?.completion_chart ?? {}} distribution={activeCycle.distribution?.completion_chart ?? {}}
startDate={activeCycle.start_date ?? ""} startDate={activeCycle.start_date ?? ""}

View File

@ -1,30 +1,19 @@
import { FC, MouseEvent, useCallback } from "react"; import { FC, useCallback } from "react";
import Link from "next/link"; import Link from "next/link";
// ui
import {
AvatarGroup,
Tooltip,
LinearProgressIndicator,
ContrastIcon,
RunningIcon,
LayersIcon,
StateGroupIcon,
Avatar,
} from "@plane/ui";
// components
import { SingleProgressStats } from "components/core";
import { ActiveCycleProgressStats } from "./active-cycle-stats";
// hooks // hooks
import { useCycle } from "hooks/store";
import useToast from "hooks/use-toast";
import useLocalStorage from "hooks/use-local-storage"; import useLocalStorage from "hooks/use-local-storage";
// icons // ui
import { ArrowRight, CalendarDays, Star, Target } from "lucide-react"; import { Tooltip, LinearProgressIndicator, Loader, PriorityIcon, Button, CycleGroupIcon } from "@plane/ui";
import { CalendarCheck } from "lucide-react";
// components
import ProgressChart from "components/core/sidebar/progress-chart";
import { StateDropdown } from "components/dropdowns";
// types // types
import { ICycle, TCycleLayout, TCycleView } from "@plane/types"; import { ICycle, TCycleGroups, TCycleLayout, TCycleView } from "@plane/types";
// helpers // helpers
import { renderFormattedDate, findHowManyDaysLeft } from "helpers/date-time.helper"; import { renderFormattedDate, findHowManyDaysLeft, renderFormattedDateWithoutYear } from "helpers/date-time.helper";
import { truncateText } from "helpers/string.helper"; import { truncateText } from "helpers/string.helper";
import { renderEmoji } from "helpers/emoji.helper";
// constants // constants
import { STATE_GROUPS_DETAILS } from "constants/cycle"; import { STATE_GROUPS_DETAILS } from "constants/cycle";
@ -36,29 +25,11 @@ export type ActiveCycleInfoProps = {
export const ActiveCycleInfo: FC<ActiveCycleInfoProps> = (props) => { export const ActiveCycleInfo: FC<ActiveCycleInfoProps> = (props) => {
const { cycle, workspaceSlug, projectId } = props; const { cycle, workspaceSlug, projectId } = props;
// store
const { addCycleToFavorites, removeCycleFromFavorites } = useCycle();
// local storage // local storage
const { setValue: setCycleTab } = useLocalStorage<TCycleView>("cycle_tab", "active"); const { setValue: setCycleTab } = useLocalStorage<TCycleView>("cycle_tab", "active");
const { setValue: setCycleLayout } = useLocalStorage<TCycleLayout>("cycle_layout", "list"); const { setValue: setCycleLayout } = useLocalStorage<TCycleLayout>("cycle_layout", "list");
// toast alert
const { setToastAlert } = useToast();
const groupedIssues: any = { const cycleIssues = cycle.issues ?? [];
backlog: cycle.backlog_issues,
unstarted: cycle.unstarted_issues,
started: cycle.started_issues,
completed: cycle.completed_issues,
cancelled: cycle.cancelled_issues,
};
const progressIndicatorData = STATE_GROUPS_DETAILS.map((group, index) => ({
id: index,
name: group.title,
value: cycle.total_issues > 0 ? ((cycle[group.key as keyof ICycle] as number) / cycle.total_issues) * 100 : 0,
color: group.color,
}));
const handleCurrentLayout = useCallback( const handleCurrentLayout = useCallback(
(_layout: TCycleLayout) => { (_layout: TCycleLayout) => {
@ -75,93 +46,70 @@ export const ActiveCycleInfo: FC<ActiveCycleInfoProps> = (props) => {
[handleCurrentLayout, setCycleTab] [handleCurrentLayout, setCycleTab]
); );
const handleAddToFavorites = (e: MouseEvent<HTMLButtonElement>) => { const groupedIssues: any = {
e.preventDefault(); completed: cycle.completed_issues,
if (!workspaceSlug || !projectId) return; started: cycle.started_issues,
unstarted: cycle.unstarted_issues,
addCycleToFavorites(workspaceSlug?.toString(), projectId.toString(), cycle.id).catch(() => { backlog: cycle.backlog_issues,
setToastAlert({
type: "error",
title: "Error!",
message: "Couldn't add the cycle to favorites. Please try again.",
});
});
}; };
const handleRemoveFromFavorites = (e: MouseEvent<HTMLButtonElement>) => { const progressIndicatorData = STATE_GROUPS_DETAILS.map((group, index) => ({
e.preventDefault(); id: index,
if (!workspaceSlug || !projectId) return; name: group.title,
value: cycle.total_issues > 0 ? (cycle[group.key as keyof ICycle] as number) : 0,
color: group.color,
}));
removeCycleFromFavorites(workspaceSlug?.toString(), projectId.toString(), cycle.id).catch(() => { const cuurentCycle = cycle.status.toLowerCase() as TCycleGroups;
setToastAlert({
type: "error", const daysLeft = findHowManyDaysLeft(cycle.end_date ?? new Date());
title: "Error!",
message: "Couldn't add the cycle to favorites. Please try again.",
});
});
};
return ( return (
<div className="grid-row-2 grid divide-y rounded-[10px] border border-custom-border-200 bg-custom-background-100 shadow"> <>
<div className="grid grid-cols-1 divide-y border-custom-border-200 lg:grid-cols-3 lg:divide-x lg:divide-y-0"> <div className="flex items-center gap-1.5 px-3 py-1.5">
<div className="flex flex-col text-xs"> {cycle.project_detail.emoji ? (
<div className="h-full w-full"> <span className="grid h-7 w-7 flex-shrink-0 place-items-center rounded uppercase">
<div className="flex h-60 flex-col justify-between gap-5 rounded-b-[10px] p-4"> {renderEmoji(cycle.project_detail.emoji)}
<div className="flex items-center justify-between gap-1"> </span>
<span className="flex items-center gap-1"> ) : cycle.project_detail.icon_prop ? (
<span className="h-5 w-5"> <div className="grid h-7 w-7 flex-shrink-0 place-items-center">
<ContrastIcon className="h-5 w-5" color="#09A953" /> {renderEmoji(cycle.project_detail.icon_prop)}
</span> </div>
<Tooltip tooltipContent={cycle.name} position="top-left"> ) : (
<h3 className="break-words text-lg font-semibold">{truncateText(cycle.name, 70)}</h3> <span className="grid h-7 w-7 flex-shrink-0 place-items-center rounded bg-gray-700 uppercase text-white">
</Tooltip> {cycle.project_detail?.name.charAt(0)}
</span> </span>
<span className="flex items-center gap-1 capitalize"> )}
<span className="rounded-full px-1.5 py-0.5 bg-green-600/5 text-green-600"> <h2 className="text-xl font-semibold">{cycle.project_detail.name}</h2>
<span className="flex gap-1 whitespace-nowrap"> </div>
<RunningIcon className="h-4 w-4" /> <div className="flex flex-col gap-2 rounded border border-custom-border-200">
{findHowManyDaysLeft(cycle.end_date ?? new Date())} Days Left <div className="flex items-center justify-between px-3 pt-3 pb-1">
</span> <div className="flex items-center gap-2 cursor-default">
</span> <CycleGroupIcon cycleGroup={cuurentCycle} className="h-4 w-4" />
{cycle.is_favorite ? ( <Tooltip tooltipContent={cycle.name} position="top-left">
<button <h3 className="break-words text-lg font-medium">{truncateText(cycle.name, 70)}</h3>
onClick={(e) => { </Tooltip>
handleRemoveFromFavorites(e); <Tooltip
}} tooltipContent={`Start date: ${renderFormattedDate(
> cycle.start_date ?? ""
<Star className="h-4 w-4 fill-orange-400 text-orange-400" /> )} Due Date: ${renderFormattedDate(cycle.end_date ?? "")}`}
</button> position="top-left"
) : ( >
<button <span className="flex gap-1 whitespace-nowrap rounded-sm text-sm px-3 py-0.5 bg-amber-500/10 text-amber-500">
onClick={(e) => { {`${daysLeft} ${daysLeft > 1 ? "Days" : "Day"} Left`}
handleAddToFavorites(e); </span>
}} </Tooltip>
> </div>
<Star className="h-4 w-4 " color="rgb(var(--color-text-200))" /> <div className="flex items-center gap-2.5">
</button> <span className="rounded-sm text-sm px-3 py-1 bg-custom-background-80">
)} <span className="flex gap-2 text-sm whitespace-nowrap font-medium">
</span> <span>Lead:</span>
</div> <div className="flex items-center gap-1.5">
<div className="flex items-center justify-start gap-5 text-custom-text-200">
<div className="flex items-start gap-1">
<CalendarDays className="h-4 w-4" />
{cycle?.start_date && <span>{renderFormattedDate(cycle?.start_date)}</span>}
</div>
<ArrowRight className="h-4 w-4 text-custom-text-200" />
<div className="flex items-start gap-1">
<Target className="h-4 w-4" />
{cycle?.end_date && <span>{renderFormattedDate(cycle?.end_date)}</span>}
</div>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2.5 text-custom-text-200">
{cycle.owned_by.avatar && cycle.owned_by.avatar !== "" ? ( {cycle.owned_by.avatar && cycle.owned_by.avatar !== "" ? (
<img <img
src={cycle.owned_by.avatar} src={cycle.owned_by.avatar}
height={16} height={18}
width={16} width={18}
className="rounded-full" className="rounded-full"
alt={cycle.owned_by.display_name} alt={cycle.owned_by.display_name}
/> />
@ -170,85 +118,143 @@ export const ActiveCycleInfo: FC<ActiveCycleInfoProps> = (props) => {
{cycle.owned_by.display_name.charAt(0)} {cycle.owned_by.display_name.charAt(0)}
</span> </span>
)} )}
<span className="text-custom-text-200">{cycle.owned_by.display_name}</span> <span>{cycle.owned_by.display_name}</span>
</div> </div>
</span>
{cycle.assignees.length > 0 && ( </span>
<div className="flex items-center gap-1 text-custom-text-200"> <Link href={`/${workspaceSlug}/projects/${projectId}/cycles`}>
<AvatarGroup> <Button
{cycle.assignees.map((assignee: any) => ( variant="primary"
<Avatar key={assignee.id} name={assignee.display_name} src={assignee.avatar} /> size="sm"
))} onClick={() => {
</AvatarGroup> handleCurrentView("active");
</div> }}
)} >
</div> View Cycle
</Button>
<div className="flex items-center gap-4 text-custom-text-200"> </Link>
<div className="flex gap-2">
<LayersIcon className="h-4 w-4 flex-shrink-0" />
{cycle.total_issues} issues
</div>
<div className="flex items-center gap-2">
<StateGroupIcon stateGroup="completed" height="14px" width="14px" />
{cycle.completed_issues} issues
</div>
</div>
<div className="flex item-center gap-2">
<Link
href={`/${workspaceSlug}/projects/${projectId}/cycles`}
onClick={() => {
handleCurrentView("active");
}}
>
<span className="w-full rounded-md bg-custom-primary px-4 py-2 text-center text-sm font-medium text-white hover:bg-custom-primary/90">
View Cycle
</span>
</Link>
<Link href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycle.id}`}>
<span className="w-full rounded-md bg-custom-primary px-4 py-2 text-center text-sm font-medium text-white hover:bg-custom-primary/90">
View Cycle Issues
</span>
</Link>
</div>
</div>
</div> </div>
</div> </div>
<div className="col-span-2 grid grid-cols-1 divide-y border-custom-border-200 md:grid-cols-2 md:divide-x md:divide-y-0"> <div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3">
<div className="flex h-60 flex-col border-custom-border-200"> <div className="flex flex-col gap-4 px-3 pt-2 min-h-52 border-r-0 border-t border-custom-border-300 lg:border-r">
<div className="flex h-full w-full flex-col p-4 text-custom-text-200"> <div className="flex items-center justify-between gap-4">
<div className="flex w-full items-center gap-2 py-1"> <h3 className="text-xl font-medium">Progress</h3>
<span>Progress</span> <span className="flex gap-1 text-sm whitespace-nowrap rounded-sm px-3 py-1 ">
<LinearProgressIndicator data={progressIndicatorData} /> {`${cycle.completed_issues + cycle.cancelled_issues}/${cycle.total_issues - cycle.cancelled_issues} ${
</div> cycle.completed_issues + cycle.cancelled_issues > 1 ? "Issues" : "Issue"
<div className="mt-2 flex flex-col items-center gap-1"> } closed`}
</span>
</div>
<LinearProgressIndicator data={progressIndicatorData} />
<div>
<div className="flex flex-col gap-2">
{Object.keys(groupedIssues).map((group, index) => ( {Object.keys(groupedIssues).map((group, index) => (
<SingleProgressStats <>
key={index} {groupedIssues[group] > 0 && (
title={ <div className="flex items-center justify-start gap-2 text-sm">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span <span
className="block h-3 w-3 rounded-full " className="block h-3 w-3 rounded-full"
style={{ style={{
backgroundColor: STATE_GROUPS_DETAILS[index].color, backgroundColor: STATE_GROUPS_DETAILS[index].color,
}} }}
/> />
<span className="text-xs capitalize">{group}</span> <span className="capitalize font-medium w-16">{group}</span>
</div>
<span>{`: ${groupedIssues[group]} ${groupedIssues[group] > 1 ? "Issues" : "Issue"}`}</span>
</div> </div>
} )}
completed={groupedIssues[group]} </>
total={cycle.total_issues}
/>
))} ))}
{cycle.cancelled_issues > 0 && (
<span className="flex items-center gap-2 text-sm text-custom-text-300">
<span>
{`${cycle.cancelled_issues} cancelled ${
cycle.cancelled_issues > 1 ? "issues are" : "issue is"
} excluded from this report.`}{" "}
</span>
</span>
)}
</div> </div>
</div> </div>
</div> </div>
<div className="h-60 overflow-y-scroll border-custom-border-200">
<ActiveCycleProgressStats cycle={cycle} /> <div className="flex flex-col gap-4 px-3 pt-2 min-h-52 border-r-0 border-t border-custom-border-300 lg:border-r">
<div className="flex items-center justify-between gap-4">
<h3 className="text-xl font-medium">Issue Burndown</h3>
</div>
<div className="relative ">
<ProgressChart
distribution={cycle.distribution?.completion_chart ?? {}}
startDate={cycle.start_date ?? ""}
endDate={cycle.end_date ?? ""}
totalIssues={cycle.total_issues}
/>
</div>
</div>
<div className="flex flex-col gap-4 px-3 pt-2 min-h-52 overflow-hidden col-span-1 lg:col-span-2 xl:col-span-1 border-t border-custom-border-300">
<div className="flex items-center justify-between gap-4">
<h3 className="text-xl font-medium">Priority</h3>
</div>
<div className="flex flex-col gap-4 h-full w-full max-h-40 overflow-y-auto pb-3">
{cycleIssues ? (
cycleIssues.length > 0 ? (
cycleIssues.map((issue: any) => (
<Link
key={issue.id}
href={`/${workspaceSlug}/projects/${projectId}/issues/${issue.id}`}
className="flex cursor-pointer items-center justify-between gap-2 rounded-md border border-custom-border-200 px-3 py-1.5"
>
<div className="flex items-center gap-1.5 flex-grow w-full truncate">
<PriorityIcon priority={issue.priority} withContainer size={12} />
<Tooltip
tooltipHeading="Issue ID"
tooltipContent={`${cycle.project_detail?.identifier}-${issue.sequence_id}`}
>
<span className="flex-shrink-0 text-xs text-custom-text-200">
{cycle.project_detail?.identifier}-{issue.sequence_id}
</span>
</Tooltip>
<Tooltip position="top-left" tooltipHeading="Title" tooltipContent={issue.name}>
<span className="text-[0.825rem] text-custom-text-100 truncate">{issue.name}</span>
</Tooltip>
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
<StateDropdown
value={issue.state_id ?? undefined}
onChange={() => {}}
projectId={projectId?.toString() ?? ""}
disabled={true}
buttonVariant="background-with-text"
/>
{issue.target_date && (
<Tooltip tooltipHeading="Target Date" tooltipContent={renderFormattedDate(issue.target_date)}>
<div className="h-full flex items-center gap-1.5 rounded text-xs px-2 py-0.5 bg-custom-background-80 cursor-not-allowed">
<CalendarCheck className="h-3 w-3 flex-shrink-0" />
<span className="text-xs">{renderFormattedDateWithoutYear(issue.target_date)}</span>
</div>
</Tooltip>
)}
</div>
</Link>
))
) : (
<div className="flex items-center justify-center h-full text-sm text-custom-text-200">
<span>There are no high priority issues present in this cycle.</span>
</div>
)
) : (
<Loader className="space-y-3">
<Loader.Item height="50px" />
<Loader.Item height="50px" />
<Loader.Item height="50px" />
</Loader>
)}
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </>
); );
}; };

View File

@ -127,7 +127,7 @@ export const ActiveCycleProgressStats: React.FC<Props> = ({ cycle }) => {
</Tab.Panels> </Tab.Panels>
) : ( ) : (
<div className="mt-4 grid place-items-center text-center text-sm text-custom-text-200"> <div className="mt-4 grid place-items-center text-center text-sm text-custom-text-200">
No issues present in the cycle. There are no high priority issues present in this cycle.
</div> </div>
)} )}
</Tab.Group> </Tab.Group>

View File

@ -126,6 +126,7 @@ export const CycleForm: React.FC<Props> = (props) => {
onChange={(date) => onChange(date ? renderFormattedPayloadDate(date) : null)} onChange={(date) => onChange(date ? renderFormattedPayloadDate(date) : null)}
buttonVariant="border-with-text" buttonVariant="border-with-text"
placeholder="Start date" placeholder="Start date"
minDate={new Date()}
maxDate={maxDate ?? undefined} maxDate={maxDate ?? undefined}
tabIndex={3} tabIndex={3}
/> />

View File

@ -1,40 +1,23 @@
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { Search, SendToBack } from "lucide-react"; import { SendToBack } from "lucide-react";
// hooks
import { useWorkspace } from "hooks/store";
// ui // ui
import { Breadcrumbs } from "@plane/ui"; import { Breadcrumbs } from "@plane/ui";
export const WorkspaceActiveCycleHeader = observer(() => { export const WorkspaceActiveCycleHeader = observer(() => (
// store hooks <div className="relative z-10 flex h-[3.75rem] w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 border-b border-custom-border-200 bg-custom-sidebar-background-100 p-4">
const { workspaceActiveCyclesSearchQuery, setWorkspaceActiveCyclesSearchQuery } = useWorkspace(); <div className="flex w-full flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap">
return ( <div className="flex items-center gap-2">
<div className="relative z-10 flex h-[3.75rem] w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 border-b border-custom-border-200 bg-custom-sidebar-background-100 p-4"> <Breadcrumbs>
<div className="flex w-full flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap"> <Breadcrumbs.BreadcrumbItem
<div className="flex items-center gap-2"> type="text"
<Breadcrumbs> icon={<SendToBack className="h-4 w-4 text-custom-text-300" />}
<Breadcrumbs.BreadcrumbItem label="Active Cycles"
type="text"
icon={<SendToBack className="h-4 w-4 text-custom-text-300" />}
label="Active Cycles"
/>
</Breadcrumbs>
<span className="flex items-center justify-center px-3.5 py-0.5 text-xs leading-4 rounded-xl text-orange-500 bg-orange-500/20">
Beta
</span>
</div>
</div>
<div className="flex items-center gap-3">
<div className="flex w-full items-center justify-start gap-1 rounded-md border border-custom-border-200 bg-custom-background-100 px-2.5 py-1.5 text-custom-text-400">
<Search className="h-3.5 w-3.5" />
<input
className="w-full min-w-[234px] border-none bg-transparent text-sm focus:outline-none"
value={workspaceActiveCyclesSearchQuery}
onChange={(e) => setWorkspaceActiveCyclesSearchQuery(e.target.value)}
placeholder="Search"
/> />
</div> </Breadcrumbs>
<span className="flex items-center justify-center px-3.5 py-0.5 text-xs leading-4 rounded-xl text-orange-500 bg-orange-500/20">
Beta
</span>
</div> </div>
</div> </div>
); </div>
}); ));

View File

@ -70,7 +70,7 @@ export const ModuleForm: React.FC<Props> = ({
const startDate = watch("start_date"); const startDate = watch("start_date");
const targetDate = watch("target_date"); const targetDate = watch("target_date");
const minDate = startDate ? new Date(startDate) : null; const minDate = startDate ? new Date(startDate) : new Date();
minDate?.setDate(minDate.getDate()); minDate?.setDate(minDate.getDate());
const maxDate = targetDate ? new Date(targetDate) : null; const maxDate = targetDate ? new Date(targetDate) : null;
@ -159,6 +159,7 @@ export const ModuleForm: React.FC<Props> = ({
onChange={(date) => onChange(date ? renderFormattedPayloadDate(date) : null)} onChange={(date) => onChange(date ? renderFormattedPayloadDate(date) : null)}
buttonVariant="border-with-text" buttonVariant="border-with-text"
placeholder="Start date" placeholder="Start date"
minDate={new Date()}
maxDate={maxDate ?? undefined} maxDate={maxDate ?? undefined}
tabIndex={3} tabIndex={3}
/> />

View File

@ -1,57 +1,90 @@
import { useEffect, useState } from "react";
import useSWR from "swr";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import useSWR from "swr"; import isEqual from "lodash/isEqual";
// components // components
import { ActiveCycleInfo } from "components/cycles"; import { ActiveCycleInfo } from "components/cycles";
import { Button, ContrastIcon, Spinner } from "@plane/ui";
// services // services
import { CycleService } from "services/cycle.service"; import { CycleService } from "services/cycle.service";
const cycleService = new CycleService(); const cycleService = new CycleService();
// hooks // constants
import { useWorkspace } from "hooks/store"; import { WORKSPACE_ACTIVE_CYCLES_LIST } from "constants/fetch-keys";
// helpers // types
import { renderEmoji } from "helpers/emoji.helper"; import { ICycle } from "@plane/types";
const per_page = 3;
export const WorkspaceActiveCyclesList = observer(() => { export const WorkspaceActiveCyclesList = observer(() => {
// state
const [cursor, setCursor] = useState<string | undefined>(`3:0:0`);
const [allCyclesData, setAllCyclesData] = useState<ICycle[]>([]);
const [hasMoreResults, setHasMoreResults] = useState(true);
// router // router
const router = useRouter(); const router = useRouter();
const { workspaceSlug } = router.query; const { workspaceSlug } = router.query;
// fetching active cycles in workspace // fetching active cycles in workspace
const { data } = useSWR("WORKSPACE_ACTIVE_CYCLES", () => cycleService.workspaceActiveCycles(workspaceSlug as string)); const { data: workspaceActiveCycles, isLoading } = useSWR(
// store workspaceSlug && cursor ? WORKSPACE_ACTIVE_CYCLES_LIST(workspaceSlug as string, cursor, `${per_page}`) : null,
const { workspaceActiveCyclesSearchQuery } = useWorkspace(); workspaceSlug && cursor
// filter cycles based on search query ? () => cycleService.workspaceActiveCycles(workspaceSlug.toString(), cursor, per_page)
const filteredCycles = data?.filter( : null
(cycle) =>
cycle.project_detail.name.toLowerCase().includes(workspaceActiveCyclesSearchQuery.toLowerCase()) ||
cycle.project_detail.identifier?.toLowerCase().includes(workspaceActiveCyclesSearchQuery.toLowerCase()) ||
cycle.name.toLowerCase().includes(workspaceActiveCyclesSearchQuery.toLowerCase())
); );
useEffect(() => {
if (workspaceActiveCycles && !isEqual(workspaceActiveCycles.results, allCyclesData)) {
setAllCyclesData((prevData) => [...prevData, ...workspaceActiveCycles.results]);
setHasMoreResults(workspaceActiveCycles.next_page_results);
}
}, [workspaceActiveCycles]);
const handleLoadMore = () => {
if (hasMoreResults) {
setCursor(workspaceActiveCycles?.next_cursor);
}
};
if (allCyclesData.length === 0 && !workspaceActiveCycles) {
return (
<div className="flex items-center justify-center h-full w-full">
<Spinner />
</div>
);
}
return ( return (
<div> <div className="h-full w-full">
{workspaceSlug && {allCyclesData.length > 0 ? (
filteredCycles && <>
filteredCycles.map((cycle) => ( {workspaceSlug &&
<div key={cycle.id} className="px-5 py-7"> allCyclesData.map((cycle) => (
<div className="flex items-center gap-1.5 px-3 py-1.5"> <div key={cycle.id} className="px-5 py-5">
{cycle.project_detail.emoji ? ( <ActiveCycleInfo workspaceSlug={workspaceSlug?.toString()} projectId={cycle.project} cycle={cycle} />
<span className="grid h-7 w-7 flex-shrink-0 place-items-center rounded uppercase"> </div>
{renderEmoji(cycle.project_detail.emoji)} ))}
</span>
) : cycle.project_detail.icon_prop ? ( {hasMoreResults && (
<div className="grid h-7 w-7 flex-shrink-0 place-items-center"> <div className="flex items-center justify-center gap-4 text-xs w-full py-5">
{renderEmoji(cycle.project_detail.icon_prop)} <Button variant="outline-primary" size="sm" onClick={handleLoadMore}>
</div> {isLoading ? "Loading..." : "Load More"}
) : ( </Button>
<span className="grid h-7 w-7 flex-shrink-0 place-items-center rounded bg-gray-700 uppercase text-white">
{cycle.project_detail?.name.charAt(0)}
</span>
)}
<h2 className="text-xl font-semibold">{cycle.project_detail.name}</h2>
</div> </div>
<ActiveCycleInfo workspaceSlug={workspaceSlug?.toString()} projectId={cycle.project} cycle={cycle} /> )}
</>
) : (
<div className="grid h-full place-items-center text-center">
<div className="space-y-2">
<div className="mx-auto flex justify-center">
<ContrastIcon className="h-40 w-40 text-custom-text-300" />
</div>
<h4 className="text-base text-custom-text-200">
No ongoing cycles are currently active in any of the projects.
</h4>
</div> </div>
))} </div>
)}
</div> </div>
); );
}); });

View File

@ -87,31 +87,25 @@ export const CYCLE_STATUS: {
}, },
]; ];
export const STATE_GROUPS_DETAILS = [ export const STATE_GROUPS_DETAILS = [
{ {
key: "backlog_issues", key: "completed_issues",
title: "Backlog", title: "Completed",
color: "#dee2e6", color: "#46A758",
},
{
key: "unstarted_issues",
title: "Unstarted",
color: "#26b5ce",
}, },
{ {
key: "started_issues", key: "started_issues",
title: "Started", title: "Started",
color: "#f7ae59", color: "#FFC53D",
}, },
{ {
key: "cancelled_issues", key: "unstarted_issues",
title: "Cancelled", title: "Unstarted",
color: "#d687ff", color: "#FB923C",
}, },
{ {
key: "completed_issues", key: "backlog_issues",
title: "Completed", title: "Backlog",
color: "#09a953", color: "#F0F0F3",
}, },
]; ];

View File

@ -142,6 +142,8 @@ export const WORKSPACE_LABELS = (workspaceSlug: string) => `WORKSPACE_LABELS_${w
export const PROJECT_GITHUB_REPOSITORY = (projectId: string) => `PROJECT_GITHUB_REPOSITORY_${projectId.toUpperCase()}`; export const PROJECT_GITHUB_REPOSITORY = (projectId: string) => `PROJECT_GITHUB_REPOSITORY_${projectId.toUpperCase()}`;
// cycles // cycles
export const WORKSPACE_ACTIVE_CYCLES_LIST = (workspaceSlug: string, cursor: string, per_page: string) =>
`WORKSPACE_ACTIVE_CYCLES_LIST_${workspaceSlug.toUpperCase()}_${cursor.toUpperCase()}_${per_page.toUpperCase()}`;
export const CYCLES_LIST = (projectId: string) => `CYCLE_LIST_${projectId.toUpperCase()}`; export const CYCLES_LIST = (projectId: string) => `CYCLE_LIST_${projectId.toUpperCase()}`;
export const INCOMPLETE_CYCLES_LIST = (projectId: string) => `INCOMPLETE_CYCLES_LIST_${projectId.toUpperCase()}`; export const INCOMPLETE_CYCLES_LIST = (projectId: string) => `INCOMPLETE_CYCLES_LIST_${projectId.toUpperCase()}`;
export const CURRENT_CYCLE_LIST = (projectId: string) => `CURRENT_CYCLE_LIST_${projectId.toUpperCase()}`; export const CURRENT_CYCLE_LIST = (projectId: string) => `CURRENT_CYCLE_LIST_${projectId.toUpperCase()}`;

View File

@ -1,7 +1,7 @@
// services // services
import { APIService } from "services/api.service"; import { APIService } from "services/api.service";
// types // types
import type { CycleDateCheckData, ICycle, TIssue, TIssueMap } from "@plane/types"; import type { CycleDateCheckData, ICycle, IWorkspaceActiveCyclesResponse, TIssue } from "@plane/types";
// helpers // helpers
import { API_BASE_URL } from "helpers/common.helper"; import { API_BASE_URL } from "helpers/common.helper";
@ -10,8 +10,17 @@ export class CycleService extends APIService {
super(API_BASE_URL); super(API_BASE_URL);
} }
async workspaceActiveCycles(workspaceSlug: string): Promise<ICycle[]> { async workspaceActiveCycles(
return this.get(`/api/workspaces/${workspaceSlug}/active-cycles/`) workspaceSlug: string,
cursor: string,
per_page: number
): Promise<IWorkspaceActiveCyclesResponse> {
return this.get(`/api/workspaces/${workspaceSlug}/active-cycles/`, {
params: {
per_page,
cursor,
},
})
.then((res) => res?.data) .then((res) => res?.data)
.catch((err) => { .catch((err) => {
throw err?.response?.data; throw err?.response?.data;

View File

@ -12,12 +12,9 @@ import { ApiTokenStore, IApiTokenStore } from "./api-token.store";
export interface IWorkspaceRootStore { export interface IWorkspaceRootStore {
// observables // observables
workspaces: Record<string, IWorkspace>; workspaces: Record<string, IWorkspace>;
workspaceActiveCyclesSearchQuery: string;
// computed // computed
currentWorkspace: IWorkspace | null; currentWorkspace: IWorkspace | null;
workspacesCreatedByCurrentUser: IWorkspace[] | null; workspacesCreatedByCurrentUser: IWorkspace[] | null;
// actions
setWorkspaceActiveCyclesSearchQuery: (query: string) => void;
// computed actions // computed actions
getWorkspaceBySlug: (workspaceSlug: string) => IWorkspace | null; getWorkspaceBySlug: (workspaceSlug: string) => IWorkspace | null;
getWorkspaceById: (workspaceId: string) => IWorkspace | null; getWorkspaceById: (workspaceId: string) => IWorkspace | null;
@ -34,7 +31,6 @@ export interface IWorkspaceRootStore {
export class WorkspaceRootStore implements IWorkspaceRootStore { export class WorkspaceRootStore implements IWorkspaceRootStore {
// observables // observables
workspaceActiveCyclesSearchQuery: string = "";
workspaces: Record<string, IWorkspace> = {}; workspaces: Record<string, IWorkspace> = {};
// services // services
workspaceService; workspaceService;
@ -49,7 +45,6 @@ export class WorkspaceRootStore implements IWorkspaceRootStore {
makeObservable(this, { makeObservable(this, {
// observables // observables
workspaces: observable, workspaces: observable,
workspaceActiveCyclesSearchQuery: observable.ref,
// computed // computed
currentWorkspace: computed, currentWorkspace: computed,
workspacesCreatedByCurrentUser: computed, workspacesCreatedByCurrentUser: computed,
@ -57,7 +52,6 @@ export class WorkspaceRootStore implements IWorkspaceRootStore {
getWorkspaceBySlug: action, getWorkspaceBySlug: action,
getWorkspaceById: action, getWorkspaceById: action,
// actions // actions
setWorkspaceActiveCyclesSearchQuery: action,
fetchWorkspaces: action, fetchWorkspaces: action,
createWorkspace: action, createWorkspace: action,
updateWorkspace: action, updateWorkspace: action,
@ -108,14 +102,6 @@ export class WorkspaceRootStore implements IWorkspaceRootStore {
*/ */
getWorkspaceById = (workspaceId: string) => this.workspaces?.[workspaceId] || null; // TODO: use undefined instead of null getWorkspaceById = (workspaceId: string) => this.workspaces?.[workspaceId] || null; // TODO: use undefined instead of null
/**
* Sets search query
* @param query
*/
setWorkspaceActiveCyclesSearchQuery = (query: string) => {
this.workspaceActiveCyclesSearchQuery = query;
};
/** /**
* fetch user workspaces from API * fetch user workspaces from API
*/ */