chore: refactoring cycles list

This commit is contained in:
sriram veeraghanta 2023-09-29 17:32:47 +05:30
parent 9ad1e73666
commit f22705846d
11 changed files with 732 additions and 357 deletions

View File

View File

View File

@ -0,0 +1,316 @@
import { FC, useEffect, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/router";
// hooks
import useToast from "hooks/use-toast";
// ui
import { RadialProgressBar } from "@plane/ui";
import { CustomMenu, LinearProgressIndicator, Tooltip } from "components/ui";
// icons
import { CalendarDaysIcon } from "@heroicons/react/20/solid";
import {
TargetIcon,
ContrastIcon,
PersonRunningIcon,
ArrowRightIcon,
TriangleExclamationIcon,
AlarmClockIcon,
} from "components/icons";
import { LinkIcon, PencilIcon, StarIcon, TrashIcon } from "@heroicons/react/24/outline";
// helpers
import { getDateRangeStatus, renderShortDateWithYearFormat, findHowManyDaysLeft } from "helpers/date-time.helper";
import { copyTextToClipboard, truncateText } from "helpers/string.helper";
// types
import { ICycle } from "types";
type TCycledListItem = {
cycle: ICycle;
handleEditCycle: () => void;
handleDeleteCycle: () => void;
handleAddToFavorites: () => void;
handleRemoveFromFavorites: () => void;
};
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 CycledListItem: FC<TCycledListItem> = (props) => {
const { cycle, handleEditCycle, handleDeleteCycle, handleAddToFavorites, handleRemoveFromFavorites } = props;
// router
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { setToastAlert } = useToast();
const cycleStatus = getDateRangeStatus(cycle.start_date, cycle.end_date);
const isCompleted = cycleStatus === "completed";
const endDate = new Date(cycle.end_date ?? "");
const startDate = new Date(cycle.start_date ?? "");
const handleCopyText = () => {
const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
copyTextToClipboard(`${originURL}/${workspaceSlug}/projects/${projectId}/cycles/${cycle.id}`).then(() => {
setToastAlert({
type: "success",
title: "Link Copied!",
message: "Cycle link copied to clipboard.",
});
});
};
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,
}));
return (
<div>
<div className="flex flex-col text-xs hover:bg-custom-background-80">
<Link href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycle.id}`}>
<a className="w-full">
<div className="flex h-full flex-col gap-4 rounded-b-[10px] p-4">
<div className="flex items-center justify-between gap-1">
<div className="flex items-start gap-2">
<ContrastIcon
className="mt-1 h-5 w-5"
color={`${
cycleStatus === "current"
? "#09A953"
: cycleStatus === "upcoming"
? "#F7AE59"
: cycleStatus === "completed"
? "#3F76FF"
: cycleStatus === "draft"
? "rgb(var(--color-text-200))"
: ""
}`}
/>
<div className="max-w-2xl">
<Tooltip tooltipContent={cycle.name} className="break-words" position="top-left">
<h3 className="break-words w-full text-base font-semibold">{truncateText(cycle.name, 60)}</h3>
</Tooltip>
<p className="mt-2 text-custom-text-200 break-words w-full">{cycle.description}</p>
</div>
</div>
<div className="flex-shrink-0 flex items-center gap-4">
<span
className={`rounded-full px-1.5 py-0.5
${
cycleStatus === "current"
? "bg-green-600/5 text-green-600"
: 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" ? (
<span className="flex gap-1 whitespace-nowrap">
<PersonRunningIcon className="h-4 w-4" />
{findHowManyDaysLeft(cycle.end_date ?? new Date())} days left
</span>
) : cycleStatus === "upcoming" ? (
<span className="flex gap-1">
<AlarmClockIcon className="h-4 w-4" />
{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>
<TriangleExclamationIcon className="h-3.5 w-3.5 fill-current" />
</span>
</Tooltip>
)}{" "}
Completed
</span>
) : (
cycleStatus
)}
</span>
{cycleStatus !== "draft" && (
<div className="flex items-center justify-start gap-2 text-custom-text-200">
<div className="flex items-start gap-1 whitespace-nowrap">
<CalendarDaysIcon className="h-4 w-4" />
<span>{renderShortDateWithYearFormat(startDate)}</span>
</div>
<ArrowRightIcon className="h-4 w-4" />
<div className="flex items-start gap-1 whitespace-nowrap">
<TargetIcon className="h-4 w-4" />
<span>{renderShortDateWithYearFormat(endDate)}</span>
</div>
</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>
)}
</div>
<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.is_favorite ? (
<button
onClick={(e) => {
e.preventDefault();
handleRemoveFromFavorites();
}}
>
<StarIcon className="h-4 w-4 text-orange-400" fill="#f6ad55" />
</button>
) : (
<button
onClick={(e) => {
e.preventDefault();
handleAddToFavorites();
}}
>
<StarIcon className="h-4 w-4 " color="rgb(var(--color-text-200))" />
</button>
)}
<div className="flex items-center">
<CustomMenu width="auto" verticalEllipsis>
{!isCompleted && (
<CustomMenu.MenuItem
onClick={(e) => {
e.preventDefault();
handleEditCycle();
}}
>
<span className="flex items-center justify-start gap-2">
<PencilIcon className="h-4 w-4" />
<span>Edit Cycle</span>
</span>
</CustomMenu.MenuItem>
)}
{!isCompleted && (
<CustomMenu.MenuItem
onClick={(e) => {
e.preventDefault();
handleDeleteCycle();
}}
>
<span className="flex items-center justify-start gap-2">
<TrashIcon 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>
</a>
</Link>
</div>
</div>
);
};

View File

@ -1,30 +1,75 @@
import { useRouter } from "next/router";
import { FC } from "react";
import useSWR from "swr";
// store
import { useMobxStore } from "lib/mobx/store-provider";
import { CyclesView } from "./cycles-view";
// ui
import { Loader } from "components/ui";
// types
import { ICycle } from "types";
export interface ICyclesList {
filter: "all" | "current" | "upcoming" | "draft" | "completed" | "incomplete";
cycles: ICycle[];
}
export const CyclesList: FC<ICyclesList> = (props) => {
const { filter } = props;
// router
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
// store
const { cycle: cycleStore } = useMobxStore();
const { cycles } = props;
useSWR(
workspaceSlug && projectId ? `CYCLES_LIST_${projectId}` : null,
workspaceSlug && projectId
? () => cycleStore.fetchCycles(workspaceSlug.toString(), projectId.toString(), filter)
: null
return (
<div>
{cycles ? (
<>
{cycles.length > 0 ? (
<div className="divide-y divide-custom-border-200">
{cycles.map((cycle) => (
<div className="hover:bg-custom-background-80">
<div className="flex flex-col border-custom-border-200">
<SingleCycleList
key={cycle.id}
cycle={cycle}
handleDeleteCycle={() => handleDeleteCycle(cycle)}
handleEditCycle={() => handleEditCycle(cycle)}
handleAddToFavorites={() => handleAddToFavorites(cycle)}
handleRemoveFromFavorites={() => handleRemoveFromFavorites(cycle)}
/>
</div>
</div>
))}
</div>
) : (
<div className="h-full grid place-items-center text-center">
<div className="space-y-2">
<div className="mx-auto flex justify-center">
<svg xmlns="http://www.w3.org/2000/svg" width="66" height="66" viewBox="0 0 66 66" fill="none">
<circle cx="34.375" cy="34.375" r="22" stroke="rgb(var(--color-text-400))" strokeLinecap="round" />
<path
d="M36.4375 20.9919C36.4375 19.2528 37.6796 17.8127 39.1709 18.1419C40.125 18.3526 41.0604 18.6735 41.9625 19.1014C43.7141 19.9322 45.3057 21.1499 46.6464 22.685C47.987 24.2202 49.0505 26.0426 49.776 28.0484C50.5016 30.0541 50.875 32.2038 50.875 34.3748C50.875 36.5458 50.5016 38.6956 49.776 40.7013C49.0505 42.7071 47.987 44.5295 46.6464 46.0647C45.3057 47.5998 43.7141 48.8175 41.9625 49.6483C41.0604 50.0762 40.125 50.3971 39.1709 50.6077C37.6796 50.937 36.4375 49.4969 36.4375 47.7578L36.4375 20.9919Z"
fill="rgb(var(--color-text-400))"
/>
</svg>
</div>
<h4 className="text-sm text-custom-text-200">
{cycleTab === "all" ? "No cycles" : `No ${cycleTab} cycles`}
</h4>
<button
type="button"
className="text-custom-primary-100 text-sm outline-none"
onClick={() => {
const e = new KeyboardEvent("keydown", {
key: "q",
});
document.dispatchEvent(e);
}}
>
Create a new cycle
</button>
</div>
</div>
)}
</>
) : (
<Loader className="space-y-4">
<Loader.Item height="50px" />
<Loader.Item height="50px" />
<Loader.Item height="50px" />
</Loader>
)}
</div>
);
if (!projectId) {
return <></>;
}
return <CyclesView cycles={cycleStore.cycles[projectId?.toString()]} viewType={filter} />;
};

View File

@ -0,0 +1,254 @@
import React, { useState } from "react";
import { useRouter } from "next/router";
import { KeyedMutator, mutate } from "swr";
// services
import cyclesService from "services/cycles.service";
// hooks
import useToast from "hooks/use-toast";
import useUserAuth from "hooks/use-user-auth";
import useLocalStorage from "hooks/use-local-storage";
// components
import {
CreateUpdateCycleModal,
CyclesListGanttChartView,
DeleteCycleModal,
SingleCycleCard,
SingleCycleList,
} from "components/cycles";
// ui
import { Loader } from "components/ui";
// helpers
import { getDateRangeStatus } from "helpers/date-time.helper";
// types
import { ICycle } from "types";
// fetch-keys
import {
COMPLETED_CYCLES_LIST,
CURRENT_CYCLE_LIST,
CYCLES_LIST,
DRAFT_CYCLES_LIST,
UPCOMING_CYCLES_LIST,
} from "constants/fetch-keys";
import { CYCLE_TAB_LIST, CYCLE_VIEWS } from "constants/cycle";
type Props = {
cycles: ICycle[] | undefined;
mutateCycles?: KeyedMutator<ICycle[]>;
viewType: string | null;
};
export const CyclesView: React.FC<Props> = ({ cycles, mutateCycles, viewType }) => {
const [createUpdateCycleModal, setCreateUpdateCycleModal] = useState(false);
const [selectedCycleToUpdate, setSelectedCycleToUpdate] = useState<ICycle | null>(null);
const [deleteCycleModal, setDeleteCycleModal] = useState(false);
const [selectedCycleToDelete, setSelectedCycleToDelete] = useState<ICycle | null>(null);
const { storedValue: cycleTab } = useLocalStorage("cycle_tab", "all");
console.log("cycleTab", cycleTab);
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { user } = useUserAuth();
const { setToastAlert } = useToast();
const handleEditCycle = (cycle: ICycle) => {
setSelectedCycleToUpdate(cycle);
setCreateUpdateCycleModal(true);
};
const handleDeleteCycle = (cycle: ICycle) => {
setSelectedCycleToDelete(cycle);
setDeleteCycleModal(true);
};
const handleAddToFavorites = (cycle: ICycle) => {
if (!workspaceSlug || !projectId) return;
const cycleStatus = getDateRangeStatus(cycle.start_date, cycle.end_date);
const fetchKey =
cycleStatus === "current"
? CURRENT_CYCLE_LIST(projectId as string)
: cycleStatus === "upcoming"
? UPCOMING_CYCLES_LIST(projectId as string)
: cycleStatus === "completed"
? COMPLETED_CYCLES_LIST(projectId as string)
: DRAFT_CYCLES_LIST(projectId as string);
mutate<ICycle[]>(
fetchKey,
(prevData) =>
(prevData ?? []).map((c) => ({
...c,
is_favorite: c.id === cycle.id ? true : c.is_favorite,
})),
false
);
mutate(
CYCLES_LIST(projectId as string),
(prevData: any) =>
(prevData ?? []).map((c: any) => ({
...c,
is_favorite: c.id === cycle.id ? true : c.is_favorite,
})),
false
);
cyclesService
.addCycleToFavorites(workspaceSlug as string, projectId as string, {
cycle: cycle.id,
})
.catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "Couldn't add the cycle to favorites. Please try again.",
});
});
};
const handleRemoveFromFavorites = (cycle: ICycle) => {
if (!workspaceSlug || !projectId) return;
const cycleStatus = getDateRangeStatus(cycle.start_date, cycle.end_date);
const fetchKey =
cycleStatus === "current"
? CURRENT_CYCLE_LIST(projectId as string)
: cycleStatus === "upcoming"
? UPCOMING_CYCLES_LIST(projectId as string)
: cycleStatus === "completed"
? COMPLETED_CYCLES_LIST(projectId as string)
: DRAFT_CYCLES_LIST(projectId as string);
mutate<ICycle[]>(
fetchKey,
(prevData) =>
(prevData ?? []).map((c) => ({
...c,
is_favorite: c.id === cycle.id ? false : c.is_favorite,
})),
false
);
mutate(
CYCLES_LIST(projectId as string),
(prevData: any) =>
(prevData ?? []).map((c: any) => ({
...c,
is_favorite: c.id === cycle.id ? false : c.is_favorite,
})),
false
);
cyclesService.removeCycleFromFavorites(workspaceSlug as string, projectId as string, cycle.id).catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "Couldn't remove the cycle from favorites. Please try again.",
});
});
};
return (
<>
<CreateUpdateCycleModal
isOpen={createUpdateCycleModal}
handleClose={() => setCreateUpdateCycleModal(false)}
data={selectedCycleToUpdate}
user={user}
/>
<DeleteCycleModal
isOpen={deleteCycleModal}
setIsOpen={setDeleteCycleModal}
data={selectedCycleToDelete}
user={user}
/>
{cycles ? (
cycles.length > 0 ? (
viewType === "list" ? (
<div className="divide-y divide-custom-border-200">
{cycles.map((cycle) => (
<div className="hover:bg-custom-background-80">
<div className="flex flex-col border-custom-border-200">
<SingleCycleList
key={cycle.id}
cycle={cycle}
handleDeleteCycle={() => handleDeleteCycle(cycle)}
handleEditCycle={() => handleEditCycle(cycle)}
handleAddToFavorites={() => handleAddToFavorites(cycle)}
handleRemoveFromFavorites={() => handleRemoveFromFavorites(cycle)}
/>
</div>
</div>
))}
</div>
) : viewType === "board" ? (
<div className="grid grid-cols-1 gap-9 lg:grid-cols-2 xl:grid-cols-3">
{cycles.map((cycle) => (
<SingleCycleCard
key={cycle.id}
cycle={cycle}
handleDeleteCycle={() => handleDeleteCycle(cycle)}
handleEditCycle={() => handleEditCycle(cycle)}
handleAddToFavorites={() => handleAddToFavorites(cycle)}
handleRemoveFromFavorites={() => handleRemoveFromFavorites(cycle)}
/>
))}
</div>
) : (
<CyclesListGanttChartView cycles={cycles ?? []} mutateCycles={mutateCycles} />
)
) : (
<div className="h-full grid place-items-center text-center">
<div className="space-y-2">
<div className="mx-auto flex justify-center">
<svg xmlns="http://www.w3.org/2000/svg" width="66" height="66" viewBox="0 0 66 66" fill="none">
<circle cx="34.375" cy="34.375" r="22" stroke="rgb(var(--color-text-400))" strokeLinecap="round" />
<path
d="M36.4375 20.9919C36.4375 19.2528 37.6796 17.8127 39.1709 18.1419C40.125 18.3526 41.0604 18.6735 41.9625 19.1014C43.7141 19.9322 45.3057 21.1499 46.6464 22.685C47.987 24.2202 49.0505 26.0426 49.776 28.0484C50.5016 30.0541 50.875 32.2038 50.875 34.3748C50.875 36.5458 50.5016 38.6956 49.776 40.7013C49.0505 42.7071 47.987 44.5295 46.6464 46.0647C45.3057 47.5998 43.7141 48.8175 41.9625 49.6483C41.0604 50.0762 40.125 50.3971 39.1709 50.6077C37.6796 50.937 36.4375 49.4969 36.4375 47.7578L36.4375 20.9919Z"
fill="rgb(var(--color-text-400))"
/>
</svg>
</div>
<h4 className="text-sm text-custom-text-200">
{cycleTab === "all" ? "No cycles" : `No ${cycleTab} cycles`}
</h4>
<button
type="button"
className="text-custom-primary-100 text-sm outline-none"
onClick={() => {
const e = new KeyboardEvent("keydown", {
key: "q",
});
document.dispatchEvent(e);
}}
>
Create a new cycle
</button>
</div>
</div>
)
) : viewType === "list" ? (
<Loader className="space-y-4">
<Loader.Item height="50px" />
<Loader.Item height="50px" />
<Loader.Item height="50px" />
</Loader>
) : viewType === "board" ? (
<Loader className="grid grid-cols-1 gap-9 md:grid-cols-2 lg:grid-cols-3">
<Loader.Item height="200px" />
<Loader.Item height="200px" />
<Loader.Item height="200px" />
</Loader>
) : (
<Loader>
<Loader.Item height="300px" />
</Loader>
)}
</>
);
};

View File

@ -1,257 +1,38 @@
import React, { useState } from "react";
import { useRouter } from "next/router";
import { KeyedMutator, mutate } from "swr";
// services
import cyclesService from "services/cycles.service";
// hooks
import useToast from "hooks/use-toast";
import useUserAuth from "hooks/use-user-auth";
import useLocalStorage from "hooks/use-local-storage";
import { FC } from "react";
import useSWR from "swr";
// store
import { useMobxStore } from "lib/mobx/store-provider";
// components
import {
CreateUpdateCycleModal,
CyclesListGanttChartView,
DeleteCycleModal,
SingleCycleCard,
SingleCycleList,
} from "components/cycles";
// ui
import { Loader } from "components/ui";
// helpers
import { getDateRangeStatus } from "helpers/date-time.helper";
// types
import { ICycle } from "types";
// fetch-keys
import {
COMPLETED_CYCLES_LIST,
CURRENT_CYCLE_LIST,
CYCLES_LIST,
DRAFT_CYCLES_LIST,
UPCOMING_CYCLES_LIST,
} from "constants/fetch-keys";
import { CYCLE_TAB_LIST, CYCLE_VIEWS } from "constants/cycle";
import { CyclesList } from "components/cycles";
type Props = {
cycles: ICycle[] | undefined;
mutateCycles?: KeyedMutator<ICycle[]>;
viewType: string | null;
};
export const CyclesView: React.FC<Props> = ({ cycles, mutateCycles, viewType }) => {
const [createUpdateCycleModal, setCreateUpdateCycleModal] = useState(false);
const [selectedCycleToUpdate, setSelectedCycleToUpdate] = useState<ICycle | null>(null);
const [deleteCycleModal, setDeleteCycleModal] = useState(false);
const [selectedCycleToDelete, setSelectedCycleToDelete] = useState<ICycle | null>(null);
const { storedValue: cycleTab } = useLocalStorage("cycle_tab", "all");
console.log("cycleTab", cycleTab);
export interface ICyclesView {
filter: "all" | "current" | "upcoming" | "draft" | "completed" | "incomplete";
view: "list" | "board" | "gantt";
}
export const CyclesView: FC<ICyclesView> = (props) => {
const { filter, view } = props;
// router
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
// store
const { cycle: cycleStore } = useMobxStore();
const { user } = useUserAuth();
const { setToastAlert } = useToast();
const handleEditCycle = (cycle: ICycle) => {
setSelectedCycleToUpdate(cycle);
setCreateUpdateCycleModal(true);
};
const handleDeleteCycle = (cycle: ICycle) => {
setSelectedCycleToDelete(cycle);
setDeleteCycleModal(true);
};
const handleAddToFavorites = (cycle: ICycle) => {
if (!workspaceSlug || !projectId) return;
const cycleStatus = getDateRangeStatus(cycle.start_date, cycle.end_date);
const fetchKey =
cycleStatus === "current"
? CURRENT_CYCLE_LIST(projectId as string)
: cycleStatus === "upcoming"
? UPCOMING_CYCLES_LIST(projectId as string)
: cycleStatus === "completed"
? COMPLETED_CYCLES_LIST(projectId as string)
: DRAFT_CYCLES_LIST(projectId as string);
mutate<ICycle[]>(
fetchKey,
(prevData) =>
(prevData ?? []).map((c) => ({
...c,
is_favorite: c.id === cycle.id ? true : c.is_favorite,
})),
false
);
mutate(
CYCLES_LIST(projectId as string),
(prevData: any) =>
(prevData ?? []).map((c: any) => ({
...c,
is_favorite: c.id === cycle.id ? true : c.is_favorite,
})),
false
);
cyclesService
.addCycleToFavorites(workspaceSlug as string, projectId as string, {
cycle: cycle.id,
})
.catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "Couldn't add the cycle to favorites. Please try again.",
});
});
};
const handleRemoveFromFavorites = (cycle: ICycle) => {
if (!workspaceSlug || !projectId) return;
const cycleStatus = getDateRangeStatus(cycle.start_date, cycle.end_date);
const fetchKey =
cycleStatus === "current"
? CURRENT_CYCLE_LIST(projectId as string)
: cycleStatus === "upcoming"
? UPCOMING_CYCLES_LIST(projectId as string)
: cycleStatus === "completed"
? COMPLETED_CYCLES_LIST(projectId as string)
: DRAFT_CYCLES_LIST(projectId as string);
mutate<ICycle[]>(
fetchKey,
(prevData) =>
(prevData ?? []).map((c) => ({
...c,
is_favorite: c.id === cycle.id ? false : c.is_favorite,
})),
false
);
mutate(
CYCLES_LIST(projectId as string),
(prevData: any) =>
(prevData ?? []).map((c: any) => ({
...c,
is_favorite: c.id === cycle.id ? false : c.is_favorite,
})),
false
);
cyclesService.removeCycleFromFavorites(workspaceSlug as string, projectId as string, cycle.id).catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "Couldn't remove the cycle from favorites. Please try again.",
});
});
};
useSWR(
workspaceSlug && projectId ? `CYCLES_LIST_${projectId}` : null,
workspaceSlug && projectId
? () => cycleStore.fetchCycles(workspaceSlug.toString(), projectId.toString(), filter)
: null
);
if (!projectId) {
return <></>;
}
return (
<>
<CreateUpdateCycleModal
isOpen={createUpdateCycleModal}
handleClose={() => setCreateUpdateCycleModal(false)}
data={selectedCycleToUpdate}
user={user}
/>
<DeleteCycleModal
isOpen={deleteCycleModal}
setIsOpen={setDeleteCycleModal}
data={selectedCycleToDelete}
user={user}
/>
{cycles ? (
cycles.length > 0 ? (
viewType === "list" ? (
<div className="divide-y divide-custom-border-200">
{cycles.map((cycle) => (
<div className="hover:bg-custom-background-80">
<div className="flex flex-col border-custom-border-200">
<SingleCycleList
key={cycle.id}
cycle={cycle}
handleDeleteCycle={() => handleDeleteCycle(cycle)}
handleEditCycle={() => handleEditCycle(cycle)}
handleAddToFavorites={() => handleAddToFavorites(cycle)}
handleRemoveFromFavorites={() => handleRemoveFromFavorites(cycle)}
/>
</div>
</div>
))}
</div>
) : viewType === "board" ? (
<div className="grid grid-cols-1 gap-9 lg:grid-cols-2 xl:grid-cols-3">
{cycles.map((cycle) => (
<SingleCycleCard
key={cycle.id}
cycle={cycle}
handleDeleteCycle={() => handleDeleteCycle(cycle)}
handleEditCycle={() => handleEditCycle(cycle)}
handleAddToFavorites={() => handleAddToFavorites(cycle)}
handleRemoveFromFavorites={() => handleRemoveFromFavorites(cycle)}
/>
))}
</div>
) : (
<CyclesListGanttChartView cycles={cycles ?? []} mutateCycles={mutateCycles} />
)
) : (
<div className="h-full grid place-items-center text-center">
<div className="space-y-2">
<div className="mx-auto flex justify-center">
<svg xmlns="http://www.w3.org/2000/svg" width="66" height="66" viewBox="0 0 66 66" fill="none">
<circle cx="34.375" cy="34.375" r="22" stroke="rgb(var(--color-text-400))" strokeLinecap="round" />
<path
d="M36.4375 20.9919C36.4375 19.2528 37.6796 17.8127 39.1709 18.1419C40.125 18.3526 41.0604 18.6735 41.9625 19.1014C43.7141 19.9322 45.3057 21.1499 46.6464 22.685C47.987 24.2202 49.0505 26.0426 49.776 28.0484C50.5016 30.0541 50.875 32.2038 50.875 34.3748C50.875 36.5458 50.5016 38.6956 49.776 40.7013C49.0505 42.7071 47.987 44.5295 46.6464 46.0647C45.3057 47.5998 43.7141 48.8175 41.9625 49.6483C41.0604 50.0762 40.125 50.3971 39.1709 50.6077C37.6796 50.937 36.4375 49.4969 36.4375 47.7578L36.4375 20.9919Z"
fill="rgb(var(--color-text-400))"
/>
</svg>
</div>
<h4 className="text-sm text-custom-text-200">
{cycleTab === "all" ? "No cycles" : `No ${cycleTab} cycles`}
</h4>
<button
type="button"
className="text-custom-primary-100 text-sm outline-none"
onClick={() => {
const e = new KeyboardEvent("keydown", {
key: "q",
});
document.dispatchEvent(e);
}}
>
Create a new cycle
</button>
</div>
</div>
)
) : viewType === "list" ? (
<Loader className="space-y-4">
<Loader.Item height="50px" />
<Loader.Item height="50px" />
<Loader.Item height="50px" />
</Loader>
) : viewType === "board" ? (
<Loader className="grid grid-cols-1 gap-9 md:grid-cols-2 lg:grid-cols-3">
<Loader.Item height="200px" />
<Loader.Item height="200px" />
<Loader.Item height="200px" />
</Loader>
) : (
<Loader>
<Loader.Item height="300px" />
</Loader>
)}
{view === "list" && <CyclesList cycles={cycleStore.cycles[projectId?.toString()]} />}
{view === "board" && <CyclesList cycles={cycleStore.cycles[projectId?.toString()]} />}
{view === "gantt" && <CyclesList cycles={cycleStore.cycles[projectId?.toString()]} />}
</>
);
};

View File

@ -1,50 +1,33 @@
import { useEffect } from "react";
// react-hook-form
import { Controller, useForm } from "react-hook-form";
// ui
import { DateSelect, Input, PrimaryButton, SecondaryButton, TextArea } from "components/ui";
import { Input, TextArea } from "@plane/ui";
import { DateSelect, PrimaryButton, SecondaryButton } from "components/ui";
// types
import { ICycle } from "types";
type Props = {
handleFormSubmit: (values: Partial<ICycle>) => Promise<void>;
handleClose: () => void;
status: boolean;
data?: ICycle | null;
};
const defaultValues: Partial<ICycle> = {
name: "",
description: "",
start_date: null,
end_date: null,
};
export const CycleForm: React.FC<Props> = ({ handleFormSubmit, handleClose, status, data }) => {
export const CycleForm: React.FC<Props> = (props) => {
const { handleFormSubmit, handleClose, data } = props;
// form data
const {
register,
formState: { errors, isSubmitting },
handleSubmit,
control,
reset,
watch,
} = useForm<ICycle>({
defaultValues,
defaultValues: {
name: data?.name || "",
description: data?.description || "",
start_date: data?.start_date || null,
end_date: data?.end_date || null,
},
});
const handleCreateUpdateCycle = async (formData: Partial<ICycle>) => {
await handleFormSubmit(formData);
};
useEffect(() => {
reset({
...defaultValues,
...data,
});
}, [data, reset]);
const startDate = watch("start_date");
const endDate = watch("end_date");
@ -55,39 +38,50 @@ export const CycleForm: React.FC<Props> = ({ handleFormSubmit, handleClose, stat
maxDate?.setDate(maxDate.getDate() - 1);
return (
<form onSubmit={handleSubmit(handleCreateUpdateCycle)}>
<form onSubmit={handleSubmit(handleFormSubmit)}>
<div className="space-y-5">
<h3 className="text-lg font-medium leading-6 text-custom-text-100">
{status ? "Update" : "Create"} Cycle
</h3>
<h3 className="text-lg font-medium leading-6 text-custom-text-100">{status ? "Update" : "Create"} Cycle</h3>
<div className="space-y-3">
<div>
<Input
autoComplete="off"
id="name"
<Controller
name="name"
type="name"
className="resize-none text-xl"
placeholder="Title"
error={errors.name}
register={register}
validations={{
control={control}
rules={{
required: "Name is required",
maxLength: {
value: 255,
message: "Name should be less than 255 characters",
},
}}
render={({ field: { value, onChange } }) => (
<Input
id="cycle_name"
name="name"
type="text"
placeholder="Cycle Name"
className="resize-none text-xl"
value={value}
onChange={onChange}
hasError={Boolean(errors?.name)}
/>
)}
/>
</div>
<div>
<TextArea
id="description"
<Controller
name="description"
placeholder="Description"
className="h-32 resize-none text-sm"
error={errors.description}
register={register}
control={control}
render={({ field: { value, onChange } }) => (
<TextArea
id="cycle_description"
name="description"
placeholder="Description"
className="h-32 resize-none text-sm"
hasError={Boolean(errors?.description)}
value={value}
onChange={onChange}
/>
)}
/>
</div>
@ -112,12 +106,7 @@ export const CycleForm: React.FC<Props> = ({ handleFormSubmit, handleClose, stat
control={control}
name="end_date"
render={({ field: { value, onChange } }) => (
<DateSelect
label="End date"
value={value}
onChange={(val) => onChange(val)}
minDate={minDate}
/>
<DateSelect label="End date" value={value} onChange={(val) => onChange(val)} minDate={minDate} />
)}
/>
</div>
@ -127,7 +116,7 @@ export const CycleForm: React.FC<Props> = ({ handleFormSubmit, handleClose, stat
<div className="-mx-5 mt-5 flex justify-end gap-2 border-t border-custom-border-200 px-5 pt-5">
<SecondaryButton onClick={handleClose}>Cancel</SecondaryButton>
<PrimaryButton type="submit" loading={isSubmitting}>
{status
{data
? isSubmitting
? "Updating Cycle..."
: "Update Cycle"

View File

@ -1,4 +1,4 @@
export * from "./cycles-list";
export * from "./cycles-view";
export * from "./active-cycle-details";
export * from "./active-cycle-stats";
export * from "./gantt-chart";
@ -12,3 +12,6 @@ export * from "./single-cycle-card";
export * from "./single-cycle-list";
export * from "./transfer-issues-modal";
export * from "./transfer-issues";
export * from "./cycles-list";
export * from "./cycles-board";
export * from "./cycles-gantt";

View File

@ -1,10 +1,6 @@
import { Fragment } from "react";
import { useRouter } from "next/router";
import { mutate } from "swr";
// headless ui
import { Dialog, Transition } from "@headlessui/react";
// services
import cycleService from "services/cycles.service";
@ -34,12 +30,7 @@ type CycleModalProps = {
user: ICurrentUserResponse | undefined;
};
export const CreateUpdateCycleModal: React.FC<CycleModalProps> = ({
isOpen,
handleClose,
data,
user,
}) => {
export const CreateUpdateCycleModal: React.FC<CycleModalProps> = ({ isOpen, handleClose, data, user }) => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
@ -116,10 +107,7 @@ export const CreateUpdateCycleModal: React.FC<CycleModalProps> = ({
mutate(DRAFT_CYCLES_LIST(projectId.toString()));
}
mutate(CYCLES_LIST(projectId.toString()));
if (
getDateRangeStatus(data?.start_date, data?.end_date) !=
getDateRangeStatus(res.start_date, res.end_date)
) {
if (getDateRangeStatus(data?.start_date, data?.end_date) != getDateRangeStatus(res.start_date, res.end_date)) {
switch (getDateRangeStatus(res.start_date, res.end_date)) {
case "completed":
mutate(COMPLETED_CYCLES_LIST(projectId.toString()));
@ -141,7 +129,7 @@ export const CreateUpdateCycleModal: React.FC<CycleModalProps> = ({
message: "Cycle updated successfully.",
});
})
.catch((err) => {
.catch(() => {
setToastAlert({
type: "error",
title: "Error!",
@ -153,11 +141,9 @@ export const CreateUpdateCycleModal: React.FC<CycleModalProps> = ({
const dateChecker = async (payload: CycleDateCheckData) => {
let status = false;
await cycleService
.cycleDateCheck(workspaceSlug as string, projectId as string, payload)
.then((res) => {
status = res.status;
});
await cycleService.cycleDateCheck(workspaceSlug as string, projectId as string, payload).then((res) => {
status = res.status;
});
return status;
};
@ -194,8 +180,7 @@ export const CreateUpdateCycleModal: React.FC<CycleModalProps> = ({
setToastAlert({
type: "error",
title: "Error!",
message:
"You already have a cycle on the given dates, if you want to create a draft cycle, remove the dates.",
message: "You already have a cycle on the given dates, if you want to create a draft cycle, remove the dates.",
});
};
@ -225,12 +210,7 @@ export const CreateUpdateCycleModal: React.FC<CycleModalProps> = ({
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform rounded-lg border border-custom-border-200 bg-custom-background-100 px-5 py-8 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-2xl sm:p-6">
<CycleForm
handleFormSubmit={handleFormSubmit}
handleClose={handleClose}
status={data ? true : false}
data={data}
/>
<CycleForm handleFormSubmit={handleFormSubmit} handleClose={handleClose} data={data} />
</Dialog.Panel>
</Transition.Child>
</div>

View File

@ -12,9 +12,7 @@ const getValueFromLocalStorage = (key: string, defaultValue: any) => {
};
const useLocalStorage = <T,>(key: string, initialValue: T) => {
const [storedValue, setStoredValue] = useState<T | null>(() =>
getValueFromLocalStorage(key, initialValue)
);
const [storedValue, setStoredValue] = useState<T | null>(() => getValueFromLocalStorage(key, initialValue));
const setValue = useCallback(
(value: T) => {

View File

@ -8,7 +8,7 @@ import useUserAuth from "hooks/use-user-auth";
// layouts
import { ProjectAuthorizationWrapper } from "layouts/auth-layout";
// components
import { CyclesList, ActiveCycleDetails, CreateUpdateCycleModal } from "components/cycles";
import { CyclesView, ActiveCycleDetails, CreateUpdateCycleModal } from "components/cycles";
// ui
import { EmptyState, Icon, PrimaryButton } from "components/ui";
import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs";
@ -27,6 +27,7 @@ import { observer } from "mobx-react-lite";
import { CYCLE_TAB_LIST, CYCLE_VIEWS } from "constants/cycle";
type ICycleAPIFilter = "all" | "current" | "upcoming" | "draft" | "completed" | "incomplete";
type ICycleView = "list" | "board" | "gantt";
const ProjectCyclesPage: NextPage = observer(() => {
// router
@ -167,19 +168,27 @@ const ProjectCyclesPage: NextPage = observer(() => {
</div>
<Tab.Panels as={React.Fragment}>
<Tab.Panel as="div" className="p-4 sm:p-5 h-full overflow-y-auto">
{cycleTab && <CyclesList filter={cycleTab as ICycleAPIFilter} />}
{cycleTab && cyclesView && (
<CyclesView filter={cycleTab as ICycleAPIFilter} view={cyclesView as ICycleView} />
)}
</Tab.Panel>
<Tab.Panel as="div" className="p-4 sm:p-5 space-y-5 h-full overflow-y-auto">
<ActiveCycleDetails />
</Tab.Panel>
<Tab.Panel as="div" className="p-4 sm:p-5 h-full overflow-y-auto">
{cycleTab && <CyclesList filter={cycleTab as ICycleAPIFilter} />}
{cycleTab && cyclesView && (
<CyclesView filter={cycleTab as ICycleAPIFilter} view={cyclesView as ICycleView} />
)}
</Tab.Panel>
<Tab.Panel as="div" className="p-4 sm:p-5 h-full overflow-y-auto">
{cycleTab && <CyclesList filter={cycleTab as ICycleAPIFilter} />}
{cycleTab && cyclesView && (
<CyclesView filter={cycleTab as ICycleAPIFilter} view={cyclesView as ICycleView} />
)}
</Tab.Panel>
<Tab.Panel as="div" className="p-4 sm:p-5 h-full overflow-y-auto">
{cycleTab && <CyclesList filter={cycleTab as ICycleAPIFilter} />}
{cycleTab && cyclesView && (
<CyclesView filter={cycleTab as ICycleAPIFilter} view={cyclesView as ICycleView} />
)}
</Tab.Panel>
</Tab.Panels>
</Tab.Group>