style: cycle ui revamp, and chore: code refactor (#2558)

* chore: cycle custom svg icon added and code refactor

* chore: module code refactor

* style: cycle ui revamp and code refactor

* chore: cycle card view layout fix

* chore: layout fix

* style: module and cycle title tooltip position
This commit is contained in:
Anmol Singh Bhatia 2023-10-30 19:22:27 +05:30 committed by GitHub
parent 7edaa49c21
commit 8eaac60aa5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 986 additions and 1107 deletions

View File

@ -0,0 +1,20 @@
import * as React from "react";
import { ISvgIcons } from "../type";
export const CircleDotFullIcon: React.FC<ISvgIcons> = ({
className = "text-current",
...rest
}) => (
<svg
viewBox="0 0 24 24"
className={`${className} stroke-2`}
stroke="currentColor"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...rest}
>
<circle cx="8.33333" cy="8.33333" r="5.33333" stroke-linecap="round" />
<circle cx="8.33333" cy="8.33333" r="4.33333" fill="currentColor" />
</svg>
);

View File

@ -1,6 +1,6 @@
import * as React from "react"; import * as React from "react";
import { ISvgIcons } from "./type"; import { ISvgIcons } from "../type";
export const ContrastIcon: React.FC<ISvgIcons> = ({ export const ContrastIcon: React.FC<ISvgIcons> = ({
className = "text-current", className = "text-current",

View File

@ -0,0 +1,33 @@
import * as React from "react";
import { ContrastIcon } from "./contrast-icon";
import { CircleDotFullIcon } from "./circle-dot-full-icon";
import { CircleDotDashed, Circle } from "lucide-react";
import { CYCLE_GROUP_COLORS, ICycleGroupIcon } from "./helper";
const iconComponents = {
current: ContrastIcon,
upcoming: CircleDotDashed,
completed: CircleDotFullIcon,
draft: Circle,
};
export const CycleGroupIcon: React.FC<ICycleGroupIcon> = ({
className = "",
color,
cycleGroup,
height = "12px",
width = "12px",
}) => {
const CycleIconComponent = iconComponents[cycleGroup] || ContrastIcon;
return (
<CycleIconComponent
height={height}
width={width}
color={color ?? CYCLE_GROUP_COLORS[cycleGroup]}
className={`flex-shrink-0 ${className}`}
/>
);
};

View File

@ -1,6 +1,6 @@
import * as React from "react"; import * as React from "react";
import { ISvgIcons } from "./type"; import { ISvgIcons } from "../type";
export const DoubleCircleIcon: React.FC<ISvgIcons> = ({ export const DoubleCircleIcon: React.FC<ISvgIcons> = ({
className = "text-current", className = "text-current",

View File

@ -0,0 +1,18 @@
export interface ICycleGroupIcon {
className?: string;
color?: string;
cycleGroup: TCycleGroups;
height?: string;
width?: string;
}
export type TCycleGroups = "current" | "upcoming" | "completed" | "draft";
export const CYCLE_GROUP_COLORS: {
[key in TCycleGroups]: string;
} = {
current: "#F59E0B",
upcoming: "#3F76FF",
completed: "#16A34A",
draft: "#525252",
};

View File

@ -0,0 +1,5 @@
export * from "./double-circle-icon";
export * from "./circle-dot-full-icon";
export * from "./contrast-icon";
export * from "./circle-dot-full-icon";
export * from "./cycle-group-icon";

View File

@ -1,5 +1,4 @@
export * from "./user-group-icon"; export * from "./user-group-icon";
export * from "./contrast-icon";
export * from "./dice-icon"; export * from "./dice-icon";
export * from "./layers-icon"; export * from "./layers-icon";
export * from "./photo-filter-icon"; export * from "./photo-filter-icon";
@ -7,7 +6,6 @@ export * from "./archive-icon";
export * from "./admin-profile-icon"; export * from "./admin-profile-icon";
export * from "./create-icon"; export * from "./create-icon";
export * from "./subscribe-icon"; export * from "./subscribe-icon";
export * from "./double-circle-icon";
export * from "./external-link-icon"; export * from "./external-link-icon";
export * from "./copy-icon"; export * from "./copy-icon";
export * from "./layer-stack"; export * from "./layer-stack";
@ -20,6 +18,7 @@ export * from "./blocked-icon";
export * from "./blocker-icon"; export * from "./blocker-icon";
export * from "./related-icon"; export * from "./related-icon";
export * from "./module"; export * from "./module";
export * from "./cycle";
export * from "./github-icon"; export * from "./github-icon";
export * from "./discord-icon"; export * from "./discord-icon";
export * from "./transfer-icon"; export * from "./transfer-icon";

View File

@ -36,7 +36,7 @@ type Props = {
module?: IModule; module?: IModule;
roundedTab?: boolean; roundedTab?: boolean;
noBackground?: boolean; noBackground?: boolean;
isPeekModuleDetails?: boolean; isPeekView?: boolean;
}; };
export const SidebarProgressStats: React.FC<Props> = ({ export const SidebarProgressStats: React.FC<Props> = ({
@ -46,7 +46,7 @@ export const SidebarProgressStats: React.FC<Props> = ({
module, module,
roundedTab, roundedTab,
noBackground, noBackground,
isPeekModuleDetails = false, isPeekView = false,
}) => { }) => {
const { filters, setFilters } = useIssuesView(); const { filters, setFilters } = useIssuesView();
@ -154,7 +154,7 @@ export const SidebarProgressStats: React.FC<Props> = ({
} }
completed={assignee.completed_issues} completed={assignee.completed_issues}
total={assignee.total_issues} total={assignee.total_issues}
{...(!isPeekModuleDetails && { {...(!isPeekView && {
onClick: () => { onClick: () => {
if (filters?.assignees?.includes(assignee.assignee_id ?? "")) if (filters?.assignees?.includes(assignee.assignee_id ?? ""))
setFilters({ setFilters({
@ -213,7 +213,7 @@ export const SidebarProgressStats: React.FC<Props> = ({
} }
completed={label.completed_issues} completed={label.completed_issues}
total={label.total_issues} total={label.total_issues}
{...(!isPeekModuleDetails && { {...(!isPeekView && {
onClick: () => { onClick: () => {
if (filters.labels?.includes(label.label_id ?? "")) if (filters.labels?.includes(label.label_id ?? ""))
setFilters({ setFilters({

View File

@ -0,0 +1,55 @@
import React, { useEffect } from "react";
import { useRouter } from "next/router";
// mobx
import { observer } from "mobx-react-lite";
import { useMobxStore } from "lib/mobx/store-provider";
// components
import { CycleDetailsSidebar } from "./sidebar";
type Props = {
projectId: string;
workspaceSlug: string;
};
export const CyclePeekOverview: React.FC<Props> = observer(({ projectId, workspaceSlug }) => {
const router = useRouter();
const { peekCycle } = router.query;
const ref = React.useRef(null);
const { cycle: cycleStore } = useMobxStore();
const { fetchCycleWithId } = cycleStore;
const handleClose = () => {
delete router.query.peekCycle;
router.push({
pathname: router.pathname,
query: { ...router.query },
});
};
useEffect(() => {
if (!peekCycle) return;
fetchCycleWithId(workspaceSlug, projectId, peekCycle.toString());
}, [fetchCycleWithId, peekCycle, projectId, workspaceSlug]);
return (
<>
{peekCycle && (
<div
ref={ref}
className="flex flex-col gap-3.5 h-full w-[24rem] z-10 overflow-y-auto border-l border-custom-border-100 bg-custom-sidebar-background-100 px-6 py-3.5 duration-300 flex-shrink-0"
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} />
</div>
)}
</>
);
});

View File

@ -1,64 +1,32 @@
import { FC, MouseEvent, useState } from "react"; import { FC, MouseEvent, useState } from "react";
import { useRouter } from "next/router";
// next imports // next imports
import Link from "next/link"; import Link from "next/link";
// headless ui
import { Disclosure, Transition } from "@headlessui/react";
// hooks // hooks
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
// components // components
import { SingleProgressStats } from "components/core";
import { CycleCreateUpdateModal, CycleDeleteModal } from "components/cycles"; import { CycleCreateUpdateModal, CycleDeleteModal } from "components/cycles";
// ui // ui
import { AssigneesList } from "components/ui/avatar"; import { AssigneesList } from "components/ui/avatar";
import { CustomMenu, Tooltip, LinearProgressIndicator, ContrastIcon, RunningIcon } from "@plane/ui"; import { CustomMenu, Tooltip, LayersIcon, CycleGroupIcon } from "@plane/ui";
// icons // icons
import { import { Info, LinkIcon, Pencil, Star, Trash2 } from "lucide-react";
AlarmClock,
AlertTriangle,
ArrowRight,
CalendarDays,
ChevronDown,
LinkIcon,
Pencil,
Star,
Target,
Trash2,
} from "lucide-react";
// helpers // helpers
import { getDateRangeStatus, renderShortDateWithYearFormat, findHowManyDaysLeft } from "helpers/date-time.helper"; import {
import { copyTextToClipboard, truncateText } from "helpers/string.helper"; getDateRangeStatus,
findHowManyDaysLeft,
renderShortDate,
renderShortMonthDate,
} from "helpers/date-time.helper";
import { copyTextToClipboard } from "helpers/string.helper";
// types // types
import { ICycle } from "types"; import { ICycle } from "types";
// store // store
import { useMobxStore } from "lib/mobx/store-provider"; import { useMobxStore } from "lib/mobx/store-provider";
// constants
const stateGroups = [ import { CYCLE_STATUS } from "constants/cycle";
{
key: "backlog_issues",
title: "Backlog",
color: "#dee2e6",
},
{
key: "unstarted_issues",
title: "Unstarted",
color: "#26b5ce",
},
{
key: "started_issues",
title: "Started",
color: "#f7ae59",
},
{
key: "cancelled_issues",
title: "Cancelled",
color: "#d687ff",
},
{
key: "completed_issues",
title: "Completed",
color: "#09a953",
},
];
export interface ICyclesBoardCard { export interface ICyclesBoardCard {
workspaceSlug: string; workspaceSlug: string;
@ -81,7 +49,34 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = (props) => {
const endDate = new Date(cycle.end_date ?? ""); const endDate = new Date(cycle.end_date ?? "");
const startDate = new Date(cycle.start_date ?? ""); const startDate = new Date(cycle.start_date ?? "");
const handleCopyText = () => { const router = useRouter();
const currentCycle = CYCLE_STATUS.find((status) => status.value === cycleStatus);
const areYearsEqual = startDate.getFullYear() === endDate.getFullYear();
const cycleTotalIssues =
cycle.backlog_issues +
cycle.unstarted_issues +
cycle.started_issues +
cycle.completed_issues +
cycle.cancelled_issues;
const completionPercentage = (cycle.completed_issues / cycleTotalIssues) * 100;
const issueCount = cycle
? cycleTotalIssues === 0
? "0 Issue"
: cycleTotalIssues === cycle.completed_issues
? cycleTotalIssues > 1
? `${cycleTotalIssues} Issues`
: `${cycleTotalIssues} Issue`
: `${cycle.completed_issues}/${cycleTotalIssues} Issues`
: "0 Issue";
const handleCopyText = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : ""; const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
copyTextToClipboard(`${originURL}/${workspaceSlug}/projects/${projectId}/cycles/${cycle.id}`).then(() => { copyTextToClipboard(`${originURL}/${workspaceSlug}/projects/${projectId}/cycles/${cycle.id}`).then(() => {
@ -93,21 +88,6 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = (props) => {
}); });
}; };
const progressIndicatorData = stateGroups.map((group, index) => ({
id: index,
name: group.title,
value: cycle.total_issues > 0 ? ((cycle[group.key as keyof ICycle] as number) / cycle.total_issues) * 100 : 0,
color: group.color,
}));
const groupedIssues: any = {
backlog: cycle.backlog_issues,
unstarted: cycle.unstarted_issues,
started: cycle.started_issues,
completed: cycle.completed_issues,
cancelled: cycle.cancelled_issues,
};
const handleAddToFavorites = (e: MouseEvent<HTMLButtonElement>) => { const handleAddToFavorites = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault(); e.preventDefault();
if (!workspaceSlug || !projectId) return; if (!workspaceSlug || !projectId) return;
@ -134,6 +114,29 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = (props) => {
}); });
}; };
const handleEditCycle = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setUpdateModal(true);
};
const handleDeleteCycle = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setDeleteModal(true);
};
const openCycleOverview = (e: MouseEvent<HTMLButtonElement>) => {
const { query } = router;
e.preventDefault();
e.stopPropagation();
router.push({
pathname: router.pathname,
query: { ...query, peekCycle: cycle.id },
});
};
return ( return (
<div> <div>
<CycleCreateUpdateModal <CycleCreateUpdateModal
@ -152,267 +155,119 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = (props) => {
projectId={projectId} projectId={projectId}
/> />
<div className="flex flex-col rounded-[10px] bg-custom-background-100 border border-custom-border-200 text-xs shadow"> <Link href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycle.id}`}>
<Link href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycle.id}`}> <a className="flex flex-col justify-between p-4 h-44 w-full min-w-[250px] text-sm rounded bg-custom-background-100 border border-custom-border-100 hover:shadow-md">
<a className="w-full"> <div>
<div className="flex h-full flex-col gap-4 rounded-b-[10px] p-4"> <div className="flex items-center justify-between gap-2">
<div className="flex items-center justify-between gap-1"> <div className="flex items-center gap-3">
<span className="flex items-center gap-1"> <span className="flex-shrink-0">
<span className="h-5 w-5"> <CycleGroupIcon cycleGroup={cycleStatus} className="h-3.5 w-3.5" />
<ContrastIcon
className="h-5 w-5"
color={`${
cycleStatus === "current"
? "#09A953"
: cycleStatus === "upcoming"
? "#F7AE59"
: cycleStatus === "completed"
? "#3F76FF"
: cycleStatus === "draft"
? "rgb(var(--color-text-200))"
: ""
}`}
/>
</span>
<Tooltip tooltipContent={cycle.name} className="break-words" position="top-left">
<h3 className="break-words text-lg font-semibold">{truncateText(cycle.name, 15)}</h3>
</Tooltip>
</span> </span>
<span className="flex items-center gap-1 capitalize"> <Tooltip tooltipContent={cycle.name} position="top">
<span className="text-base font-medium truncate">{cycle.name}</span>
</Tooltip>
</div>
<div className="flex items-center gap-2">
{currentCycle && (
<span <span
className={`rounded-full px-1.5 py-0.5 className="flex items-center justify-center text-xs text-center h-6 w-20 rounded-sm"
${ style={{
cycleStatus === "current" color: currentCycle.color,
? "bg-green-600/5 text-green-600" backgroundColor: `${currentCycle.color}20`,
: cycleStatus === "upcoming" }}
? "bg-orange-300/5 text-orange-300"
: cycleStatus === "completed"
? "bg-blue-500/5 text-blue-500"
: cycleStatus === "draft"
? "bg-neutral-400/5 text-neutral-400"
: ""
}`}
> >
{cycleStatus === "current" ? ( {currentCycle.value === "current"
<span className="flex gap-1 whitespace-nowrap"> ? `${findHowManyDaysLeft(cycle.end_date ?? new Date())} ${currentCycle.label}`
<RunningIcon className="h-4 w-4" /> : `${currentCycle.label}`}
{findHowManyDaysLeft(cycle.end_date ?? new Date())} Days Left
</span>
) : cycleStatus === "upcoming" ? (
<span className="flex gap-1 whitespace-nowrap">
<AlarmClock className="h-4 w-4" />
{findHowManyDaysLeft(cycle.start_date ?? new Date())} Days Left
</span>
) : cycleStatus === "completed" ? (
<span className="flex gap-1 whitespace-nowrap">
{cycle.total_issues - cycle.completed_issues > 0 && (
<Tooltip
tooltipContent={`${cycle.total_issues - cycle.completed_issues} more pending ${
cycle.total_issues - cycle.completed_issues === 1 ? "issue" : "issues"
}`}
>
<span>
<AlertTriangle className="h-3.5 w-3.5" />
</span>
</Tooltip>
)}{" "}
Completed
</span>
) : (
cycleStatus
)}
</span> </span>
{cycle.is_favorite ? (
<button onClick={handleRemoveFromFavorites}>
<Star className="h-4 w-4 text-orange-400" fill="#f6ad55" />
</button>
) : (
<button onClick={handleAddToFavorites}>
<Star className="h-4 w-4 " color="rgb(var(--color-text-200))" />
</button>
)}
</span>
</div>
<div className="flex h-4 items-center justify-start gap-5 text-custom-text-200">
{cycleStatus !== "draft" && (
<>
<div className="flex items-start gap-1">
<CalendarDays className="h-4 w-4" />
<span>{renderShortDateWithYearFormat(startDate)}</span>
</div>
<ArrowRight className="h-4 w-4" />
<div className="flex items-start gap-1">
<Target className="h-4 w-4" />
<span>{renderShortDateWithYearFormat(endDate)}</span>
</div>
</>
)} )}
</div> <button onClick={openCycleOverview}>
<Info className="h-4 w-4 text-custom-text-400" />
<div className="flex justify-between items-end"> </button>
<div className="flex flex-col gap-2 text-xs text-custom-text-200">
<div className="flex items-center gap-2">
<div className="w-16">Creator:</div>
<div className="flex items-center gap-2.5 text-custom-text-200">
{cycle.owned_by.avatar && cycle.owned_by.avatar !== "" ? (
<img
src={cycle.owned_by.avatar}
height={16}
width={16}
className="rounded-full"
alt={cycle.owned_by.display_name}
/>
) : (
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-orange-300 capitalize text-white">
{cycle.owned_by.display_name.charAt(0)}
</span>
)}
<span className="text-custom-text-200">{cycle.owned_by.display_name}</span>
</div>
</div>
<div className="flex h-5 items-center gap-2">
<div className="w-16">Members:</div>
{cycle.assignees.length > 0 ? (
<div className="flex items-center gap-1 text-custom-text-200">
<AssigneesList users={cycle.assignees} length={4} />
</div>
) : (
"No members"
)}
</div>
</div>
<div className="flex items-center">
{!isCompleted && (
<button
onClick={(e) => {
e.preventDefault();
setUpdateModal(true);
}}
className="cursor-pointer rounded p-1 text-custom-text-200 duration-300 hover:bg-custom-background-80"
>
<Pencil className="h-4 w-4" />
</button>
)}
<CustomMenu width="auto" verticalEllipsis>
{!isCompleted && (
<CustomMenu.MenuItem
onClick={(e) => {
e.preventDefault();
setDeleteModal(true);
}}
>
<span className="flex items-center justify-start gap-2">
<Trash2 className="h-4 w-4" />
<span>Delete cycle</span>
</span>
</CustomMenu.MenuItem>
)}
<CustomMenu.MenuItem
onClick={(e) => {
e.preventDefault();
handleCopyText();
}}
>
<span className="flex items-center justify-start gap-2">
<LinkIcon className="h-4 w-4" />
<span>Copy cycle link</span>
</span>
</CustomMenu.MenuItem>
</CustomMenu>
</div>
</div> </div>
</div> </div>
</a> </div>
</Link>
<div className="flex h-full flex-col rounded-b-[10px]"> <div className="flex flex-col gap-3">
<Disclosure> <div className="flex items-center justify-between">
{({ open }) => ( <div className="flex items-center gap-1.5 text-custom-text-200">
<div <LayersIcon className="h-4 w-4 text-custom-text-300" />
className={`flex h-full w-full flex-col rounded-b-[10px] border-t border-custom-border-200 bg-custom-background-80 text-custom-text-200 ${ <span className="text-xs text-custom-text-300">{issueCount}</span>
open ? "" : "flex-row"
}`}
>
<div className="flex w-full items-center gap-2 px-4 py-1">
<span>Progress</span>
<Tooltip
tooltipContent={
<div className="flex w-56 flex-col">
{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: stateGroups[index].color,
}}
/>
<span className="text-xs capitalize">{group}</span>
</div>
}
completed={groupedIssues[group]}
total={cycle.total_issues}
/>
))}
</div>
}
position="bottom"
>
<div className="flex w-full items-center">
<LinearProgressIndicator data={progressIndicatorData} noTooltip={true} />
</div>
</Tooltip>
<Disclosure.Button>
<span className="p-1">
<ChevronDown className={`h-3 w-3 ${open ? "rotate-180 transform" : ""}`} aria-hidden="true" />
</span>
</Disclosure.Button>
</div>
<Transition show={open}>
<Disclosure.Panel>
<div className="overflow-hidden rounded-b-md bg-custom-background-80 py-3 shadow">
<div className="col-span-2 space-y-3 px-4">
<div className="space-y-3 text-xs">
{stateGroups.map((group) => (
<div key={group.key} className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<span
className="block h-2 w-2 rounded-full"
style={{
backgroundColor: group.color,
}}
/>
<h6 className="text-xs">{group.title}</h6>
</div>
<div>
<span>
{cycle[group.key as keyof ICycle] as number}{" "}
<span className="text-custom-text-200">
-{" "}
{cycle.total_issues > 0
? `${Math.round(
((cycle[group.key as keyof ICycle] as number) / cycle.total_issues) * 100
)}%`
: "0%"}
</span>
</span>
</div>
</div>
))}
</div>
</div>
</div>
</Disclosure.Panel>
</Transition>
</div> </div>
)} {cycle.assignees.length > 0 && (
</Disclosure> <Tooltip tooltipContent={`${cycle.assignees.length} Members`}>
</div> <div className="flex items-center gap-1 cursor-default">
</div> <AssigneesList users={cycle.assignees} length={3} />
</div>
</Tooltip>
)}
</div>
<Tooltip
tooltipContent={isNaN(completionPercentage) ? "0" : `${completionPercentage.toFixed(0)}%`}
position="top-left"
>
<div className="flex items-center w-full">
<div
className="bar relative h-1.5 w-full rounded bg-custom-background-90"
style={{
boxShadow: "1px 1px 4px 0px rgba(161, 169, 191, 0.35) inset",
}}
>
<div
className="absolute top-0 left-0 h-1.5 rounded bg-blue-600 duration-300"
style={{
width: `${isNaN(completionPercentage) ? 0 : completionPercentage.toFixed(0)}%`,
}}
/>
</div>
</div>
</Tooltip>
<div className="flex items-center justify-between">
<span className="text-xs text-custom-text-300">
{areYearsEqual ? renderShortDate(startDate, "_ _") : renderShortMonthDate(startDate, "_ _")} -{" "}
{areYearsEqual ? renderShortDate(endDate, "_ _") : renderShortMonthDate(endDate, "_ _")}
</span>
<div className="flex items-center gap-1.5 z-10">
{cycle.is_favorite ? (
<button type="button" onClick={handleRemoveFromFavorites}>
<Star className="h-3.5 w-3.5 text-amber-500 fill-current" />
</button>
) : (
<button type="button" onClick={handleAddToFavorites}>
<Star className="h-3.5 w-3.5 text-custom-text-200" />
</button>
)}
<CustomMenu width="auto" ellipsis className="z-10">
{!isCompleted && (
<>
<CustomMenu.MenuItem onClick={handleEditCycle}>
<span className="flex items-center justify-start gap-2">
<Pencil className="h-3 w-3" />
<span>Edit cycle</span>
</span>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={handleDeleteCycle}>
<span className="flex items-center justify-start gap-2">
<Trash2 className="h-3 w-3" />
<span>Delete module</span>
</span>
</CustomMenu.MenuItem>
</>
)}
<CustomMenu.MenuItem onClick={handleCopyText}>
<span className="flex items-center justify-start gap-2">
<LinkIcon className="h-3 w-3" />
<span>Copy cycle link</span>
</span>
</CustomMenu.MenuItem>
</CustomMenu>
</div>
</div>
</div>
</a>
</Link>
</div> </div>
); );
}; };

View File

@ -2,26 +2,41 @@ import { FC } from "react";
// types // types
import { ICycle } from "types"; import { ICycle } from "types";
// components // components
import { CyclesBoardCard } from "components/cycles"; import { CyclePeekOverview, CyclesBoardCard } from "components/cycles";
export interface ICyclesBoard { export interface ICyclesBoard {
cycles: ICycle[]; cycles: ICycle[];
filter: string; filter: string;
workspaceSlug: string; workspaceSlug: string;
projectId: string; projectId: string;
peekCycle: string;
} }
export const CyclesBoard: FC<ICyclesBoard> = (props) => { export const CyclesBoard: FC<ICyclesBoard> = (props) => {
const { cycles, filter, workspaceSlug, projectId } = props; const { cycles, filter, workspaceSlug, projectId, peekCycle } = props;
return ( return (
<div className="grid grid-cols-1 gap-9 lg:grid-cols-2 xl:grid-cols-3"> <>
{cycles.length > 0 ? ( {cycles.length > 0 ? (
<> <div className="h-full w-full">
{cycles.map((cycle) => ( <div className="flex justify-between h-full w-full">
<CyclesBoardCard key={cycle.id} workspaceSlug={workspaceSlug} projectId={projectId} cycle={cycle} /> <div
))} className={`grid grid-cols-1 gap-6 p-8 h-full w-full overflow-y-auto ${
</> peekCycle
? "lg:grid-cols-1 xl:grid-cols-2 3xl:grid-cols-3"
: "lg:grid-cols-2 xl:grid-cols-3 3xl:grid-cols-4"
} auto-rows-max transition-all `}
>
{cycles.map((cycle) => (
<CyclesBoardCard key={cycle.id} workspaceSlug={workspaceSlug} projectId={projectId} cycle={cycle} />
))}
</div>
<CyclePeekOverview
projectId={projectId?.toString() ?? ""}
workspaceSlug={workspaceSlug?.toString() ?? ""}
/>
</div>
</div>
) : ( ) : (
<div className="h-full grid place-items-center text-center"> <div className="h-full grid place-items-center text-center">
<div className="space-y-2"> <div className="space-y-2">
@ -50,6 +65,6 @@ export const CyclesBoard: FC<ICyclesBoard> = (props) => {
</div> </div>
</div> </div>
)} )}
</div> </>
); );
}; };

View File

@ -1,29 +1,30 @@
import { FC, MouseEvent, useState } from "react"; import { FC, MouseEvent, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router";
// stores
import { useMobxStore } from "lib/mobx/store-provider";
// hooks // hooks
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
// components // components
import { CycleCreateUpdateModal, CycleDeleteModal } from "components/cycles"; import { CycleCreateUpdateModal, CycleDeleteModal } from "components/cycles";
import { AssigneesList } from "components/ui";
// ui // ui
import { CustomMenu, RadialProgressBar, Tooltip, LinearProgressIndicator, ContrastIcon, RunningIcon } from "@plane/ui"; import { CustomMenu, Tooltip, CircularProgressIndicator, CycleGroupIcon } from "@plane/ui";
// icons // icons
import { import { Check, Info, LinkIcon, Pencil, Star, Trash2, User2 } from "lucide-react";
AlarmClock,
AlertTriangle,
ArrowRight,
CalendarDays,
LinkIcon,
Pencil,
Star,
Target,
Trash2,
} from "lucide-react";
// helpers // helpers
import { getDateRangeStatus, renderShortDateWithYearFormat, findHowManyDaysLeft } from "helpers/date-time.helper"; import {
getDateRangeStatus,
findHowManyDaysLeft,
renderShortDate,
renderShortMonthDate,
} from "helpers/date-time.helper";
import { copyTextToClipboard } from "helpers/string.helper"; import { copyTextToClipboard } from "helpers/string.helper";
// types // types
import { ICycle } from "types"; import { ICycle } from "types";
import { useMobxStore } from "lib/mobx/store-provider"; // constants
import { CYCLE_STATUS } from "constants/cycle";
type TCyclesListItem = { type TCyclesListItem = {
cycle: ICycle; cycle: ICycle;
@ -35,34 +36,6 @@ type TCyclesListItem = {
projectId: string; projectId: string;
}; };
const stateGroups = [
{
key: "backlog_issues",
title: "Backlog",
color: "#dee2e6",
},
{
key: "unstarted_issues",
title: "Unstarted",
color: "#26b5ce",
},
{
key: "started_issues",
title: "Started",
color: "#f7ae59",
},
{
key: "cancelled_issues",
title: "Cancelled",
color: "#d687ff",
},
{
key: "completed_issues",
title: "Completed",
color: "#09a953",
},
];
export const CyclesListItem: FC<TCyclesListItem> = (props) => { export const CyclesListItem: FC<TCyclesListItem> = (props) => {
const { cycle, workspaceSlug, projectId } = props; const { cycle, workspaceSlug, projectId } = props;
// store // store
@ -78,7 +51,28 @@ export const CyclesListItem: FC<TCyclesListItem> = (props) => {
const endDate = new Date(cycle.end_date ?? ""); const endDate = new Date(cycle.end_date ?? "");
const startDate = new Date(cycle.start_date ?? ""); const startDate = new Date(cycle.start_date ?? "");
const handleCopyText = () => { const router = useRouter();
const cycleTotalIssues =
cycle.backlog_issues +
cycle.unstarted_issues +
cycle.started_issues +
cycle.completed_issues +
cycle.cancelled_issues;
const renderDate = cycle.start_date || cycle.end_date;
const areYearsEqual = startDate.getFullYear() === endDate.getFullYear();
const completionPercentage = (cycle.completed_issues / cycleTotalIssues) * 100;
const progress = isNaN(completionPercentage) ? 0 : Math.floor(completionPercentage);
const currentCycle = CYCLE_STATUS.find((status) => status.value === cycleStatus);
const handleCopyText = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : ""; const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
copyTextToClipboard(`${originURL}/${workspaceSlug}/projects/${projectId}/cycles/${cycle.id}`).then(() => { copyTextToClipboard(`${originURL}/${workspaceSlug}/projects/${projectId}/cycles/${cycle.id}`).then(() => {
@ -90,13 +84,6 @@ export const CyclesListItem: FC<TCyclesListItem> = (props) => {
}); });
}; };
const progressIndicatorData = stateGroups.map((group, index) => ({
id: index,
name: group.title,
value: cycle.total_issues > 0 ? ((cycle[group.key as keyof ICycle] as number) / cycle.total_issues) * 100 : 0,
color: group.color,
}));
const handleAddToFavorites = (e: MouseEvent<HTMLButtonElement>) => { const handleAddToFavorites = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault(); e.preventDefault();
if (!workspaceSlug || !projectId) return; if (!workspaceSlug || !projectId) return;
@ -123,224 +110,31 @@ export const CyclesListItem: FC<TCyclesListItem> = (props) => {
}); });
}; };
const handleEditCycle = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setUpdateModal(true);
};
const handleDeleteCycle = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setDeleteModal(true);
};
const openCycleOverview = (e: MouseEvent<HTMLButtonElement>) => {
const { query } = router;
e.preventDefault();
e.stopPropagation();
router.push({
pathname: router.pathname,
query: { ...query, peekCycle: cycle.id },
});
};
return ( return (
<> <>
<div className="relative flex items-center gap-1 hover:bg-custom-background-80 transition-all rounded px-2 pl-3">
<div className="w-full text-xs py-3">
<Link href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycle.id}`}>
<a className="w-full h-full relative overflow-hidden flex items-center gap-2">
{/* left content */}
<div className="relative flex items-center gap-2 overflow-hidden">
{/* cycle state */}
<div className="flex-shrink-0">
<ContrastIcon
className="h-5 w-5"
color={`${
cycleStatus === "current"
? "#09A953"
: cycleStatus === "upcoming"
? "#F7AE59"
: cycleStatus === "completed"
? "#3F76FF"
: cycleStatus === "draft"
? "rgb(var(--color-text-200))"
: ""
}`}
/>
</div>
{/* cycle title and description */}
<div className="max-w-xl">
<Tooltip tooltipContent={cycle.name} className="break-words" position="top-left">
<div className="text-base font-semibold line-clamp-1 pr-5 overflow-hidden break-words">
{cycle.name}
</div>
</Tooltip>
{cycle.description && (
<div className="mt-1 text-custom-text-200 break-words w-full line-clamp-2">{cycle.description}</div>
)}
</div>
</div>
{/* right content */}
<div className="ml-auto flex-shrink-0 relative flex items-center gap-3 p-2">
{/* cycle status */}
<div
className={`rounded-full px-2 py-1
${
cycleStatus === "current"
? "bg-green-600/10 text-green-600"
: cycleStatus === "upcoming"
? "bg-orange-300/10 text-orange-300"
: cycleStatus === "completed"
? "bg-blue-500/10 text-blue-500"
: cycleStatus === "draft"
? "bg-neutral-400/10 text-neutral-400"
: ""
}`}
>
{cycleStatus === "current" ? (
<span className="flex items-center gap-1 whitespace-nowrap">
<RunningIcon className="h-3.5 w-3.5" />
{findHowManyDaysLeft(cycle.end_date ?? new Date())} days left
</span>
) : cycleStatus === "upcoming" ? (
<span className="flex items-center gap-1">
<AlarmClock className="h-3.5 w-3.5" />
{findHowManyDaysLeft(cycle.start_date ?? new Date())} days left
</span>
) : cycleStatus === "completed" ? (
<span className="flex items-center gap-1">
{cycle.total_issues - cycle.completed_issues > 0 && (
<Tooltip
tooltipContent={`${cycle.total_issues - cycle.completed_issues} more pending ${
cycle.total_issues - cycle.completed_issues === 1 ? "issue" : "issues"
}`}
>
<span>
<AlertTriangle className="h-3.5 w-3.5" />
</span>
</Tooltip>
)}{" "}
Completed
</span>
) : (
cycleStatus
)}
</div>
{/* cycle start_date and target_date */}
{cycleStatus !== "draft" && (
<div className="flex items-center justify-start gap-2 text-custom-text-200">
<div className="flex items-start gap-1 whitespace-nowrap">
<CalendarDays className="h-4 w-4" />
<span>{renderShortDateWithYearFormat(startDate)}</span>
</div>
<ArrowRight className="h-4 w-4" />
<div className="flex items-start gap-1 whitespace-nowrap">
<Target className="h-4 w-4" />
<span>{renderShortDateWithYearFormat(endDate)}</span>
</div>
</div>
)}
{/* cycle created by */}
<div className="flex items-center text-custom-text-200">
{cycle.owned_by.avatar && cycle.owned_by.avatar !== "" ? (
<img
src={cycle.owned_by.avatar}
height={16}
width={16}
className="rounded-full"
alt={cycle.owned_by.display_name}
/>
) : (
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-orange-300 capitalize text-white">
{cycle.owned_by.display_name.charAt(0)}
</span>
)}
</div>
{/* cycle progress */}
<Tooltip
position="top-right"
tooltipContent={
<div className="flex w-80 items-center gap-2 px-4 py-1">
<span>Progress</span>
<LinearProgressIndicator data={progressIndicatorData} />
</div>
}
>
<span
className={`rounded-md px-1.5 py-1
${
cycleStatus === "current"
? "border border-green-600 bg-green-600/5 text-green-600"
: cycleStatus === "upcoming"
? "border border-orange-300 bg-orange-300/5 text-orange-300"
: cycleStatus === "completed"
? "border border-blue-500 bg-blue-500/5 text-blue-500"
: cycleStatus === "draft"
? "border border-neutral-400 bg-neutral-400/5 text-neutral-400"
: ""
}`}
>
{cycleStatus === "current" ? (
<span className="flex gap-1 whitespace-nowrap">
{cycle.total_issues > 0 ? (
<>
<RadialProgressBar progress={(cycle.completed_issues / cycle.total_issues) * 100} />
<span>{Math.floor((cycle.completed_issues / cycle.total_issues) * 100)} %</span>
</>
) : (
<span className="normal-case">No issues present</span>
)}
</span>
) : cycleStatus === "upcoming" ? (
<span className="flex gap-1">
<RadialProgressBar progress={100} /> Yet to start
</span>
) : cycleStatus === "completed" ? (
<span className="flex gap-1">
<RadialProgressBar progress={100} />
<span>{100} %</span>
</span>
) : (
<span className="flex gap-1">
<RadialProgressBar progress={(cycle.total_issues / cycle.completed_issues) * 100} />
{cycleStatus}
</span>
)}
</span>
</Tooltip>
{/* cycle favorite */}
{cycle.is_favorite ? (
<button type="button" onClick={handleRemoveFromFavorites}>
<Star className="h-4 w-4 text-orange-400" fill="#f6ad55" />
</button>
) : (
<button type="button" onClick={handleAddToFavorites}>
<Star className="h-4 w-4 " color="rgb(var(--color-text-200))" />
</button>
)}
</div>
</a>
</Link>
</div>
<div className="flex-shrink-0">
<CustomMenu width="auto" verticalEllipsis>
{!isCompleted && (
<CustomMenu.MenuItem onClick={() => setUpdateModal(true)}>
<span className="flex items-center justify-start gap-2">
<Pencil className="h-4 w-4" />
<span>Edit Cycle</span>
</span>
</CustomMenu.MenuItem>
)}
{!isCompleted && (
<CustomMenu.MenuItem onClick={() => setDeleteModal(true)}>
<span className="flex items-center justify-start gap-2">
<Trash2 className="h-4 w-4" />
<span>Delete cycle</span>
</span>
</CustomMenu.MenuItem>
)}
<CustomMenu.MenuItem onClick={handleCopyText}>
<span className="flex items-center justify-start gap-2">
<LinkIcon className="h-4 w-4" />
<span>Copy cycle link</span>
</span>
</CustomMenu.MenuItem>
</CustomMenu>
</div>
</div>
<CycleCreateUpdateModal <CycleCreateUpdateModal
data={cycle} data={cycle}
isOpen={updateModal} isOpen={updateModal}
@ -348,7 +142,6 @@ export const CyclesListItem: FC<TCyclesListItem> = (props) => {
workspaceSlug={workspaceSlug} workspaceSlug={workspaceSlug}
projectId={projectId} projectId={projectId}
/> />
<CycleDeleteModal <CycleDeleteModal
cycle={cycle} cycle={cycle}
isOpen={deleteModal} isOpen={deleteModal}
@ -356,6 +149,110 @@ export const CyclesListItem: FC<TCyclesListItem> = (props) => {
workspaceSlug={workspaceSlug} workspaceSlug={workspaceSlug}
projectId={projectId} projectId={projectId}
/> />
<Link href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycle.id}`}>
<a className="group flex items-center justify-between gap-5 px-10 py-6 h-16 w-full text-sm bg-custom-background-100 border-b border-custom-border-100 hover:bg-custom-background-90">
<div className="flex items-center gap-3 w-full truncate">
<div className="flex items-center gap-4 truncate">
<span className="flex-shrink-0">
<CircularProgressIndicator size={38} percentage={progress}>
{isCompleted ? (
<span className="text-sm text-custom-primary-100">{`!`}</span>
) : progress === 100 ? (
<Check className="h-3 w-3 text-custom-primary-100 stroke-[2]" />
) : (
<span className="text-xs text-custom-text-300">{`${progress}%`}</span>
)}
</CircularProgressIndicator>
</span>
<div className="flex items-center gap-2.5">
<span className="flex-shrink-0">
<CycleGroupIcon cycleGroup={cycleStatus} className="h-3.5 w-3.5" />
</span>
<Tooltip tooltipContent={cycle.name} position="top">
<span className="text-base font-medium truncate">{cycle.name}</span>
</Tooltip>
</div>
</div>
<button onClick={openCycleOverview} className="flex-shrink-0 hidden group-hover:flex z-10">
<Info className="h-4 w-4 text-custom-text-400" />
</button>
</div>
<div className="flex items-center gap-2.5 justify-end w-full md:w-auto md:flex-shrink-0 ">
<div className="flex items-center justify-center">
{currentCycle && (
<span
className="flex items-center justify-center text-xs text-center h-6 w-20 rounded-sm"
style={{
color: currentCycle.color,
backgroundColor: `${currentCycle.color}20`,
}}
>
{currentCycle.value === "current"
? `${findHowManyDaysLeft(cycle.end_date ?? new Date())} ${currentCycle.label}`
: `${currentCycle.label}`}
</span>
)}
</div>
{renderDate && (
<span className="flex items-center justify-center gap-2 w-28 text-xs text-custom-text-300">
{areYearsEqual ? renderShortDate(startDate, "_ _") : renderShortMonthDate(startDate, "_ _")}
{" - "}
{areYearsEqual ? renderShortDate(endDate, "_ _") : renderShortMonthDate(endDate, "_ _")}
</span>
)}
<Tooltip tooltipContent={`${cycle.assignees.length} Members`}>
<div className="flex items-center justify-center gap-1 cursor-default w-16">
{cycle.assignees.length > 0 ? (
<AssigneesList users={cycle.assignees} length={2} />
) : (
<span className="flex items-end justify-center h-5 w-5 bg-custom-background-80 rounded-full border border-dashed border-custom-text-400">
<User2 className="h-4 w-4 text-custom-text-400" />
</span>
)}
</div>
</Tooltip>
{cycle.is_favorite ? (
<button type="button" onClick={handleRemoveFromFavorites}>
<Star className="h-3.5 w-3.5 text-amber-500 fill-current" />
</button>
) : (
<button type="button" onClick={handleAddToFavorites}>
<Star className="h-3.5 w-3.5 text-custom-text-200" />
</button>
)}
<CustomMenu width="auto" ellipsis className="z-10">
{!isCompleted && (
<>
<CustomMenu.MenuItem onClick={handleEditCycle}>
<span className="flex items-center justify-start gap-2">
<Pencil className="h-3 w-3" />
<span>Edit cycle</span>
</span>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={handleDeleteCycle}>
<span className="flex items-center justify-start gap-2">
<Trash2 className="h-3 w-3" />
<span>Delete module</span>
</span>
</CustomMenu.MenuItem>
</>
)}
<CustomMenu.MenuItem onClick={handleCopyText}>
<span className="flex items-center justify-start gap-2">
<LinkIcon className="h-3 w-3" />
<span>Copy cycle link</span>
</span>
</CustomMenu.MenuItem>
</CustomMenu>
</div>
</a>
</Link>
</> </>
); );
}; };

View File

@ -1,6 +1,7 @@
import { FC } from "react"; import { FC } from "react";
// components // components
import { CyclesListItem } from "./cycles-list-item"; import { CyclePeekOverview, CyclesListItem } from "components/cycles";
// ui // ui
import { Loader } from "@plane/ui"; import { Loader } from "@plane/ui";
// types // types
@ -17,18 +18,22 @@ export const CyclesList: FC<ICyclesList> = (props) => {
const { cycles, filter, workspaceSlug, projectId } = props; const { cycles, filter, workspaceSlug, projectId } = props;
return ( return (
<div> <>
{cycles ? ( {cycles ? (
<> <>
{cycles.length > 0 ? ( {cycles.length > 0 ? (
<div className="divide-y divide-custom-border-200"> <div className="h-full overflow-y-auto">
{cycles.map((cycle) => ( <div className="flex justify-between h-full w-full">
<div className="hover:bg-custom-background-80" key={cycle.id}> <div className="flex flex-col h-full w-full overflow-y-auto">
<div className="flex flex-col border-custom-border-200"> {cycles.map((cycle) => (
<CyclesListItem cycle={cycle} workspaceSlug={workspaceSlug} projectId={projectId} /> <CyclesListItem cycle={cycle} workspaceSlug={workspaceSlug} projectId={projectId} />
</div> ))}
</div> </div>
))} <CyclePeekOverview
projectId={projectId?.toString() ?? ""}
workspaceSlug={workspaceSlug?.toString() ?? ""}
/>
</div>
</div> </div>
) : ( ) : (
<div className="h-full grid place-items-center text-center"> <div className="h-full grid place-items-center text-center">
@ -68,6 +73,6 @@ export const CyclesList: FC<ICyclesList> = (props) => {
<Loader.Item height="50px" /> <Loader.Item height="50px" />
</Loader> </Loader>
)} )}
</div> </>
); );
}; };

View File

@ -15,10 +15,11 @@ export interface ICyclesView {
layout: TCycleLayout; layout: TCycleLayout;
workspaceSlug: string; workspaceSlug: string;
projectId: string; projectId: string;
peekCycle: string;
} }
export const CyclesView: FC<ICyclesView> = observer((props) => { export const CyclesView: FC<ICyclesView> = observer((props) => {
const { filter, layout, workspaceSlug, projectId } = props; const { filter, layout, workspaceSlug, projectId, peekCycle } = props;
// store // store
const { cycle: cycleStore } = useMobxStore(); const { cycle: cycleStore } = useMobxStore();
@ -50,7 +51,13 @@ export const CyclesView: FC<ICyclesView> = observer((props) => {
{layout === "board" && ( {layout === "board" && (
<> <>
{!isLoading ? ( {!isLoading ? (
<CyclesBoard cycles={cyclesList} filter={filter} workspaceSlug={workspaceSlug} projectId={projectId} /> <CyclesBoard
cycles={cyclesList}
filter={filter}
workspaceSlug={workspaceSlug}
projectId={projectId}
peekCycle={peekCycle}
/>
) : ( ) : (
<Loader className="grid grid-cols-1 gap-9 md:grid-cols-2 lg:grid-cols-3"> <Loader className="grid grid-cols-1 gap-9 md:grid-cols-2 lg:grid-cols-3">
<Loader.Item height="200px" /> <Loader.Item height="200px" />

View File

@ -17,3 +17,5 @@ export * from "./cycles-board";
export * from "./cycles-board-card"; export * from "./cycles-board-card";
export * from "./cycles-gantt"; export * from "./cycles-gantt";
export * from "./delete-modal"; export * from "./delete-modal";
export * from "./cycle-peek-overview";
export * from "./cycles-list-item";

View File

@ -15,36 +15,29 @@ import { SidebarProgressStats } from "components/core";
import ProgressChart from "components/core/sidebar/progress-chart"; import ProgressChart from "components/core/sidebar/progress-chart";
import { CycleDeleteModal } from "components/cycles/delete-modal"; import { CycleDeleteModal } from "components/cycles/delete-modal";
// ui // ui
import { CustomRangeDatePicker } from "components/ui"; import { Avatar, CustomRangeDatePicker } from "components/ui";
import { CustomMenu, Loader, ProgressBar } from "@plane/ui"; import { CustomMenu, Loader, LayersIcon } from "@plane/ui";
// icons // icons
import { import { ChevronDown, LinkIcon, Trash2, UserCircle2, AlertCircle, ChevronRight, MoveRight } from "lucide-react";
CalendarDays,
ChevronDown,
File,
MoveRight,
LinkIcon,
PieChart,
Trash2,
UserCircle2,
AlertCircle,
} from "lucide-react";
// helpers // helpers
import { capitalizeFirstLetter, copyUrlToClipboard } from "helpers/string.helper"; import { copyUrlToClipboard } from "helpers/string.helper";
import { import {
findHowManyDaysLeft,
getDateRangeStatus, getDateRangeStatus,
isDateGreaterThanToday, isDateGreaterThanToday,
renderDateFormat, renderDateFormat,
renderShortDateWithYearFormat, renderShortDate,
renderShortMonthDate,
} from "helpers/date-time.helper"; } from "helpers/date-time.helper";
// types // types
import { ICycle } from "types"; import { ICycle } from "types";
// fetch-keys // fetch-keys
import { CYCLE_DETAILS } from "constants/fetch-keys"; import { CYCLE_DETAILS } from "constants/fetch-keys";
import { CYCLE_STATUS } from "constants/cycle";
type Props = { type Props = {
isOpen: boolean;
cycleId: string; cycleId: string;
handleClose: () => void;
}; };
// services // services
@ -52,12 +45,12 @@ const cycleService = new CycleService();
// TODO: refactor the whole component // TODO: refactor the whole component
export const CycleDetailsSidebar: React.FC<Props> = observer((props) => { export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
const { isOpen, cycleId } = props; const { cycleId, handleClose } = props;
const [cycleDeleteModal, setCycleDeleteModal] = useState(false); const [cycleDeleteModal, setCycleDeleteModal] = useState(false);
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId } = router.query; const { workspaceSlug, projectId, peekCycle } = router.query;
const { user: userStore, cycle: cycleDetailsStore } = useMobxStore(); const { user: userStore, cycle: cycleDetailsStore } = useMobxStore();
@ -280,6 +273,22 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
if (!cycleDetails) return null; if (!cycleDetails) return null;
const endDate = new Date(cycleDetails.end_date ?? "");
const startDate = new Date(cycleDetails.start_date ?? "");
const areYearsEqual = startDate.getFullYear() === endDate.getFullYear();
const currentCycle = CYCLE_STATUS.find((status) => status.value === cycleStatus);
const issueCount =
cycleDetails.total_issues === 0
? "0 Issue"
: cycleDetails.total_issues === cycleDetails.completed_issues
? cycleDetails.total_issues > 1
? `${cycleDetails.total_issues}`
: `${cycleDetails.total_issues}`
: `${cycleDetails.completed_issues}/${cycleDetails.total_issues}`;
return ( return (
<> <>
{cycleDetails && workspaceSlug && projectId && ( {cycleDetails && workspaceSlug && projectId && (
@ -291,327 +300,266 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
projectId={projectId.toString()} projectId={projectId.toString()}
/> />
)} )}
<div
className={`fixed top-[66px] ${
isOpen ? "right-0" : "-right-[24rem]"
} h-full w-[24rem] overflow-y-auto border-l border-custom-border-200 bg-custom-sidebar-background-100 pt-5 pb-10 duration-300`}
>
{cycleDetails ? (
<>
<div className="flex flex-col items-start justify-center">
<div className="flex gap-2.5 px-5 text-sm">
<div className="flex items-center">
<span className="flex items-center rounded border-[0.5px] border-custom-border-200 bg-custom-background-90 px-2 py-1 text-center text-xs capitalize">
{capitalizeFirstLetter(cycleStatus)}
</span>
</div>
<div className="relative flex h-full w-52 items-center gap-2">
<Popover className="flex h-full items-center justify-center rounded-lg">
{({}) => (
<>
<Popover.Button
disabled={isCompleted ?? false}
className={`group flex h-full items-center gap-2 whitespace-nowrap rounded border-[0.5px] border-custom-border-200 bg-custom-background-90 px-2 py-1 text-xs ${
cycleDetails.start_date ? "" : "text-custom-text-200"
}`}
>
<CalendarDays className="h-3 w-3" />
<span>
{renderShortDateWithYearFormat(
new Date(`${watch("start_date") ? watch("start_date") : cycleDetails?.start_date}`),
"Start date"
)}
</span>
</Popover.Button>
<Transition {cycleDetails ? (
as={React.Fragment} <>
enter="transition ease-out duration-200" <div className="flex items-center justify-between w-full">
enterFrom="opacity-0 translate-y-1" <div>
enterTo="opacity-100 translate-y-0" {peekCycle && (
leave="transition ease-in duration-150" <button
leaveFrom="opacity-100 translate-y-0" className="flex items-center justify-center h-5 w-5 rounded-full bg-custom-border-300"
leaveTo="opacity-0 translate-y-1" onClick={() => handleClose()}
> >
<Popover.Panel className="absolute top-10 -right-5 z-20 transform overflow-hidden"> <ChevronRight className="h-3 w-3 text-white stroke-2" />
<CustomRangeDatePicker </button>
value={watch("start_date") ? watch("start_date") : cycleDetails?.start_date} )}
onChange={(val) => { </div>
if (val) { <div className="flex items-center gap-3.5">
handleStartDateChange(val); <button onClick={handleCopyText}>
} <LinkIcon className="h-3 w-3 text-custom-text-300" />
}} </button>
startDate={watch("start_date") ? `${watch("start_date")}` : null} {!isCompleted && (
endDate={watch("end_date") ? `${watch("end_date")}` : null} <CustomMenu width="lg" ellipsis>
maxDate={new Date(`${watch("end_date")}`)} <CustomMenu.MenuItem onClick={() => setCycleDeleteModal(true)}>
selectsStart <span className="flex items-center justify-start gap-2">
/> <Trash2 className="h-4 w-4" />
</Popover.Panel> <span>Delete</span>
</Transition> </span>
</> </CustomMenu.MenuItem>
)} </CustomMenu>
</Popover> )}
<span> </div>
<MoveRight className="h-3 w-3 text-custom-text-200" /> </div>
</span>
<Popover className="flex h-full items-center justify-center rounded-lg">
{({}) => (
<>
<Popover.Button
disabled={isCompleted ?? false}
className={`group flex items-center gap-2 whitespace-nowrap rounded border-[0.5px] border-custom-border-200 bg-custom-background-90 px-2 py-1 text-xs ${
cycleDetails.end_date ? "" : "text-custom-text-200"
}`}
>
<CalendarDays className="h-3 w-3" />
<span> <div className="flex flex-col gap-3">
{renderShortDateWithYearFormat( <h4 className="text-xl font-semibold break-words w-full text-custom-text-100">{cycleDetails.name}</h4>
new Date(`${watch("end_date") ? watch("end_date") : cycleDetails?.end_date}`), <div className="flex items-center gap-5">
"End date" {currentCycle && (
)} <span
</span> className="flex items-center justify-center text-xs text-center h-6 w-20 rounded-sm"
</Popover.Button> style={{
color: currentCycle.color,
backgroundColor: `${currentCycle.color}20`,
}}
>
{currentCycle.value === "current"
? `${findHowManyDaysLeft(cycleDetails.end_date ?? new Date())} ${currentCycle.label}`
: `${currentCycle.label}`}
</span>
)}
<div className="relative flex h-full w-52 items-center gap-2.5">
<Popover className="flex h-full items-center justify-center rounded-lg">
{({}) => (
<>
<Popover.Button
disabled={isCompleted ?? false}
className="text-sm text-custom-text-300 font-medium cursor-default"
>
{areYearsEqual ? renderShortDate(startDate, "_ _") : renderShortMonthDate(startDate, "_ _")}
</Popover.Button>
<Transition <Transition
as={React.Fragment} as={React.Fragment}
enter="transition ease-out duration-200" enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1" enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0" enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150" leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0" leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1" leaveTo="opacity-0 translate-y-1"
> >
<Popover.Panel className="absolute top-10 -right-5 z-20 transform overflow-hidden"> <Popover.Panel className="absolute top-10 -right-5 z-20 transform overflow-hidden">
<CustomRangeDatePicker <CustomRangeDatePicker
value={watch("end_date") ? watch("end_date") : cycleDetails?.end_date} value={watch("start_date") ? watch("start_date") : cycleDetails?.start_date}
onChange={(val) => { onChange={(val) => {
if (val) { if (val) {
handleEndDateChange(val); handleStartDateChange(val);
} }
}} }}
startDate={watch("start_date") ? `${watch("start_date")}` : null} startDate={watch("start_date") ? `${watch("start_date")}` : null}
endDate={watch("end_date") ? `${watch("end_date")}` : null} endDate={watch("end_date") ? `${watch("end_date")}` : null}
minDate={new Date(`${watch("start_date")}`)} maxDate={new Date(`${watch("end_date")}`)}
selectsEnd selectsStart
/> />
</Popover.Panel> </Popover.Panel>
</Transition> </Transition>
</> </>
)} )}
</Popover> </Popover>
</div> <MoveRight className="h-4 w-4 text-custom-text-300" />
<Popover className="flex h-full items-center justify-center rounded-lg">
{({}) => (
<>
<Popover.Button
disabled={isCompleted ?? false}
className="text-sm text-custom-text-300 font-medium cursor-default"
>
{areYearsEqual ? renderShortDate(endDate, "_ _") : renderShortMonthDate(endDate, "_ _")}
</Popover.Button>
<Transition
as={React.Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="absolute top-10 -right-5 z-20 transform overflow-hidden">
<CustomRangeDatePicker
value={watch("end_date") ? watch("end_date") : cycleDetails?.end_date}
onChange={(val) => {
if (val) {
handleEndDateChange(val);
}
}}
startDate={watch("start_date") ? `${watch("start_date")}` : null}
endDate={watch("end_date") ? `${watch("end_date")}` : null}
minDate={new Date(`${watch("start_date")}`)}
selectsEnd
/>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
</div> </div>
</div>
</div>
<div className="flex w-full flex-col gap-6 px-6 py-6"> {cycleDetails.description && (
<div className="flex w-full flex-col items-start justify-start gap-2"> <span className="whitespace-normal text-sm leading-5 py-2.5 text-custom-text-200 break-words w-full">
<div className="flex w-full items-start justify-between gap-2"> {cycleDetails.description}
<div className="max-w-[300px]"> </span>
<h4 className="text-xl font-semibold text-custom-text-100 break-words w-full"> )}
{cycleDetails.name}
</h4>
</div>
<CustomMenu width="lg" ellipsis>
{!isCompleted && (
<CustomMenu.MenuItem onClick={() => setCycleDeleteModal(true)}>
<span className="flex items-center justify-start gap-2">
<Trash2 className="h-4 w-4" />
<span>Delete</span>
</span>
</CustomMenu.MenuItem>
)}
<CustomMenu.MenuItem onClick={handleCopyText}>
<span className="flex items-center justify-start gap-2">
<LinkIcon className="h-4 w-4" />
<span>Copy link</span>
</span>
</CustomMenu.MenuItem>
</CustomMenu>
</div>
<span className="whitespace-normal text-sm leading-5 text-custom-text-200 break-words w-full"> <div className="flex flex-col gap-5 pt-2.5 pb-6">
{cycleDetails.description} <div className="flex items-center justify-start gap-1">
</span> <div className="flex w-1/2 items-center justify-start gap-2 text-custom-text-300">
</div> <UserCircle2 className="h-4 w-4" />
<span className="text-base">Lead</span>
<div className="flex flex-col gap-4 text-sm"> </div>
<div className="flex items-center justify-start gap-1"> <div className="flex items-center w-1/2 rounded-sm">
<div className="flex w-40 items-center justify-start gap-2 text-custom-text-200"> <div className="flex items-center gap-2.5">
<UserCircle2 className="h-5 w-5" /> <Avatar user={cycleDetails.owned_by} />
<span>Lead</span> <span className="text-sm text-custom-text-200">{cycleDetails.owned_by.display_name}</span>
</div>
<div className="flex items-center gap-2.5">
{cycleDetails.owned_by.avatar && cycleDetails.owned_by.avatar !== "" ? (
<img
src={cycleDetails.owned_by.avatar}
height={12}
width={12}
className="rounded-full"
alt={cycleDetails.owned_by.display_name}
/>
) : (
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-gray-800 capitalize text-white">
{cycleDetails.owned_by.display_name.charAt(0)}
</span>
)}
<span className="text-custom-text-200">{cycleDetails.owned_by.display_name}</span>
</div>
</div>
<div className="flex items-center justify-start gap-1">
<div className="flex w-40 items-center justify-start gap-2 text-custom-text-200">
<PieChart className="h-5 w-5" />
<span>Progress</span>
</div>
<div className="flex items-center gap-2.5 text-custom-text-200">
<span className="h-4 w-4">
<ProgressBar value={cycleDetails.completed_issues} maxValue={cycleDetails.total_issues} />
</span>
{cycleDetails.completed_issues}/{cycleDetails.total_issues}
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
<div className="flex w-full flex-col items-center justify-start gap-2 border-t border-custom-border-200 p-6">
<Disclosure defaultOpen> <div className="flex items-center justify-start gap-1">
<div className="flex w-1/2 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 items-center w-1/2">
<span className="text-sm text-custom-text-300 px-1.5">{issueCount}</span>
</div>
</div>
</div>
<div className="flex flex-col">
<div className="flex w-full flex-col items-center justify-start gap-2 border-t border-custom-border-200 py-5 px-1.5">
<Disclosure>
{({ open }) => ( {({ open }) => (
<div className={`relative flex h-full w-full flex-col ${open ? "" : "flex-row"}`}> <div className={`relative flex h-full w-full flex-col ${open ? "" : "flex-row"}`}>
<div className="flex w-full items-center justify-between gap-2 "> <div className="flex w-full items-center justify-between gap-2">
<div className="flex items-center justify-start gap-2 text-sm"> <div className="flex items-center justify-start gap-2 text-sm">
<span className="font-medium text-custom-text-200">Progress</span> <span className="font-medium text-custom-text-200">Progress</span>
{!open && progressPercentage ? ( </div>
<span className="rounded bg-[#09A953]/10 px-1.5 py-0.5 text-xs text-[#09A953]">
<div className="flex items-center gap-2.5">
{progressPercentage ? (
<span className="flex items-center justify-center h-5 w-9 rounded text-xs font-medium text-amber-500 bg-amber-50">
{progressPercentage ? `${progressPercentage}%` : ""} {progressPercentage ? `${progressPercentage}%` : ""}
</span> </span>
) : ( ) : (
"" ""
)} )}
{isStartValid && isEndValid ? (
<Disclosure.Button className="p-1.5">
<ChevronDown
className={`h-3 w-3 ${open ? "rotate-180 transform" : ""}`}
aria-hidden="true"
/>
</Disclosure.Button>
) : (
<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">
Invalid date. Please enter valid date.
</span>
</div>
)}
</div> </div>
{isStartValid && isEndValid ? (
<Disclosure.Button>
<ChevronDown className={`h-3 w-3 ${open ? "rotate-180 transform" : ""}`} aria-hidden="true" />
</Disclosure.Button>
) : (
<div className="flex items-center gap-1">
<AlertCircle className="h-3.5 w-3.5 text-custom-text-200" />
<span className="text-xs italic text-custom-text-200">
{cycleStatus === "upcoming"
? "Cycle is yet to start."
: "Invalid date. Please enter valid date."}
</span>
</div>
)}
</div> </div>
<Transition show={open}> <Transition show={open}>
<Disclosure.Panel> <Disclosure.Panel>
{isStartValid && isEndValid ? ( <div className="flex flex-col gap-3">
<div className=" h-full w-full py-4"> {isStartValid && isEndValid ? (
<div className="flex items-start justify-between gap-4 py-2 text-xs"> <div className=" h-full w-full pt-4">
<div className="flex items-center gap-1"> <div className="flex items-start gap-4 py-2 text-xs">
<span> <div className="flex items-center gap-3 text-custom-text-100">
<File className="h-3 w-3 text-custom-text-200" /> <div className="flex items-center justify-center gap-1">
</span> <span className="h-2.5 w-2.5 rounded-full bg-[#A9BBD0]" />
<span> <span>Ideal</span>
Pending Issues -{" "} </div>
{cycleDetails.total_issues - <div className="flex items-center justify-center gap-1">
(cycleDetails.completed_issues + cycleDetails.cancelled_issues)} <span className="h-2.5 w-2.5 rounded-full bg-[#4C8FFF]" />
</span> <span>Current</span>
</div>
</div>
</div> </div>
<div className="relative h-40 w-80">
<div className="flex items-center gap-3 text-custom-text-100"> <ProgressChart
<div className="flex items-center justify-center gap-1"> distribution={cycleDetails.distribution.completion_chart}
<span className="h-2.5 w-2.5 rounded-full bg-[#A9BBD0]" /> startDate={cycleDetails.start_date ?? ""}
<span>Ideal</span> endDate={cycleDetails.end_date ?? ""}
</div> totalIssues={cycleDetails.total_issues}
<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>
</div> </div>
<div className="relative"> ) : (
<ProgressChart ""
distribution={cycleDetails.distribution.completion_chart} )}
startDate={cycleDetails.start_date ?? ""} {cycleDetails.total_issues > 0 && (
endDate={cycleDetails.end_date ?? ""} <div className="h-full w-full pt-5 border-t border-custom-border-200">
<SidebarProgressStats
distribution={cycleDetails.distribution}
groupedIssues={{
backlog: cycleDetails.backlog_issues,
unstarted: cycleDetails.unstarted_issues,
started: cycleDetails.started_issues,
completed: cycleDetails.completed_issues,
cancelled: cycleDetails.cancelled_issues,
}}
totalIssues={cycleDetails.total_issues} totalIssues={cycleDetails.total_issues}
isPeekView={Boolean(peekCycle)}
/> />
</div> </div>
</div> )}
) : (
""
)}
</Disclosure.Panel>
</Transition>
</div>
)}
</Disclosure>
</div>
<div className="flex w-full flex-col items-center justify-start gap-2 border-t border-custom-border-200 p-6">
<Disclosure defaultOpen>
{({ open }) => (
<div className={`relative flex h-full w-full flex-col ${open ? "" : "flex-row"}`}>
<div className="flex w-full items-center justify-between gap-2">
<div className="flex items-center justify-start gap-2 text-sm">
<span className="font-medium text-custom-text-200">Other Information</span>
</div>
{cycleDetails.total_issues > 0 ? (
<Disclosure.Button>
<ChevronDown className={`h-3 w-3 ${open ? "rotate-180 transform" : ""}`} aria-hidden="true" />
</Disclosure.Button>
) : (
<div className="flex items-center gap-1">
<AlertCircle className="h-3.5 w-3.5 text-custom-text-200" />
<span className="text-xs italic text-custom-text-200">
No issues found. Please add issue.
</span>
</div> </div>
)}
</div>
<Transition show={open}>
<Disclosure.Panel>
{cycleDetails.total_issues > 0 ? (
<div className="h-full w-full py-4">
<SidebarProgressStats
distribution={cycleDetails.distribution}
groupedIssues={{
backlog: cycleDetails.backlog_issues,
unstarted: cycleDetails.unstarted_issues,
started: cycleDetails.started_issues,
completed: cycleDetails.completed_issues,
cancelled: cycleDetails.cancelled_issues,
}}
totalIssues={cycleDetails.total_issues}
/>
</div>
) : (
""
)}
</Disclosure.Panel> </Disclosure.Panel>
</Transition> </Transition>
</div> </div>
)} )}
</Disclosure> </Disclosure>
</div> </div>
</> </div>
) : ( </>
<Loader className="px-5"> ) : (
<div className="space-y-2"> <Loader className="px-5">
<Loader.Item height="15px" width="50%" /> <div className="space-y-2">
<Loader.Item height="15px" width="30%" /> <Loader.Item height="15px" width="50%" />
</div> <Loader.Item height="15px" width="30%" />
<div className="mt-8 space-y-3"> </div>
<Loader.Item height="30px" /> <div className="mt-8 space-y-3">
<Loader.Item height="30px" /> <Loader.Item height="30px" />
<Loader.Item height="30px" /> <Loader.Item height="30px" />
</div> <Loader.Item height="30px" />
</Loader> </div>
)} </Loader>
</div> )}
</> </>
); );
}); });

View File

@ -28,8 +28,8 @@ type Props = {
export const ModuleCardItem: React.FC<Props> = observer((props) => { export const ModuleCardItem: React.FC<Props> = observer((props) => {
const { module } = props; const { module } = props;
const [editModuleModal, setEditModuleModal] = useState(false); const [editModal, setEditModal] = useState(false);
const [moduleDeleteModal, setModuleDeleteModal] = useState(false); const [deleteModal, setDeleteModal] = useState(false);
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId } = router.query; const { workspaceSlug, projectId } = router.query;
@ -38,50 +38,7 @@ export const ModuleCardItem: React.FC<Props> = observer((props) => {
const { module: moduleStore } = useMobxStore(); const { module: moduleStore } = useMobxStore();
const completionPercentage = ((module.completed_issues + module.cancelled_issues) / module.total_issues) * 100; const completionPercentage = (module.completed_issues / module.total_issues) * 100;
const handleAddToFavorites = () => {
if (!workspaceSlug || !projectId) return;
moduleStore.addModuleToFavorites(workspaceSlug.toString(), projectId.toString(), module.id).catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "Couldn't add the module to favorites. Please try again.",
});
});
};
const handleRemoveFromFavorites = () => {
if (!workspaceSlug || !projectId) return;
moduleStore.removeModuleFromFavorites(workspaceSlug.toString(), projectId.toString(), module.id).catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "Couldn't remove the module from favorites. Please try again.",
});
});
};
const handleCopyText = () => {
copyUrlToClipboard(`${workspaceSlug}/projects/${projectId}/modules/${module.id}`).then(() => {
setToastAlert({
type: "success",
title: "Link Copied!",
message: "Module link copied to clipboard.",
});
});
};
const openModuleOverview = () => {
const { query } = router;
router.push({
pathname: router.pathname,
query: { ...query, peekModule: module.id },
});
};
const endDate = new Date(module.target_date ?? ""); const endDate = new Date(module.target_date ?? "");
const startDate = new Date(module.start_date ?? ""); const startDate = new Date(module.start_date ?? "");
@ -101,23 +58,86 @@ export const ModuleCardItem: React.FC<Props> = observer((props) => {
: `${module.completed_issues}/${module.total_issues} Issues` : `${module.completed_issues}/${module.total_issues} Issues`
: "0 Issue"; : "0 Issue";
const handleAddToFavorites = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
e.preventDefault();
if (!workspaceSlug || !projectId) return;
moduleStore.addModuleToFavorites(workspaceSlug.toString(), projectId.toString(), module.id).catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "Couldn't add the module to favorites. Please try again.",
});
});
};
const handleRemoveFromFavorites = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
e.preventDefault();
if (!workspaceSlug || !projectId) return;
moduleStore.removeModuleFromFavorites(workspaceSlug.toString(), projectId.toString(), module.id).catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "Couldn't remove the module from favorites. Please try again.",
});
});
};
const handleCopyText = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
e.preventDefault();
copyUrlToClipboard(`${workspaceSlug}/projects/${projectId}/modules/${module.id}`).then(() => {
setToastAlert({
type: "success",
title: "Link Copied!",
message: "Module link copied to clipboard.",
});
});
};
const handleEditModule = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setEditModal(true);
};
const handleDeleteModule = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setDeleteModal(true);
};
const openModuleOverview = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
e.preventDefault();
const { query } = router;
router.push({
pathname: router.pathname,
query: { ...query, peekModule: module.id },
});
};
return ( return (
<> <>
{workspaceSlug && projectId && ( {workspaceSlug && projectId && (
<CreateUpdateModuleModal <CreateUpdateModuleModal
isOpen={editModuleModal} isOpen={editModal}
onClose={() => setEditModuleModal(false)} onClose={() => setEditModal(false)}
data={module} data={module}
projectId={projectId.toString()} projectId={projectId.toString()}
workspaceSlug={workspaceSlug.toString()} workspaceSlug={workspaceSlug.toString()}
/> />
)} )}
<DeleteModuleModal data={module} isOpen={moduleDeleteModal} onClose={() => setModuleDeleteModal(false)} /> <DeleteModuleModal data={module} isOpen={deleteModal} onClose={() => setDeleteModal(false)} />
<Link href={`/${workspaceSlug}/projects/${module.project}/modules/${module.id}`}> <Link href={`/${workspaceSlug}/projects/${module.project}/modules/${module.id}`}>
<a className="flex flex-col justify-between p-4 h-44 w-full min-w-[250px] text-sm rounded bg-custom-background-100 border border-custom-border-100 hover:shadow-md"> <a className="flex flex-col justify-between p-4 h-44 w-full min-w-[250px] text-sm rounded bg-custom-background-100 border border-custom-border-100 hover:shadow-md">
<div> <div>
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<Tooltip tooltipContent={module.name} position="auto"> <Tooltip tooltipContent={module.name} position="top">
<span className="text-base font-medium truncate">{module.name}</span> <span className="text-base font-medium truncate">{module.name}</span>
</Tooltip> </Tooltip>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -128,13 +148,7 @@ export const ModuleCardItem: React.FC<Props> = observer((props) => {
{moduleStatus.label} {moduleStatus.label}
</span> </span>
)} )}
<button <button onClick={openModuleOverview}>
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
openModuleOverview();
}}
>
<Info className="h-4 w-4 text-custom-text-400" /> <Info className="h-4 w-4 text-custom-text-400" />
</button> </button>
</div> </div>
@ -184,60 +198,28 @@ export const ModuleCardItem: React.FC<Props> = observer((props) => {
</span> </span>
<div className="flex items-center gap-1.5 z-10"> <div className="flex items-center gap-1.5 z-10">
{module.is_favorite ? ( {module.is_favorite ? (
<button <button type="button" onClick={handleRemoveFromFavorites}>
type="button"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
handleRemoveFromFavorites();
}}
>
<Star className="h-3.5 w-3.5 text-amber-500 fill-current" /> <Star className="h-3.5 w-3.5 text-amber-500 fill-current" />
</button> </button>
) : ( ) : (
<button <button type="button" onClick={handleAddToFavorites}>
type="button"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
handleAddToFavorites();
}}
>
<Star className="h-3.5 w-3.5 text-custom-text-200" /> <Star className="h-3.5 w-3.5 text-custom-text-200" />
</button> </button>
)} )}
<CustomMenu width="auto" ellipsis className="z-10"> <CustomMenu width="auto" ellipsis className="z-10">
<CustomMenu.MenuItem <CustomMenu.MenuItem onClick={handleEditModule}>
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setEditModuleModal(true);
}}
>
<span className="flex items-center justify-start gap-2"> <span className="flex items-center justify-start gap-2">
<Pencil className="h-3 w-3" /> <Pencil className="h-3 w-3" />
<span>Edit module</span> <span>Edit module</span>
</span> </span>
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
<CustomMenu.MenuItem <CustomMenu.MenuItem onClick={handleDeleteModule}>
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setModuleDeleteModal(true);
}}
>
<span className="flex items-center justify-start gap-2"> <span className="flex items-center justify-start gap-2">
<Trash2 className="h-3 w-3" /> <Trash2 className="h-3 w-3" />
<span>Delete module</span> <span>Delete module</span>
</span> </span>
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
<CustomMenu.MenuItem <CustomMenu.MenuItem onClick={handleCopyText}>
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleCopyText();
}}
>
<span className="flex items-center justify-start gap-2"> <span className="flex items-center justify-start gap-2">
<LinkIcon className="h-3 w-3" /> <LinkIcon className="h-3 w-3" />
<span>Copy module link</span> <span>Copy module link</span>

View File

@ -28,8 +28,8 @@ type Props = {
export const ModuleListItem: React.FC<Props> = observer((props) => { export const ModuleListItem: React.FC<Props> = observer((props) => {
const { module } = props; const { module } = props;
const [editModuleModal, setEditModuleModal] = useState(false); const [editModal, setEditModal] = useState(false);
const [moduleDeleteModal, setModuleDeleteModal] = useState(false); const [deleteModal, setDeleteModal] = useState(false);
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId } = router.query; const { workspaceSlug, projectId } = router.query;
@ -40,40 +40,6 @@ export const ModuleListItem: React.FC<Props> = observer((props) => {
const completionPercentage = ((module.completed_issues + module.cancelled_issues) / module.total_issues) * 100; const completionPercentage = ((module.completed_issues + module.cancelled_issues) / module.total_issues) * 100;
const handleAddToFavorites = () => {
if (!workspaceSlug || !projectId) return;
moduleStore.addModuleToFavorites(workspaceSlug.toString(), projectId.toString(), module.id).catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "Couldn't add the module to favorites. Please try again.",
});
});
};
const handleRemoveFromFavorites = () => {
if (!workspaceSlug || !projectId) return;
moduleStore.removeModuleFromFavorites(workspaceSlug.toString(), projectId.toString(), module.id).catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "Couldn't remove the module from favorites. Please try again.",
});
});
};
const handleCopyText = () => {
copyUrlToClipboard(`${workspaceSlug}/projects/${projectId}/modules/${module.id}`).then(() => {
setToastAlert({
type: "success",
title: "Link Copied!",
message: "Module link copied to clipboard.",
});
});
};
const endDate = new Date(module.target_date ?? ""); const endDate = new Date(module.target_date ?? "");
const startDate = new Date(module.start_date ?? ""); const startDate = new Date(module.start_date ?? "");
@ -87,7 +53,61 @@ export const ModuleListItem: React.FC<Props> = observer((props) => {
const completedModuleCheck = module.status === "completed" && module.total_issues - module.completed_issues; const completedModuleCheck = module.status === "completed" && module.total_issues - module.completed_issues;
const openModuleOverview = () => { const handleAddToFavorites = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
e.preventDefault();
if (!workspaceSlug || !projectId) return;
moduleStore.addModuleToFavorites(workspaceSlug.toString(), projectId.toString(), module.id).catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "Couldn't add the module to favorites. Please try again.",
});
});
};
const handleRemoveFromFavorites = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
e.preventDefault();
if (!workspaceSlug || !projectId) return;
moduleStore.removeModuleFromFavorites(workspaceSlug.toString(), projectId.toString(), module.id).catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "Couldn't remove the module from favorites. Please try again.",
});
});
};
const handleCopyText = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
e.preventDefault();
copyUrlToClipboard(`${workspaceSlug}/projects/${projectId}/modules/${module.id}`).then(() => {
setToastAlert({
type: "success",
title: "Link Copied!",
message: "Module link copied to clipboard.",
});
});
};
const handleEditModule = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setEditModal(true);
};
const handleDeleteModule = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setDeleteModal(true);
};
const openModuleOverview = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
e.preventDefault();
const { query } = router; const { query } = router;
router.push({ router.push({
@ -100,14 +120,14 @@ export const ModuleListItem: React.FC<Props> = observer((props) => {
<> <>
{workspaceSlug && projectId && ( {workspaceSlug && projectId && (
<CreateUpdateModuleModal <CreateUpdateModuleModal
isOpen={editModuleModal} isOpen={editModal}
onClose={() => setEditModuleModal(false)} onClose={() => setEditModal(false)}
data={module} data={module}
projectId={projectId.toString()} projectId={projectId.toString()}
workspaceSlug={workspaceSlug.toString()} workspaceSlug={workspaceSlug.toString()}
/> />
)} )}
<DeleteModuleModal data={module} isOpen={moduleDeleteModal} onClose={() => setModuleDeleteModal(false)} /> <DeleteModuleModal data={module} isOpen={deleteModal} onClose={() => setDeleteModal(false)} />
<Link href={`/${workspaceSlug}/projects/${module.project}/modules/${module.id}`}> <Link href={`/${workspaceSlug}/projects/${module.project}/modules/${module.id}`}>
<a className="group flex items-center justify-between gap-5 px-10 py-6 h-16 w-full text-sm bg-custom-background-100 border-b border-custom-border-100 hover:bg-custom-background-90"> <a className="group flex items-center justify-between gap-5 px-10 py-6 h-16 w-full text-sm bg-custom-background-100 border-b border-custom-border-100 hover:bg-custom-background-90">
<div className="flex items-center gap-3 w-full truncate"> <div className="flex items-center gap-3 w-full truncate">
@ -123,18 +143,11 @@ export const ModuleListItem: React.FC<Props> = observer((props) => {
)} )}
</CircularProgressIndicator> </CircularProgressIndicator>
</span> </span>
<Tooltip tooltipContent={module.name} position="auto"> <Tooltip tooltipContent={module.name} position="top">
<span className="text-base font-medium truncate">{module.name}</span> <span className="text-base font-medium truncate">{module.name}</span>
</Tooltip> </Tooltip>
</div> </div>
<button <button onClick={openModuleOverview} className="flex-shrink-0 hidden group-hover:flex z-10">
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
openModuleOverview();
}}
className="flex-shrink-0 hidden group-hover:flex z-10"
>
<Info className="h-4 w-4 text-custom-text-400" /> <Info className="h-4 w-4 text-custom-text-400" />
</button> </button>
</div> </div>
@ -171,63 +184,29 @@ export const ModuleListItem: React.FC<Props> = observer((props) => {
</Tooltip> </Tooltip>
{module.is_favorite ? ( {module.is_favorite ? (
<button <button type="button" onClick={handleRemoveFromFavorites} className="z-[1]">
type="button"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
handleRemoveFromFavorites();
}}
className="z-[1]"
>
<Star className="h-3.5 w-3.5 text-amber-500 fill-current" /> <Star className="h-3.5 w-3.5 text-amber-500 fill-current" />
</button> </button>
) : ( ) : (
<button <button type="button" onClick={handleAddToFavorites} className="z-[1]">
type="button"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
handleAddToFavorites();
}}
className="z-[1]"
>
<Star className="h-3.5 w-3.5 text-custom-text-300" /> <Star className="h-3.5 w-3.5 text-custom-text-300" />
</button> </button>
)} )}
<CustomMenu width="auto" verticalEllipsis buttonClassName="z-[1]"> <CustomMenu width="auto" verticalEllipsis buttonClassName="z-[1]">
<CustomMenu.MenuItem <CustomMenu.MenuItem onClick={handleEditModule}>
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setEditModuleModal(true);
}}
>
<span className="flex items-center justify-start gap-2"> <span className="flex items-center justify-start gap-2">
<Pencil className="h-3 w-3" /> <Pencil className="h-3 w-3" />
<span>Edit module</span> <span>Edit module</span>
</span> </span>
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
<CustomMenu.MenuItem <CustomMenu.MenuItem onClick={handleDeleteModule}>
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setModuleDeleteModal(true);
}}
>
<span className="flex items-center justify-start gap-2"> <span className="flex items-center justify-start gap-2">
<Trash2 className="h-3 w-3" /> <Trash2 className="h-3 w-3" />
<span>Delete module</span> <span>Delete module</span>
</span> </span>
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
<CustomMenu.MenuItem <CustomMenu.MenuItem onClick={handleCopyText}>
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleCopyText();
}}
>
<span className="flex items-center justify-start gap-2"> <span className="flex items-center justify-start gap-2">
<LinkIcon className="h-3 w-3" /> <LinkIcon className="h-3 w-3" />
<span>Copy module link</span> <span>Copy module link</span>

View File

@ -400,7 +400,7 @@ export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
}} }}
totalIssues={moduleDetails.total_issues} totalIssues={moduleDetails.total_issues}
module={moduleDetails} module={moduleDetails}
isPeekModuleDetails={Boolean(peekModule)} isPeekView={Boolean(peekModule)}
/> />
</div> </div>
)} )}

View File

@ -37,3 +37,40 @@ export const CYCLE_VIEWS = [
icon: <GanttChart className="h-4 w-4" />, icon: <GanttChart className="h-4 w-4" />,
}, },
]; ];
export const CYCLE_STATUS: {
label: string;
value: "current" | "upcoming" | "completed" | "draft";
color: string;
textColor: string;
bgColor: string;
}[] = [
{
label: "day left",
value: "current",
color: "#F59E0B",
textColor: "text-amber-500",
bgColor: "bg-amber-50",
},
{
label: "Yet to start",
value: "upcoming",
color: "#3F76FF",
textColor: "text-blue-500",
bgColor: "bg-indigo-50",
},
{
label: "Completed",
value: "completed",
color: "#16A34A",
textColor: "text-green-600",
bgColor: "bg-green-50",
},
{
label: "Draft",
value: "draft",
color: "#525252",
textColor: "text-custom-text-300",
bgColor: "bg-custom-background-90",
},
];

View File

@ -25,7 +25,7 @@ const SingleCycle: React.FC = () => {
const { cycle: cycleStore } = useMobxStore(); const { cycle: cycleStore } = useMobxStore();
const { storedValue } = useLocalStorage("cycle_sidebar_collapsed", "false"); const { setValue, storedValue } = useLocalStorage("cycle_sidebar_collapsed", "false");
const isSidebarCollapsed = storedValue ? (storedValue === "true" ? true : false) : false; const isSidebarCollapsed = storedValue ? (storedValue === "true" ? true : false) : false;
const { error } = useSWR( const { error } = useSWR(
@ -35,6 +35,10 @@ const SingleCycle: React.FC = () => {
: null : null
); );
const toggleSidebar = () => {
setValue(`${!isSidebarCollapsed}`);
};
// TODO: add this function to bulk add issues to cycle // TODO: add this function to bulk add issues to cycle
// const handleAddIssuesToCycle = async (data: ISearchIssueResponse[]) => { // const handleAddIssuesToCycle = async (data: ISearchIssueResponse[]) => {
// if (!workspaceSlug || !projectId) return; // if (!workspaceSlug || !projectId) return;
@ -75,11 +79,21 @@ const SingleCycle: React.FC = () => {
/> />
) : ( ) : (
<> <>
<div className="relative w-full h-full flex overflow-auto"> <div className="flex h-full w-full">
<div className={`h-full w-full ${isSidebarCollapsed ? "" : "mr-[24rem]"} duration-300`}> <div className="h-full w-full">
<CycleLayoutRoot /> <CycleLayoutRoot />
</div> </div>
{cycleId && <CycleDetailsSidebar isOpen={!isSidebarCollapsed} cycleId={cycleId.toString()} />} {cycleId && !isSidebarCollapsed && (
<div
className="flex flex-col gap-3.5 h-full w-[24rem] z-10 overflow-y-auto border-l border-custom-border-100 bg-custom-sidebar-background-100 px-6 py-3.5 duration-300 flex-shrink-0"
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} />
</div>
)}
</div> </div>
</> </>
)} )}

View File

@ -29,7 +29,11 @@ const ProjectCyclesPage: NextPage = observer(() => {
const { project: projectStore, cycle: cycleStore } = useMobxStore(); const { project: projectStore, cycle: cycleStore } = useMobxStore();
// router // router
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId } = router.query as { workspaceSlug: string; projectId: string }; const { workspaceSlug, projectId, peekCycle } = router.query as {
workspaceSlug: string;
projectId: string;
peekCycle: string;
};
// fetching project details // fetching project details
useSWR( useSWR(
workspaceSlug && projectId ? `PROJECT_DETAILS_${projectId}` : null, workspaceSlug && projectId ? `PROJECT_DETAILS_${projectId}` : null,
@ -150,13 +154,14 @@ const ProjectCyclesPage: NextPage = observer(() => {
</div> </div>
<Tab.Panels as={Fragment}> <Tab.Panels as={Fragment}>
<Tab.Panel as="div" className="p-4 sm:p-5 h-full overflow-y-auto"> <Tab.Panel as="div" className="h-full overflow-y-auto">
{cycleView && cycleLayout && workspaceSlug && projectId && ( {cycleView && cycleLayout && workspaceSlug && projectId && (
<CyclesView <CyclesView
filter={"all"} filter={"all"}
layout={cycleLayout as TCycleLayout} layout={cycleLayout as TCycleLayout}
workspaceSlug={workspaceSlug} workspaceSlug={workspaceSlug}
projectId={projectId} projectId={projectId}
peekCycle={peekCycle}
/> />
)} )}
</Tab.Panel> </Tab.Panel>
@ -165,35 +170,38 @@ const ProjectCyclesPage: NextPage = observer(() => {
<ActiveCycleDetails workspaceSlug={workspaceSlug} projectId={projectId} /> <ActiveCycleDetails workspaceSlug={workspaceSlug} projectId={projectId} />
</Tab.Panel> </Tab.Panel>
<Tab.Panel as="div" className="p-4 sm:p-5 h-full overflow-y-auto"> <Tab.Panel as="div" className="h-full overflow-y-auto">
{cycleView && cycleLayout && workspaceSlug && projectId && ( {cycleView && cycleLayout && workspaceSlug && projectId && (
<CyclesView <CyclesView
filter={"upcoming"} filter={"upcoming"}
layout={cycleLayout as TCycleLayout} layout={cycleLayout as TCycleLayout}
workspaceSlug={workspaceSlug} workspaceSlug={workspaceSlug}
projectId={projectId} projectId={projectId}
peekCycle={peekCycle}
/> />
)} )}
</Tab.Panel> </Tab.Panel>
<Tab.Panel as="div" className="p-4 sm:p-5 h-full overflow-y-auto"> <Tab.Panel as="div" className="h-full overflow-y-auto">
{cycleView && cycleLayout && workspaceSlug && projectId && ( {cycleView && cycleLayout && workspaceSlug && projectId && (
<CyclesView <CyclesView
filter={"completed"} filter={"completed"}
layout={cycleLayout as TCycleLayout} layout={cycleLayout as TCycleLayout}
workspaceSlug={workspaceSlug} workspaceSlug={workspaceSlug}
projectId={projectId} projectId={projectId}
peekCycle={peekCycle}
/> />
)} )}
</Tab.Panel> </Tab.Panel>
<Tab.Panel as="div" className="p-4 sm:p-5 h-full overflow-y-auto"> <Tab.Panel as="div" className="h-full overflow-y-auto">
{cycleView && cycleLayout && workspaceSlug && projectId && ( {cycleView && cycleLayout && workspaceSlug && projectId && (
<CyclesView <CyclesView
filter={"draft"} filter={"draft"}
layout={cycleLayout as TCycleLayout} layout={cycleLayout as TCycleLayout}
workspaceSlug={workspaceSlug} workspaceSlug={workspaceSlug}
projectId={projectId} projectId={projectId}
peekCycle={peekCycle}
/> />
)} )}
</Tab.Panel> </Tab.Panel>

View File

@ -2755,7 +2755,7 @@
dependencies: dependencies:
"@types/react" "*" "@types/react" "*"
"@types/react-color@^3.0.6": "@types/react-color@^3.0.6", "@types/react-color@^3.0.9":
version "3.0.9" version "3.0.9"
resolved "https://registry.yarnpkg.com/@types/react-color/-/react-color-3.0.9.tgz#8dbb0d798f2979c3d7e2e26dd46321e80da950b4" resolved "https://registry.yarnpkg.com/@types/react-color/-/react-color-3.0.9.tgz#8dbb0d798f2979c3d7e2e26dd46321e80da950b4"
integrity sha512-Ojyc6jySSKvM6UYQrZxaYe0JZXtgHHXwR2q9H4MhcNCswFdeZH1owYZCvPtdHtMOfh7t8h1fY0Gd0nvU1JGDkQ== integrity sha512-Ojyc6jySSKvM6UYQrZxaYe0JZXtgHHXwR2q9H4MhcNCswFdeZH1owYZCvPtdHtMOfh7t8h1fY0Gd0nvU1JGDkQ==
@ -7393,7 +7393,7 @@ react-markdown@^8.0.7:
unist-util-visit "^4.0.0" unist-util-visit "^4.0.0"
vfile "^5.0.0" vfile "^5.0.0"
react-moveable@^0.54.1: react-moveable@^0.54.1, react-moveable@^0.54.2:
version "0.54.2" version "0.54.2"
resolved "https://registry.yarnpkg.com/react-moveable/-/react-moveable-0.54.2.tgz#87ce9af3499dc1c8218bce7e174b10264c1bbecf" resolved "https://registry.yarnpkg.com/react-moveable/-/react-moveable-0.54.2.tgz#87ce9af3499dc1c8218bce7e174b10264c1bbecf"
integrity sha512-NGaVLbn0i9pb3+BWSKGWFqI/Mgm4+WMeWHxXXQ4Qi1tHxWCXrUrbGvpxEpt69G/hR7dez+/m68ex+fabjnvcUg== integrity sha512-NGaVLbn0i9pb3+BWSKGWFqI/Mgm4+WMeWHxXXQ4Qi1tHxWCXrUrbGvpxEpt69G/hR7dez+/m68ex+fabjnvcUg==