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:
parent
632282d0df
commit
4940dc2193
31 changed files with 1149 additions and 632 deletions
|
|
@ -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>
|
||||
|
|
|
|||
1
web/ce/components/cycles/active-cycle/index.ts
Normal file
1
web/ce/components/cycles/active-cycle/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "./root";
|
||||
89
web/ce/components/cycles/active-cycle/root.tsx
Normal file
89
web/ce/components/cycles/active-cycle/root.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
1
web/ce/components/cycles/analytics-sidebar/index.ts
Normal file
1
web/ce/components/cycles/analytics-sidebar/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "./sidebar-chart";
|
||||
57
web/ce/components/cycles/analytics-sidebar/sidebar-chart.tsx
Normal file
57
web/ce/components/cycles/analytics-sidebar/sidebar-chart.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
2
web/ce/components/cycles/index.ts
Normal file
2
web/ce/components/cycles/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from "./active-cycle";
|
||||
export * from "./analytics-sidebar";
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
export * from "./root";
|
||||
export * from "./header";
|
||||
export * from "./stats";
|
||||
export * from "./upcoming-cycles-list-item";
|
||||
|
|
|
|||
|
|
@ -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 ?? ""}
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
export * from "./root";
|
||||
export * from "./issue-progress";
|
||||
export * from "./progress-stats";
|
||||
export * from "./sidebar-header";
|
||||
export * from "./sidebar-details";
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
138
web/core/components/cycles/analytics-sidebar/sidebar-details.tsx
Normal file
138
web/core/components/cycles/analytics-sidebar/sidebar-details.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
326
web/core/components/cycles/analytics-sidebar/sidebar-header.tsx
Normal file
326
web/core/components/cycles/analytics-sidebar/sidebar-header.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 }) => (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue