mirror of
https://github.com/makeplane/plane
synced 2024-06-14 14:31:34 +00:00
[WEB-413] chore: project active cycle UI revamp (#3997)
* chore: project active cycle ui revamp * chore: resolved liniting issues --------- Co-authored-by: gurusainath <gurusainath007@gmail.com>
This commit is contained in:
parent
bca3e13242
commit
7142889c23
@ -12,6 +12,7 @@ type Props = {
|
||||
startDate: string | Date;
|
||||
endDate: string | Date;
|
||||
totalIssues: number;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const styleById = {
|
||||
@ -40,7 +41,7 @@ const DashedLine = ({ series, lineGenerator, xScale, yScale }: any) =>
|
||||
/>
|
||||
));
|
||||
|
||||
const ProgressChart: React.FC<Props> = ({ distribution, startDate, endDate, totalIssues }) => {
|
||||
const ProgressChart: React.FC<Props> = ({ distribution, startDate, endDate, totalIssues, className = "" }) => {
|
||||
const chartData = Object.keys(distribution ?? []).map((key) => ({
|
||||
currentDate: renderFormattedDateWithoutYear(key),
|
||||
pending: distribution[key],
|
||||
@ -73,7 +74,7 @@ const ProgressChart: React.FC<Props> = ({ distribution, startDate, endDate, tota
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex w-full items-center justify-center">
|
||||
<div className={`flex w-full items-center justify-center ${className}`}>
|
||||
<LineGraph
|
||||
animate
|
||||
curve="monotoneX"
|
||||
|
260
web/components/cycles/active-cycle/cycle-stats.tsx
Normal file
260
web/components/cycles/active-cycle/cycle-stats.tsx
Normal file
@ -0,0 +1,260 @@
|
||||
import { FC, Fragment } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import useSWR from "swr";
|
||||
import { CalendarCheck } from "lucide-react";
|
||||
import { Tab } from "@headlessui/react";
|
||||
// types
|
||||
import { ICycle, TIssue } from "@plane/types";
|
||||
// ui
|
||||
import { Tooltip, Loader, PriorityIcon, Avatar } from "@plane/ui";
|
||||
// components
|
||||
import { SingleProgressStats } from "@/components/core";
|
||||
import { StateDropdown } from "@/components/dropdowns";
|
||||
// constants
|
||||
import { CYCLE_ISSUES_WITH_PARAMS } from "@/constants/fetch-keys";
|
||||
import { EIssuesStoreType } from "@/constants/issue";
|
||||
// helper
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
import { renderFormattedDate, renderFormattedDateWithoutYear } from "@/helpers/date-time.helper";
|
||||
// hooks
|
||||
import { useIssues, useProject } from "@/hooks/store";
|
||||
import useLocalStorage from "@/hooks/use-local-storage";
|
||||
|
||||
export type ActiveCycleStatsProps = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
cycle: ICycle;
|
||||
};
|
||||
|
||||
export const ActiveCycleStats: FC<ActiveCycleStatsProps> = observer((props) => {
|
||||
const { workspaceSlug, projectId, cycle } = props;
|
||||
|
||||
const { storedValue: tab, setValue: setTab } = useLocalStorage("activeCycleTab", "Assignees");
|
||||
|
||||
const currentValue = (tab: string | null) => {
|
||||
switch (tab) {
|
||||
case "Priority-Issues":
|
||||
return 0;
|
||||
case "Assignees":
|
||||
return 1;
|
||||
case "Labels":
|
||||
return 2;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
const {
|
||||
issues: { fetchActiveCycleIssues },
|
||||
} = useIssues(EIssuesStoreType.CYCLE);
|
||||
|
||||
const { currentProjectDetails } = useProject();
|
||||
|
||||
const { data: activeCycleIssues } = useSWR(
|
||||
workspaceSlug && projectId && cycle.id ? CYCLE_ISSUES_WITH_PARAMS(cycle.id, { priority: "urgent,high" }) : null,
|
||||
workspaceSlug && projectId && cycle.id ? () => fetchActiveCycleIssues(workspaceSlug, projectId, cycle.id) : null
|
||||
);
|
||||
|
||||
const cycleIssues = activeCycleIssues ?? [];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 p-4 min-h-[17rem] overflow-hidden col-span-1 lg:col-span-2 xl:col-span-1 border border-custom-border-200 rounded-lg">
|
||||
<Tab.Group
|
||||
as={Fragment}
|
||||
defaultIndex={currentValue(tab)}
|
||||
onChange={(i) => {
|
||||
switch (i) {
|
||||
case 0:
|
||||
return setTab("Priority-Issues");
|
||||
case 1:
|
||||
return setTab("Assignees");
|
||||
case 2:
|
||||
return setTab("Labels");
|
||||
|
||||
default:
|
||||
return setTab("Priority-Issues");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Tab.List
|
||||
as="div"
|
||||
className="relative border-[0.5px] border-custom-border-200 rounded bg-custom-background-80 p-[1px] grid"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(3, 1fr)`,
|
||||
}}
|
||||
>
|
||||
<Tab
|
||||
className={({ selected }) =>
|
||||
cn(
|
||||
"relative z-[1] font-semibold text-xs rounded-[3px] py-1.5 text-custom-text-400 focus:outline-none transition duration-500",
|
||||
{
|
||||
"text-custom-text-300 bg-custom-background-100": selected,
|
||||
"hover:text-custom-text-300": !selected,
|
||||
}
|
||||
)
|
||||
}
|
||||
>
|
||||
Priority Issues
|
||||
</Tab>
|
||||
<Tab
|
||||
className={({ selected }) =>
|
||||
cn(
|
||||
"relative z-[1] font-semibold text-xs rounded-[3px] py-1.5 text-custom-text-400 focus:outline-none transition duration-500",
|
||||
{
|
||||
"text-custom-text-300 bg-custom-background-100": selected,
|
||||
"hover:text-custom-text-300": !selected,
|
||||
}
|
||||
)
|
||||
}
|
||||
>
|
||||
Assignees
|
||||
</Tab>
|
||||
<Tab
|
||||
className={({ selected }) =>
|
||||
cn(
|
||||
"relative z-[1] font-semibold text-xs rounded-[3px] py-1.5 text-custom-text-400 focus:outline-none transition duration-500",
|
||||
{
|
||||
"text-custom-text-300 bg-custom-background-100": selected,
|
||||
"hover:text-custom-text-300": !selected,
|
||||
}
|
||||
)
|
||||
}
|
||||
>
|
||||
Labels
|
||||
</Tab>
|
||||
</Tab.List>
|
||||
|
||||
<Tab.Panels as={Fragment}>
|
||||
<Tab.Panel
|
||||
as="div"
|
||||
className="flex h-52 w-full flex-col gap-1 overflow-y-auto text-custom-text-200 vertical-scrollbar scrollbar-sm"
|
||||
>
|
||||
<div className="flex flex-col gap-1 h-full w-full overflow-y-auto vertical-scrollbar scrollbar-sm">
|
||||
{cycleIssues ? (
|
||||
cycleIssues.length > 0 ? (
|
||||
cycleIssues.map((issue: TIssue) => (
|
||||
<Link
|
||||
key={issue.id}
|
||||
href={`/${workspaceSlug}/projects/${projectId}/issues/${issue.id}`}
|
||||
className="group flex cursor-pointer items-center justify-between gap-2 rounded-md hover:bg-custom-background-90 p-1"
|
||||
>
|
||||
<div className="flex items-center gap-1.5 flex-grow w-full min-w-24 truncate">
|
||||
<PriorityIcon priority={issue.priority} withContainer size={12} />
|
||||
|
||||
<Tooltip
|
||||
tooltipHeading="Issue ID"
|
||||
tooltipContent={`${currentProjectDetails?.identifier}-${issue.sequence_id}`}
|
||||
>
|
||||
<span className="flex-shrink-0 text-xs text-custom-text-200">
|
||||
{currentProjectDetails?.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
|
||||
buttonVariant="background-with-text"
|
||||
buttonContainerClassName="cursor-pointer max-w-24"
|
||||
showTooltip
|
||||
/>
|
||||
{issue.target_date && (
|
||||
<Tooltip tooltipHeading="Target Date" tooltipContent={renderFormattedDate(issue.target_date)}>
|
||||
<div className="h-full flex truncate items-center gap-1.5 rounded text-xs px-2 py-0.5 bg-custom-background-80 group-hover:bg-custom-background-100 cursor-pointer">
|
||||
<CalendarCheck className="h-3 w-3 flex-shrink-0" />
|
||||
<span className="text-xs truncate">
|
||||
{renderFormattedDateWithoutYear(issue.target_date)}
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
))
|
||||
) : (
|
||||
<div className="flex items-center justify-center text-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>
|
||||
</Tab.Panel>
|
||||
|
||||
<Tab.Panel
|
||||
as="div"
|
||||
className="flex h-52 w-full flex-col gap-1 overflow-y-auto text-custom-text-200 vertical-scrollbar scrollbar-sm"
|
||||
>
|
||||
{cycle.distribution?.assignees?.map((assignee, index) => {
|
||||
if (assignee.assignee_id)
|
||||
return (
|
||||
<SingleProgressStats
|
||||
key={assignee.assignee_id}
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar name={assignee?.display_name ?? undefined} src={assignee?.avatar ?? undefined} />
|
||||
|
||||
<span>{assignee.display_name}</span>
|
||||
</div>
|
||||
}
|
||||
completed={assignee.completed_issues}
|
||||
total={assignee.total_issues}
|
||||
/>
|
||||
);
|
||||
else
|
||||
return (
|
||||
<SingleProgressStats
|
||||
key={`unassigned-${index}`}
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-5 w-5 rounded-full border-2 border-custom-border-200 bg-custom-background-80">
|
||||
<img src="/user.png" height="100%" width="100%" className="rounded-full" alt="User" />
|
||||
</div>
|
||||
<span>No assignee</span>
|
||||
</div>
|
||||
}
|
||||
completed={assignee.completed_issues}
|
||||
total={assignee.total_issues}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Tab.Panel>
|
||||
|
||||
<Tab.Panel
|
||||
as="div"
|
||||
className="flex h-52 w-full flex-col gap-1 overflow-y-auto text-custom-text-200 vertical-scrollbar scrollbar-sm"
|
||||
>
|
||||
{cycle.distribution?.labels?.map((label, index) => (
|
||||
<SingleProgressStats
|
||||
key={label.label_id ?? `no-label-${index}`}
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="block h-3 w-3 rounded-full"
|
||||
style={{
|
||||
backgroundColor: label.color ?? "#000000",
|
||||
}}
|
||||
/>
|
||||
<span className="text-xs">{label.label_name ?? "No labels"}</span>
|
||||
</div>
|
||||
}
|
||||
completed={label.completed_issues}
|
||||
total={label.total_issues}
|
||||
/>
|
||||
))}
|
||||
</Tab.Panel>
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
</div>
|
||||
);
|
||||
});
|
77
web/components/cycles/active-cycle/header.tsx
Normal file
77
web/components/cycles/active-cycle/header.tsx
Normal file
@ -0,0 +1,77 @@
|
||||
import { FC } from "react";
|
||||
import Link from "next/link";
|
||||
// types
|
||||
import { ICycle, TCycleGroups } from "@plane/types";
|
||||
// ui
|
||||
import { Tooltip, CycleGroupIcon, getButtonStyling, Avatar, AvatarGroup } from "@plane/ui";
|
||||
// helpers
|
||||
import { renderFormattedDate, findHowManyDaysLeft } from "@/helpers/date-time.helper";
|
||||
import { truncateText } from "@/helpers/string.helper";
|
||||
// hooks
|
||||
import { useMember } from "@/hooks/store";
|
||||
|
||||
export type ActiveCycleHeaderProps = {
|
||||
cycle: ICycle;
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
};
|
||||
|
||||
export const ActiveCycleHeader: FC<ActiveCycleHeaderProps> = (props) => {
|
||||
const { cycle, workspaceSlug, projectId } = props;
|
||||
// store
|
||||
const { getUserDetails } = useMember();
|
||||
const cycleOwnerDetails = cycle && cycle.owned_by_id ? getUserDetails(cycle.owned_by_id) : undefined;
|
||||
|
||||
const daysLeft = findHowManyDaysLeft(cycle.end_date) ?? 0;
|
||||
const currentCycleStatus = cycle.status.toLocaleLowerCase() as TCycleGroups;
|
||||
|
||||
const cycleAssignee = (cycle.distribution?.assignees ?? []).filter((assignee) => assignee.display_name);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between px-3 py-1.5 rounded border-[0.5px] border-custom-border-100 bg-custom-background-90">
|
||||
<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-custom-text-400 font-semibold text-sm leading-5">
|
||||
{`${daysLeft} ${daysLeft > 1 ? "days" : "day"} left`}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="rounded-sm text-sm">
|
||||
<div className="flex gap-2 divide-x spac divide-x-border-300 text-sm whitespace-nowrap text-custom-text-300 font-medium">
|
||||
<Avatar name={cycleOwnerDetails?.display_name} src={cycleOwnerDetails?.avatar} />
|
||||
{cycleAssignee.length > 0 && (
|
||||
<span className="pl-2">
|
||||
<AvatarGroup showTooltip>
|
||||
{cycleAssignee.map((member) => (
|
||||
<Avatar
|
||||
key={member.assignee_id}
|
||||
name={member?.display_name ?? ""}
|
||||
src={member?.avatar ?? ""}
|
||||
showTooltip={false}
|
||||
/>
|
||||
))}
|
||||
</AvatarGroup>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycle.id}`}
|
||||
className={`${getButtonStyling("outline-primary", "sm")} cursor-pointer`}
|
||||
>
|
||||
View Cycle
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -1,4 +1,8 @@
|
||||
export * from "./root";
|
||||
export * from "./header";
|
||||
export * from "./stats";
|
||||
export * from "./upcoming-cycles-list-item";
|
||||
export * from "./upcoming-cycles-list";
|
||||
export * from "./cycle-stats";
|
||||
export * from "./progress";
|
||||
export * from "./productivity";
|
||||
|
46
web/components/cycles/active-cycle/productivity.tsx
Normal file
46
web/components/cycles/active-cycle/productivity.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
import { FC } from "react";
|
||||
// types
|
||||
import { ICycle } from "@plane/types";
|
||||
// components
|
||||
import ProgressChart from "@/components/core/sidebar/progress-chart";
|
||||
|
||||
export type ActiveCycleProductivityProps = {
|
||||
cycle: ICycle;
|
||||
};
|
||||
|
||||
export const ActiveCycleProductivity: FC<ActiveCycleProductivityProps> = (props) => {
|
||||
const { cycle } = props;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col justify-center min-h-[17rem] gap-5 py-4 px-3.5 border border-custom-border-200 rounded-lg">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<h3 className="text-base text-custom-text-300 font-semibold">Issue burndown</h3>
|
||||
</div>
|
||||
|
||||
<div className="h-full w-full px-2">
|
||||
<div className="flex items-center justify-between gap-4 py-1 text-xs text-custom-text-300">
|
||||
<div className="flex items-center gap-3 text-custom-text-300">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<span className="h-2 w-2 rounded-full bg-[#A9BBD0]" />
|
||||
<span>Ideal</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<span className="h-2 w-2 rounded-full bg-[#4C8FFF]" />
|
||||
<span>Current</span>
|
||||
</div>
|
||||
</div>
|
||||
<span>{`Pending issues - ${cycle.backlog_issues + cycle.unstarted_issues + cycle.started_issues}`}</span>
|
||||
</div>
|
||||
<div className="relative h-full">
|
||||
<ProgressChart
|
||||
className="h-full"
|
||||
distribution={cycle.distribution?.completion_chart ?? {}}
|
||||
startDate={cycle.start_date ?? ""}
|
||||
endDate={cycle.end_date ?? ""}
|
||||
totalIssues={cycle.total_issues}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
79
web/components/cycles/active-cycle/progress.tsx
Normal file
79
web/components/cycles/active-cycle/progress.tsx
Normal file
@ -0,0 +1,79 @@
|
||||
import { FC } from "react";
|
||||
// types
|
||||
import { ICycle } from "@plane/types";
|
||||
// ui
|
||||
import { LinearProgressIndicator } from "@plane/ui";
|
||||
// constants
|
||||
import { CYCLE_STATE_GROUPS_DETAILS } from "@/constants/cycle";
|
||||
|
||||
export type ActiveCycleProgressProps = {
|
||||
cycle: ICycle;
|
||||
};
|
||||
|
||||
export const ActiveCycleProgress: FC<ActiveCycleProgressProps> = (props) => {
|
||||
const { cycle } = props;
|
||||
|
||||
const progressIndicatorData = 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 min-h-[17rem] gap-5 py-4 px-3.5 border border-custom-border-200 rounded-lg">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<h3 className="text-base text-custom-text-300 font-semibold">Progress</h3>
|
||||
<span className="flex gap-1 text-sm text-custom-text-400 font-medium 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 size="lg" data={progressIndicatorData} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-5">
|
||||
{Object.keys(groupedIssues).map((group, index) => (
|
||||
<>
|
||||
{groupedIssues[group] > 0 && (
|
||||
<div key={index}>
|
||||
<div className="flex items-center justify-between gap-2 text-sm">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="block h-3 w-3 rounded-full"
|
||||
style={{
|
||||
backgroundColor: CYCLE_STATE_GROUPS_DETAILS[index].color,
|
||||
}}
|
||||
/>
|
||||
<span className="text-custom-text-300 capitalize font-medium w-16">{group}</span>
|
||||
</div>
|
||||
<span className="text-custom-text-300">{`${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>
|
||||
);
|
||||
};
|
@ -1,48 +1,20 @@
|
||||
import { MouseEvent } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import Link from "next/link";
|
||||
import useSWR from "swr";
|
||||
// hooks
|
||||
import { ArrowRight, CalendarCheck, CalendarDays, Star, Target } from "lucide-react";
|
||||
import { ICycle, TCycleGroups } from "@plane/types";
|
||||
import {
|
||||
AvatarGroup,
|
||||
Loader,
|
||||
Tooltip,
|
||||
LinearProgressIndicator,
|
||||
LayersIcon,
|
||||
StateGroupIcon,
|
||||
PriorityIcon,
|
||||
Avatar,
|
||||
CycleGroupIcon,
|
||||
setPromiseToast,
|
||||
getButtonStyling,
|
||||
} from "@plane/ui";
|
||||
import { SingleProgressStats } from "@/components/core";
|
||||
// ui
|
||||
import { Loader } from "@plane/ui";
|
||||
// components
|
||||
import ProgressChart from "@/components/core/sidebar/progress-chart";
|
||||
import { ActiveCycleProgressStats, UpcomingCyclesList } from "@/components/cycles";
|
||||
import { StateDropdown } from "@/components/dropdowns";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
// icons
|
||||
// helpers
|
||||
// types
|
||||
// constants
|
||||
import { CYCLE_STATE_GROUPS_DETAILS } from "@/constants/cycle";
|
||||
import { EmptyStateType } from "@/constants/empty-state";
|
||||
import { CYCLE_ISSUES_WITH_PARAMS } from "@/constants/fetch-keys";
|
||||
import { EIssuesStoreType } from "@/constants/issue";
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
import {
|
||||
renderFormattedDate,
|
||||
findHowManyDaysLeft,
|
||||
renderFormattedDateWithoutYear,
|
||||
getDate,
|
||||
} from "@/helpers/date-time.helper";
|
||||
import { truncateText } from "@/helpers/string.helper";
|
||||
import { useCycle, useCycleFilter, useIssues, useMember, useProject } from "@/hooks/store";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
ActiveCycleHeader,
|
||||
ActiveCycleProductivity,
|
||||
ActiveCycleProgress,
|
||||
ActiveCycleStats,
|
||||
UpcomingCyclesList,
|
||||
} from "@/components/cycles";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
// constants
|
||||
import { EmptyStateType } from "@/constants/empty-state";
|
||||
// hooks
|
||||
import { useCycle, useCycleFilter } from "@/hooks/store";
|
||||
|
||||
interface IActiveCycleDetails {
|
||||
workspaceSlug: string;
|
||||
@ -52,41 +24,24 @@ interface IActiveCycleDetails {
|
||||
export const ActiveCycleRoot: React.FC<IActiveCycleDetails> = observer((props) => {
|
||||
// props
|
||||
const { workspaceSlug, projectId } = props;
|
||||
// hooks
|
||||
const { isMobile } = usePlatformOS();
|
||||
// store hooks
|
||||
const {
|
||||
issues: { fetchActiveCycleIssues },
|
||||
} = useIssues(EIssuesStoreType.CYCLE);
|
||||
const {
|
||||
currentProjectActiveCycleId,
|
||||
currentProjectUpcomingCycleIds,
|
||||
fetchActiveCycle,
|
||||
getActiveCycleById,
|
||||
addCycleToFavorites,
|
||||
removeCycleFromFavorites,
|
||||
} = useCycle();
|
||||
const { currentProjectDetails } = useProject();
|
||||
const { getUserDetails } = useMember();
|
||||
const { fetchActiveCycle, currentProjectActiveCycleId, currentProjectUpcomingCycleIds, getActiveCycleById } =
|
||||
useCycle();
|
||||
// cycle filters hook
|
||||
const { updateDisplayFilters } = useCycleFilter();
|
||||
// derived values
|
||||
const activeCycle = currentProjectActiveCycleId ? getActiveCycleById(currentProjectActiveCycleId) : null;
|
||||
const cycleOwnerDetails = activeCycle ? getUserDetails(activeCycle.owned_by_id) : undefined;
|
||||
// fetch active cycle details
|
||||
const { isLoading } = useSWR(
|
||||
workspaceSlug && projectId ? `PROJECT_ACTIVE_CYCLE_${projectId}` : null,
|
||||
workspaceSlug && projectId ? () => fetchActiveCycle(workspaceSlug, projectId) : null
|
||||
);
|
||||
// fetch active cycle issues
|
||||
const { data: activeCycleIssues } = useSWR(
|
||||
workspaceSlug && projectId && currentProjectActiveCycleId
|
||||
? CYCLE_ISSUES_WITH_PARAMS(currentProjectActiveCycleId, { priority: "urgent,high" })
|
||||
: null,
|
||||
workspaceSlug && projectId && currentProjectActiveCycleId
|
||||
? () => fetchActiveCycleIssues(workspaceSlug, projectId, currentProjectActiveCycleId)
|
||||
: null
|
||||
);
|
||||
|
||||
const handleEmptyStateAction = () =>
|
||||
updateDisplayFilters(projectId, {
|
||||
active_tab: "all",
|
||||
});
|
||||
|
||||
// show loader if active cycle is loading
|
||||
if (!activeCycle && isLoading)
|
||||
return (
|
||||
@ -110,310 +65,28 @@ export const ActiveCycleRoot: React.FC<IActiveCycleDetails> = observer((props) =
|
||||
Create new cycles to find them here or check
|
||||
<br />
|
||||
{"'"}All{"'"} cycles tab to see all cycles or{" "}
|
||||
<button
|
||||
type="button"
|
||||
className="text-custom-primary-100 font-medium"
|
||||
onClick={() =>
|
||||
updateDisplayFilters(projectId, {
|
||||
active_tab: "all",
|
||||
})
|
||||
}
|
||||
>
|
||||
<button type="button" className="text-custom-primary-100 font-medium" onClick={handleEmptyStateAction}>
|
||||
click here
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<UpcomingCyclesList />
|
||||
<UpcomingCyclesList handleEmptyStateAction={handleEmptyStateAction} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const endDate = getDate(activeCycle.end_date);
|
||||
const startDate = getDate(activeCycle.start_date);
|
||||
const daysLeft = findHowManyDaysLeft(activeCycle.end_date) ?? 0;
|
||||
const cycleStatus = activeCycle.status.toLowerCase() as TCycleGroups;
|
||||
|
||||
const groupedIssues: any = {
|
||||
backlog: activeCycle.backlog_issues,
|
||||
unstarted: activeCycle.unstarted_issues,
|
||||
started: activeCycle.started_issues,
|
||||
completed: activeCycle.completed_issues,
|
||||
cancelled: activeCycle.cancelled_issues,
|
||||
};
|
||||
|
||||
const handleAddToFavorites = (e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
const addToFavoritePromise = addCycleToFavorites(workspaceSlug?.toString(), projectId.toString(), activeCycle.id);
|
||||
|
||||
setPromiseToast(addToFavoritePromise, {
|
||||
loading: "Adding cycle to favorites...",
|
||||
success: {
|
||||
title: "Success!",
|
||||
message: () => "Cycle added to favorites.",
|
||||
},
|
||||
error: {
|
||||
title: "Error!",
|
||||
message: () => "Couldn't add the cycle to favorites. Please try again.",
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveFromFavorites = (e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
const removeFromFavoritePromise = removeCycleFromFavorites(
|
||||
workspaceSlug?.toString(),
|
||||
projectId.toString(),
|
||||
activeCycle.id
|
||||
);
|
||||
|
||||
setPromiseToast(removeFromFavoritePromise, {
|
||||
loading: "Removing cycle from favorites...",
|
||||
success: {
|
||||
title: "Success!",
|
||||
message: () => "Cycle removed from favorites.",
|
||||
},
|
||||
error: {
|
||||
title: "Error!",
|
||||
message: () => "Couldn't remove the cycle from favorites. Please try again.",
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const progressIndicatorData = CYCLE_STATE_GROUPS_DETAILS.map((group, index) => ({
|
||||
id: index,
|
||||
name: group.title,
|
||||
value:
|
||||
activeCycle.total_issues > 0
|
||||
? ((activeCycle[group.key as keyof ICycle] as number) / activeCycle.total_issues) * 100
|
||||
: 0,
|
||||
color: group.color,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="grid-row-2 grid divide-y rounded-[10px] border border-custom-border-200 bg-custom-background-100 shadow">
|
||||
<div className="grid grid-cols-1 divide-y border-custom-border-200 lg:grid-cols-3 lg:divide-x lg:divide-y-0">
|
||||
<div className="flex flex-col text-xs">
|
||||
<div className="h-full w-full">
|
||||
<div className="flex h-60 flex-col justify-between gap-5 rounded-b-[10px] p-4">
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="h-5 w-5">
|
||||
<CycleGroupIcon cycleGroup={cycleStatus} className="h-4 w-4" />
|
||||
</span>
|
||||
<Tooltip tooltipContent={activeCycle.name} position="top-left" isMobile={isMobile}>
|
||||
<h3 className="break-words text-lg font-semibold">{truncateText(activeCycle.name, 70)}</h3>
|
||||
</Tooltip>
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="flex gap-1 whitespace-nowrap rounded-sm bg-amber-500/10 px-3 py-0.5 text-sm text-amber-500">
|
||||
{`${daysLeft} ${daysLeft > 1 ? "days" : "day"} left`}
|
||||
</span>
|
||||
{activeCycle.is_favorite ? (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
handleRemoveFromFavorites(e);
|
||||
}}
|
||||
>
|
||||
<Star className="h-4 w-4 fill-orange-400 text-orange-400" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
handleAddToFavorites(e);
|
||||
}}
|
||||
>
|
||||
<Star className="h-4 w-4 " color="rgb(var(--color-text-200))" />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-start gap-5 text-custom-text-200">
|
||||
<div className="flex items-start gap-1">
|
||||
<CalendarDays className="h-4 w-4" />
|
||||
<span>{renderFormattedDate(startDate)}</span>
|
||||
</div>
|
||||
<ArrowRight className="h-4 w-4 text-custom-text-200" />
|
||||
<div className="flex items-start gap-1">
|
||||
<Target className="h-4 w-4" />
|
||||
<span>{renderFormattedDate(endDate)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2.5 text-custom-text-200">
|
||||
<Avatar src={cycleOwnerDetails?.avatar} name={cycleOwnerDetails?.display_name} />
|
||||
<span className="text-custom-text-200">{cycleOwnerDetails?.display_name}</span>
|
||||
</div>
|
||||
|
||||
{activeCycle.assignee_ids.length > 0 && (
|
||||
<div className="flex items-center gap-1 text-custom-text-200">
|
||||
<AvatarGroup>
|
||||
{activeCycle.assignee_ids.map((assignee_id) => {
|
||||
const member = getUserDetails(assignee_id);
|
||||
return <Avatar key={member?.id} name={member?.display_name} src={member?.avatar} />;
|
||||
})}
|
||||
</AvatarGroup>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 text-custom-text-200">
|
||||
<div className="flex gap-2">
|
||||
<LayersIcon className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
{activeCycle.total_issues} issues
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<StateGroupIcon stateGroup="completed" height="14px" width="14px" />
|
||||
{activeCycle.completed_issues} issues
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href={`/${workspaceSlug}/projects/${projectId}/cycles/${activeCycle.id}`}
|
||||
className={cn(getButtonStyling("primary", "lg"), "w-min whitespace-nowrap")}
|
||||
>
|
||||
View cycle
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2 grid grid-cols-1 divide-y border-custom-border-200 md:grid-cols-2 md:divide-x md:divide-y-0">
|
||||
<div className="flex h-60 flex-col border-custom-border-200">
|
||||
<div className="flex h-full w-full flex-col p-4 text-custom-text-200">
|
||||
<div className="flex w-full items-center gap-2 py-1">
|
||||
<span>Progress</span>
|
||||
<LinearProgressIndicator size="md" data={progressIndicatorData} inPercentage />
|
||||
</div>
|
||||
<div className="mt-2 flex flex-col items-center gap-1">
|
||||
{Object.keys(groupedIssues).map((group, index) => (
|
||||
<SingleProgressStats
|
||||
key={index}
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="block h-3 w-3 rounded-full "
|
||||
style={{
|
||||
backgroundColor: CYCLE_STATE_GROUPS_DETAILS[index].color,
|
||||
}}
|
||||
/>
|
||||
<span className="text-xs capitalize">{group}</span>
|
||||
</div>
|
||||
}
|
||||
completed={groupedIssues[group]}
|
||||
total={activeCycle.total_issues}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-60 overflow-y-scroll border-custom-border-200">
|
||||
<ActiveCycleProgressStats cycle={activeCycle} />
|
||||
</div>
|
||||
<>
|
||||
<div className="flex flex-col gap-4">
|
||||
<ActiveCycleHeader cycle={activeCycle} workspaceSlug={workspaceSlug} projectId={projectId} />
|
||||
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2 xl:grid-cols-3">
|
||||
<ActiveCycleProgress cycle={activeCycle} />
|
||||
<ActiveCycleProductivity cycle={activeCycle} />
|
||||
<ActiveCycleStats cycle={activeCycle} workspaceSlug={workspaceSlug} projectId={projectId} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 divide-y border-custom-border-200 lg:grid-cols-2 lg:divide-x lg:divide-y-0">
|
||||
<div className="flex max-h-60 flex-col gap-3 overflow-hidden p-4">
|
||||
<div className="text-custom-primary">High priority issues</div>
|
||||
<div className="flex h-full flex-col gap-2.5 overflow-y-scroll rounded-md">
|
||||
{activeCycleIssues ? (
|
||||
activeCycleIssues.length > 0 ? (
|
||||
activeCycleIssues.map((issue) => (
|
||||
<Link
|
||||
key={issue.id}
|
||||
href={`/${workspaceSlug}/projects/${projectId}/issues/${issue.id}`}
|
||||
className="flex cursor-pointer flex-wrap items-center justify-between gap-2 rounded-md border border-custom-border-200 px-3 py-1.5"
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<PriorityIcon priority={issue.priority} withContainer size={12} />
|
||||
|
||||
<Tooltip
|
||||
isMobile={isMobile}
|
||||
tooltipHeading="Issue ID"
|
||||
tooltipContent={`${currentProjectDetails?.identifier}-${issue.sequence_id}`}
|
||||
>
|
||||
<span className="flex-shrink-0 text-xs text-custom-text-200">
|
||||
{currentProjectDetails?.identifier}-{issue.sequence_id}
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip position="top-left" tooltipContent={issue.name} isMobile={isMobile}>
|
||||
<span className="text-[0.825rem] text-custom-text-100">{truncateText(issue.name, 30)}</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="flex flex-shrink-0 items-center gap-1.5">
|
||||
<StateDropdown
|
||||
value={issue.state_id}
|
||||
onChange={() => {}}
|
||||
projectId={projectId}
|
||||
disabled
|
||||
buttonVariant="background-with-text"
|
||||
/>
|
||||
{issue.target_date && (
|
||||
<Tooltip
|
||||
tooltipHeading="Target Date"
|
||||
tooltipContent={renderFormattedDate(issue.target_date)}
|
||||
isMobile={isMobile}
|
||||
>
|
||||
<div className="flex h-full cursor-not-allowed items-center gap-1.5 rounded bg-custom-background-80 px-2 py-0.5 text-xs">
|
||||
<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 h-full items-center justify-center text-sm text-custom-text-200">
|
||||
There are no high priority issues present in this cycle.
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<Loader className="space-y-3">
|
||||
<Loader.Item height="50px" />
|
||||
<Loader.Item height="50px" />
|
||||
<Loader.Item height="50px" />
|
||||
</Loader>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex max-h-60 flex-col border-custom-border-200 p-4">
|
||||
<div className="flex items-start justify-between gap-4 py-1.5 text-xs">
|
||||
<div className="flex items-center gap-3 text-custom-text-100">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<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">
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-[#4c8fff]" />
|
||||
<span>Current</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span>
|
||||
<LayersIcon className="h-3.5 w-3.5 flex-shrink-0 text-custom-text-200" />
|
||||
</span>
|
||||
<span>
|
||||
Pending issues-{" "}
|
||||
{activeCycle.total_issues - (activeCycle.completed_issues + activeCycle.cancelled_issues)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative h-full">
|
||||
<ProgressChart
|
||||
distribution={activeCycle.distribution?.completion_chart ?? {}}
|
||||
startDate={activeCycle.start_date ?? ""}
|
||||
endDate={activeCycle.end_date ?? ""}
|
||||
totalIssues={activeCycle.total_issues}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{currentProjectUpcomingCycleIds && <UpcomingCyclesList handleEmptyStateAction={handleEmptyStateAction} />}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
@ -1,10 +1,16 @@
|
||||
import { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// hooks
|
||||
import { UpcomingCycleListItem } from "@/components/cycles";
|
||||
import { useCycle } from "@/hooks/store";
|
||||
// components
|
||||
import { UpcomingCycleListItem } from "@/components/cycles";
|
||||
// hooks
|
||||
import { useCycle } from "@/hooks/store";
|
||||
|
||||
export const UpcomingCyclesList = observer(() => {
|
||||
type Props = {
|
||||
handleEmptyStateAction: () => void;
|
||||
};
|
||||
|
||||
export const UpcomingCyclesList: FC<Props> = observer((props) => {
|
||||
const { handleEmptyStateAction } = props;
|
||||
// store hooks
|
||||
const { currentProjectUpcomingCycleIds } = useCycle();
|
||||
|
||||
@ -12,14 +18,30 @@ export const UpcomingCyclesList = observer(() => {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="bg-custom-background-80 font-semibold text-sm py-1 px-2 rounded inline-block">
|
||||
Upcoming cycles
|
||||
</div>
|
||||
<div className="mt-2 divide-y-[0.5px] divide-custom-border-200 border-b-[0.5px] border-custom-border-200">
|
||||
{currentProjectUpcomingCycleIds.map((cycleId) => (
|
||||
<UpcomingCycleListItem key={cycleId} cycleId={cycleId} />
|
||||
))}
|
||||
<div className="bg-custom-background-80 font-semibold text-sm py-1 px-2 rounded inline-block text-custom-text-400">
|
||||
Next cycles
|
||||
</div>
|
||||
{currentProjectUpcomingCycleIds.length > 0 ? (
|
||||
<div className="mt-2 divide-y-[0.5px] divide-custom-border-200 border-b-[0.5px] border-custom-border-200">
|
||||
{currentProjectUpcomingCycleIds.map((cycleId) => (
|
||||
<UpcomingCycleListItem key={cycleId} cycleId={cycleId} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full grid place-items-center py-20">
|
||||
<div className="text-center">
|
||||
<h5 className="text-xl font-medium mb-1">No upcoming cycles</h5>
|
||||
<p className="text-custom-text-400 text-base">
|
||||
Create new cycles to find them here or check
|
||||
<br />
|
||||
{"'"}All{"'"} cycles tab to see all cycles or{" "}
|
||||
<button type="button" className="text-custom-primary-100 font-medium" onClick={handleEmptyStateAction}>
|
||||
click here
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
Loading…
Reference in New Issue
Block a user