mirror of
https://github.com/makeplane/plane.git
synced 2026-02-24 20:20:49 +01:00
fix: active cycles pagination issue fixes (#97)
* fix: breaking down active cycles info component into multiple components * fix: pagination fixes * fix: minor bugfixes and edge handling
This commit is contained in:
committed by
GitHub
parent
74069425ea
commit
910ac659fa
@@ -1,21 +1,14 @@
|
||||
import { FC, useCallback } from "react";
|
||||
import Link from "next/link";
|
||||
// hooks
|
||||
import useLocalStorage from "hooks/use-local-storage";
|
||||
// ui
|
||||
import { Tooltip, LinearProgressIndicator, Loader, PriorityIcon, Button, CycleGroupIcon } from "@plane/ui";
|
||||
import { CalendarCheck } from "lucide-react";
|
||||
import { FC } from "react";
|
||||
// components
|
||||
import ProgressChart from "components/core/sidebar/progress-chart";
|
||||
import { StateDropdown } from "components/dropdowns";
|
||||
import {
|
||||
ActiveCyclesProjectTitle,
|
||||
ActiveCycleHeader,
|
||||
ActiveCycleProgress,
|
||||
ActiveCycleProductivity,
|
||||
ActiveCyclePriorityIssues,
|
||||
} from "components/cycles/active-cycles";
|
||||
// types
|
||||
import { ICycle, TCycleGroups, TCycleLayout, TCycleView } from "@plane/types";
|
||||
// helpers
|
||||
import { renderFormattedDate, findHowManyDaysLeft, renderFormattedDateWithoutYear } from "helpers/date-time.helper";
|
||||
import { truncateText } from "helpers/string.helper";
|
||||
import { renderEmoji } from "helpers/emoji.helper";
|
||||
// constants
|
||||
import { WORKSPACE_ACTIVE_CYCLE_STATE_GROUPS_DETAILS } from "constants/cycle";
|
||||
import { ICycle } from "@plane/types";
|
||||
|
||||
export type ActiveCycleInfoProps = {
|
||||
cycle: ICycle;
|
||||
@@ -25,234 +18,16 @@ export type ActiveCycleInfoProps = {
|
||||
|
||||
export const ActiveCycleInfo: FC<ActiveCycleInfoProps> = (props) => {
|
||||
const { cycle, workspaceSlug, projectId } = props;
|
||||
// local storage
|
||||
const { setValue: setCycleTab } = useLocalStorage<TCycleView>("cycle_tab", "active");
|
||||
const { setValue: setCycleLayout } = useLocalStorage<TCycleLayout>("cycle_layout", "list");
|
||||
|
||||
const cycleIssues = cycle.issues ?? [];
|
||||
|
||||
const handleCurrentLayout = useCallback(
|
||||
(_layout: TCycleLayout) => {
|
||||
setCycleLayout(_layout);
|
||||
},
|
||||
[setCycleLayout]
|
||||
);
|
||||
|
||||
const handleCurrentView = useCallback(
|
||||
(_view: TCycleView) => {
|
||||
setCycleTab(_view);
|
||||
if (_view === "draft") handleCurrentLayout("list");
|
||||
},
|
||||
[handleCurrentLayout, setCycleTab]
|
||||
);
|
||||
|
||||
const groupedIssues: any = {
|
||||
completed: cycle.completed_issues,
|
||||
started: cycle.started_issues,
|
||||
unstarted: cycle.unstarted_issues,
|
||||
backlog: cycle.backlog_issues,
|
||||
};
|
||||
|
||||
const progressIndicatorData = WORKSPACE_ACTIVE_CYCLE_STATE_GROUPS_DETAILS.map((group, index) => ({
|
||||
id: index,
|
||||
name: group.title,
|
||||
value: cycle.total_issues > 0 ? (cycle[group.key as keyof ICycle] as number) : 0,
|
||||
color: group.color,
|
||||
}));
|
||||
|
||||
const cuurentCycle = cycle.status.toLowerCase() as TCycleGroups;
|
||||
|
||||
const daysLeft = findHowManyDaysLeft(cycle.end_date ?? new Date());
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-1.5 px-3 py-1.5">
|
||||
{cycle.project_detail.emoji ? (
|
||||
<span className="grid h-7 w-7 flex-shrink-0 place-items-center rounded uppercase">
|
||||
{renderEmoji(cycle.project_detail.emoji)}
|
||||
</span>
|
||||
) : cycle.project_detail.icon_prop ? (
|
||||
<div className="grid h-7 w-7 flex-shrink-0 place-items-center">
|
||||
{renderEmoji(cycle.project_detail.icon_prop)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="grid h-7 w-7 flex-shrink-0 place-items-center rounded bg-gray-700 uppercase text-white">
|
||||
{cycle.project_detail?.name.charAt(0)}
|
||||
</span>
|
||||
)}
|
||||
<h2 className="text-xl font-semibold">{cycle.project_detail.name}</h2>
|
||||
</div>
|
||||
<ActiveCyclesProjectTitle project={cycle.project_detail} />
|
||||
<div className="flex flex-col gap-2 rounded border border-custom-border-200">
|
||||
<div className="flex items-center justify-between px-3 pt-3 pb-1">
|
||||
<div className="flex items-center gap-2 cursor-default">
|
||||
<CycleGroupIcon cycleGroup={cuurentCycle} className="h-4 w-4" />
|
||||
<Tooltip tooltipContent={cycle.name} position="top-left">
|
||||
<h3 className="break-words text-lg font-medium">{truncateText(cycle.name, 70)}</h3>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
tooltipContent={`Start date: ${renderFormattedDate(
|
||||
cycle.start_date ?? ""
|
||||
)} Due Date: ${renderFormattedDate(cycle.end_date ?? "")}`}
|
||||
position="top-left"
|
||||
>
|
||||
<span className="flex gap-1 whitespace-nowrap rounded-sm text-sm px-3 py-0.5 bg-amber-500/10 text-amber-500">
|
||||
{`${daysLeft} ${daysLeft > 1 ? "days" : "day"} left`}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="rounded-sm text-sm px-3 py-1 bg-custom-background-80">
|
||||
<span className="flex gap-2 text-sm whitespace-nowrap font-medium">
|
||||
<span>Lead:</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{cycle.owned_by.avatar && cycle.owned_by.avatar !== "" ? (
|
||||
<img
|
||||
src={cycle.owned_by.avatar}
|
||||
height={18}
|
||||
width={18}
|
||||
className="rounded-full"
|
||||
alt={cycle.owned_by.display_name}
|
||||
/>
|
||||
) : (
|
||||
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-custom-background-100 capitalize">
|
||||
{cycle.owned_by.display_name.charAt(0)}
|
||||
</span>
|
||||
)}
|
||||
<span>{cycle.owned_by.display_name}</span>
|
||||
</div>
|
||||
</span>
|
||||
</span>
|
||||
<Link href={`/${workspaceSlug}/projects/${projectId}/cycles`}>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
handleCurrentView("active");
|
||||
}}
|
||||
>
|
||||
View Cycle
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<ActiveCycleHeader cycle={cycle} workspaceSlug={workspaceSlug} projectId={projectId} />
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3">
|
||||
<div className="flex flex-col gap-4 px-3 pt-2 min-h-52 border-r-0 border-t border-custom-border-300 lg:border-r">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<h3 className="text-xl font-medium">Progress</h3>
|
||||
<span className="flex gap-1 text-sm whitespace-nowrap rounded-sm px-3 py-1 ">
|
||||
{`${cycle.completed_issues + cycle.cancelled_issues}/${cycle.total_issues - cycle.cancelled_issues} ${
|
||||
cycle.completed_issues + cycle.cancelled_issues > 1 ? "Issues" : "Issue"
|
||||
} closed`}
|
||||
</span>
|
||||
</div>
|
||||
<LinearProgressIndicator data={progressIndicatorData} />
|
||||
<div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{Object.keys(groupedIssues).map((group, index) => (
|
||||
<>
|
||||
{groupedIssues[group] > 0 && (
|
||||
<div className="flex items-center justify-start gap-2 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="block h-3 w-3 rounded-full"
|
||||
style={{
|
||||
backgroundColor: WORKSPACE_ACTIVE_CYCLE_STATE_GROUPS_DETAILS[index].color,
|
||||
}}
|
||||
/>
|
||||
<span className="capitalize font-medium w-16">{group}</span>
|
||||
</div>
|
||||
<span>{`: ${groupedIssues[group]} ${groupedIssues[group] > 1 ? "Issues" : "Issue"}`}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
))}
|
||||
{cycle.cancelled_issues > 0 && (
|
||||
<span className="flex items-center gap-2 text-sm text-custom-text-300">
|
||||
<span>
|
||||
{`${cycle.cancelled_issues} cancelled ${
|
||||
cycle.cancelled_issues > 1 ? "issues are" : "issue is"
|
||||
} excluded from this report.`}{" "}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 px-3 pt-2 min-h-52 border-r-0 border-t border-custom-border-300 lg:border-r">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<h3 className="text-xl font-medium">Issue Burndown</h3>
|
||||
</div>
|
||||
|
||||
<div className="relative ">
|
||||
<ProgressChart
|
||||
distribution={cycle.distribution?.completion_chart ?? {}}
|
||||
startDate={cycle.start_date ?? ""}
|
||||
endDate={cycle.end_date ?? ""}
|
||||
totalIssues={cycle.total_issues}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 px-3 pt-2 min-h-52 overflow-hidden col-span-1 lg:col-span-2 xl:col-span-1 border-t border-custom-border-300">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<h3 className="text-xl font-medium">Priority</h3>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 h-full w-full max-h-40 overflow-y-auto pb-3">
|
||||
{cycleIssues ? (
|
||||
cycleIssues.length > 0 ? (
|
||||
cycleIssues.map((issue: any) => (
|
||||
<Link
|
||||
key={issue.id}
|
||||
href={`/${workspaceSlug}/projects/${projectId}/issues/${issue.id}`}
|
||||
className="flex cursor-pointer items-center justify-between gap-2 rounded-md border border-custom-border-200 px-3 py-1.5"
|
||||
>
|
||||
<div className="flex items-center gap-1.5 flex-grow w-full truncate">
|
||||
<PriorityIcon priority={issue.priority} withContainer size={12} />
|
||||
<Tooltip
|
||||
tooltipHeading="Issue ID"
|
||||
tooltipContent={`${cycle.project_detail?.identifier}-${issue.sequence_id}`}
|
||||
>
|
||||
<span className="flex-shrink-0 text-xs text-custom-text-200">
|
||||
{cycle.project_detail?.identifier}-{issue.sequence_id}
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip position="top-left" tooltipHeading="Title" tooltipContent={issue.name}>
|
||||
<span className="text-[0.825rem] text-custom-text-100 truncate">{issue.name}</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||
<StateDropdown
|
||||
value={issue.state_id ?? undefined}
|
||||
onChange={() => {}}
|
||||
projectId={projectId?.toString() ?? ""}
|
||||
disabled={true}
|
||||
buttonVariant="background-with-text"
|
||||
/>
|
||||
{issue.target_date && (
|
||||
<Tooltip tooltipHeading="Target Date" tooltipContent={renderFormattedDate(issue.target_date)}>
|
||||
<div className="h-full flex items-center gap-1.5 rounded text-xs px-2 py-0.5 bg-custom-background-80 cursor-not-allowed">
|
||||
<CalendarCheck className="h-3 w-3 flex-shrink-0" />
|
||||
<span className="text-xs">{renderFormattedDateWithoutYear(issue.target_date)}</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
))
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-sm text-custom-text-200">
|
||||
<span>There are no high priority issues present in this cycle.</span>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<Loader className="space-y-3">
|
||||
<Loader.Item height="50px" />
|
||||
<Loader.Item height="50px" />
|
||||
<Loader.Item height="50px" />
|
||||
</Loader>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ActiveCycleProgress cycle={cycle} />
|
||||
<ActiveCycleProductivity cycle={cycle} />
|
||||
<ActiveCyclePriorityIssues cycle={cycle} workspaceSlug={workspaceSlug} projectId={projectId} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
35
web/components/cycles/active-cycles/card.tsx
Normal file
35
web/components/cycles/active-cycles/card.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { FC } from "react";
|
||||
// components
|
||||
import {
|
||||
ActiveCyclesProjectTitle,
|
||||
ActiveCycleHeader,
|
||||
ActiveCycleProgress,
|
||||
ActiveCycleProductivity,
|
||||
ActiveCyclePriorityIssues,
|
||||
} from "components/cycles/active-cycles";
|
||||
// types
|
||||
import { ICycle } from "@plane/types";
|
||||
|
||||
export type ActiveCycleInfoCardProps = {
|
||||
cycle: ICycle;
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
};
|
||||
|
||||
export const ActiveCycleInfoCard: FC<ActiveCycleInfoCardProps> = (props) => {
|
||||
const { cycle, workspaceSlug, projectId } = props;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ActiveCyclesProjectTitle project={cycle.project_detail} />
|
||||
<div className="flex flex-col gap-2 rounded border border-custom-border-200">
|
||||
<ActiveCycleHeader cycle={cycle} workspaceSlug={workspaceSlug} projectId={projectId} />
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3">
|
||||
<ActiveCycleProgress cycle={cycle} />
|
||||
<ActiveCycleProductivity cycle={cycle} />
|
||||
<ActiveCyclePriorityIssues cycle={cycle} workspaceSlug={workspaceSlug} projectId={projectId} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
97
web/components/cycles/active-cycles/header.tsx
Normal file
97
web/components/cycles/active-cycles/header.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { FC, useCallback } from "react";
|
||||
import Link from "next/link";
|
||||
// hooks
|
||||
import useLocalStorage from "hooks/use-local-storage";
|
||||
// ui
|
||||
import { Tooltip, Button, CycleGroupIcon } from "@plane/ui";
|
||||
// types
|
||||
import { ICycle, TCycleGroups, TCycleLayout, TCycleView } from "@plane/types";
|
||||
// helpers
|
||||
import { truncateText } from "helpers/string.helper";
|
||||
import { renderFormattedDate, findHowManyDaysLeft } from "helpers/date-time.helper";
|
||||
|
||||
export type ActiveCycleHeaderProps = {
|
||||
cycle: ICycle;
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
};
|
||||
|
||||
export const ActiveCycleHeader: FC<ActiveCycleHeaderProps> = (props) => {
|
||||
const { cycle, workspaceSlug, projectId } = props;
|
||||
// local storage
|
||||
const { setValue: setCycleTab } = useLocalStorage<TCycleView>("cycle_tab", "active");
|
||||
const { setValue: setCycleLayout } = useLocalStorage<TCycleLayout>("cycle_layout", "list");
|
||||
|
||||
const handleCurrentLayout = useCallback(
|
||||
(_layout: TCycleLayout) => {
|
||||
setCycleLayout(_layout);
|
||||
},
|
||||
[setCycleLayout]
|
||||
);
|
||||
|
||||
const handleCurrentView = useCallback(
|
||||
(_view: TCycleView) => {
|
||||
setCycleTab(_view);
|
||||
if (_view === "draft") handleCurrentLayout("list");
|
||||
},
|
||||
[handleCurrentLayout, setCycleTab]
|
||||
);
|
||||
|
||||
const daysLeft = findHowManyDaysLeft(cycle.end_date ?? new Date());
|
||||
const currentCycleStatus = cycle.status.toLocaleLowerCase() as TCycleGroups;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between px-3 pt-3 pb-1">
|
||||
<div className="flex items-center gap-2 cursor-default">
|
||||
<CycleGroupIcon cycleGroup={currentCycleStatus} className="h-4 w-4" />
|
||||
<Tooltip tooltipContent={cycle.name} position="top-left">
|
||||
<h3 className="break-words text-lg font-medium">{truncateText(cycle.name, 70)}</h3>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
tooltipContent={`Start date: ${renderFormattedDate(cycle.start_date ?? "")} Due Date: ${renderFormattedDate(
|
||||
cycle.end_date ?? ""
|
||||
)}`}
|
||||
position="top-left"
|
||||
>
|
||||
<span className="flex gap-1 whitespace-nowrap rounded-sm text-sm px-3 py-0.5 bg-amber-500/10 text-amber-500">
|
||||
{`${daysLeft} ${daysLeft > 1 ? "days" : "day"} left`}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="rounded-sm text-sm px-3 py-1 bg-custom-background-80">
|
||||
<span className="flex gap-2 text-sm whitespace-nowrap font-medium">
|
||||
<span>Lead:</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{cycle.owned_by.avatar && cycle.owned_by.avatar !== "" ? (
|
||||
<img
|
||||
src={cycle.owned_by.avatar}
|
||||
height={18}
|
||||
width={18}
|
||||
className="rounded-full"
|
||||
alt={cycle.owned_by.display_name}
|
||||
/>
|
||||
) : (
|
||||
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-custom-background-100 capitalize">
|
||||
{cycle.owned_by.display_name.charAt(0)}
|
||||
</span>
|
||||
)}
|
||||
<span>{cycle.owned_by.display_name}</span>
|
||||
</div>
|
||||
</span>
|
||||
</span>
|
||||
<Link href={`/${workspaceSlug}/projects/${projectId}/cycles`}>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
handleCurrentView("active");
|
||||
}}
|
||||
>
|
||||
View Cycle
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
7
web/components/cycles/active-cycles/index.ts
Normal file
7
web/components/cycles/active-cycles/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export * from "./header";
|
||||
export * from "./progress";
|
||||
export * from "./project-title";
|
||||
export * from "./productivity";
|
||||
export * from "./priority-issues";
|
||||
export * from "./card";
|
||||
export * from "./list-page";
|
||||
54
web/components/cycles/active-cycles/list-page.tsx
Normal file
54
web/components/cycles/active-cycles/list-page.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { FC, useEffect } from "react";
|
||||
import useSWR from "swr";
|
||||
// components
|
||||
import { ActiveCycleInfoCard } from "components/cycles/active-cycles";
|
||||
// constants
|
||||
import { WORKSPACE_ACTIVE_CYCLES_LIST } from "constants/fetch-keys";
|
||||
// services
|
||||
import { CycleService } from "services/cycle.service";
|
||||
const cycleService = new CycleService();
|
||||
// ui
|
||||
import { Spinner } from "@plane/ui";
|
||||
|
||||
export type ActiveCyclesListPageProps = {
|
||||
workspaceSlug: string;
|
||||
cursor: string;
|
||||
perPage: number;
|
||||
updateTotalPages: (count: number) => void;
|
||||
updateResultsCount: (count: number) => void;
|
||||
};
|
||||
|
||||
export const ActiveCyclesListPage: FC<ActiveCyclesListPageProps> = (props) => {
|
||||
const { workspaceSlug, cursor, perPage, updateTotalPages, updateResultsCount } = props;
|
||||
|
||||
// fetching active cycles in workspace
|
||||
const { data: workspaceActiveCycles } = useSWR(
|
||||
workspaceSlug && cursor ? WORKSPACE_ACTIVE_CYCLES_LIST(workspaceSlug as string, cursor, `${perPage}`) : null,
|
||||
workspaceSlug && cursor ? () => cycleService.workspaceActiveCycles(workspaceSlug.toString(), cursor, perPage) : null
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (workspaceActiveCycles) {
|
||||
updateTotalPages(workspaceActiveCycles.total_pages);
|
||||
updateResultsCount(workspaceActiveCycles.results.length);
|
||||
}
|
||||
}, [updateTotalPages, updateResultsCount, workspaceActiveCycles]);
|
||||
|
||||
if (!workspaceActiveCycles) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full w-full">
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{workspaceActiveCycles.results.map((cycle: any) => (
|
||||
<div key={cycle.id} className="px-5 py-5">
|
||||
<ActiveCycleInfoCard workspaceSlug={workspaceSlug?.toString()} projectId={cycle.project} cycle={cycle} />
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
87
web/components/cycles/active-cycles/priority-issues.tsx
Normal file
87
web/components/cycles/active-cycles/priority-issues.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { FC } from "react";
|
||||
import Link from "next/link";
|
||||
// ui
|
||||
import { Tooltip, Loader, PriorityIcon } from "@plane/ui";
|
||||
// icons
|
||||
import { CalendarCheck } from "lucide-react";
|
||||
// types
|
||||
import { ICycle } from "@plane/types";
|
||||
// components
|
||||
import { StateDropdown } from "components/dropdowns";
|
||||
// helpers
|
||||
import { renderFormattedDate, renderFormattedDateWithoutYear } from "helpers/date-time.helper";
|
||||
|
||||
export type ActiveCyclePriorityIssuesProps = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
cycle: ICycle;
|
||||
};
|
||||
|
||||
export const ActiveCyclePriorityIssues: FC<ActiveCyclePriorityIssuesProps> = (props) => {
|
||||
const { workspaceSlug, projectId, cycle } = props;
|
||||
|
||||
const cycleIssues = cycle.issues ?? [];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 px-3 pt-2 min-h-52 overflow-hidden col-span-1 lg:col-span-2 xl:col-span-1 border-t border-custom-border-300">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<h3 className="text-lg font-medium">Priority</h3>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 h-full w-full max-h-40 overflow-y-auto pb-3">
|
||||
{cycleIssues ? (
|
||||
cycleIssues.length > 0 ? (
|
||||
cycleIssues.map((issue: any) => (
|
||||
<Link
|
||||
key={issue.id}
|
||||
href={`/${workspaceSlug}/projects/${projectId}/issues/${issue.id}`}
|
||||
className="flex cursor-pointer items-center justify-between gap-2 rounded-md border border-custom-border-200 px-3 py-1.5"
|
||||
>
|
||||
<div className="flex items-center gap-1.5 flex-grow w-full truncate">
|
||||
<PriorityIcon priority={issue.priority} withContainer size={12} />
|
||||
<Tooltip
|
||||
tooltipHeading="Issue ID"
|
||||
tooltipContent={`${cycle.project_detail?.identifier}-${issue.sequence_id}`}
|
||||
>
|
||||
<span className="flex-shrink-0 text-xs text-custom-text-200">
|
||||
{cycle.project_detail?.identifier}-{issue.sequence_id}
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip position="top-left" tooltipHeading="Title" tooltipContent={issue.name}>
|
||||
<span className="text-[0.825rem] text-custom-text-100 truncate">{issue.name}</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||
<StateDropdown
|
||||
value={issue.state_id ?? undefined}
|
||||
onChange={() => {}}
|
||||
projectId={projectId?.toString() ?? ""}
|
||||
disabled={true}
|
||||
buttonVariant="background-with-text"
|
||||
/>
|
||||
{issue.target_date && (
|
||||
<Tooltip tooltipHeading="Target Date" tooltipContent={renderFormattedDate(issue.target_date)}>
|
||||
<div className="h-full flex items-center gap-1.5 rounded text-xs px-2 py-0.5 bg-custom-background-80 cursor-not-allowed">
|
||||
<CalendarCheck className="h-3 w-3 flex-shrink-0" />
|
||||
<span className="text-xs">{renderFormattedDateWithoutYear(issue.target_date)}</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
))
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-sm text-custom-text-200">
|
||||
<span>There are no high priority issues present in this cycle.</span>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<Loader className="space-y-3">
|
||||
<Loader.Item height="50px" />
|
||||
<Loader.Item height="50px" />
|
||||
<Loader.Item height="50px" />
|
||||
</Loader>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
30
web/components/cycles/active-cycles/productivity.tsx
Normal file
30
web/components/cycles/active-cycles/productivity.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { FC } from "react";
|
||||
// components
|
||||
import ProgressChart from "components/core/sidebar/progress-chart";
|
||||
// types
|
||||
import { ICycle } from "@plane/types";
|
||||
|
||||
export type ActiveCycleProductivityProps = {
|
||||
cycle: ICycle;
|
||||
};
|
||||
|
||||
export const ActiveCycleProductivity: FC<ActiveCycleProductivityProps> = (props) => {
|
||||
const { cycle } = props;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 px-3 pt-2 min-h-52 border-r-0 border-t border-custom-border-300 lg:border-r">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<h3 className="text-lg font-medium">Issue Burndown</h3>
|
||||
</div>
|
||||
|
||||
<div className="relative ">
|
||||
<ProgressChart
|
||||
distribution={cycle.distribution?.completion_chart ?? {}}
|
||||
startDate={cycle.start_date ?? ""}
|
||||
endDate={cycle.end_date ?? ""}
|
||||
totalIssues={cycle.total_issues}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
74
web/components/cycles/active-cycles/progress.tsx
Normal file
74
web/components/cycles/active-cycles/progress.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { FC } from "react";
|
||||
// ui
|
||||
import { LinearProgressIndicator } from "@plane/ui";
|
||||
// types
|
||||
import { ICycle } from "@plane/types";
|
||||
// constants
|
||||
import { WORKSPACE_ACTIVE_CYCLE_STATE_GROUPS_DETAILS } from "constants/cycle";
|
||||
|
||||
export type ActiveCycleProgressProps = {
|
||||
cycle: ICycle;
|
||||
};
|
||||
|
||||
export const ActiveCycleProgress: FC<ActiveCycleProgressProps> = (props) => {
|
||||
const { cycle } = props;
|
||||
|
||||
const progressIndicatorData = WORKSPACE_ACTIVE_CYCLE_STATE_GROUPS_DETAILS.map((group, index) => ({
|
||||
id: index,
|
||||
name: group.title,
|
||||
value: cycle.total_issues > 0 ? (cycle[group.key as keyof ICycle] as number) : 0,
|
||||
color: group.color,
|
||||
}));
|
||||
|
||||
const groupedIssues: any = {
|
||||
completed: cycle.completed_issues,
|
||||
started: cycle.started_issues,
|
||||
unstarted: cycle.unstarted_issues,
|
||||
backlog: cycle.backlog_issues,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 px-3 pt-2 min-h-52 border-r-0 border-t border-custom-border-300 lg:border-r">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<h3 className="text-lg font-medium">Progress</h3>
|
||||
<span className="flex gap-1 text-sm whitespace-nowrap rounded-sm px-3 py-1 ">
|
||||
{`${cycle.completed_issues + cycle.cancelled_issues}/${cycle.total_issues - cycle.cancelled_issues} ${
|
||||
cycle.completed_issues + cycle.cancelled_issues > 1 ? "Issues" : "Issue"
|
||||
} closed`}
|
||||
</span>
|
||||
</div>
|
||||
<LinearProgressIndicator data={progressIndicatorData} />
|
||||
<div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{Object.keys(groupedIssues).map((group, index) => (
|
||||
<div key={index}>
|
||||
{groupedIssues[group] > 0 && (
|
||||
<div className="flex items-center justify-start gap-2 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="block h-3 w-3 rounded-full"
|
||||
style={{
|
||||
backgroundColor: WORKSPACE_ACTIVE_CYCLE_STATE_GROUPS_DETAILS[index].color,
|
||||
}}
|
||||
/>
|
||||
<span className="capitalize font-medium w-16">{group}</span>
|
||||
</div>
|
||||
<span>{`: ${groupedIssues[group]} ${groupedIssues[group] > 1 ? "Issues" : "Issue"}`}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{cycle.cancelled_issues > 0 && (
|
||||
<span className="flex items-center gap-2 text-sm text-custom-text-300">
|
||||
<span>
|
||||
{`${cycle.cancelled_issues} cancelled ${
|
||||
cycle.cancelled_issues > 1 ? "issues are" : "issue is"
|
||||
} excluded from this report.`}{" "}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
29
web/components/cycles/active-cycles/project-title.tsx
Normal file
29
web/components/cycles/active-cycles/project-title.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { FC } from "react";
|
||||
// helpers
|
||||
import { renderEmoji } from "helpers/emoji.helper";
|
||||
// types
|
||||
import { IProjectLite } from "@plane/types";
|
||||
|
||||
export type ActiveCyclesProjectTitleProps = {
|
||||
project: IProjectLite | undefined;
|
||||
};
|
||||
|
||||
export const ActiveCyclesProjectTitle: FC<ActiveCyclesProjectTitleProps> = (props) => {
|
||||
const { project } = props;
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 px-3 py-1.5">
|
||||
{project?.emoji ? (
|
||||
<span className="grid h-7 w-7 flex-shrink-0 place-items-center rounded uppercase">
|
||||
{renderEmoji(project.emoji)}
|
||||
</span>
|
||||
) : project?.icon_prop ? (
|
||||
<div className="grid h-7 w-7 flex-shrink-0 place-items-center">{renderEmoji(project.icon_prop)}</div>
|
||||
) : (
|
||||
<span className="grid h-7 w-7 flex-shrink-0 place-items-center rounded bg-gray-700 uppercase text-white">
|
||||
{project?.name.charAt(0)}
|
||||
</span>
|
||||
)}
|
||||
<h2 className="text-xl font-semibold">{project?.name}</h2>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,65 +1,59 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import useSWR from "swr";
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useRouter } from "next/router";
|
||||
import isEqual from "lodash/isEqual";
|
||||
// hooks
|
||||
import { useUser } from "hooks/store";
|
||||
// components
|
||||
import { ActiveCycleInfo } from "components/cycles";
|
||||
import { Button, Spinner } from "@plane/ui";
|
||||
import { ActiveCyclesListPage } from "components/cycles/active-cycles";
|
||||
import { Button } from "@plane/ui";
|
||||
import { EmptyState, getEmptyStateImagePath } from "components/empty-state";
|
||||
// services
|
||||
import { CycleService } from "services/cycle.service";
|
||||
const cycleService = new CycleService();
|
||||
// constants
|
||||
import { WORKSPACE_ACTIVE_CYCLES_LIST } from "constants/fetch-keys";
|
||||
import { EUserWorkspaceRoles } from "constants/workspace";
|
||||
// types
|
||||
import { ICycle } from "@plane/types";
|
||||
|
||||
const per_page = 3;
|
||||
const perPage = 3;
|
||||
|
||||
export const WorkspaceActiveCyclesList = observer(() => {
|
||||
// state
|
||||
const [cursor, setCursor] = useState<string | undefined>(`3:0:0`);
|
||||
const [allCyclesData, setAllCyclesData] = useState<ICycle[]>([]);
|
||||
const [hasMoreResults, setHasMoreResults] = useState(true);
|
||||
const [pageCount, setPageCount] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(0);
|
||||
const [resultsCount, setResultsCount] = useState(0); // workspaceActiveCycles.results.length
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
|
||||
// store
|
||||
const {
|
||||
membership: { currentWorkspaceRole },
|
||||
currentUser,
|
||||
} = useUser();
|
||||
|
||||
// fetching active cycles in workspace
|
||||
const { data: workspaceActiveCycles, isLoading } = useSWR(
|
||||
workspaceSlug && cursor ? WORKSPACE_ACTIVE_CYCLES_LIST(workspaceSlug as string, cursor, `${per_page}`) : null,
|
||||
workspaceSlug && cursor
|
||||
? () => cycleService.workspaceActiveCycles(workspaceSlug.toString(), cursor, per_page)
|
||||
: null
|
||||
);
|
||||
const activeCyclesPages = [];
|
||||
|
||||
useEffect(() => {
|
||||
if (workspaceActiveCycles && !isEqual(workspaceActiveCycles.results, allCyclesData)) {
|
||||
setAllCyclesData((prevData) => [...prevData, ...workspaceActiveCycles.results]);
|
||||
setHasMoreResults(workspaceActiveCycles.next_page_results);
|
||||
}
|
||||
}, [workspaceActiveCycles]);
|
||||
|
||||
const handleLoadMore = () => {
|
||||
if (hasMoreResults) {
|
||||
setCursor(workspaceActiveCycles?.next_cursor);
|
||||
}
|
||||
const updateTotalPages = (count: number) => {
|
||||
setTotalPages(count);
|
||||
};
|
||||
|
||||
if (allCyclesData.length === 0 && !workspaceActiveCycles) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full w-full">
|
||||
<Spinner />
|
||||
</div>
|
||||
const updateResultsCount = (count: number) => {
|
||||
setResultsCount(count);
|
||||
};
|
||||
|
||||
const handleLoadMore = () => {
|
||||
setPageCount(pageCount + 1);
|
||||
};
|
||||
|
||||
if (!workspaceSlug) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (let i = 1; i <= pageCount; i++) {
|
||||
activeCyclesPages.push(
|
||||
<ActiveCyclesListPage
|
||||
cursor={`${perPage}:${i - 1}:0`}
|
||||
perPage={perPage}
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
updateTotalPages={updateTotalPages}
|
||||
updateResultsCount={updateResultsCount}
|
||||
key={i}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -68,28 +62,22 @@ export const WorkspaceActiveCyclesList = observer(() => {
|
||||
"workspace-active-cycles",
|
||||
currentUser?.theme.theme === "light"
|
||||
);
|
||||
|
||||
const isEditingAllowed = !!currentWorkspaceRole && currentWorkspaceRole >= EUserWorkspaceRoles.MEMBER;
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
{allCyclesData.length > 0 ? (
|
||||
<>
|
||||
{workspaceSlug &&
|
||||
allCyclesData.map((cycle) => (
|
||||
<div key={cycle.id} className="px-5 py-5">
|
||||
<ActiveCycleInfo workspaceSlug={workspaceSlug?.toString()} projectId={cycle.project} cycle={cycle} />
|
||||
</div>
|
||||
))}
|
||||
{activeCyclesPages}
|
||||
|
||||
{hasMoreResults && (
|
||||
<div className="flex items-center justify-center gap-4 text-xs w-full py-5">
|
||||
<Button variant="outline-primary" size="sm" onClick={handleLoadMore}>
|
||||
{isLoading ? "Loading..." : "Load More"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
{pageCount < totalPages && resultsCount !== 0 && (
|
||||
<div className="flex items-center justify-center gap-4 text-xs w-full py-5">
|
||||
<Button variant="outline-primary" size="sm" onClick={handleLoadMore}>
|
||||
Load More
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{resultsCount === 0 && (
|
||||
<EmptyState
|
||||
image={EmptyStateImagePath}
|
||||
title="No active cycles"
|
||||
|
||||
Reference in New Issue
Block a user