Chore: progress chart changes (#5707)

* fix: progress chart code splitting

* fix: progress chart code splitting

* fix: build errors + review changes
This commit is contained in:
Akshita Goyal
2024-10-01 18:59:49 +05:30
committed by GitHub
parent 632282d0df
commit 4940dc2193
31 changed files with 1149 additions and 632 deletions

View File

@@ -1,4 +1,4 @@
import type {TIssue, IIssueFilterOptions} from "@plane/types";
import type { TIssue, IIssueFilterOptions } from "@plane/types";
export type TCycleGroups = "current" | "upcoming" | "completed" | "draft";
@@ -43,6 +43,18 @@ export type TCycleEstimateDistribution = {
completion_chart: TCycleCompletionChartDistribution;
labels: (TCycleLabelsDistribution & TCycleEstimateDistributionBase)[];
};
export type TCycleProgress = {
date: string;
started: number;
pending: number;
ideal: number | null;
scope: number;
completed: number;
actual: number;
unstarted: number;
backlog: number;
cancelled: number;
};
export type TProgressSnapshot = {
total_issues: number;
@@ -90,6 +102,7 @@ export interface ICycle extends TProgressSnapshot {
};
workspace_id: string;
project_detail: IProjectDetails;
progress: any[];
}
export interface CycleIssueResponse {
@@ -107,7 +120,7 @@ export interface CycleIssueResponse {
}
export type SelectCycleType =
| (ICycle & {actionType: "edit" | "delete" | "create-issue"})
| (ICycle & { actionType: "edit" | "delete" | "create-issue" })
| undefined;
export type CycleDateCheckData = {
@@ -116,4 +129,5 @@ export type CycleDateCheckData = {
cycle_id?: string;
};
export type TCyclePlotType = "burndown" | "points";
export type TCycleEstimateType = "issues" | "points";
export type TCyclePlotType = "burndown" | "burnup";

View File

@@ -0,0 +1,22 @@
import * as React from "react";
import { ISvgIcons } from "./type";
export const DoneState: React.FC<ISvgIcons> = ({ width = "10", height = "11", className, color }) => (
<svg
width={width}
height={height}
viewBox="0 0 10 11"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<circle cx="5" cy="5.5" r="4.4" stroke="#15A34A" stroke-width="1.2" />
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M2.5 5.59375L3.82582 6.91957L4.26777 6.47763L2.94194 5.15181L2.5 5.59375ZM4.26777 7.36152L7.36136 4.26793L6.91942 3.82599L3.82583 6.91958L4.26777 7.36152Z"
fill="#15A34A"
/>
</svg>
);

View File

@@ -0,0 +1,17 @@
import * as React from "react";
import { ISvgIcons } from "./type";
export const InProgressState: React.FC<ISvgIcons> = ({ width = "10", height = "11", className, color }) => (
<svg
width={width}
height={height}
viewBox="0 0 12 13"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<circle cx="6" cy="6.5" r="4.4" stroke="#EA8900" stroke-width="1.2" />
<circle cx="6" cy="6.5" r="2.4" stroke="#EA8900" stroke-width="1.2" stroke-dasharray="4 4" />
</svg>
);

View File

@@ -28,3 +28,6 @@ export * from "./dropdown-icon";
export * from "./intake";
export * from "./user-activity-icon";
export * from "./favorite-folder-icon";
export * from "./planned-icon";
export * from "./in-progress-icon";
export * from "./done-icon";

View File

@@ -0,0 +1,40 @@
import * as React from "react";
import { ISvgIcons } from "./type";
export const PlannedState: React.FC<ISvgIcons> = ({ width = "10", height = "11", className, color }) => (
<svg
width={width}
height={height}
viewBox="0 0 12 13"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<g clip-path="url(#clip0_3180_28635)">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M7.11853 4.7C7.20073 4.88749 7.38342 5.01866 7.59656 5.02037C7.88848 5.02271 8.12698 4.7813 8.12925 4.48116L8.13344 3.92962C8.1348 3.74982 8.04958 3.58096 7.90581 3.47859C7.76203 3.37623 7.57832 3.3536 7.41509 3.41815L3.97959 4.77682C3.77547 4.85755 3.64077 5.05919 3.64077 5.28406L3.64077 9.0883C3.64077 9.27834 3.73732 9.45458 3.8954 9.55308C4.05347 9.65157 4.25011 9.65802 4.41396 9.57008L4.90523 9.30643C5.16402 9.16754 5.26431 8.83925 5.12922 8.57317C5.04115 8.39971 4.8748 8.29551 4.69795 8.28247L4.69795 5.65729L7.11853 4.7Z"
fill="#455068"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M5.00428 3.06914C5.08648 3.25663 5.26916 3.3878 5.4823 3.38951C5.77422 3.39185 6.01272 3.15044 6.015 2.8503L6.01918 2.29876C6.02054 2.11896 5.93532 1.9501 5.79155 1.84774C5.64777 1.74537 5.46406 1.72274 5.30084 1.78729L1.86534 3.14597C1.66121 3.22669 1.52652 3.42834 1.52652 3.6532L1.52652 7.45745C1.52652 7.64749 1.62307 7.82372 1.78114 7.92222C1.93922 8.02071 2.13585 8.02716 2.29971 7.93922L2.79097 7.67557C3.04977 7.53668 3.15005 7.20839 3.01496 6.94231C2.92689 6.76885 2.76054 6.66465 2.5837 6.65161L2.5837 4.02643L5.00428 3.06914Z"
fill="#455068"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M10.473 9.34799C10.4728 9.57269 10.3382 9.77413 10.1342 9.85482L6.70129 11.2129C6.53874 11.2772 6.35582 11.255 6.21225 11.1536C6.06867 11.0523 5.98288 10.8847 5.98288 10.7056L5.98288 6.90139C5.98288 6.67653 6.11757 6.47489 6.3217 6.39416L9.7572 5.03548C9.91981 4.97118 10.1028 4.99338 10.2464 5.09484C10.3899 5.19629 10.4757 5.36397 10.4756 5.5431L10.473 9.34799ZM9.41784 6.33426L7.04006 7.27463L7.04006 9.91423L9.41605 8.97431L9.41784 6.33426Z"
fill="#455068"
/>
</g>
<defs>
<clipPath id="clip0_3180_28635">
<rect width="12" height="12" fill="white" transform="translate(0 0.5)" />
</clipPath>
</defs>
</svg>
);

View File

@@ -16,10 +16,11 @@ const Loader = ({ children, className = "" }: Props) => (
type ItemProps = {
height?: string;
width?: string;
className?: string;
};
const Item: React.FC<ItemProps> = ({ height = "auto", width = "auto" }) => (
<div className="rounded-md bg-custom-background-80" style={{ height: height, width: width }} />
const Item: React.FC<ItemProps> = ({ height = "auto", width = "auto", className = "" }) => (
<div className={cn("rounded-md bg-custom-background-80", className)} style={{ height: height, width: width }} />
);
Loader.Item = Item;

View File

@@ -9,7 +9,7 @@ interface ICircularProgressIndicator {
}
export const CircularProgressIndicator: React.FC<ICircularProgressIndicator> = (props) => {
const { size = 40, percentage = 25, strokeWidth = 6, children } = props;
const { size = 40, percentage = 25, strokeWidth = 6, strokeColor = "stroke-custom-primary-100", children } = props;
const sqSize = size;
const radius = (size - strokeWidth) / 2;
@@ -27,7 +27,7 @@ export const CircularProgressIndicator: React.FC<ICircularProgressIndicator> = (
strokeWidth={`${strokeWidth}px`}
style={{ filter: "url(#filter0_bi_377_19141)" }}
/>
<defs>
{/* <defs>
<filter
id="filter0_bi_377_19141"
x="-3.57544"
@@ -53,9 +53,9 @@ export const CircularProgressIndicator: React.FC<ICircularProgressIndicator> = (
<feColorMatrix type="matrix" values="0 0 0 0 0.63125 0 0 0 0 0.6625 0 0 0 0 0.75 0 0 0 0.35 0" />
<feBlend mode="normal" in2="shape" result="effect2_innerShadow_377_19141" />
</filter>
</defs>
</defs> */}
<circle
className="fill-none stroke-custom-primary-100 "
className={`fill-none ${strokeColor}`}
cx={size / 2}
cy={size / 2}
r={radius}

View File

@@ -70,14 +70,14 @@ const CycleDetailPage = observer(() => {
{cycleId && !isSidebarCollapsed && (
<div
className={cn(
"flex h-full w-[24rem] flex-shrink-0 flex-col gap-3.5 overflow-y-auto border-l border-custom-border-100 bg-custom-sidebar-background-100 px-6 duration-300 vertical-scrollbar scrollbar-sm absolute right-0 z-[13]"
"flex h-full w-[21.5rem] flex-shrink-0 flex-col gap-3.5 overflow-y-auto border-l border-custom-border-100 bg-custom-sidebar-background-100 px-4 duration-300 vertical-scrollbar scrollbar-sm absolute right-0 z-[13]"
)}
style={{
boxShadow:
"0px 1px 4px 0px rgba(0, 0, 0, 0.06), 0px 2px 4px 0px rgba(16, 24, 40, 0.06), 0px 1px 8px -1px rgba(16, 24, 40, 0.06)",
}}
>
<CycleDetailsSidebar cycleId={cycleId.toString()} handleClose={toggleSidebar} />
<CycleDetailsSidebar handleClose={toggleSidebar} />
</div>
)}
</div>

View File

@@ -0,0 +1 @@
export * from "./root";

View File

@@ -0,0 +1,89 @@
"use client";
import { observer } from "mobx-react";
import { Disclosure } from "@headlessui/react";
// ui
import { Row } from "@plane/ui";
// components
import {
ActiveCycleProductivity,
ActiveCycleProgress,
ActiveCycleStats,
CycleListGroupHeader,
CyclesListItem,
} from "@/components/cycles";
import useCyclesDetails from "@/components/cycles/active-cycle/use-cycles-details";
import { EmptyState } from "@/components/empty-state";
// constants
import { EmptyStateType } from "@/constants/empty-state";
import { useCycle } from "@/hooks/store";
import { ActiveCycleIssueDetails } from "@/store/issue/cycle";
interface IActiveCycleDetails {
workspaceSlug: string;
projectId: string;
}
export const ActiveCycleRoot: React.FC<IActiveCycleDetails> = observer((props) => {
const { workspaceSlug, projectId } = props;
const { currentProjectActiveCycle, currentProjectActiveCycleId } = useCycle();
const {
handleFiltersUpdate,
cycle: activeCycle,
cycleIssueDetails,
} = useCyclesDetails({ workspaceSlug, projectId, cycleId: currentProjectActiveCycleId });
return (
<>
<Disclosure as="div" className="flex flex-shrink-0 flex-col" defaultOpen>
{({ open }) => (
<>
<Disclosure.Button className="sticky top-0 z-[2] w-full flex-shrink-0 border-b border-custom-border-200 bg-custom-background-90 cursor-pointer">
<CycleListGroupHeader title="Active cycle" type="current" isExpanded={open} />
</Disclosure.Button>
<Disclosure.Panel>
{!currentProjectActiveCycle ? (
<EmptyState type={EmptyStateType.PROJECT_CYCLE_ACTIVE} size="sm" />
) : (
<div className="flex flex-col border-b border-custom-border-200">
{currentProjectActiveCycleId && (
<CyclesListItem
key={currentProjectActiveCycleId}
cycleId={currentProjectActiveCycleId}
workspaceSlug={workspaceSlug}
projectId={projectId}
className="!border-b-transparent"
/>
)}
<Row className="bg-custom-background-100 pt-3 pb-6">
<div className="grid grid-cols-1 bg-custom-background-100 gap-3 lg:grid-cols-2 xl:grid-cols-3">
<ActiveCycleProgress
handleFiltersUpdate={handleFiltersUpdate}
projectId={projectId}
workspaceSlug={workspaceSlug}
cycle={activeCycle}
/>
<ActiveCycleProductivity
workspaceSlug={workspaceSlug}
projectId={projectId}
cycle={activeCycle}
/>
<ActiveCycleStats
workspaceSlug={workspaceSlug}
projectId={projectId}
cycle={activeCycle}
cycleId={currentProjectActiveCycleId}
handleFiltersUpdate={handleFiltersUpdate}
cycleIssueDetails={cycleIssueDetails as ActiveCycleIssueDetails}
/>
</div>
</Row>
</div>
)}
</Disclosure.Panel>
</>
)}
</Disclosure>
</>
);
});

View File

@@ -0,0 +1 @@
export * from "./sidebar-chart";

View File

@@ -0,0 +1,57 @@
import { Fragment } from "react";
import { TCycleDistribution, TCycleEstimateDistribution } from "@plane/types";
import { Loader } from "@plane/ui";
import ProgressChart from "@/components/core/sidebar/progress-chart";
type ProgressChartProps = {
chartDistributionData: TCycleEstimateDistribution | TCycleDistribution | undefined;
cycleStartDate: Date | undefined;
cycleEndDate: Date | undefined;
totalEstimatePoints: number;
totalIssues: number;
plotType: string;
};
export const SidebarBaseChart = (props: ProgressChartProps) => {
const { chartDistributionData, cycleStartDate, cycleEndDate, totalEstimatePoints, totalIssues, plotType } = props;
const completionChartDistributionData = chartDistributionData?.completion_chart || undefined;
return (
<div>
<div className="relative flex items-center gap-2">
<div className="flex items-center justify-center gap-1 text-xs">
<span className="h-2.5 w-2.5 rounded-full bg-[#A9BBD0]" />
<span>Ideal</span>
</div>
<div className="flex items-center justify-center gap-1 text-xs">
<span className="h-2.5 w-2.5 rounded-full bg-[#4C8FFF]" />
<span>Current</span>
</div>
</div>
{cycleStartDate && cycleEndDate && completionChartDistributionData ? (
<Fragment>
{plotType === "points" ? (
<ProgressChart
distribution={completionChartDistributionData}
startDate={cycleStartDate}
endDate={cycleEndDate}
totalIssues={totalEstimatePoints}
plotTitle={"points"}
/>
) : (
<ProgressChart
distribution={completionChartDistributionData}
startDate={cycleStartDate}
endDate={cycleEndDate}
totalIssues={totalIssues}
plotTitle={"issues"}
/>
)}
</Fragment>
) : (
<Loader className="w-full h-[160px] mt-4">
<Loader.Item width="100%" height="100%" />
</Loader>
)}
</div>
);
};

View File

@@ -0,0 +1,2 @@
export * from "./active-cycle";
export * from "./analytics-sidebar";

View File

@@ -1,4 +1,3 @@
export * from "./root";
export * from "./header";
export * from "./stats";
export * from "./upcoming-cycles-list-item";

View File

@@ -1,7 +1,7 @@
import { FC, Fragment } from "react";
import { observer } from "mobx-react";
import Link from "next/link";
import { ICycle, TCyclePlotType } from "@plane/types";
import { ICycle, TCycleEstimateType, TCyclePlotType } from "@plane/types";
import { CustomSelect, Loader } from "@plane/ui";
// components
import ProgressChart from "@/components/core/sidebar/progress-chart";
@@ -19,22 +19,22 @@ export type ActiveCycleProductivityProps = {
};
const cycleBurnDownChartOptions = [
{ value: "burndown", label: "Issues" },
{ value: "issues", label: "Issues" },
{ value: "points", label: "Points" },
];
export const ActiveCycleProductivity: FC<ActiveCycleProductivityProps> = observer((props) => {
const { workspaceSlug, projectId, cycle } = props;
// hooks
const { getPlotTypeByCycleId, setPlotType } = useCycle();
const { getEstimateTypeByCycleId, setEstimateType } = useCycle();
const { currentActiveEstimateId, areEstimateEnabledByProjectId, estimateById } = useProjectEstimates();
// derived values
const plotType: TCyclePlotType = (cycle && getPlotTypeByCycleId(cycle.id)) || "burndown";
const estimateType: TCycleEstimateType = (cycle && getEstimateTypeByCycleId(cycle.id)) || "issues";
const onChange = async (value: TCyclePlotType) => {
const onChange = async (value: TCycleEstimateType) => {
if (!workspaceSlug || !projectId || !cycle || !cycle.id) return;
setPlotType(cycle.id, value);
setEstimateType(cycle.id, value);
};
const isCurrentProjectEstimateEnabled = projectId && areEstimateEnabledByProjectId(projectId) ? true : false;
@@ -43,7 +43,7 @@ export const ActiveCycleProductivity: FC<ActiveCycleProductivityProps> = observe
const isCurrentEstimateTypeIsPoints = estimateDetails && estimateDetails?.type === EEstimateSystem.POINTS;
const chartDistributionData =
cycle && plotType === "points" ? cycle?.estimate_distribution : cycle?.distribution || undefined;
cycle && estimateType === "points" ? cycle?.estimate_distribution : cycle?.distribution || undefined;
const completionChartDistributionData = chartDistributionData?.completion_chart || undefined;
return cycle && completionChartDistributionData ? (
@@ -55,8 +55,8 @@ export const ActiveCycleProductivity: FC<ActiveCycleProductivityProps> = observe
{isCurrentEstimateTypeIsPoints && (
<div className="relative flex items-center gap-2">
<CustomSelect
value={plotType}
label={<span>{cycleBurnDownChartOptions.find((v) => v.value === plotType)?.label ?? "None"}</span>}
value={estimateType}
label={<span>{cycleBurnDownChartOptions.find((v) => v.value === estimateType)?.label ?? "None"}</span>}
onChange={onChange}
maxHeight="lg"
>
@@ -85,7 +85,7 @@ export const ActiveCycleProductivity: FC<ActiveCycleProductivityProps> = observe
<span>Current</span>
</div>
</div>
{plotType === "points" ? (
{estimateType === "points" ? (
<span>{`Pending points - ${cycle.backlog_estimate_points + cycle.unstarted_estimate_points + cycle.started_estimate_points}`}</span>
) : (
<span>{`Pending issues - ${cycle.backlog_issues + cycle.unstarted_issues + cycle.started_issues}`}</span>
@@ -95,7 +95,7 @@ export const ActiveCycleProductivity: FC<ActiveCycleProductivityProps> = observe
<div className="relative h-full">
{completionChartDistributionData && (
<Fragment>
{plotType === "points" ? (
{estimateType === "points" ? (
<ProgressChart
distribution={completionChartDistributionData}
startDate={cycle.start_date ?? ""}

View File

@@ -10,7 +10,7 @@ import { useCycle, useIssues } from "@/hooks/store";
interface IActiveCycleDetails {
workspaceSlug: string;
projectId: string;
cycleId: string | null;
cycleId: string | null | undefined;
}
const useCyclesDetails = (props: IActiveCycleDetails) => {

View File

@@ -1,3 +1,5 @@
export * from "./root";
export * from "./issue-progress";
export * from "./progress-stats";
export * from "./sidebar-header";
export * from "./sidebar-details";

View File

@@ -5,12 +5,11 @@ import isEmpty from "lodash/isEmpty";
import isEqual from "lodash/isEqual";
import { observer } from "mobx-react";
import { useSearchParams } from "next/navigation";
import { AlertCircle, ChevronUp, ChevronDown } from "lucide-react";
import { ChevronUp, ChevronDown } from "lucide-react";
import { Disclosure, Transition } from "@headlessui/react";
import { ICycle, IIssueFilterOptions, TCyclePlotType, TProgressSnapshot } from "@plane/types";
import { CustomSelect, Loader, Spinner } from "@plane/ui";
import { ICycle, IIssueFilterOptions, TCycleEstimateType, TCyclePlotType, TProgressSnapshot } from "@plane/types";
import { CustomSelect } from "@plane/ui";
// components
import ProgressChart from "@/components/core/sidebar/progress-chart";
import { CycleProgressStats } from "@/components/cycles";
// constants
import { EIssueFilterType, EIssuesStoreType } from "@/constants/issue";
@@ -19,6 +18,7 @@ import { getDate } from "@/helpers/date-time.helper";
// hooks
import { useIssues, useCycle, useProjectEstimates } from "@/hooks/store";
// plane web constants
import { SidebarBaseChart } from "@/plane-web/components/cycles/analytics-sidebar/sidebar-chart";
import { EEstimateSystem } from "@/plane-web/constants/estimates";
type TCycleAnalyticsProgress = {
@@ -27,11 +27,6 @@ type TCycleAnalyticsProgress = {
cycleId: string;
};
const cycleBurnDownChartOptions = [
{ value: "burndown", label: "Issues" },
{ value: "points", label: "Points" },
];
const validateCycleSnapshot = (cycleDetails: ICycle | null): ICycle | null => {
if (!cycleDetails || cycleDetails === null) return cycleDetails;
@@ -47,6 +42,18 @@ const validateCycleSnapshot = (cycleDetails: ICycle | null): ICycle | null => {
return updatedCycleDetails;
};
type options = {
value: string;
label: string;
};
export const cycleChartOptions: options[] = [
{ value: "burndown", label: "Burn-down" },
{ value: "burnup", label: "Burn-up" },
];
export const cycleEstimateOptions: options[] = [
{ value: "issues", label: "issues" },
{ value: "points", label: "points" },
];
export const CycleAnalyticsProgress: FC<TCycleAnalyticsProgress> = observer((props) => {
// props
const { workspaceSlug, projectId, cycleId } = props;
@@ -55,7 +62,15 @@ export const CycleAnalyticsProgress: FC<TCycleAnalyticsProgress> = observer((pro
const peekCycle = searchParams.get("peekCycle") || undefined;
// hooks
const { areEstimateEnabledByProjectId, currentActiveEstimateId, estimateById } = useProjectEstimates();
const { getPlotTypeByCycleId, setPlotType, getCycleById, fetchCycleDetails, fetchArchivedCycleDetails } = useCycle();
const {
getPlotTypeByCycleId,
getEstimateTypeByCycleId,
setPlotType,
getCycleById,
fetchCycleDetails,
fetchArchivedCycleDetails,
setEstimateType,
} = useCycle();
const {
issuesFilter: { issueFilters, updateFilters },
} = useIssues(EIssuesStoreType.CYCLE);
@@ -65,6 +80,7 @@ export const CycleAnalyticsProgress: FC<TCycleAnalyticsProgress> = observer((pro
// derived values
const cycleDetails = validateCycleSnapshot(getCycleById(cycleId));
const plotType: TCyclePlotType = getPlotTypeByCycleId(cycleId);
const estimateType = getEstimateTypeByCycleId(cycleId);
const isCurrentProjectEstimateEnabled = projectId && areEstimateEnabledByProjectId(projectId) ? true : false;
const estimateDetails =
isCurrentProjectEstimateEnabled && currentActiveEstimateId && estimateById(currentActiveEstimateId);
@@ -76,7 +92,7 @@ export const CycleAnalyticsProgress: FC<TCycleAnalyticsProgress> = observer((pro
const totalEstimatePoints = cycleDetails?.total_estimate_points || 0;
const progressHeaderPercentage = cycleDetails
? plotType === "points"
? estimateType === "points"
? completedEstimatePoints != 0 && totalEstimatePoints != 0
? Math.round((completedEstimatePoints / totalEstimatePoints) * 100)
: 0
@@ -86,21 +102,22 @@ export const CycleAnalyticsProgress: FC<TCycleAnalyticsProgress> = observer((pro
: 0;
const chartDistributionData =
plotType === "points" ? cycleDetails?.estimate_distribution : cycleDetails?.distribution || undefined;
const completionChartDistributionData = chartDistributionData?.completion_chart || undefined;
estimateType === "points" ? cycleDetails?.estimate_distribution : cycleDetails?.distribution || undefined;
const groupedIssues = useMemo(
() => ({
backlog: plotType === "points" ? cycleDetails?.backlog_estimate_points || 0 : cycleDetails?.backlog_issues || 0,
backlog:
estimateType === "points" ? cycleDetails?.backlog_estimate_points || 0 : cycleDetails?.backlog_issues || 0,
unstarted:
plotType === "points" ? cycleDetails?.unstarted_estimate_points || 0 : cycleDetails?.unstarted_issues || 0,
started: plotType === "points" ? cycleDetails?.started_estimate_points || 0 : cycleDetails?.started_issues || 0,
estimateType === "points" ? cycleDetails?.unstarted_estimate_points || 0 : cycleDetails?.unstarted_issues || 0,
started:
estimateType === "points" ? cycleDetails?.started_estimate_points || 0 : cycleDetails?.started_issues || 0,
completed:
plotType === "points" ? cycleDetails?.completed_estimate_points || 0 : cycleDetails?.completed_issues || 0,
estimateType === "points" ? cycleDetails?.completed_estimate_points || 0 : cycleDetails?.completed_issues || 0,
cancelled:
plotType === "points" ? cycleDetails?.cancelled_estimate_points || 0 : cycleDetails?.cancelled_issues || 0,
estimateType === "points" ? cycleDetails?.cancelled_estimate_points || 0 : cycleDetails?.cancelled_issues || 0,
}),
[plotType, cycleDetails]
[estimateType, cycleDetails]
);
const cycleStartDate = getDate(cycleDetails?.start_date);
@@ -111,8 +128,8 @@ export const CycleAnalyticsProgress: FC<TCycleAnalyticsProgress> = observer((pro
const isArchived = !!cycleDetails?.archived_at;
// handlers
const onChange = async (value: TCyclePlotType) => {
setPlotType(cycleId, value);
const onChange = async (value: TCycleEstimateType) => {
setEstimateType(cycleId, value);
if (!workspaceSlug || !projectId || !cycleId) return;
try {
setLoader(true);
@@ -124,7 +141,7 @@ export const CycleAnalyticsProgress: FC<TCycleAnalyticsProgress> = observer((pro
setLoader(false);
} catch (error) {
setLoader(false);
setPlotType(cycleId, plotType);
setEstimateType(cycleId, estimateType);
}
};
@@ -161,40 +178,16 @@ export const CycleAnalyticsProgress: FC<TCycleAnalyticsProgress> = observer((pro
if (!cycleDetails) return <></>;
return (
<div className="border-t border-custom-border-200 space-y-4 py-4 px-3">
<div className="border-t border-custom-border-200 space-y-4 py-5">
<Disclosure defaultOpen={isCycleDateValid ? true : false}>
{({ open }) => (
<div className="space-y-6">
<div className="flex flex-col">
{/* progress bar header */}
{isCycleDateValid ? (
<div className="relative w-full flex justify-between items-center gap-2">
<Disclosure.Button className="relative flex items-center gap-2 w-full">
<div className="font-medium text-custom-text-200 text-sm">Progress</div>
{progressHeaderPercentage > 0 && (
<div className="flex h-5 w-9 items-center justify-center rounded bg-amber-500/20 text-xs font-medium text-amber-500">{`${progressHeaderPercentage}%`}</div>
)}
</Disclosure.Button>
{isCurrentEstimateTypeIsPoints && (
<>
<div>
<CustomSelect
value={plotType}
label={
<span>{cycleBurnDownChartOptions.find((v) => v.value === plotType)?.label ?? "None"}</span>
}
onChange={onChange}
maxHeight="lg"
>
{cycleBurnDownChartOptions.map((item) => (
<CustomSelect.Option key={item.value} value={item.value}>
{item.label}
</CustomSelect.Option>
))}
</CustomSelect>
</div>
{loader && <Spinner className="h-3 w-3" />}
</>
)}
<Disclosure.Button className="ml-auto">
{open ? (
<ChevronUp className="h-3.5 w-3.5" aria-hidden="true" />
@@ -206,67 +199,45 @@ export const CycleAnalyticsProgress: FC<TCycleAnalyticsProgress> = observer((pro
) : (
<div className="relative w-full flex justify-between items-center gap-2">
<div className="font-medium text-custom-text-200 text-sm">Progress</div>
<div className="flex items-center gap-1">
<AlertCircle height={14} width={14} className="text-custom-text-200" />
<span className="text-xs italic text-custom-text-200">
{cycleDetails?.start_date && cycleDetails?.end_date
? "This cycle isn't active yet."
: "Invalid date. Please enter valid date."}
</span>
</div>
</div>
)}
<Transition show={open}>
<Disclosure.Panel className="space-y-4">
{/* progress burndown chart */}
<div>
<div className="relative flex items-center gap-2">
<div className="flex items-center justify-center gap-1 text-xs">
<span className="h-2.5 w-2.5 rounded-full bg-[#A9BBD0]" />
<span>Ideal</span>
</div>
<div className="flex items-center justify-center gap-1 text-xs">
<span className="h-2.5 w-2.5 rounded-full bg-[#4C8FFF]" />
<span>Current</span>
</div>
</div>
{cycleStartDate && cycleEndDate && completionChartDistributionData ? (
<Fragment>
{plotType === "points" ? (
<ProgressChart
distribution={completionChartDistributionData}
startDate={cycleStartDate}
endDate={cycleEndDate}
totalIssues={totalEstimatePoints}
plotTitle={"points"}
/>
) : (
<ProgressChart
distribution={completionChartDistributionData}
startDate={cycleStartDate}
endDate={cycleEndDate}
totalIssues={totalIssues}
plotTitle={"issues"}
/>
)}
</Fragment>
) : (
<Loader className="w-full h-[160px] mt-4">
<Loader.Item width="100%" height="100%" />
</Loader>
)}
<Disclosure.Panel className="flex flex-col">
<div className="relative flex items-center justify-between gap-2 pt-4">
<CustomSelect
value={estimateType}
label={<span>{cycleEstimateOptions.find((v) => v.value === estimateType)?.label ?? "None"}</span>}
onChange={onChange}
maxHeight="lg"
buttonClassName="border-none rounded text-sm font-medium"
>
{cycleEstimateOptions.map((item) => (
<CustomSelect.Option key={item.value} value={item.value}>
{item.label}
</CustomSelect.Option>
))}
</CustomSelect>
</div>
<div className="py-4">
<SidebarBaseChart
chartDistributionData={chartDistributionData}
cycleStartDate={cycleStartDate}
cycleEndDate={cycleEndDate}
totalEstimatePoints={totalEstimatePoints}
totalIssues={totalIssues}
plotType={plotType}
/>
</div>
{/* progress detailed view */}
{chartDistributionData && (
<div className="w-full border-t border-custom-border-200 pt-5">
<div className="w-full border-t border-custom-border-200 py-4">
<CycleProgressStats
cycleId={cycleId}
plotType={plotType}
distribution={chartDistributionData}
groupedIssues={groupedIssues}
totalIssuesCount={plotType === "points" ? totalEstimatePoints || 0 : totalIssues || 0}
totalIssuesCount={estimateType === "points" ? totalEstimatePoints || 0 : totalIssues || 0}
isEditable={Boolean(!peekCycle)}
size="xs"
roundedTab={false}

View File

@@ -219,6 +219,10 @@ export const StateStatComponent = observer((props: TStateStatComponent) => {
});
const progressStats = [
{
key: "stat-states",
title: "States",
},
{
key: "stat-assignees",
title: "Assignees",
@@ -227,10 +231,6 @@ const progressStats = [
key: "stat-labels",
title: "Labels",
},
{
key: "stat-states",
title: "States",
},
];
type TCycleProgressStats = {
@@ -341,6 +341,14 @@ export const CycleProgressStats: FC<TCycleProgressStats> = observer((props) => {
))}
</Tab.List>
<Tab.Panels className="py-3 text-custom-text-200">
<Tab.Panel key={"stat-states"}>
<StateStatComponent
distribution={distributionStateData}
totalIssuesCount={totalIssuesCount}
isEditable={isEditable}
handleFiltersUpdate={handleFiltersUpdate}
/>
</Tab.Panel>
<Tab.Panel key={"stat-assignees"}>
<AssigneeStatComponent
distribution={distributionAssigneeData}
@@ -357,14 +365,6 @@ export const CycleProgressStats: FC<TCycleProgressStats> = observer((props) => {
handleFiltersUpdate={handleFiltersUpdate}
/>
</Tab.Panel>
<Tab.Panel key={"stat-states"}>
<StateStatComponent
distribution={distributionStateData}
totalIssuesCount={totalIssuesCount}
isEditable={isEditable}
handleFiltersUpdate={handleFiltersUpdate}
/>
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
</div>

View File

@@ -1,192 +1,33 @@
"use client";
import React, { useEffect, useState } from "react";
import isEmpty from "lodash/isEmpty";
import React from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { Controller, useForm } from "react-hook-form";
// icons
import { ArchiveRestoreIcon, LinkIcon, Trash2, ChevronRight, CalendarClock, SquareUser } from "lucide-react";
// types
import { ICycle } from "@plane/types";
// ui
import { Avatar, ArchiveIcon, CustomMenu, Loader, LayersIcon, TOAST_TYPE, setToast, TextArea } from "@plane/ui";
import { Loader } from "@plane/ui";
// components
import { ArchiveCycleModal, CycleDeleteModal, CycleAnalyticsProgress } from "@/components/cycles";
import { DateRangeDropdown } from "@/components/dropdowns";
// constants
import { CYCLE_STATUS } from "@/constants/cycle";
import { CYCLE_UPDATED } from "@/constants/event-tracker";
// helpers
import { findHowManyDaysLeft, getDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper";
import { copyUrlToClipboard } from "@/helpers/string.helper";
import { CycleAnalyticsProgress, CycleSidebarHeader, CycleSidebarDetails } from "@/components/cycles";
import useCyclesDetails from "../active-cycle/use-cycles-details";
// hooks
import { useEventTracker, useCycle, useMember, useProjectEstimates, useUserPermissions } from "@/hooks/store";
import { useAppRouter } from "@/hooks/use-app-router";
// plane web constants
import { EEstimateSystem } from "@/plane-web/constants/estimates";
// services
import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions";
import { CycleService } from "@/services/cycle.service";
type Props = {
cycleId: string;
handleClose: () => void;
isArchived?: boolean;
cycleId?: string;
};
const defaultValues: Partial<ICycle> = {
start_date: null,
end_date: null,
};
// services
const cycleService = new CycleService();
// TODO: refactor the whole component
export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
const { cycleId, handleClose, isArchived } = props;
// states
const [archiveCycleModal, setArchiveCycleModal] = useState(false);
const [cycleDeleteModal, setCycleDeleteModal] = useState(false);
const { handleClose, isArchived } = props;
// router
const router = useAppRouter();
const { workspaceSlug, projectId } = useParams();
const { workspaceSlug, projectId, cycleId } = useParams();
// store hooks
const { setTrackElement, captureCycleEvent } = useEventTracker();
const { areEstimateEnabledByProjectId, currentActiveEstimateId, estimateById } = useProjectEstimates();
const { allowPermissions } = useUserPermissions();
const { getCycleById, updateCycleDetails, restoreCycle } = useCycle();
const { getUserDetails } = useMember();
// derived values
const cycleDetails = getCycleById(cycleId);
const cycleOwnerDetails = cycleDetails ? getUserDetails(cycleDetails.owned_by_id) : undefined;
// form info
const { control, reset } = useForm({
defaultValues,
const { cycle: cycleDetails } = useCyclesDetails({
workspaceSlug: workspaceSlug.toString(),
projectId: projectId.toString(),
cycleId: cycleId?.toString() || props.cycleId,
});
const submitChanges = (data: Partial<ICycle>, changedProperty: string) => {
if (!workspaceSlug || !projectId || !cycleId) return;
updateCycleDetails(workspaceSlug.toString(), projectId.toString(), cycleId.toString(), data)
.then((res) => {
captureCycleEvent({
eventName: CYCLE_UPDATED,
payload: {
...res,
changed_properties: [changedProperty],
element: "Right side-peek",
state: "SUCCESS",
},
});
})
.catch(() => {
captureCycleEvent({
eventName: CYCLE_UPDATED,
payload: {
...data,
element: "Right side-peek",
state: "FAILED",
},
});
});
};
const handleCopyText = () => {
copyUrlToClipboard(`${workspaceSlug}/projects/${projectId}/cycles/${cycleId}`)
.then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Link Copied!",
message: "Cycle link copied to clipboard.",
});
})
.catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: "Some error occurred",
});
});
};
const handleRestoreCycle = async () => {
if (!workspaceSlug || !projectId) return;
await restoreCycle(workspaceSlug.toString(), projectId.toString(), cycleId)
.then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Restore success",
message: "Your cycle can be found in project cycles.",
});
router.push(`/${workspaceSlug.toString()}/projects/${projectId.toString()}/archives/cycles`);
})
.catch(() =>
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Cycle could not be restored. Please try again.",
})
);
};
useEffect(() => {
if (cycleDetails)
reset({
...cycleDetails,
});
}, [cycleDetails, reset]);
const dateChecker = async (payload: any) => {
try {
const res = await cycleService.cycleDateCheck(workspaceSlug as string, projectId as string, payload);
return res.status;
} catch (err) {
return false;
}
};
const handleDateChange = async (startDate: Date | undefined, endDate: Date | undefined) => {
if (!startDate || !endDate) return;
let isDateValid = false;
const payload = {
start_date: renderFormattedPayloadDate(startDate),
end_date: renderFormattedPayloadDate(endDate),
};
if (cycleDetails && cycleDetails.start_date && cycleDetails.end_date)
isDateValid = await dateChecker({
...payload,
cycle_id: cycleDetails.id,
});
else isDateValid = await dateChecker(payload);
if (isDateValid) {
submitChanges(payload, "date_range");
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Cycle updated successfully.",
});
} else {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message:
"You already have a cycle on the given dates, if you want to create a draft cycle, you can do that by removing both the dates.",
});
reset({ ...cycleDetails });
}
};
const cycleStatus = cycleDetails?.status?.toLocaleLowerCase();
const isCompleted = cycleStatus === "completed";
if (!cycleDetails)
return (
<Loader className="px-5">
@@ -202,248 +43,26 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
</Loader>
);
const currentCycle = CYCLE_STATUS.find((status) => status.value === cycleStatus);
const areEstimateEnabled = projectId && areEstimateEnabledByProjectId(projectId.toString());
const estimateType = areEstimateEnabled && currentActiveEstimateId && estimateById(currentActiveEstimateId);
// NOTE: validate if the cycle is snapshot and the estimate system is points
const isEstimatePointValid = isEmpty(cycleDetails?.progress_snapshot || {})
? estimateType && estimateType?.type == EEstimateSystem.POINTS
? true
: false
: isEmpty(cycleDetails?.progress_snapshot?.estimate_distribution || {})
? false
: true;
const issueCount =
isCompleted && !isEmpty(cycleDetails.progress_snapshot)
? cycleDetails.progress_snapshot.total_issues === 0
? "0 Issue"
: `${cycleDetails.progress_snapshot.completed_issues}/${cycleDetails.progress_snapshot.total_issues}`
: cycleDetails.total_issues === 0
? "0 Issue"
: `${cycleDetails.completed_issues}/${cycleDetails.total_issues}`;
const issueEstimatePointCount =
isCompleted && !isEmpty(cycleDetails.progress_snapshot)
? cycleDetails.progress_snapshot.total_issues === 0
? "0 Issue"
: `${cycleDetails.progress_snapshot.completed_estimate_points}/${cycleDetails.progress_snapshot.total_estimate_points}`
: cycleDetails.total_issues === 0
? "0 Issue"
: `${cycleDetails.completed_estimate_points}/${cycleDetails.total_estimate_points}`;
const daysLeft = findHowManyDaysLeft(cycleDetails.end_date);
const isEditingAllowed = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.PROJECT
);
return (
<div className="relative">
{cycleDetails && workspaceSlug && projectId && (
<>
<ArchiveCycleModal
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
cycleId={cycleId}
isOpen={archiveCycleModal}
handleClose={() => setArchiveCycleModal(false)}
/>
<CycleDeleteModal
cycle={cycleDetails}
isOpen={cycleDeleteModal}
handleClose={() => setCycleDeleteModal(false)}
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
/>
</>
<div className="relative pb-2">
<div className="flex flex-col gap-5 w-full">
<CycleSidebarHeader
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
cycleDetails={cycleDetails}
isArchived={isArchived}
handleClose={handleClose}
/>
<CycleSidebarDetails projectId={projectId.toString()} cycleDetails={cycleDetails} />
</div>
{workspaceSlug && projectId && cycleDetails?.id && (
<CycleAnalyticsProgress
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
cycleId={cycleDetails?.id}
/>
)}
<>
<div
className={`sticky z-10 top-0 flex items-center justify-between bg-custom-sidebar-background-100 pb-5 pt-5`}
>
<div>
<button
className="flex h-5 w-5 items-center justify-center rounded-full bg-custom-border-300"
onClick={() => handleClose()}
>
<ChevronRight className="h-3 w-3 stroke-2 text-white" />
</button>
</div>
<div className="flex items-center gap-3.5">
{!isArchived && (
<button onClick={handleCopyText}>
<LinkIcon className="h-3 w-3 text-custom-text-300" />
</button>
)}
{isEditingAllowed && (
<CustomMenu placement="bottom-end" ellipsis>
{!isArchived && (
<CustomMenu.MenuItem onClick={() => setArchiveCycleModal(true)} disabled={!isCompleted}>
{isCompleted ? (
<div className="flex items-center gap-2">
<ArchiveIcon className="h-3 w-3" />
Archive cycle
</div>
) : (
<div className="flex items-start gap-2">
<ArchiveIcon className="h-3 w-3" />
<div className="-mt-1">
<p>Archive cycle</p>
<p className="text-xs text-custom-text-400">
Only completed cycle <br /> can be archived.
</p>
</div>
</div>
)}
</CustomMenu.MenuItem>
)}
{isArchived && (
<CustomMenu.MenuItem onClick={handleRestoreCycle}>
<span className="flex items-center justify-start gap-2">
<ArchiveRestoreIcon className="h-3 w-3" />
<span>Restore cycle</span>
</span>
</CustomMenu.MenuItem>
)}
{!isCompleted && (
<CustomMenu.MenuItem
onClick={() => {
setTrackElement("CYCLE_PAGE_SIDEBAR");
setCycleDeleteModal(true);
}}
>
<span className="flex items-center justify-start gap-2">
<Trash2 className="h-3 w-3" />
<span>Delete cycle</span>
</span>
</CustomMenu.MenuItem>
)}
</CustomMenu>
)}
</div>
</div>
<div className="flex flex-col gap-3 pt-2">
<div className="flex items-center gap-5">
{currentCycle && (
<span
className="flex h-6 w-20 items-center justify-center rounded-sm text-center text-xs"
style={{
color: currentCycle.color,
backgroundColor: `${currentCycle.color}20`,
}}
>
{currentCycle.value === "current" && daysLeft !== undefined
? `${daysLeft} ${currentCycle.label}`
: `${currentCycle.label}`}
</span>
)}
</div>
<h4 className="w-full break-words text-xl font-semibold text-custom-text-100">{cycleDetails.name}</h4>
</div>
{cycleDetails.description && (
<TextArea
className="outline-none ring-none w-full max-h-max bg-transparent !p-0 !m-0 !border-0 resize-none text-sm leading-5 text-custom-text-200"
value={cycleDetails.description}
disabled
/>
)}
<div className="flex flex-col gap-5 pb-6 pt-2.5">
<div className="flex items-center justify-start gap-1">
<div className="flex w-2/5 items-center justify-start gap-2 text-custom-text-300">
<CalendarClock className="h-4 w-4" />
<span className="text-base">Date range</span>
</div>
<div className="h-7 w-3/5">
<Controller
control={control}
name="start_date"
render={({ field: { value: startDateValue, onChange: onChangeStartDate } }) => (
<Controller
control={control}
name="end_date"
render={({ field: { value: endDateValue, onChange: onChangeEndDate } }) => (
<DateRangeDropdown
className="h-7"
buttonContainerClassName="w-full"
buttonVariant="background-with-text"
minDate={new Date()}
value={{
from: getDate(startDateValue),
to: getDate(endDateValue),
}}
onSelect={(val) => {
onChangeStartDate(val?.from ? renderFormattedPayloadDate(val.from) : null);
onChangeEndDate(val?.to ? renderFormattedPayloadDate(val.to) : null);
handleDateChange(val?.from, val?.to);
}}
placeholder={{
from: "Start date",
to: "End date",
}}
required={cycleDetails.status !== "draft"}
disabled={!isEditingAllowed || isArchived || isCompleted}
/>
)}
/>
)}
/>
</div>
</div>
<div className="flex items-center justify-start gap-1">
<div className="flex w-2/5 items-center justify-start gap-2 text-custom-text-300">
<SquareUser className="h-4 w-4" />
<span className="text-base">Lead</span>
</div>
<div className="flex w-3/5 items-center rounded-sm">
<div className="flex items-center gap-2.5">
<Avatar name={cycleOwnerDetails?.display_name} src={cycleOwnerDetails?.avatar} />
<span className="text-sm text-custom-text-200">{cycleOwnerDetails?.display_name}</span>
</div>
</div>
</div>
<div className="flex items-center justify-start gap-1">
<div className="flex w-2/5 items-center justify-start gap-2 text-custom-text-300">
<LayersIcon className="h-4 w-4" />
<span className="text-base">Issues</span>
</div>
<div className="flex w-3/5 items-center">
<span className="px-1.5 text-sm text-custom-text-300">{issueCount}</span>
</div>
</div>
{/**
* NOTE: Render this section when estimate points of he projects is enabled and the estimate system is points
*/}
{isEstimatePointValid && (
<div className="flex items-center justify-start gap-1">
<div className="flex w-2/5 items-center justify-start gap-2 text-custom-text-300">
<LayersIcon className="h-4 w-4" />
<span className="text-base">Points</span>
</div>
<div className="flex w-3/5 items-center">
<span className="px-1.5 text-sm text-custom-text-300">{issueEstimatePointCount}</span>
</div>
</div>
)}
</div>
{workspaceSlug && projectId && cycleDetails?.id && (
<CycleAnalyticsProgress
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
cycleId={cycleDetails?.id}
/>
)}
</>
</div>
);
});

View File

@@ -0,0 +1,138 @@
"use client";
import React, { FC } from "react";
import isEmpty from "lodash/isEmpty";
import { observer } from "mobx-react";
import { LayersIcon, SquareUser, Users } from "lucide-react";
// ui
import { ICycle } from "@plane/types";
import { Avatar, AvatarGroup, TextArea } from "@plane/ui";
// types
// hooks
import { useMember, useProjectEstimates } from "@/hooks/store";
// plane web
import { EEstimateSystem } from "@/plane-web/constants/estimates";
type Props = {
projectId: string;
cycleDetails: ICycle;
};
export const CycleSidebarDetails: FC<Props> = observer((props) => {
const { projectId, cycleDetails } = props;
// hooks
const { getUserDetails } = useMember();
const { areEstimateEnabledByProjectId, currentActiveEstimateId, estimateById } = useProjectEstimates();
const areEstimateEnabled = projectId && areEstimateEnabledByProjectId(projectId.toString());
const cycleStatus = cycleDetails?.status?.toLocaleLowerCase();
const isCompleted = cycleStatus === "completed";
const issueCount =
isCompleted && !isEmpty(cycleDetails?.progress_snapshot)
? cycleDetails?.progress_snapshot?.total_issues === 0
? "0 Issue"
: `${cycleDetails?.progress_snapshot?.completed_issues}/${cycleDetails?.progress_snapshot?.total_issues}`
: cycleDetails?.total_issues === 0
? "0 Issue"
: `${cycleDetails?.completed_issues}/${cycleDetails?.total_issues}`;
const estimateType = areEstimateEnabled && currentActiveEstimateId && estimateById(currentActiveEstimateId);
const cycleOwnerDetails = cycleDetails ? getUserDetails(cycleDetails.owned_by_id) : undefined;
const isEstimatePointValid = isEmpty(cycleDetails?.progress_snapshot || {})
? estimateType && estimateType?.type == EEstimateSystem.POINTS
? true
: false
: isEmpty(cycleDetails?.progress_snapshot?.estimate_distribution || {})
? false
: true;
const issueEstimatePointCount =
isCompleted && !isEmpty(cycleDetails?.progress_snapshot)
? cycleDetails?.progress_snapshot.total_issues === 0
? "0 Issue"
: `${cycleDetails?.progress_snapshot.completed_estimate_points}/${cycleDetails?.progress_snapshot.total_estimate_points}`
: cycleDetails?.total_issues === 0
? "0 Issue"
: `${cycleDetails?.completed_estimate_points}/${cycleDetails?.total_estimate_points}`;
return (
<div className="flex flex-col gap-5 w-full">
{cycleDetails?.description && (
<TextArea
className="outline-none ring-none w-full max-h-max bg-transparent !p-0 !m-0 !border-0 resize-none text-sm leading-5 text-custom-text-200"
value={cycleDetails.description}
disabled
/>
)}
<div className="flex flex-col gap-5 pb-6 pt-2.5">
<div className="flex items-center justify-start gap-1">
<div className="flex w-2/5 items-center justify-start gap-2 text-custom-text-300">
<SquareUser className="h-4 w-4" />
<span className="text-base">Lead</span>
</div>
<div className="flex w-3/5 items-center rounded-sm">
<div className="flex items-center gap-2.5">
<Avatar name={cycleOwnerDetails?.display_name} src={cycleOwnerDetails?.avatar} />
<span className="text-sm text-custom-text-200">{cycleOwnerDetails?.display_name}</span>
</div>
</div>
</div>
<div className="flex items-center justify-start gap-1">
<div className="flex w-2/5 items-center justify-start gap-2 text-custom-text-300">
<Users className="h-4 w-4" />
<span className="text-base">Members</span>
</div>
<div className="flex w-3/5 items-center rounded-sm">
<div className="flex items-center gap-2.5">
{cycleDetails?.assignee_ids && cycleDetails.assignee_ids.length > 0 ? (
<>
<AvatarGroup showTooltip>
{cycleDetails.assignee_ids.map((member) => {
const memberDetails = getUserDetails(member);
return (
<Avatar
key={memberDetails?.id}
name={memberDetails?.display_name ?? ""}
src={memberDetails?.avatar ?? ""}
showTooltip={false}
/>
);
})}
</AvatarGroup>
</>
) : (
<span className="px-1.5 text-sm text-custom-text-300">No assignees</span>
)}
</div>
</div>
</div>
<div className="flex items-center justify-start gap-1">
<div className="flex w-2/5 items-center justify-start gap-2 text-custom-text-300">
<LayersIcon className="h-4 w-4" />
<span className="text-base">Issues</span>
</div>
<div className="flex w-3/5 items-center">
<span className="px-1.5 text-sm text-custom-text-300">{issueCount}</span>
</div>
</div>
{/**
* NOTE: Render this section when estimate points of he projects is enabled and the estimate system is points
*/}
{isEstimatePointValid && (
<div className="flex items-center justify-start gap-1">
<div className="flex w-2/5 items-center justify-start gap-2 text-custom-text-300">
<LayersIcon className="h-4 w-4" />
<span className="text-base">Points</span>
</div>
<div className="flex w-3/5 items-center">
<span className="px-1.5 text-sm text-custom-text-300">{issueEstimatePointCount}</span>
</div>
</div>
)}
</div>
</div>
);
});

View File

@@ -0,0 +1,326 @@
"use client";
import React, { FC, useEffect, useState } from "react";
import { observer } from "mobx-react";
import { Controller, useForm } from "react-hook-form";
import { ArchiveIcon, ArchiveRestoreIcon, ChevronRight, EllipsisIcon, LinkIcon, Trash2 } from "lucide-react";
// types
import { ICycle } from "@plane/types";
// ui
import { CustomMenu, setToast, TOAST_TYPE } from "@plane/ui";
// components
import { DateRangeDropdown } from "@/components/dropdowns";
// constants
import { CYCLE_STATUS } from "@/constants/cycle";
import { CYCLE_UPDATED } from "@/constants/event-tracker";
// helpers
import { renderFormattedPayloadDate, getDate } from "@/helpers/date-time.helper";
import { copyUrlToClipboard } from "@/helpers/string.helper";
// hooks
import { useCycle, useEventTracker, useUserPermissions } from "@/hooks/store";
import { useAppRouter } from "@/hooks/use-app-router";
// plane web constants
import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions";
// services
import { CycleService } from "@/services/cycle.service";
// local components
import { ArchiveCycleModal } from "../archived-cycles";
import { CycleDeleteModal } from "../delete-modal";
type Props = {
workspaceSlug: string;
projectId: string;
cycleDetails: ICycle;
handleClose: () => void;
isArchived?: boolean;
};
const defaultValues: Partial<ICycle> = {
start_date: null,
end_date: null,
};
const cycleService = new CycleService();
export const CycleSidebarHeader: FC<Props> = observer((props) => {
const { workspaceSlug, projectId, cycleDetails, handleClose, isArchived = false } = props;
// router
const router = useAppRouter();
// states
const [archiveCycleModal, setArchiveCycleModal] = useState(false);
const [cycleDeleteModal, setCycleDeleteModal] = useState(false);
// hooks
const { allowPermissions } = useUserPermissions();
const { updateCycleDetails, restoreCycle } = useCycle();
const { setTrackElement, captureCycleEvent } = useEventTracker();
// form info
const { control, reset } = useForm({
defaultValues,
});
const cycleStatus = cycleDetails?.status?.toLocaleLowerCase();
const isCompleted = cycleStatus === "completed";
const currentCycle = CYCLE_STATUS.find((status) => status.value === cycleStatus);
const handleRestoreCycle = async () => {
if (!workspaceSlug || !projectId) return;
await restoreCycle(workspaceSlug.toString(), projectId.toString(), cycleDetails.id)
.then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Restore success",
message: "Your cycle can be found in project cycles.",
});
router.push(`/${workspaceSlug.toString()}/projects/${projectId.toString()}/archives/cycles`);
})
.catch(() =>
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Cycle could not be restored. Please try again.",
})
);
};
const handleCopyText = () => {
copyUrlToClipboard(`${workspaceSlug}/projects/${projectId}/cycles/${cycleDetails.id}`)
.then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Link Copied!",
message: "Cycle link copied to clipboard.",
});
})
.catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: "Some error occurred",
});
});
};
const submitChanges = (data: Partial<ICycle>, changedProperty: string) => {
if (!workspaceSlug || !projectId || !cycleDetails.id) return;
updateCycleDetails(workspaceSlug.toString(), projectId.toString(), cycleDetails.id.toString(), data)
.then((res) => {
captureCycleEvent({
eventName: CYCLE_UPDATED,
payload: {
...res,
changed_properties: [changedProperty],
element: "Right side-peek",
state: "SUCCESS",
},
});
})
.catch(() => {
captureCycleEvent({
eventName: CYCLE_UPDATED,
payload: {
...data,
element: "Right side-peek",
state: "FAILED",
},
});
});
};
useEffect(() => {
if (cycleDetails)
reset({
...cycleDetails,
});
}, [cycleDetails, reset]);
const dateChecker = async (payload: any) => {
try {
const res = await cycleService.cycleDateCheck(workspaceSlug as string, projectId as string, payload);
return res.status;
} catch (err) {
return false;
}
};
const handleDateChange = async (startDate: Date | undefined, endDate: Date | undefined) => {
if (!startDate || !endDate) return;
let isDateValid = false;
const payload = {
start_date: renderFormattedPayloadDate(startDate),
end_date: renderFormattedPayloadDate(endDate),
};
if (cycleDetails?.start_date && cycleDetails.end_date)
isDateValid = await dateChecker({
...payload,
cycle_id: cycleDetails.id,
});
else isDateValid = await dateChecker(payload);
if (isDateValid) {
submitChanges(payload, "date_range");
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Cycle updated successfully.",
});
} else {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message:
"You already have a cycle on the given dates, if you want to create a draft cycle, you can do that by removing both the dates.",
});
reset({ ...cycleDetails });
}
};
const isEditingAllowed = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.PROJECT
);
return (
<>
{cycleDetails && workspaceSlug && projectId && (
<>
<ArchiveCycleModal
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
cycleId={cycleDetails.id}
isOpen={archiveCycleModal}
handleClose={() => setArchiveCycleModal(false)}
/>
<CycleDeleteModal
cycle={cycleDetails}
isOpen={cycleDeleteModal}
handleClose={() => setCycleDeleteModal(false)}
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
/>
</>
)}
<div className="sticky z-10 top-0 pt-2 flex items-center justify-between bg-custom-sidebar-background-100">
<div className="flex items-center justify-center size-5">
<button
className="flex size-4 items-center justify-center rounded-full bg-custom-border-200"
onClick={() => handleClose()}
>
<ChevronRight className="h-3 w-3 stroke-2 text-white" />
</button>
</div>
<div className="flex items-center gap-3">
{!isArchived && (
<button onClick={handleCopyText} className="size-4">
<LinkIcon className="size-3.5 text-custom-text-300" />
</button>
)}
{isEditingAllowed && (
<CustomMenu
placement="bottom-end"
customButtonClassName="size-4"
customButton={<EllipsisIcon className="size-3.5 text-custom-text-300" />}
>
{!isArchived && (
<CustomMenu.MenuItem onClick={() => setArchiveCycleModal(true)} disabled={!isCompleted}>
{isCompleted ? (
<div className="flex items-center gap-2">
<ArchiveIcon className="h-3 w-3" />
Archive cycle
</div>
) : (
<div className="flex items-start gap-2">
<ArchiveIcon className="h-3 w-3" />
<div className="-mt-1">
<p>Archive cycle</p>
<p className="text-xs text-custom-text-400">
Only completed cycle <br /> can be archived.
</p>
</div>
</div>
)}
</CustomMenu.MenuItem>
)}
{isArchived && (
<CustomMenu.MenuItem onClick={handleRestoreCycle}>
<span className="flex items-center justify-start gap-2">
<ArchiveRestoreIcon className="h-3 w-3" />
<span>Restore cycle</span>
</span>
</CustomMenu.MenuItem>
)}
{!isCompleted && (
<CustomMenu.MenuItem
onClick={() => {
setTrackElement("CYCLE_PAGE_SIDEBAR");
setCycleDeleteModal(true);
}}
>
<span className="flex items-center justify-start gap-2">
<Trash2 className="h-3 w-3" />
<span>Delete cycle</span>
</span>
</CustomMenu.MenuItem>
)}
</CustomMenu>
)}
</div>
</div>
<div className="flex flex-col gap-2 w-full">
<div className="flex items-start justify-between gap-3 pt-2">
<h4 className="w-full break-words text-xl font-semibold text-custom-text-100">{cycleDetails.name}</h4>
{currentCycle && (
<span
className="flex h-6 min-w-20 px-3 items-center justify-center rounded text-center text-xs font-medium"
style={{
color: currentCycle.color,
backgroundColor: `${currentCycle.color}20`,
}}
>
{currentCycle.title}
</span>
)}
</div>
<Controller
control={control}
name="start_date"
render={({ field: { value: startDateValue, onChange: onChangeStartDate } }) => (
<Controller
control={control}
name="end_date"
render={({ field: { value: endDateValue, onChange: onChangeEndDate } }) => (
<DateRangeDropdown
className="h-7"
buttonVariant="transparent-with-text"
minDate={new Date()}
value={{
from: getDate(startDateValue),
to: getDate(endDateValue),
}}
onSelect={(val) => {
onChangeStartDate(val?.from ? renderFormattedPayloadDate(val.from) : null);
onChangeEndDate(val?.to ? renderFormattedPayloadDate(val.to) : null);
handleDateChange(val?.from, val?.to);
}}
placeholder={{
from: "Start date",
to: "End date",
}}
required={cycleDetails.status !== "draft"}
disabled={!isEditingAllowed || isArchived || isCompleted}
/>
)}
/>
)}
/>
</div>
</>
);
});

View File

@@ -41,17 +41,13 @@ export const CyclePeekOverview: React.FC<Props> = observer(({ projectId, workspa
{peekCycle && (
<div
ref={ref}
className="flex h-full w-full max-w-[24rem] flex-shrink-0 flex-col gap-3.5 overflow-y-auto border-l border-custom-border-100 bg-custom-sidebar-background-100 px-6 duration-300 fixed md:relative right-0 z-[9]"
className="flex h-full w-full max-w-[21.5rem] flex-shrink-0 flex-col gap-3.5 overflow-y-auto border-l border-custom-border-100 bg-custom-sidebar-background-100 px-4 duration-300 fixed md:relative right-0 z-[9]"
style={{
boxShadow:
"0px 1px 4px 0px rgba(0, 0, 0, 0.06), 0px 2px 4px 0px rgba(16, 24, 40, 0.06), 0px 1px 8px -1px rgba(16, 24, 40, 0.06)",
}}
>
<CycleDetailsSidebar
cycleId={peekCycle?.toString() ?? ""}
handleClose={handleClose}
isArchived={isArchived}
/>
<CycleDetailsSidebar handleClose={handleClose} isArchived={isArchived} cycleId={peekCycle} />
</div>
)}
</>

View File

@@ -2,8 +2,9 @@
import React, { FC, MouseEvent, useEffect } from "react";
import { observer } from "mobx-react";
import { usePathname, useSearchParams } from "next/navigation";
import { Controller, useForm } from "react-hook-form";
import { Users } from "lucide-react";
import { Eye, Users } from "lucide-react";
// types
import { ICycle, TCycleGroups } from "@plane/types";
// ui
@@ -18,7 +19,9 @@ import { CYCLE_FAVORITED, CYCLE_UNFAVORITED } from "@/constants/event-tracker";
// helpers
import { findHowManyDaysLeft, getDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper";
// hooks
import { generateQueryParams } from "@/helpers/router.helper";
import { useCycle, useEventTracker, useMember, useUserPermissions } from "@/hooks/store";
import { useAppRouter } from "@/hooks/use-app-router";
import { usePlatformOS } from "@/hooks/use-platform-os";
import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions";
import { CycleService } from "@/services/cycle.service";
@@ -31,6 +34,7 @@ type Props = {
cycleId: string;
cycleDetails: ICycle;
parentRef: React.RefObject<HTMLDivElement>;
isActive?: boolean;
};
const defaultValues: Partial<ICycle> = {
@@ -39,9 +43,13 @@ const defaultValues: Partial<ICycle> = {
};
export const CycleListItemAction: FC<Props> = observer((props) => {
const { workspaceSlug, projectId, cycleId, cycleDetails, parentRef } = props;
const { workspaceSlug, projectId, cycleId, cycleDetails, parentRef, isActive = false } = props;
// hooks
const { isMobile } = usePlatformOS();
// router
const router = useAppRouter();
const searchParams = useSearchParams();
const pathname = usePathname();
// store hooks
const { addCycleToFavorites, removeCycleFromFavorites, updateCycleDetails } = useCycle();
const { captureEvent } = useEventTracker();
@@ -183,77 +191,90 @@ export const CycleListItemAction: FC<Props> = observer((props) => {
const isCompleted = cycleStatus === "completed";
const isDisabled = !isEditingAllowed || isArchived || isCompleted;
// handlers
const openCycleOverview = (e: MouseEvent<HTMLButtonElement | HTMLAnchorElement>) => {
e.preventDefault();
e.stopPropagation();
const query = generateQueryParams(searchParams, ["peekCycle"]);
if (searchParams.has("peekCycle") && searchParams.get("peekCycle") === cycleId) {
router.push(`${pathname}?${query}`);
} else {
router.push(`${pathname}?${query && `${query}&`}peekCycle=${cycleId}`);
}
};
return (
<>
<Controller
control={control}
name="start_date"
render={({ field: { value: startDateValue, onChange: onChangeStartDate } }) => (
<Controller
control={control}
name="end_date"
render={({ field: { value: endDateValue, onChange: onChangeEndDate } }) => (
<DateRangeDropdown
buttonContainerClassName={`h-6 w-full flex ${isDisabled ? "cursor-not-allowed" : "cursor-pointer"} items-center gap-1.5 text-custom-text-300 border-[0.5px] border-custom-border-300 rounded text-xs`}
buttonVariant="transparent-with-text"
minDate={new Date()}
value={{
from: getDate(startDateValue),
to: getDate(endDateValue),
}}
onSelect={(val) => {
onChangeStartDate(val?.from ? renderFormattedPayloadDate(val.from) : null);
onChangeEndDate(val?.to ? renderFormattedPayloadDate(val.to) : null);
handleDateChange(val?.from, val?.to);
}}
placeholder={{
from: "Start date",
to: "End date",
}}
required={cycleDetails.status !== "draft"}
disabled={isDisabled}
hideIcon={{ from: renderIcon ?? true, to: renderIcon }}
/>
)}
/>
)}
/>
{currentCycle && (
<div
className="relative flex h-6 w-20 flex-shrink-0 items-center justify-center rounded-sm text-center text-xs"
style={{
color: currentCycle.color,
backgroundColor: `${currentCycle.color}20`,
}}
>
{currentCycle.value === "current"
? `${daysLeft} ${daysLeft > 1 ? "days" : "day"} left`
: `${currentCycle.label}`}
</div>
<button
onClick={openCycleOverview}
className={`z-[1] flex text-custom-primary-200 text-xs gap-1 flex-shrink-0 ${isMobile || isActive ? "flex" : "hidden group-hover:flex"}`}
>
<Eye className="h-4 w-4 my-auto text-custom-primary-200" />
<span>More details</span>
</button>
{!isActive && (
<Controller
control={control}
name="start_date"
render={({ field: { value: startDateValue, onChange: onChangeStartDate } }) => (
<Controller
control={control}
name="end_date"
render={({ field: { value: endDateValue, onChange: onChangeEndDate } }) => (
<DateRangeDropdown
buttonContainerClassName={`h-6 w-full flex ${isDisabled ? "cursor-not-allowed" : "cursor-pointer"} items-center gap-1.5 text-custom-text-300 border-[0.5px] border-custom-border-300 rounded text-xs`}
buttonVariant="transparent-with-text"
minDate={new Date()}
value={{
from: getDate(startDateValue),
to: getDate(endDateValue),
}}
onSelect={(val) => {
onChangeStartDate(val?.from ? renderFormattedPayloadDate(val.from) : null);
onChangeEndDate(val?.to ? renderFormattedPayloadDate(val.to) : null);
handleDateChange(val?.from, val?.to);
}}
placeholder={{
from: "Start date",
to: "End date",
}}
required={cycleDetails.status !== "draft"}
disabled={isDisabled}
hideIcon={{ from: renderIcon ?? true, to: renderIcon }}
/>
)}
/>
)}
/>
)}
{/* created by */}
{createdByDetails && <ButtonAvatars showTooltip={false} userIds={createdByDetails?.id} />}
{createdByDetails && !isActive && <ButtonAvatars showTooltip={false} userIds={createdByDetails?.id} />}
<Tooltip tooltipContent={`${cycleDetails.assignee_ids?.length} Members`} isMobile={isMobile}>
<div className="flex w-10 cursor-default items-center justify-center">
{cycleDetails.assignee_ids && cycleDetails.assignee_ids?.length > 0 ? (
<AvatarGroup showTooltip={false}>
{cycleDetails.assignee_ids?.map((assignee_id) => {
const member = getUserDetails(assignee_id);
return <Avatar key={member?.id} name={member?.display_name} src={member?.avatar} />;
})}
</AvatarGroup>
) : (
<Users className="h-4 w-4 text-custom-text-300" />
)}
</div>
</Tooltip>
{!isActive && (
<Tooltip tooltipContent={`${cycleDetails.assignee_ids?.length} Members`} isMobile={isMobile}>
<div className="flex w-10 cursor-default items-center justify-center">
{cycleDetails.assignee_ids && cycleDetails.assignee_ids?.length > 0 ? (
<AvatarGroup showTooltip={false}>
{cycleDetails.assignee_ids?.map((assignee_id) => {
const member = getUserDetails(assignee_id);
return <Avatar key={member?.id} name={member?.display_name} src={member?.avatar} />;
})}
</AvatarGroup>
) : (
<Users className="h-4 w-4 text-custom-text-300" />
)}
</div>
</Tooltip>
)}
{isEditingAllowed && !cycleDetails.archived_at && (
<FavoriteStar
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (cycleDetails.is_favorite) handleRemoveFromFavorites(e);
else handleAddToFavorites(e);
}}

View File

@@ -106,14 +106,6 @@ export const CyclesListItem: FC<TCyclesListItem> = observer((props) => {
)}
</CircularProgressIndicator>
}
appendTitleElement={
<button
onClick={openCycleOverview}
className={`z-[1] flex-shrink-0 ${isMobile ? "flex" : "hidden group-hover:flex"}`}
>
<Info className="h-4 w-4 text-custom-text-400" />
</button>
}
actionableItems={
<CycleListItemAction
workspaceSlug={workspaceSlug}

View File

@@ -1,10 +1,11 @@
import { FC } from "react";
import React, { FC } from "react";
import { observer } from "mobx-react";
import { Disclosure } from "@headlessui/react";
// components
import { ContentWrapper, ERowVariant } from "@plane/ui";
import { ListLayout } from "@/components/core/list";
import { ActiveCycleRoot, CycleListGroupHeader, CyclePeekOverview, CyclesListMap } from "@/components/cycles";
import { CycleListGroupHeader, CyclePeekOverview, CyclesListMap } from "@/components/cycles";
import { ActiveCycleRoot } from "@/plane-web/components/cycles";
export interface ICyclesList {
completedCycleIds: string[];
@@ -27,7 +28,7 @@ export const CyclesList: FC<ICyclesList> = observer((props) => {
</>
) : (
<>
<ActiveCycleRoot workspaceSlug={workspaceSlug.toString()} projectId={projectId.toString()} />
<ActiveCycleRoot workspaceSlug={workspaceSlug} projectId={projectId} />
{upcomingCycleIds && (
<Disclosure as="div" className="flex flex-shrink-0 flex-col" defaultOpen>
@@ -49,7 +50,6 @@ export const CyclesList: FC<ICyclesList> = observer((props) => {
)}
</Disclosure>
)}
<Disclosure as="div" className="flex flex-shrink-0 flex-col pb-7">
{({ open }) => (
<>

View File

@@ -43,6 +43,18 @@ export class CycleService extends APIService {
});
}
async workspaceActiveCyclesProgressPro(
workspaceSlug: string,
projectId: string,
cycleId: string
): Promise<TProgressSnapshot> {
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/cycles/${cycleId}/cycle-progress/`)
.then((res) => res?.data)
.catch((err) => {
throw err?.response?.data;
});
}
async workspaceActiveCycles(
workspaceSlug: string,
cursor: string,

View File

@@ -11,9 +11,11 @@ import {
TProgressSnapshot,
TCycleEstimateDistribution,
TCycleDistribution,
TCycleEstimateType,
TCycleProgress,
} from "@plane/types";
// helpers
import { orderCycles, shouldFilterCycle } from "@/helpers/cycle.helper";
import { orderCycles, shouldFilterCycle, formatActiveCycle } from "@/helpers/cycle.helper";
import { getDate } from "@/helpers/date-time.helper";
import { DistributionUpdates, updateDistribution } from "@/helpers/distribution-update.helper";
// services
@@ -27,11 +29,14 @@ import { CoreRootStore } from "./root.store";
export interface ICycleStore {
// loaders
loader: boolean;
progressLoader: boolean;
// observables
fetchedMap: Record<string, boolean>;
cycleMap: Record<string, ICycle>;
plotType: Record<string, TCyclePlotType>;
estimatedType: Record<string, TCycleEstimateType>;
activeCycleIdMap: Record<string, boolean>;
// computed
currentProjectCycleIds: string[] | null;
currentProjectCompletedCycleIds: string[] | null;
@@ -43,6 +48,7 @@ export interface ICycleStore {
currentProjectActiveCycle: ICycle | null;
// computed actions
getActiveCycleProgress: (cycleId?: string) => { cycle: ICycle; isBurnDown: boolean; isTypeIssue: boolean } | null;
getFilteredCycleIds: (projectId: string, sortByManual: boolean) => string[] | null;
getFilteredCompletedCycleIds: (projectId: string) => string[] | null;
getFilteredArchivedCycleIds: (projectId: string) => string[] | null;
@@ -51,10 +57,12 @@ export interface ICycleStore {
getActiveCycleById: (cycleId: string) => ICycle | null;
getProjectCycleIds: (projectId: string) => string[] | null;
getPlotTypeByCycleId: (cycleId: string) => TCyclePlotType;
getEstimateTypeByCycleId: (cycleId: string) => TCycleEstimateType;
// actions
updateCycleDistribution: (distributionUpdates: DistributionUpdates, cycleId: string) => void;
validateDate: (workspaceSlug: string, projectId: string, payload: CycleDateCheckData) => Promise<any>;
setPlotType: (cycleId: string, plotType: TCyclePlotType) => void;
setEstimateType: (cycleId: string, estimateType: TCycleEstimateType) => void;
// fetch
fetchWorkspaceCycles: (workspaceSlug: string) => Promise<ICycle[]>;
fetchAllCycles: (workspaceSlug: string, projectId: string) => Promise<undefined | ICycle[]>;
@@ -63,6 +71,11 @@ export interface ICycleStore {
fetchArchivedCycleDetails: (workspaceSlug: string, projectId: string, cycleId: string) => Promise<ICycle>;
fetchCycleDetails: (workspaceSlug: string, projectId: string, cycleId: string) => Promise<ICycle>;
fetchActiveCycleProgress: (workspaceSlug: string, projectId: string, cycleId: string) => Promise<TProgressSnapshot>;
fetchActiveCycleProgressPro: (
workspaceSlug: string,
projectId: string,
cycleId: string
) => Promise<TProgressSnapshot> | Promise<null>;
fetchActiveCycleAnalytics: (
workspaceSlug: string,
projectId: string,
@@ -89,8 +102,10 @@ export interface ICycleStore {
export class CycleStore implements ICycleStore {
// observables
loader: boolean = false;
progressLoader: boolean = false;
cycleMap: Record<string, ICycle> = {};
plotType: Record<string, TCyclePlotType> = {};
estimatedType: Record<string, TCycleEstimateType> = {};
activeCycleIdMap: Record<string, boolean> = {};
//loaders
fetchedMap: Record<string, boolean> = {};
@@ -106,8 +121,10 @@ export class CycleStore implements ICycleStore {
makeObservable(this, {
// observables
loader: observable.ref,
progressLoader: observable,
cycleMap: observable,
plotType: observable,
estimatedType: observable,
activeCycleIdMap: observable,
fetchedMap: observable,
// computed
@@ -122,12 +139,14 @@ export class CycleStore implements ICycleStore {
// actions
setPlotType: action,
setEstimateType: action,
fetchWorkspaceCycles: action,
fetchAllCycles: action,
fetchActiveCycle: action,
fetchArchivedCycles: action,
fetchArchivedCycleDetails: action,
fetchActiveCycleProgress: action,
fetchActiveCycleProgressPro: action,
fetchActiveCycleAnalytics: action,
fetchCycleDetails: action,
createCycle: action,
@@ -258,6 +277,19 @@ export class CycleStore implements ICycleStore {
return this.cycleMap?.[this.currentProjectActiveCycleId!] ?? null;
}
/**
* returns active cycle progress for a project
*/
getActiveCycleProgress = computedFn((cycleId?: string) => {
const cycle = cycleId ? this.cycleMap[cycleId] : this.currentProjectActiveCycle;
if (!cycle?.progress) return null;
const isTypeIssue = this.getEstimateTypeByCycleId(cycle.id) === "issues";
const isBurnDown = this.getPlotTypeByCycleId(cycle.id) === "burndown";
return { cycle, isTypeIssue, isBurnDown };
});
/**
* @description returns filtered cycle ids based on display filters and filters
* @param {TCycleDisplayFilters} displayFilters
@@ -374,13 +406,19 @@ export class CycleStore implements ICycleStore {
* @description gets the plot type for the module store
* @param {TCyclePlotType} plotType
*/
getPlotTypeByCycleId = (cycleId: string) => {
getPlotTypeByCycleId = computedFn((cycleId: string) => this.plotType[cycleId] || "burndown");
/**
* @description gets the estimate type for the module store
* @param {TCycleEstimateType} estimateType
*/
getEstimateTypeByCycleId = computedFn((cycleId: string) => {
const { projectId } = this.rootStore.router;
return projectId && this.rootStore.projectEstimate.areEstimateEnabledByProjectId(projectId)
? this.plotType[cycleId] || "burndown"
: "burndown";
};
? this.estimatedType[cycleId] || "issues"
: "issues";
});
/**
* @description updates the plot type for the module store
@@ -390,6 +428,14 @@ export class CycleStore implements ICycleStore {
set(this.plotType, [cycleId], plotType);
};
/**
* @description updates the estimate type for the module store
* @param {TCycleEstimateType} estimateType
*/
setEstimateType = (cycleId: string, estimateType: TCycleEstimateType) => {
set(this.estimatedType, [cycleId], estimateType);
};
/**
* @description fetch all cycles
* @param workspaceSlug
@@ -481,13 +527,25 @@ export class CycleStore implements ICycleStore {
* @param cycleId
* @returns
*/
fetchActiveCycleProgress = async (workspaceSlug: string, projectId: string, cycleId: string) =>
await this.cycleService.workspaceActiveCyclesProgress(workspaceSlug, projectId, cycleId).then((progress) => {
fetchActiveCycleProgress = async (workspaceSlug: string, projectId: string, cycleId: string) => {
this.progressLoader = true;
return await this.cycleService.workspaceActiveCyclesProgress(workspaceSlug, projectId, cycleId).then((progress) => {
runInAction(() => {
set(this.cycleMap, [cycleId], { ...this.cycleMap[cycleId], ...progress });
this.progressLoader = false;
});
return progress;
});
};
/**
* @description fetches active cycle progress for pro users
* @param workspaceSlug
* @param projectId
* @param cycleId
* @returns
*/
fetchActiveCycleProgressPro = async (workspaceSlug: string, projectId: string, cycleId: string) => null;
/**
* @description fetches active cycle analytics

View File

@@ -139,6 +139,10 @@ export class CycleIssues extends BaseIssuesStore implements ICycleIssues {
const cycleId = id ?? this.cycleId;
projectId && cycleId && this.rootIssueStore.rootStore.cycle.fetchCycleDetails(workspaceSlug, projectId, cycleId);
// fetch cycle progress
projectId &&
cycleId &&
this.rootIssueStore.rootStore.cycle.fetchActiveCycleProgressPro(workspaceSlug, projectId, cycleId);
};
updateParentStats = (prevIssueState?: TIssue, nextIssueState?: TIssue, id?: string | undefined) => {

View File

@@ -1,9 +1,23 @@
import { isEmpty, orderBy, uniqBy } from "lodash";
import sortBy from "lodash/sortBy";
import { ICycle, TCycleFilters } from "@plane/types";
// helpers
import { getDate } from "@/helpers/date-time.helper";
import { generateDateArray, getDate, getToday } from "@/helpers/date-time.helper";
import { satisfiesDateFilter } from "@/helpers/filter.helper";
export type TProgressChartData = {
date: string;
scope: number;
completed: number;
backlog: number;
started: number;
unstarted: number;
cancelled: number;
pending: number;
ideal: number;
actual: number;
}[];
/**
* @description orders cycles based on their status
* @param {ICycle[]} cycles
@@ -60,3 +74,48 @@ export const shouldFilterCycle = (cycle: ICycle, filter: TCycleFilters): boolean
return fallsInFilters;
};
export const formatActiveCycle = (args: {
cycle: ICycle;
isBurnDown?: boolean | undefined;
isTypeIssue?: boolean | undefined;
}) => {
const { cycle, isBurnDown, isTypeIssue } = args;
let today = getToday();
const endDate: Date | string = new Date(cycle.end_date!);
const extendedArray = endDate > today ? generateDateArray(today as Date, endDate) : [];
if (isEmpty(cycle.progress)) return extendedArray;
today = getToday(true);
const scope = (p: any) => (isTypeIssue ? p.total_issues : p.total_estimate_points);
const ideal = (p: any) =>
isTypeIssue
? Math.abs(p.total_issues - p.completed_issues + (Math.random() < 0.5 ? -1 : 1))
: Math.abs(p.total_estimate_points - p.completed_estimate_points + (Math.random() < 0.5 ? -1 : 1));
const scopeToday = scope(cycle?.progress[cycle?.progress.length - 1]);
const idealToday = ideal(cycle?.progress[cycle?.progress.length - 1]);
const progress = [...orderBy(cycle?.progress, "date"), ...extendedArray].map((p) => {
const pending = isTypeIssue
? p.total_issues - p.completed_issues - p.cancelled_issues
: p.total_estimate_points - p.completed_estimate_points - p.cancelled_estimate_points;
const completed = isTypeIssue ? p.completed_issues : p.completed_estimate_points;
return {
date: p.date,
scope: p.date! < today ? scope(p) : p.date! < cycle.end_date! ? scopeToday : null,
completed,
backlog: isTypeIssue ? p.backlog_issues : p.backlog_estimate_points,
started: isTypeIssue ? p.started_issues : p.started_estimate_points,
unstarted: isTypeIssue ? p.unstarted_issues : p.unstarted_estimate_points,
cancelled: isTypeIssue ? p.cancelled_issues : p.cancelled_estimate_points,
pending: Math.abs(pending),
// TODO: This is a temporary logic to show the ideal line in the cycle chart
ideal: p.date! < today ? ideal(p) : p.date! < cycle.end_date! ? idealToday : null,
actual: p.date! <= today ? (isBurnDown ? Math.abs(pending) : completed) : undefined,
};
});
return uniqBy(progress, "date");
};

View File

@@ -357,3 +357,76 @@ export const getReadTimeFromWordsCount = (wordsCount: number): number => {
const minutes = wordsCount / wordsPerMinute;
return minutes * 60;
};
/**
* @description calculates today's date
* @param {boolean} format
* @returns {Date | string} today's date
* @example getToday() // Output: 2024-09-29T00:00:00.000Z
* @example getToday(true) // Output: 2024-09-29
*/
export const getToday = (format: boolean = false) => {
const today = new Date();
today.setHours(0, 0, 0, 0);
if (!format) return today;
const year = today.getFullYear();
const month = String(today.getMonth() + 1).padStart(2, "0"); // Months are 0-based, so add 1
const day = String(today.getDate()).padStart(2, "0"); // Add leading zero for single digits
return `${year}-${month}-${day}`;
};
/**
* @description calculates the date of the day before today
* @param {boolean} format
* @returns {Date | string} date of the day before today
* @example dateFormatter() // Output: "Sept 20, 2024"
*/
export const dateFormatter = (dateString: string) => {
// Convert to Date object
const date = new Date(dateString);
// Options for the desired format (Month Day, Year)
const options: Intl.DateTimeFormatOptions = { year: "numeric", month: "short", day: "numeric" };
// Format the date
const formattedDate = date.toLocaleDateString("en-US", options);
return formattedDate;
};
/**
* @description calculates days left from today to the end date
* @returns {Date | string} number of days left
*/
export const daysLeft = (end_date: string) =>
end_date ? Math.ceil((new Date(end_date).getTime() - new Date().getTime()) / (1000 * 3600 * 24)) : 0;
/**
* @description generates an array of dates between the start and end dates
* @param startDate
* @param endDate
* @returns
*/
export const generateDateArray = (startDate: Date, endDate: Date) => {
// Convert the start and end dates to Date objects if they aren't already
const start = new Date(startDate);
// start.setDate(start.getDate() + 1);
const end = new Date(endDate);
end.setDate(end.getDate() + 1);
// Create an empty array to store the dates
const dateArray = [];
// Use a while loop to generate dates between the range
while (start <= end) {
// Increment the date by 1 day (86400000 milliseconds)
start.setDate(start.getDate() + 1);
// Push the current date (converted to ISO string for consistency)
dateArray.push({
date: new Date(start).toISOString().split("T")[0],
});
}
return dateArray;
};