Merge pull request #358 from makeplane/feat/cycle_validations

feat: cycle validations
This commit is contained in:
Aaryan Khandelwal 2023-03-03 13:53:09 +05:30 committed by GitHub
commit f290a417bc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 411 additions and 205 deletions

View File

@ -0,0 +1,82 @@
// react
import { useState } from "react";
// next
import { useRouter } from "next/router";
import useSWR from "swr";
// components
import { DeleteCycleModal, SingleCycleCard } from "components/cycles";
// types
import { ICycle, SelectCycleType } from "types";
import { CompletedCycleIcon } from "components/icons";
import cyclesService from "services/cycles.service";
import { CYCLE_COMPLETE_LIST } from "constants/fetch-keys";
export interface CompletedCyclesListProps {
setCreateUpdateCycleModal: React.Dispatch<React.SetStateAction<boolean>>;
setSelectedCycle: React.Dispatch<React.SetStateAction<SelectCycleType>>;
}
export const CompletedCyclesList: React.FC<CompletedCyclesListProps> = ({
setCreateUpdateCycleModal,
setSelectedCycle,
}) => {
const [cycleDeleteModal, setCycleDeleteModal] = useState(false);
const [selectedCycleForDelete, setSelectedCycleForDelete] = useState<SelectCycleType>();
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { data: completedCycles } = useSWR(
workspaceSlug && projectId ? CYCLE_COMPLETE_LIST(projectId as string) : null,
workspaceSlug && projectId
? () => cyclesService.getCompletedCycles(workspaceSlug as string, projectId as string)
: null
);
const handleDeleteCycle = (cycle: ICycle) => {
setSelectedCycleForDelete({ ...cycle, actionType: "delete" });
setCycleDeleteModal(true);
};
const handleEditCycle = (cycle: ICycle) => {
setSelectedCycle({ ...cycle, actionType: "edit" });
setCreateUpdateCycleModal(true);
};
return (
<>
{completedCycles && (
<>
<DeleteCycleModal
isOpen={
cycleDeleteModal &&
!!selectedCycleForDelete &&
selectedCycleForDelete.actionType === "delete"
}
setIsOpen={setCycleDeleteModal}
data={selectedCycleForDelete}
/>
{completedCycles?.completed_cycles.length > 0 ? (
completedCycles.completed_cycles.map((cycle) => (
<SingleCycleCard
key={cycle.id}
cycle={cycle}
handleDeleteCycle={() => handleDeleteCycle(cycle)}
handleEditCycle={() => handleEditCycle(cycle)}
/>
))
) : (
<div className="flex flex-col items-center justify-center gap-4 text-center">
<CompletedCycleIcon height="56" width="56" />
<h3 className="text-gray-500">
No completed cycles yet. Create with{" "}
<pre className="inline rounded bg-gray-200 px-2 py-1">Q</pre>.
</h3>
</div>
)}
</>
)}
</>
);
};

View File

@ -13,7 +13,7 @@ type TCycleStatsViewProps = {
type: "current" | "upcoming" | "completed"; type: "current" | "upcoming" | "completed";
}; };
export const CyclesListView: React.FC<TCycleStatsViewProps> = ({ export const CyclesList: React.FC<TCycleStatsViewProps> = ({
cycles, cycles,
setCreateUpdateCycleModal, setCreateUpdateCycleModal,
setSelectedCycle, setSelectedCycle,

View File

@ -1,11 +1,16 @@
import { useEffect } from "react"; import { useEffect, useState } from "react";
import { useRouter } from "next/router";
// toast
import useToast from "hooks/use-toast";
// react-hook-form // react-hook-form
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
// ui // ui
import { Button, CustomDatePicker, CustomSelect, Input, TextArea } from "components/ui"; import { Button, CustomDatePicker, CustomSelect, Input, TextArea } from "components/ui";
// types // types
import { ICycle } from "types"; import { ICycle } from "types";
// services
import cyclesService from "services/cycles.service";
type Props = { type Props = {
handleFormSubmit: (values: Partial<ICycle>) => Promise<void>; handleFormSubmit: (values: Partial<ICycle>) => Promise<void>;
@ -17,17 +22,24 @@ type Props = {
const defaultValues: Partial<ICycle> = { const defaultValues: Partial<ICycle> = {
name: "", name: "",
description: "", description: "",
status: "draft", start_date: null,
start_date: "", end_date: null,
end_date: "",
}; };
export const CycleForm: React.FC<Props> = ({ handleFormSubmit, handleClose, status, data }) => { export const CycleForm: React.FC<Props> = ({ handleFormSubmit, handleClose, status, data }) => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { setToastAlert } = useToast();
const [isDateValid, setIsDateValid] = useState(true);
const { const {
register, register,
formState: { errors, isSubmitting }, formState: { errors, isSubmitting },
handleSubmit, handleSubmit,
control, control,
watch,
reset, reset,
} = useForm<ICycle>({ } = useForm<ICycle>({
defaultValues, defaultValues,
@ -41,6 +53,31 @@ export const CycleForm: React.FC<Props> = ({ handleFormSubmit, handleClose, stat
}); });
}; };
const dateChecker = async (payload: any) => {
await cyclesService
.cycleDateCheck(workspaceSlug as string, projectId as string, payload)
.then((res) => {
if (res.status) {
setIsDateValid(true);
} else {
setIsDateValid(false);
setToastAlert({
type: "error",
title: "Error!",
message:
"You have a cycle already on the given dates, if you want to create your draft cycle you can do that by removing dates",
});
}
})
.catch((err) => {
console.log(err);
});
};
const checkEmptyDate =
(watch("start_date") === "" && watch("end_date") === "") ||
(!watch("start_date") && !watch("end_date"));
useEffect(() => { useEffect(() => {
reset({ reset({
...defaultValues, ...defaultValues,
@ -84,30 +121,7 @@ export const CycleForm: React.FC<Props> = ({ handleFormSubmit, handleClose, stat
register={register} register={register}
/> />
</div> </div>
<div>
<h6 className="text-gray-500">Status</h6>
<Controller
name="status"
control={control}
render={({ field }) => (
<CustomSelect
{...field}
label={<span className="capitalize">{field.value ?? "Select Status"}</span>}
input
>
{[
{ label: "Draft", value: "draft" },
{ label: "Started", value: "started" },
{ label: "Completed", value: "completed" },
].map((item) => (
<CustomSelect.Option key={item.value} value={item.value}>
{item.label}
</CustomSelect.Option>
))}
</CustomSelect>
)}
/>
</div>
<div className="flex gap-x-2"> <div className="flex gap-x-2">
<div className="w-full"> <div className="w-full">
<h6 className="text-gray-500">Start Date</h6> <h6 className="text-gray-500">Start Date</h6>
@ -115,12 +129,19 @@ export const CycleForm: React.FC<Props> = ({ handleFormSubmit, handleClose, stat
<Controller <Controller
control={control} control={control}
name="start_date" name="start_date"
rules={{ required: "Start date is required" }}
render={({ field: { value, onChange } }) => ( render={({ field: { value, onChange } }) => (
<CustomDatePicker <CustomDatePicker
renderAs="input" renderAs="input"
value={value} value={value}
onChange={onChange} onChange={(val) => {
onChange(val);
watch("end_date")
? dateChecker({
start_date: val,
end_date: watch("end_date"),
})
: "";
}}
error={errors.start_date ? true : false} error={errors.start_date ? true : false}
/> />
)} )}
@ -136,12 +157,19 @@ export const CycleForm: React.FC<Props> = ({ handleFormSubmit, handleClose, stat
<Controller <Controller
control={control} control={control}
name="end_date" name="end_date"
rules={{ required: "End date is required" }}
render={({ field: { value, onChange } }) => ( render={({ field: { value, onChange } }) => (
<CustomDatePicker <CustomDatePicker
renderAs="input" renderAs="input"
value={value} value={value}
onChange={onChange} onChange={(val) => {
onChange(val);
watch("start_date")
? dateChecker({
start_date: watch("start_date"),
end_date: val,
})
: "";
}}
error={errors.end_date ? true : false} error={errors.end_date ? true : false}
/> />
)} )}
@ -158,7 +186,18 @@ export const CycleForm: React.FC<Props> = ({ handleFormSubmit, handleClose, stat
<Button theme="secondary" onClick={handleClose}> <Button theme="secondary" onClick={handleClose}>
Cancel Cancel
</Button> </Button>
<Button type="submit" disabled={isSubmitting}>
<Button
type="submit"
className={
checkEmptyDate
? "cursor-pointer"
: isDateValid
? "cursor-pointer"
: "cursor-not-allowed"
}
disabled={isSubmitting || checkEmptyDate ? false : isDateValid ? false : true}
>
{status {status
? isSubmitting ? isSubmitting
? "Updating Cycle..." ? "Updating Cycle..."

View File

@ -1,4 +1,5 @@
export * from "./cycles-list-view"; export * from "./completed-cycles-list";
export * from "./cycles-list";
export * from "./delete-cycle-modal"; export * from "./delete-cycle-modal";
export * from "./form"; export * from "./form";
export * from "./modal"; export * from "./modal";

View File

@ -12,10 +12,16 @@ import cycleService from "services/cycles.service";
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
// components // components
import { CycleForm } from "components/cycles"; import { CycleForm } from "components/cycles";
// helper
import { getDateRangeStatus } from "helpers/date-time.helper";
// types // types
import type { ICycle } from "types"; import type { ICycle } from "types";
// fetch keys // fetch keys
import { CYCLE_LIST } from "constants/fetch-keys"; import {
CYCLE_COMPLETE_LIST,
CYCLE_CURRENT_AND_UPCOMING_LIST,
CYCLE_DRAFT_LIST,
} from "constants/fetch-keys";
type CycleModalProps = { type CycleModalProps = {
isOpen: boolean; isOpen: boolean;
@ -37,7 +43,23 @@ export const CreateUpdateCycleModal: React.FC<CycleModalProps> = ({
await cycleService await cycleService
.createCycle(workspaceSlug as string, projectId as string, payload) .createCycle(workspaceSlug as string, projectId as string, payload)
.then((res) => { .then((res) => {
mutate(CYCLE_LIST(projectId as string)); switch (
res?.start_date && res.end_date
? getDateRangeStatus(res?.start_date, res.end_date)
: "draft"
) {
case "completed":
mutate(CYCLE_COMPLETE_LIST(projectId as string));
break;
case "current":
mutate(CYCLE_CURRENT_AND_UPCOMING_LIST(projectId as string));
break;
case "upcoming":
mutate(CYCLE_CURRENT_AND_UPCOMING_LIST(projectId as string));
break;
default:
mutate(CYCLE_DRAFT_LIST(projectId as string));
}
handleClose(); handleClose();
setToastAlert({ setToastAlert({
@ -59,7 +81,23 @@ export const CreateUpdateCycleModal: React.FC<CycleModalProps> = ({
await cycleService await cycleService
.updateCycle(workspaceSlug as string, projectId as string, cycleId, payload) .updateCycle(workspaceSlug as string, projectId as string, cycleId, payload)
.then((res) => { .then((res) => {
mutate(CYCLE_LIST(projectId as string)); switch (
res?.start_date && res.end_date
? getDateRangeStatus(res?.start_date, res.end_date)
: "draft"
) {
case "completed":
mutate(CYCLE_COMPLETE_LIST(projectId as string));
break;
case "current":
mutate(CYCLE_CURRENT_AND_UPCOMING_LIST(projectId as string));
break;
case "upcoming":
mutate(CYCLE_CURRENT_AND_UPCOMING_LIST(projectId as string));
break;
default:
mutate(CYCLE_DRAFT_LIST(projectId as string));
}
handleClose(); handleClose();
setToastAlert({ setToastAlert({
@ -113,7 +151,7 @@ export const CreateUpdateCycleModal: React.FC<CycleModalProps> = ({
leaveFrom="opacity-100 translate-y-0 sm:scale-100" leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
> >
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-white px-5 py-8 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-2xl sm:p-6"> <Dialog.Panel className="relative transform rounded-lg bg-white px-5 py-8 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-2xl sm:p-6">
<CycleForm <CycleForm
handleFormSubmit={handleFormSubmit} handleFormSubmit={handleFormSubmit}
handleClose={handleClose} handleClose={handleClose}

View File

@ -6,7 +6,7 @@ import Image from "next/image";
import { mutate } from "swr"; import { mutate } from "swr";
// react-hook-form // react-hook-form
import { Controller, useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { Popover, Transition } from "@headlessui/react"; import { Popover, Transition } from "@headlessui/react";
import DatePicker from "react-datepicker"; import DatePicker from "react-datepicker";
// icons // icons
@ -19,7 +19,7 @@ import {
UserIcon, UserIcon,
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
// ui // ui
import { CustomSelect, Loader, ProgressBar } from "components/ui"; import { Loader, ProgressBar } from "components/ui";
// hooks // hooks
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
// services // services
@ -36,17 +36,22 @@ import { renderDateFormat, renderShortNumericDateFormat } from "helpers/date-tim
import { CycleIssueResponse, ICycle, IIssue } from "types"; import { CycleIssueResponse, ICycle, IIssue } from "types";
// fetch-keys // fetch-keys
import { CYCLE_DETAILS } from "constants/fetch-keys"; import { CYCLE_DETAILS } from "constants/fetch-keys";
// constants
import { CYCLE_STATUS } from "constants/cycle";
type Props = { type Props = {
issues: IIssue[]; issues: IIssue[];
cycle: ICycle | undefined; cycle: ICycle | undefined;
isOpen: boolean; isOpen: boolean;
cycleIssues: CycleIssueResponse[]; cycleIssues: CycleIssueResponse[];
cycleStatus: string;
}; };
export const CycleDetailsSidebar: React.FC<Props> = ({ issues, cycle, isOpen, cycleIssues }) => { export const CycleDetailsSidebar: React.FC<Props> = ({
issues,
cycle,
isOpen,
cycleIssues,
cycleStatus,
}) => {
const [cycleDeleteModal, setCycleDeleteModal] = useState(false); const [cycleDeleteModal, setCycleDeleteModal] = useState(false);
const router = useRouter(); const router = useRouter();
@ -60,7 +65,6 @@ export const CycleDetailsSidebar: React.FC<Props> = ({ issues, cycle, isOpen, cy
const defaultValues: Partial<ICycle> = { const defaultValues: Partial<ICycle> = {
start_date: new Date().toString(), start_date: new Date().toString(),
end_date: new Date().toString(), end_date: new Date().toString(),
status: cycle?.status,
}; };
const groupedIssues = { const groupedIssues = {
@ -72,7 +76,7 @@ export const CycleDetailsSidebar: React.FC<Props> = ({ issues, cycle, isOpen, cy
...groupBy(cycleIssues ?? [], "issue_detail.state_detail.group"), ...groupBy(cycleIssues ?? [], "issue_detail.state_detail.group"),
}; };
const { reset, watch, control } = useForm({ const { reset } = useForm({
defaultValues, defaultValues,
}); });
@ -118,32 +122,18 @@ export const CycleDetailsSidebar: React.FC<Props> = ({ issues, cycle, isOpen, cy
<> <>
<div className="flex gap-1 text-sm my-2"> <div className="flex gap-1 text-sm my-2">
<div className="flex items-center "> <div className="flex items-center ">
<Controller
control={control}
name="status"
render={({ field: { value } }) => (
<CustomSelect
label={
<span <span
className={`flex items-center gap-1 text-left capitalize p-1 text-xs h-full w-full text-gray-900`} className={`flex items-center gap-1 text-left capitalize p-1 text-xs h-full w-full text-gray-900`}
> >
<Squares2X2Icon className="h-4 w-4 flex-shrink-0" /> <Squares2X2Icon className="h-4 w-4 flex-shrink-0" />
{watch("status")} {cycleStatus === "current"
? "In Progress"
: cycleStatus === "completed"
? "Completed"
: cycleStatus === "upcoming"
? "Upcoming"
: "Draft"}
</span> </span>
}
value={value}
onChange={(value: any) => {
submitChanges({ status: value });
}}
>
{CYCLE_STATUS.map((option) => (
<CustomSelect.Option key={option.value} value={option.value}>
<span className="text-xs">{option.label}</span>
</CustomSelect.Option>
))}
</CustomSelect>
)}
/>
</div> </div>
<div className="flex justify-center items-center gap-2 rounded-md border bg-transparent h-full p-2 px-4 text-xs font-medium text-gray-900 hover:bg-gray-100 hover:text-gray-900 focus:outline-none"> <div className="flex justify-center items-center gap-2 rounded-md border bg-transparent h-full p-2 px-4 text-xs font-medium text-gray-900 hover:bg-gray-100 hover:text-gray-900 focus:outline-none">
<Popover className="flex justify-center items-center relative rounded-lg"> <Popover className="flex justify-center items-center relative rounded-lg">
@ -289,14 +279,16 @@ export const CycleDetailsSidebar: React.FC<Props> = ({ issues, cycle, isOpen, cy
</div> </div>
) : ( ) : (
<div className="grid h-5 w-5 place-items-center rounded-full border-2 border-white bg-gray-700 capitalize text-white"> <div className="grid h-5 w-5 place-items-center rounded-full border-2 border-white bg-gray-700 capitalize text-white">
{cycle.owned_by?.first_name && cycle.owned_by.first_name !== "" {cycle.owned_by &&
? cycle.owned_by.first_name.charAt(0) cycle.owned_by?.first_name &&
cycle.owned_by?.first_name !== ""
? cycle.owned_by?.first_name.charAt(0)
: cycle.owned_by?.email.charAt(0)} : cycle.owned_by?.email.charAt(0)}
</div> </div>
))} ))}
{cycle.owned_by.first_name !== "" {cycle.owned_by?.first_name !== ""
? cycle.owned_by.first_name ? cycle.owned_by?.first_name
: cycle.owned_by.email} : cycle.owned_by?.email}
</div> </div>
</div> </div>
<div className="flex flex-wrap items-center py-2"> <div className="flex flex-wrap items-center py-2">

View File

@ -1,5 +0,0 @@
export const CYCLE_STATUS = [
{ label: "Started", value: "started", color: "#5e6ad2" },
{ label: "Completed", value: "completed", color: "#eb5757" },
{ label: "Draft", value: "draft", color: "#f2c94c" },
];

View File

@ -35,6 +35,9 @@ export const PROJECT_GITHUB_REPOSITORY = (projectId: string) =>
export const CYCLE_LIST = (projectId: string) => `CYCLE_LIST_${projectId}`; export const CYCLE_LIST = (projectId: string) => `CYCLE_LIST_${projectId}`;
export const CYCLE_ISSUES = (cycleId: string) => `CYCLE_ISSUES_${cycleId}`; export const CYCLE_ISSUES = (cycleId: string) => `CYCLE_ISSUES_${cycleId}`;
export const CYCLE_DETAILS = (cycleId: string) => `CYCLE_DETAIL_${cycleId}`; export const CYCLE_DETAILS = (cycleId: string) => `CYCLE_DETAIL_${cycleId}`;
export const CYCLE_CURRENT_AND_UPCOMING_LIST = (projectId: string) => `CYCLE_CURRENT_AND_UPCOMING_LIST_${projectId}`;
export const CYCLE_DRAFT_LIST = (projectId: string) => `CYCLE_DRAFT_LIST_${projectId}`;
export const CYCLE_COMPLETE_LIST = (projectId: string) => `CYCLE_COMPLETE_LIST_${projectId}`;
export const STATE_LIST = (projectId: string) => `STATE_LIST_${projectId}`; export const STATE_LIST = (projectId: string) => `STATE_LIST_${projectId}`;
export const STATE_DETAIL = "STATE_DETAIL"; export const STATE_DETAIL = "STATE_DETAIL";

View File

@ -88,3 +88,17 @@ export const timeAgo = (time: any) => {
} }
return time; return time;
}; };
export const getDateRangeStatus = (startDate: string , endDate: string ) => {
const now = new Date();
const start = new Date(startDate);
const end = new Date(endDate);
if (end < now) {
return "completed";
} else if (start <= now && end >= now) {
return "current";
} else {
return "upcoming";
}
}

View File

@ -25,6 +25,7 @@ import { CustomMenu, EmptySpace, EmptySpaceItem, Spinner } from "components/ui";
import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs"; import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs";
// helpers // helpers
import { truncateText } from "helpers/string.helper"; import { truncateText } from "helpers/string.helper";
import { getDateRangeStatus } from "helpers/date-time.helper";
// types // types
import { CycleIssueResponse, UserAuth } from "types"; import { CycleIssueResponse, UserAuth } from "types";
// fetch-keys // fetch-keys
@ -78,6 +79,11 @@ const SingleCycle: React.FC<UserAuth> = (props) => {
: null : null
); );
const cycleStatus =
cycleDetails?.start_date && cycleDetails?.end_date
? getDateRangeStatus(cycleDetails?.start_date, cycleDetails?.end_date)
: "draft";
const { data: cycleIssues } = useSWR<CycleIssueResponse[]>( const { data: cycleIssues } = useSWR<CycleIssueResponse[]>(
workspaceSlug && projectId && cycleId ? CYCLE_ISSUES(cycleId as string) : null, workspaceSlug && projectId && cycleId ? CYCLE_ISSUES(cycleId as string) : null,
workspaceSlug && projectId && cycleId workspaceSlug && projectId && cycleId
@ -218,6 +224,7 @@ const SingleCycle: React.FC<UserAuth> = (props) => {
</div> </div>
)} )}
<CycleDetailsSidebar <CycleDetailsSidebar
cycleStatus={cycleStatus}
issues={cycleIssuesArray ?? []} issues={cycleIssuesArray ?? []}
cycle={cycleDetails} cycle={cycleDetails}
isOpen={cycleSidebar} isOpen={cycleSidebar}

View File

@ -1,30 +1,47 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import dynamic from "next/dynamic";
import useSWR from "swr"; import useSWR from "swr";
import { PlusIcon } from "@heroicons/react/24/outline"; import { PlusIcon } from "@heroicons/react/24/outline";
import { Tab } from "@headlessui/react"; import { Tab } from "@headlessui/react";
// lib // lib
import { requiredAuth } from "lib/auth"; import { requiredAuth } from "lib/auth";
import { CyclesIcon } from "components/icons";
// services // services
import cycleService from "services/cycles.service"; import cycleService from "services/cycles.service";
import projectService from "services/project.service"; import projectService from "services/project.service";
import workspaceService from "services/workspace.service";
// layouts // layouts
import AppLayout from "layouts/app-layout"; import AppLayout from "layouts/app-layout";
// components // components
import { CreateUpdateCycleModal, CyclesListView } from "components/cycles"; import { CompletedCyclesListProps, CreateUpdateCycleModal, CyclesList } from "components/cycles";
// ui // ui
import { HeaderButton, EmptySpace, EmptySpaceItem, Loader } from "components/ui"; import { HeaderButton, Loader } from "components/ui";
import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs"; import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs";
// icons // icons
// types // types
import { ICycle, SelectCycleType } from "types"; import { SelectCycleType } from "types";
import type { NextPage, GetServerSidePropsContext } from "next"; import type { NextPage, GetServerSidePropsContext } from "next";
// fetching keys // fetching keys
import { CYCLE_LIST, PROJECT_DETAILS, WORKSPACE_DETAILS } from "constants/fetch-keys"; import {
CYCLE_CURRENT_AND_UPCOMING_LIST,
CYCLE_DRAFT_LIST,
PROJECT_DETAILS,
} from "constants/fetch-keys";
const CompletedCyclesList = dynamic<CompletedCyclesListProps>(
() => import("components/cycles").then((a) => a.CompletedCyclesList),
{
ssr: false,
loading: () => (
<Loader className="mb-5">
<Loader.Item height="12rem" width="100%" />
</Loader>
),
}
);
const ProjectCycles: NextPage = () => { const ProjectCycles: NextPage = () => {
const [selectedCycle, setSelectedCycle] = useState<SelectCycleType>(); const [selectedCycle, setSelectedCycle] = useState<SelectCycleType>();
@ -34,43 +51,25 @@ const ProjectCycles: NextPage = () => {
query: { workspaceSlug, projectId }, query: { workspaceSlug, projectId },
} = useRouter(); } = useRouter();
const { data: activeWorkspace } = useSWR(
workspaceSlug ? WORKSPACE_DETAILS(workspaceSlug as string) : null,
() => (workspaceSlug ? workspaceService.getWorkspace(workspaceSlug as string) : null)
);
const { data: activeProject } = useSWR( const { data: activeProject } = useSWR(
activeWorkspace && projectId ? PROJECT_DETAILS(projectId as string) : null, workspaceSlug && projectId ? PROJECT_DETAILS(projectId as string) : null,
activeWorkspace && projectId workspaceSlug && projectId
? () => projectService.getProject(activeWorkspace.slug, projectId as string) ? () => projectService.getProject(workspaceSlug as string, projectId as string)
: null : null
); );
const { data: cycles } = useSWR<ICycle[]>( const { data: draftCycles } = useSWR(
activeWorkspace && projectId ? CYCLE_LIST(projectId as string) : null, workspaceSlug && projectId ? CYCLE_DRAFT_LIST(projectId as string) : null,
activeWorkspace && projectId workspaceSlug && projectId
? () => cycleService.getCycles(activeWorkspace.slug, projectId as string) ? () => cycleService.getDraftCycles(workspaceSlug as string, projectId as string)
: null : null
); );
const getCycleStatus = (startDate: string, endDate: string) => { const { data: currentAndUpcomingCycles } = useSWR(
const today = new Date(); workspaceSlug && projectId ? CYCLE_CURRENT_AND_UPCOMING_LIST(projectId as string) : null,
workspaceSlug && projectId
if (today < new Date(startDate)) return "upcoming"; ? () => cycleService.getCurrentAndUpcomingCycles(workspaceSlug as string, projectId as string)
else if (today > new Date(endDate)) return "completed"; : null
else return "current";
};
const currentCycles = cycles?.filter(
(c) => getCycleStatus(c.start_date, c.end_date) === "current"
);
const upcomingCycles = cycles?.filter(
(c) => getCycleStatus(c.start_date, c.end_date) === "upcoming"
);
const completedCycles = cycles?.filter(
(c) => getCycleStatus(c.start_date, c.end_date) === "completed"
); );
useEffect(() => { useEffect(() => {
@ -110,13 +109,11 @@ const ProjectCycles: NextPage = () => {
handleClose={() => setCreateUpdateCycleModal(false)} handleClose={() => setCreateUpdateCycleModal(false)}
data={selectedCycle} data={selectedCycle}
/> />
{cycles ? (
cycles.length > 0 ? (
<div className="space-y-8"> <div className="space-y-8">
<h3 className="text-xl font-medium leading-6 text-gray-900">Current Cycle</h3> <h3 className="text-xl font-medium leading-6 text-gray-900">Current Cycle</h3>
<div className="space-y-5"> <div className="space-y-5">
<CyclesListView <CyclesList
cycles={currentCycles ?? []} cycles={currentAndUpcomingCycles?.current_cycle ?? []}
setCreateUpdateCycleModal={setCreateUpdateCycleModal} setCreateUpdateCycleModal={setCreateUpdateCycleModal}
setSelectedCycle={setSelectedCycle} setSelectedCycle={setSelectedCycle}
type="current" type="current"
@ -126,76 +123,57 @@ const ProjectCycles: NextPage = () => {
<Tab.Group> <Tab.Group>
<Tab.List <Tab.List
as="div" as="div"
className="grid grid-cols-2 items-center gap-2 rounded-lg bg-gray-100 p-2 text-sm" className="flex justify-between items-center gap-2 rounded-lg bg-gray-100 p-2 text-sm"
> >
<Tab <Tab
className={({ selected }) => className={({ selected }) =>
`rounded-lg px-6 py-2 ${selected ? "bg-gray-300" : "hover:bg-gray-200"}` `w-1/3 rounded-lg px-6 py-2 ${selected ? "bg-gray-300" : "hover:bg-gray-200"}`
} }
> >
Upcoming Upcoming
</Tab> </Tab>
<Tab <Tab
className={({ selected }) => className={({ selected }) =>
`rounded-lg px-6 py-2 ${selected ? "bg-gray-300" : "hover:bg-gray-200"}` `w-1/3 rounded-lg px-6 py-2 ${selected ? "bg-gray-300" : "hover:bg-gray-200"}`
} }
> >
Completed Completed
</Tab> </Tab>
<Tab
className={({ selected }) =>
` w-1/3 rounded-lg px-6 py-2 ${selected ? "bg-gray-300" : "hover:bg-gray-200"}`
}
>
Draft
</Tab>
</Tab.List> </Tab.List>
<Tab.Panels> <Tab.Panels>
<Tab.Panel as="div" className="mt-8 space-y-5"> <Tab.Panel as="div" className="mt-8 space-y-5">
<CyclesListView <CyclesList
cycles={upcomingCycles ?? []} cycles={currentAndUpcomingCycles?.upcoming_cycle ?? []}
setCreateUpdateCycleModal={setCreateUpdateCycleModal} setCreateUpdateCycleModal={setCreateUpdateCycleModal}
setSelectedCycle={setSelectedCycle} setSelectedCycle={setSelectedCycle}
type="upcoming" type="upcoming"
/> />
</Tab.Panel> </Tab.Panel>
<Tab.Panel as="div" className="mt-8 space-y-5"> <Tab.Panel as="div" className="mt-8 space-y-5">
<CyclesListView <CompletedCyclesList
cycles={completedCycles ?? []}
setCreateUpdateCycleModal={setCreateUpdateCycleModal} setCreateUpdateCycleModal={setCreateUpdateCycleModal}
setSelectedCycle={setSelectedCycle} setSelectedCycle={setSelectedCycle}
type="completed"
/> />
</Tab.Panel> </Tab.Panel>
</Tab.Panels> </Tab.Panels>
<Tab.Panel as="div" className="mt-8 space-y-5">
<CyclesList
cycles={draftCycles?.draft_cycles ?? []}
setCreateUpdateCycleModal={setCreateUpdateCycleModal}
setSelectedCycle={setSelectedCycle}
type="upcoming"
/>
</Tab.Panel>
</Tab.Group> </Tab.Group>
</div> </div>
</div> </div>
) : (
<div className="flex h-full w-full flex-col items-center justify-center px-4">
<EmptySpace
title="You don't have any cycle yet."
description="A cycle is a fixed time period where a team commits to a set number of issues from their backlog. Cycles are usually one, two, or four weeks long."
Icon={CyclesIcon}
>
<EmptySpaceItem
title="Create a new cycle"
description={
<span>
Use <pre className="inline rounded bg-gray-200 px-2 py-1">Q</pre> shortcut to
create a new cycle
</span>
}
Icon={PlusIcon}
action={() => {
const e = new KeyboardEvent("keydown", {
key: "q",
});
document.dispatchEvent(e);
}}
/>
</EmptySpace>
</div>
)
) : (
<Loader className="space-y-5">
<Loader.Item height="150px" />
<Loader.Item height="150px" />
</Loader>
)}
</AppLayout> </AppLayout>
); );
}; };

View File

@ -1,7 +1,7 @@
// services // services
import APIService from "services/api.service"; import APIService from "services/api.service";
// types // types
import type { ICycle } from "types"; import type { CompletedCyclesResponse, CurrentAndUpcomingCyclesResponse, DraftCyclesResponse, ICycle } from "types";
const { NEXT_PUBLIC_API_BASE_URL } = process.env; const { NEXT_PUBLIC_API_BASE_URL } = process.env;
@ -87,6 +87,47 @@ class ProjectCycleServices extends APIService {
throw error?.response?.data; throw error?.response?.data;
}); });
} }
async cycleDateCheck(workspaceSlug: string, projectId: string, data: {
start_date: string,
end_date: string
}): Promise<any> {
return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/cycles/date-check/`, data)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async getCurrentAndUpcomingCycles(workspaceSlug: string, projectId: string): Promise<CurrentAndUpcomingCyclesResponse> {
return this.get(
`/api/workspaces/${workspaceSlug}/projects/${projectId}/cycles/current-upcoming-cycles/`
)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async getDraftCycles(workspaceSlug: string, projectId: string): Promise<DraftCyclesResponse> {
return this.get(
`/api/workspaces/${workspaceSlug}/projects/${projectId}/cycles/draft-cycles/`
)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async getCompletedCycles(workspaceSlug: string, projectId: string): Promise<CompletedCyclesResponse> {
return this.get(
`/api/workspaces/${workspaceSlug}/projects/${projectId}/cycles/completed-cycles/`
)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
} }
export default new ProjectCycleServices(); export default new ProjectCycleServices();

View File

@ -7,16 +7,32 @@ export interface ICycle {
updated_at: Date; updated_at: Date;
name: string; name: string;
description: string; description: string;
start_date: string; start_date: string | null;
end_date: string; end_date: string | null;
status: string;
created_by: string; created_by: string;
updated_by: string; updated_by: string;
project: string; project: string;
workspace: string; workspace: string;
issue: string; issue: string;
current_cycle: [];
upcoming_cycle: [];
past_cycles: [];
} }
export interface CurrentAndUpcomingCyclesResponse {
current_cycle : ICycle[];
upcoming_cycle : ICycle[];
}
export interface DraftCyclesResponse {
draft_cycles : ICycle[];
}
export interface CompletedCyclesResponse {
completed_cycles : ICycle[];
}
export interface CycleIssueResponse { export interface CycleIssueResponse {
id: string; id: string;
issue_detail: IIssue; issue_detail: IIssue;