forked from github/plane
Merge pull request #358 from makeplane/feat/cycle_validations
feat: cycle validations
This commit is contained in:
commit
f290a417bc
82
apps/app/components/cycles/completed-cycles-list.tsx
Normal file
82
apps/app/components/cycles/completed-cycles-list.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
@ -13,7 +13,7 @@ type TCycleStatsViewProps = {
|
||||
type: "current" | "upcoming" | "completed";
|
||||
};
|
||||
|
||||
export const CyclesListView: React.FC<TCycleStatsViewProps> = ({
|
||||
export const CyclesList: React.FC<TCycleStatsViewProps> = ({
|
||||
cycles,
|
||||
setCreateUpdateCycleModal,
|
||||
setSelectedCycle,
|
@ -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
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
// ui
|
||||
import { Button, CustomDatePicker, CustomSelect, Input, TextArea } from "components/ui";
|
||||
// types
|
||||
import { ICycle } from "types";
|
||||
// services
|
||||
import cyclesService from "services/cycles.service";
|
||||
|
||||
type Props = {
|
||||
handleFormSubmit: (values: Partial<ICycle>) => Promise<void>;
|
||||
@ -17,17 +22,24 @@ type Props = {
|
||||
const defaultValues: Partial<ICycle> = {
|
||||
name: "",
|
||||
description: "",
|
||||
status: "draft",
|
||||
start_date: "",
|
||||
end_date: "",
|
||||
start_date: null,
|
||||
end_date: null,
|
||||
};
|
||||
|
||||
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 {
|
||||
register,
|
||||
formState: { errors, isSubmitting },
|
||||
handleSubmit,
|
||||
control,
|
||||
watch,
|
||||
reset,
|
||||
} = useForm<ICycle>({
|
||||
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(() => {
|
||||
reset({
|
||||
...defaultValues,
|
||||
@ -84,30 +121,7 @@ export const CycleForm: React.FC<Props> = ({ handleFormSubmit, handleClose, stat
|
||||
register={register}
|
||||
/>
|
||||
</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="w-full">
|
||||
<h6 className="text-gray-500">Start Date</h6>
|
||||
@ -115,12 +129,19 @@ export const CycleForm: React.FC<Props> = ({ handleFormSubmit, handleClose, stat
|
||||
<Controller
|
||||
control={control}
|
||||
name="start_date"
|
||||
rules={{ required: "Start date is required" }}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<CustomDatePicker
|
||||
renderAs="input"
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
@ -136,12 +157,19 @@ export const CycleForm: React.FC<Props> = ({ handleFormSubmit, handleClose, stat
|
||||
<Controller
|
||||
control={control}
|
||||
name="end_date"
|
||||
rules={{ required: "End date is required" }}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<CustomDatePicker
|
||||
renderAs="input"
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
@ -158,7 +186,18 @@ export const CycleForm: React.FC<Props> = ({ handleFormSubmit, handleClose, stat
|
||||
<Button theme="secondary" onClick={handleClose}>
|
||||
Cancel
|
||||
</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
|
||||
? isSubmitting
|
||||
? "Updating Cycle..."
|
||||
|
@ -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 "./form";
|
||||
export * from "./modal";
|
||||
|
@ -12,10 +12,16 @@ import cycleService from "services/cycles.service";
|
||||
import useToast from "hooks/use-toast";
|
||||
// components
|
||||
import { CycleForm } from "components/cycles";
|
||||
// helper
|
||||
import { getDateRangeStatus } from "helpers/date-time.helper";
|
||||
// types
|
||||
import type { ICycle } from "types";
|
||||
// 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 = {
|
||||
isOpen: boolean;
|
||||
@ -37,7 +43,23 @@ export const CreateUpdateCycleModal: React.FC<CycleModalProps> = ({
|
||||
await cycleService
|
||||
.createCycle(workspaceSlug as string, projectId as string, payload)
|
||||
.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();
|
||||
|
||||
setToastAlert({
|
||||
@ -59,7 +81,23 @@ export const CreateUpdateCycleModal: React.FC<CycleModalProps> = ({
|
||||
await cycleService
|
||||
.updateCycle(workspaceSlug as string, projectId as string, cycleId, payload)
|
||||
.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();
|
||||
|
||||
setToastAlert({
|
||||
@ -113,7 +151,7 @@ export const CreateUpdateCycleModal: React.FC<CycleModalProps> = ({
|
||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||
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
|
||||
handleFormSubmit={handleFormSubmit}
|
||||
handleClose={handleClose}
|
||||
|
@ -6,7 +6,7 @@ import Image from "next/image";
|
||||
import { mutate } from "swr";
|
||||
|
||||
// react-hook-form
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Popover, Transition } from "@headlessui/react";
|
||||
import DatePicker from "react-datepicker";
|
||||
// icons
|
||||
@ -19,7 +19,7 @@ import {
|
||||
UserIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
// ui
|
||||
import { CustomSelect, Loader, ProgressBar } from "components/ui";
|
||||
import { Loader, ProgressBar } from "components/ui";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
// services
|
||||
@ -36,17 +36,22 @@ import { renderDateFormat, renderShortNumericDateFormat } from "helpers/date-tim
|
||||
import { CycleIssueResponse, ICycle, IIssue } from "types";
|
||||
// fetch-keys
|
||||
import { CYCLE_DETAILS } from "constants/fetch-keys";
|
||||
// constants
|
||||
import { CYCLE_STATUS } from "constants/cycle";
|
||||
|
||||
type Props = {
|
||||
issues: IIssue[];
|
||||
cycle: ICycle | undefined;
|
||||
isOpen: boolean;
|
||||
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 router = useRouter();
|
||||
@ -60,7 +65,6 @@ export const CycleDetailsSidebar: React.FC<Props> = ({ issues, cycle, isOpen, cy
|
||||
const defaultValues: Partial<ICycle> = {
|
||||
start_date: new Date().toString(),
|
||||
end_date: new Date().toString(),
|
||||
status: cycle?.status,
|
||||
};
|
||||
|
||||
const groupedIssues = {
|
||||
@ -72,7 +76,7 @@ export const CycleDetailsSidebar: React.FC<Props> = ({ issues, cycle, isOpen, cy
|
||||
...groupBy(cycleIssues ?? [], "issue_detail.state_detail.group"),
|
||||
};
|
||||
|
||||
const { reset, watch, control } = useForm({
|
||||
const { reset } = useForm({
|
||||
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 items-center ">
|
||||
<Controller
|
||||
control={control}
|
||||
name="status"
|
||||
render={({ field: { value } }) => (
|
||||
<CustomSelect
|
||||
label={
|
||||
<span
|
||||
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" />
|
||||
{watch("status")}
|
||||
{cycleStatus === "current"
|
||||
? "In Progress"
|
||||
: cycleStatus === "completed"
|
||||
? "Completed"
|
||||
: cycleStatus === "upcoming"
|
||||
? "Upcoming"
|
||||
: "Draft"}
|
||||
</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 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">
|
||||
@ -289,14 +279,16 @@ export const CycleDetailsSidebar: React.FC<Props> = ({ issues, cycle, isOpen, cy
|
||||
</div>
|
||||
) : (
|
||||
<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.first_name.charAt(0)
|
||||
{cycle.owned_by &&
|
||||
cycle.owned_by?.first_name &&
|
||||
cycle.owned_by?.first_name !== ""
|
||||
? cycle.owned_by?.first_name.charAt(0)
|
||||
: cycle.owned_by?.email.charAt(0)}
|
||||
</div>
|
||||
))}
|
||||
{cycle.owned_by.first_name !== ""
|
||||
? cycle.owned_by.first_name
|
||||
: cycle.owned_by.email}
|
||||
{cycle.owned_by?.first_name !== ""
|
||||
? cycle.owned_by?.first_name
|
||||
: cycle.owned_by?.email}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center py-2">
|
||||
|
@ -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" },
|
||||
];
|
@ -35,6 +35,9 @@ export const PROJECT_GITHUB_REPOSITORY = (projectId: string) =>
|
||||
export const CYCLE_LIST = (projectId: string) => `CYCLE_LIST_${projectId}`;
|
||||
export const CYCLE_ISSUES = (cycleId: string) => `CYCLE_ISSUES_${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_DETAIL = "STATE_DETAIL";
|
||||
|
@ -88,3 +88,17 @@ export const timeAgo = (time: any) => {
|
||||
}
|
||||
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";
|
||||
}
|
||||
}
|
@ -25,6 +25,7 @@ import { CustomMenu, EmptySpace, EmptySpaceItem, Spinner } from "components/ui";
|
||||
import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs";
|
||||
// helpers
|
||||
import { truncateText } from "helpers/string.helper";
|
||||
import { getDateRangeStatus } from "helpers/date-time.helper";
|
||||
// types
|
||||
import { CycleIssueResponse, UserAuth } from "types";
|
||||
// fetch-keys
|
||||
@ -78,6 +79,11 @@ const SingleCycle: React.FC<UserAuth> = (props) => {
|
||||
: null
|
||||
);
|
||||
|
||||
const cycleStatus =
|
||||
cycleDetails?.start_date && cycleDetails?.end_date
|
||||
? getDateRangeStatus(cycleDetails?.start_date, cycleDetails?.end_date)
|
||||
: "draft";
|
||||
|
||||
const { data: cycleIssues } = useSWR<CycleIssueResponse[]>(
|
||||
workspaceSlug && projectId && cycleId ? CYCLE_ISSUES(cycleId as string) : null,
|
||||
workspaceSlug && projectId && cycleId
|
||||
@ -218,6 +224,7 @@ const SingleCycle: React.FC<UserAuth> = (props) => {
|
||||
</div>
|
||||
)}
|
||||
<CycleDetailsSidebar
|
||||
cycleStatus={cycleStatus}
|
||||
issues={cycleIssuesArray ?? []}
|
||||
cycle={cycleDetails}
|
||||
isOpen={cycleSidebar}
|
||||
|
@ -1,30 +1,47 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
import dynamic from "next/dynamic";
|
||||
import useSWR from "swr";
|
||||
import { PlusIcon } from "@heroicons/react/24/outline";
|
||||
import { Tab } from "@headlessui/react";
|
||||
|
||||
// lib
|
||||
import { requiredAuth } from "lib/auth";
|
||||
import { CyclesIcon } from "components/icons";
|
||||
|
||||
// services
|
||||
import cycleService from "services/cycles.service";
|
||||
import projectService from "services/project.service";
|
||||
import workspaceService from "services/workspace.service";
|
||||
|
||||
// layouts
|
||||
import AppLayout from "layouts/app-layout";
|
||||
// components
|
||||
import { CreateUpdateCycleModal, CyclesListView } from "components/cycles";
|
||||
import { CompletedCyclesListProps, CreateUpdateCycleModal, CyclesList } from "components/cycles";
|
||||
// ui
|
||||
import { HeaderButton, EmptySpace, EmptySpaceItem, Loader } from "components/ui";
|
||||
import { HeaderButton, Loader } from "components/ui";
|
||||
import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs";
|
||||
// icons
|
||||
// types
|
||||
import { ICycle, SelectCycleType } from "types";
|
||||
import { SelectCycleType } from "types";
|
||||
import type { NextPage, GetServerSidePropsContext } from "next";
|
||||
// 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 [selectedCycle, setSelectedCycle] = useState<SelectCycleType>();
|
||||
@ -34,43 +51,25 @@ const ProjectCycles: NextPage = () => {
|
||||
query: { workspaceSlug, projectId },
|
||||
} = useRouter();
|
||||
|
||||
const { data: activeWorkspace } = useSWR(
|
||||
workspaceSlug ? WORKSPACE_DETAILS(workspaceSlug as string) : null,
|
||||
() => (workspaceSlug ? workspaceService.getWorkspace(workspaceSlug as string) : null)
|
||||
);
|
||||
|
||||
const { data: activeProject } = useSWR(
|
||||
activeWorkspace && projectId ? PROJECT_DETAILS(projectId as string) : null,
|
||||
activeWorkspace && projectId
|
||||
? () => projectService.getProject(activeWorkspace.slug, projectId as string)
|
||||
workspaceSlug && projectId ? PROJECT_DETAILS(projectId as string) : null,
|
||||
workspaceSlug && projectId
|
||||
? () => projectService.getProject(workspaceSlug as string, projectId as string)
|
||||
: null
|
||||
);
|
||||
|
||||
const { data: cycles } = useSWR<ICycle[]>(
|
||||
activeWorkspace && projectId ? CYCLE_LIST(projectId as string) : null,
|
||||
activeWorkspace && projectId
|
||||
? () => cycleService.getCycles(activeWorkspace.slug, projectId as string)
|
||||
const { data: draftCycles } = useSWR(
|
||||
workspaceSlug && projectId ? CYCLE_DRAFT_LIST(projectId as string) : null,
|
||||
workspaceSlug && projectId
|
||||
? () => cycleService.getDraftCycles(workspaceSlug as string, projectId as string)
|
||||
: null
|
||||
);
|
||||
|
||||
const getCycleStatus = (startDate: string, endDate: string) => {
|
||||
const today = new Date();
|
||||
|
||||
if (today < new Date(startDate)) return "upcoming";
|
||||
else if (today > new Date(endDate)) return "completed";
|
||||
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"
|
||||
const { data: currentAndUpcomingCycles } = useSWR(
|
||||
workspaceSlug && projectId ? CYCLE_CURRENT_AND_UPCOMING_LIST(projectId as string) : null,
|
||||
workspaceSlug && projectId
|
||||
? () => cycleService.getCurrentAndUpcomingCycles(workspaceSlug as string, projectId as string)
|
||||
: null
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@ -110,13 +109,11 @@ const ProjectCycles: NextPage = () => {
|
||||
handleClose={() => setCreateUpdateCycleModal(false)}
|
||||
data={selectedCycle}
|
||||
/>
|
||||
{cycles ? (
|
||||
cycles.length > 0 ? (
|
||||
<div className="space-y-8">
|
||||
<h3 className="text-xl font-medium leading-6 text-gray-900">Current Cycle</h3>
|
||||
<div className="space-y-5">
|
||||
<CyclesListView
|
||||
cycles={currentCycles ?? []}
|
||||
<CyclesList
|
||||
cycles={currentAndUpcomingCycles?.current_cycle ?? []}
|
||||
setCreateUpdateCycleModal={setCreateUpdateCycleModal}
|
||||
setSelectedCycle={setSelectedCycle}
|
||||
type="current"
|
||||
@ -126,76 +123,57 @@ const ProjectCycles: NextPage = () => {
|
||||
<Tab.Group>
|
||||
<Tab.List
|
||||
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
|
||||
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
|
||||
</Tab>
|
||||
<Tab
|
||||
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
|
||||
</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.Panels>
|
||||
<Tab.Panel as="div" className="mt-8 space-y-5">
|
||||
<CyclesListView
|
||||
cycles={upcomingCycles ?? []}
|
||||
<CyclesList
|
||||
cycles={currentAndUpcomingCycles?.upcoming_cycle ?? []}
|
||||
setCreateUpdateCycleModal={setCreateUpdateCycleModal}
|
||||
setSelectedCycle={setSelectedCycle}
|
||||
type="upcoming"
|
||||
/>
|
||||
</Tab.Panel>
|
||||
<Tab.Panel as="div" className="mt-8 space-y-5">
|
||||
<CyclesListView
|
||||
cycles={completedCycles ?? []}
|
||||
<CompletedCyclesList
|
||||
setCreateUpdateCycleModal={setCreateUpdateCycleModal}
|
||||
setSelectedCycle={setSelectedCycle}
|
||||
type="completed"
|
||||
/>
|
||||
</Tab.Panel>
|
||||
</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>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
@ -1,7 +1,7 @@
|
||||
// services
|
||||
import APIService from "services/api.service";
|
||||
// types
|
||||
import type { ICycle } from "types";
|
||||
import type { CompletedCyclesResponse, CurrentAndUpcomingCyclesResponse, DraftCyclesResponse, ICycle } from "types";
|
||||
|
||||
const { NEXT_PUBLIC_API_BASE_URL } = process.env;
|
||||
|
||||
@ -87,6 +87,47 @@ class ProjectCycleServices extends APIService {
|
||||
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();
|
||||
|
22
apps/app/types/cycles.d.ts
vendored
22
apps/app/types/cycles.d.ts
vendored
@ -7,16 +7,32 @@ export interface ICycle {
|
||||
updated_at: Date;
|
||||
name: string;
|
||||
description: string;
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
status: string;
|
||||
start_date: string | null;
|
||||
end_date: string | null;
|
||||
created_by: string;
|
||||
updated_by: string;
|
||||
project: string;
|
||||
workspace: 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 {
|
||||
id: string;
|
||||
issue_detail: IIssue;
|
||||
|
Loading…
Reference in New Issue
Block a user