mirror of
https://github.com/makeplane/plane
synced 2024-06-14 14:31:34 +00:00
chore: update cycle store structure
This commit is contained in:
parent
8521a54e49
commit
019e2e4356
@ -4,8 +4,7 @@ import { useRouter } from "next/router";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import useSWR from "swr";
|
||||
// hooks
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
import { useApplication } from "hooks/store";
|
||||
import { useApplication, useCycle } from "hooks/store";
|
||||
import useToast from "hooks/use-toast";
|
||||
// ui
|
||||
import { SingleProgressStats } from "components/core";
|
||||
@ -71,20 +70,20 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = props;
|
||||
// store hooks
|
||||
const { cycle: cycleStore } = useMobxStore();
|
||||
const {
|
||||
commandPalette: { toggleCreateCycleModal },
|
||||
} = useApplication();
|
||||
const { fetchActiveCycle, projectActiveCycle, getActiveCycleById, addCycleToFavorites, removeCycleFromFavorites } =
|
||||
useCycle();
|
||||
// toast alert
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const { isLoading } = useSWR(
|
||||
workspaceSlug && projectId ? `ACTIVE_CYCLE_ISSUE_${projectId}_CURRENT` : null,
|
||||
workspaceSlug && projectId ? () => cycleStore.fetchCycles(workspaceSlug, projectId, "current") : null
|
||||
workspaceSlug && projectId ? `PROJECT_ACTIVE_CYCLE_${projectId}` : null,
|
||||
workspaceSlug && projectId ? () => fetchActiveCycle(workspaceSlug, projectId) : null
|
||||
);
|
||||
|
||||
const activeCycle = cycleStore.cycles?.[projectId]?.current || null;
|
||||
const cycle = activeCycle ? activeCycle[0] : null;
|
||||
const activeCycle = projectActiveCycle ? getActiveCycleById(projectActiveCycle) : null;
|
||||
const issues = (cycleStore?.active_cycle_issues as any) || null;
|
||||
|
||||
// const { data: issues } = useSWR(
|
||||
@ -97,14 +96,14 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
|
||||
// : null
|
||||
// ) as { data: IIssue[] | undefined };
|
||||
|
||||
if (!cycle && isLoading)
|
||||
if (!activeCycle && isLoading)
|
||||
return (
|
||||
<Loader>
|
||||
<Loader.Item height="250px" />
|
||||
</Loader>
|
||||
);
|
||||
|
||||
if (!cycle)
|
||||
if (!activeCycle)
|
||||
return (
|
||||
<div className="grid h-full place-items-center text-center">
|
||||
<div className="space-y-2">
|
||||
@ -129,24 +128,24 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
|
||||
</div>
|
||||
);
|
||||
|
||||
const endDate = new Date(cycle.end_date ?? "");
|
||||
const startDate = new Date(cycle.start_date ?? "");
|
||||
const endDate = new Date(activeCycle.end_date ?? "");
|
||||
const startDate = new Date(activeCycle.start_date ?? "");
|
||||
|
||||
const groupedIssues: any = {
|
||||
backlog: cycle.backlog_issues,
|
||||
unstarted: cycle.unstarted_issues,
|
||||
started: cycle.started_issues,
|
||||
completed: cycle.completed_issues,
|
||||
cancelled: cycle.cancelled_issues,
|
||||
backlog: activeCycle.backlog_issues,
|
||||
unstarted: activeCycle.unstarted_issues,
|
||||
started: activeCycle.started_issues,
|
||||
completed: activeCycle.completed_issues,
|
||||
cancelled: activeCycle.cancelled_issues,
|
||||
};
|
||||
|
||||
const cycleStatus = getDateRangeStatus(cycle.start_date, cycle.end_date);
|
||||
const cycleStatus = getDateRangeStatus(activeCycle.start_date, activeCycle.end_date);
|
||||
|
||||
const handleAddToFavorites = (e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
cycleStore.addCycleToFavorites(workspaceSlug?.toString(), projectId.toString(), cycle).catch(() => {
|
||||
addCycleToFavorites(workspaceSlug?.toString(), projectId.toString(), activeCycle.id).catch(() => {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
@ -159,7 +158,7 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
|
||||
e.preventDefault();
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
cycleStore.removeCycleFromFavorites(workspaceSlug?.toString(), projectId.toString(), cycle).catch(() => {
|
||||
removeCycleFromFavorites(workspaceSlug?.toString(), projectId.toString(), activeCycle.id).catch(() => {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
@ -171,7 +170,10 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
|
||||
const progressIndicatorData = stateGroups.map((group, index) => ({
|
||||
id: index,
|
||||
name: group.title,
|
||||
value: cycle.total_issues > 0 ? ((cycle[group.key as keyof ICycle] as number) / cycle.total_issues) * 100 : 0,
|
||||
value:
|
||||
activeCycle.total_issues > 0
|
||||
? ((activeCycle[group.key as keyof ICycle] as number) / activeCycle.total_issues) * 100
|
||||
: 0,
|
||||
color: group.color,
|
||||
}));
|
||||
|
||||
@ -199,8 +201,8 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
|
||||
}`}
|
||||
/>
|
||||
</span>
|
||||
<Tooltip tooltipContent={cycle.name} position="top-left">
|
||||
<h3 className="break-words text-lg font-semibold">{truncateText(cycle.name, 70)}</h3>
|
||||
<Tooltip tooltipContent={activeCycle.name} position="top-left">
|
||||
<h3 className="break-words text-lg font-semibold">{truncateText(activeCycle.name, 70)}</h3>
|
||||
</Tooltip>
|
||||
</span>
|
||||
<span className="flex items-center gap-1 capitalize">
|
||||
@ -221,19 +223,19 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
|
||||
{cycleStatus === "current" ? (
|
||||
<span className="flex gap-1 whitespace-nowrap">
|
||||
<RunningIcon className="h-4 w-4" />
|
||||
{findHowManyDaysLeft(cycle.end_date ?? new Date())} Days Left
|
||||
{findHowManyDaysLeft(activeCycle.end_date ?? new Date())} Days Left
|
||||
</span>
|
||||
) : cycleStatus === "upcoming" ? (
|
||||
<span className="flex gap-1 whitespace-nowrap">
|
||||
<AlarmClock className="h-4 w-4" />
|
||||
{findHowManyDaysLeft(cycle.start_date ?? new Date())} Days Left
|
||||
{findHowManyDaysLeft(activeCycle.start_date ?? new Date())} Days Left
|
||||
</span>
|
||||
) : cycleStatus === "completed" ? (
|
||||
<span className="flex gap-1 whitespace-nowrap">
|
||||
{cycle.total_issues - cycle.completed_issues > 0 && (
|
||||
{activeCycle.total_issues - activeCycle.completed_issues > 0 && (
|
||||
<Tooltip
|
||||
tooltipContent={`${cycle.total_issues - cycle.completed_issues} more pending ${
|
||||
cycle.total_issues - cycle.completed_issues === 1 ? "issue" : "issues"
|
||||
tooltipContent={`${activeCycle.total_issues - activeCycle.completed_issues} more pending ${
|
||||
activeCycle.total_issues - activeCycle.completed_issues === 1 ? "issue" : "issues"
|
||||
}`}
|
||||
>
|
||||
<span>
|
||||
@ -247,7 +249,7 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
|
||||
cycleStatus
|
||||
)}
|
||||
</span>
|
||||
{cycle.is_favorite ? (
|
||||
{activeCycle.is_favorite ? (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
handleRemoveFromFavorites(e);
|
||||
@ -281,26 +283,26 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2.5 text-custom-text-200">
|
||||
{cycle.owned_by.avatar && cycle.owned_by.avatar !== "" ? (
|
||||
{activeCycle.owned_by.avatar && activeCycle.owned_by.avatar !== "" ? (
|
||||
<img
|
||||
src={cycle.owned_by.avatar}
|
||||
src={activeCycle.owned_by.avatar}
|
||||
height={16}
|
||||
width={16}
|
||||
className="rounded-full"
|
||||
alt={cycle.owned_by.display_name}
|
||||
alt={activeCycle.owned_by.display_name}
|
||||
/>
|
||||
) : (
|
||||
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-custom-background-100 capitalize">
|
||||
{cycle.owned_by.display_name.charAt(0)}
|
||||
{activeCycle.owned_by.display_name.charAt(0)}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-custom-text-200">{cycle.owned_by.display_name}</span>
|
||||
<span className="text-custom-text-200">{activeCycle.owned_by.display_name}</span>
|
||||
</div>
|
||||
|
||||
{cycle.assignees.length > 0 && (
|
||||
{activeCycle.assignees.length > 0 && (
|
||||
<div className="flex items-center gap-1 text-custom-text-200">
|
||||
<AvatarGroup>
|
||||
{cycle.assignees.map((assignee) => (
|
||||
{activeCycle.assignees.map((assignee) => (
|
||||
<Avatar key={assignee.id} name={assignee.display_name} src={assignee.avatar} />
|
||||
))}
|
||||
</AvatarGroup>
|
||||
@ -311,15 +313,15 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
|
||||
<div className="flex items-center gap-4 text-custom-text-200">
|
||||
<div className="flex gap-2">
|
||||
<LayersIcon className="h-4 w-4 flex-shrink-0" />
|
||||
{cycle.total_issues} issues
|
||||
{activeCycle.total_issues} issues
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<StateGroupIcon stateGroup="completed" height="14px" width="14px" />
|
||||
{cycle.completed_issues} issues
|
||||
{activeCycle.completed_issues} issues
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Link href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycle.id}`}>
|
||||
<Link href={`/${workspaceSlug}/projects/${projectId}/cycles/${activeCycle.id}`}>
|
||||
<span className="w-full rounded-md bg-custom-primary px-4 py-2 text-center text-sm font-medium text-white hover:bg-custom-primary/90">
|
||||
View Cycle
|
||||
</span>
|
||||
@ -350,14 +352,14 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
|
||||
</div>
|
||||
}
|
||||
completed={groupedIssues[group]}
|
||||
total={cycle.total_issues}
|
||||
total={activeCycle.total_issues}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-60 overflow-y-scroll border-custom-border-200">
|
||||
<ActiveCycleProgressStats cycle={cycle} />
|
||||
<ActiveCycleProgressStats cycle={activeCycle} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -469,15 +471,18 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
|
||||
<span>
|
||||
<LayersIcon className="h-5 w-5 flex-shrink-0 text-custom-text-200" />
|
||||
</span>
|
||||
<span>Pending Issues - {cycle.total_issues - (cycle.completed_issues + cycle.cancelled_issues)}</span>
|
||||
<span>
|
||||
Pending Issues -{" "}
|
||||
{activeCycle.total_issues - (activeCycle.completed_issues + activeCycle.cancelled_issues)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative h-64">
|
||||
<ProgressChart
|
||||
distribution={cycle.distribution?.completion_chart ?? {}}
|
||||
startDate={cycle.start_date ?? ""}
|
||||
endDate={cycle.end_date ?? ""}
|
||||
totalIssues={cycle.total_issues}
|
||||
distribution={activeCycle.distribution?.completion_chart ?? {}}
|
||||
startDate={activeCycle.start_date ?? ""}
|
||||
endDate={activeCycle.end_date ?? ""}
|
||||
totalIssues={activeCycle.total_issues}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,10 +1,8 @@
|
||||
import React, { useEffect } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
// mobx
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
// hooks
|
||||
import { useCycle } from "hooks/store";
|
||||
// components
|
||||
import { CycleDetailsSidebar } from "./sidebar";
|
||||
|
||||
@ -14,14 +12,13 @@ type Props = {
|
||||
};
|
||||
|
||||
export const CyclePeekOverview: React.FC<Props> = observer(({ projectId, workspaceSlug }) => {
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { peekCycle } = router.query;
|
||||
|
||||
// refs
|
||||
const ref = React.useRef(null);
|
||||
|
||||
const { cycle: cycleStore } = useMobxStore();
|
||||
|
||||
const { fetchCycleWithId } = cycleStore;
|
||||
// store hooks
|
||||
const { fetchCycleDetails } = useCycle();
|
||||
|
||||
const handleClose = () => {
|
||||
delete router.query.peekCycle;
|
||||
@ -33,8 +30,8 @@ export const CyclePeekOverview: React.FC<Props> = observer(({ projectId, workspa
|
||||
|
||||
useEffect(() => {
|
||||
if (!peekCycle) return;
|
||||
fetchCycleWithId(workspaceSlug, projectId, peekCycle.toString());
|
||||
}, [fetchCycleWithId, peekCycle, projectId, workspaceSlug]);
|
||||
fetchCycleDetails(workspaceSlug, projectId, peekCycle.toString());
|
||||
}, [fetchCycleDetails, peekCycle, projectId, workspaceSlug]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
@ -2,6 +2,7 @@ import { FC, MouseEvent, useState } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import Link from "next/link";
|
||||
// hooks
|
||||
import { useApplication, useCycle, useUser } from "hooks/store";
|
||||
import useToast from "hooks/use-toast";
|
||||
// components
|
||||
import { CycleCreateUpdateModal, CycleDeleteModal } from "components/cycles";
|
||||
@ -17,10 +18,6 @@ import {
|
||||
renderShortMonthDate,
|
||||
} from "helpers/date-time.helper";
|
||||
import { copyTextToClipboard } from "helpers/string.helper";
|
||||
// types
|
||||
import { ICycle } from "types";
|
||||
// store
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
// constants
|
||||
import { CYCLE_STATUS } from "constants/cycle";
|
||||
import { EUserWorkspaceRoles } from "constants/workspace";
|
||||
@ -28,61 +25,33 @@ import { EUserWorkspaceRoles } from "constants/workspace";
|
||||
export interface ICyclesBoardCard {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
cycle: ICycle;
|
||||
cycleId: string;
|
||||
}
|
||||
|
||||
export const CyclesBoardCard: FC<ICyclesBoardCard> = (props) => {
|
||||
const { cycle, workspaceSlug, projectId } = props;
|
||||
// store
|
||||
const {
|
||||
cycle: cycleStore,
|
||||
trackEvent: { setTrackElement },
|
||||
user: userStore,
|
||||
} = useMobxStore();
|
||||
// toast
|
||||
const { setToastAlert } = useToast();
|
||||
const { cycleId, workspaceSlug, projectId } = props;
|
||||
// states
|
||||
const [updateModal, setUpdateModal] = useState(false);
|
||||
const [deleteModal, setDeleteModal] = useState(false);
|
||||
// computed
|
||||
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 isDateValid = cycle.start_date || cycle.end_date;
|
||||
|
||||
const { currentProjectRole } = userStore;
|
||||
const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserWorkspaceRoles.MEMBER;
|
||||
|
||||
// router
|
||||
const router = useRouter();
|
||||
|
||||
const currentCycle = CYCLE_STATUS.find((status) => status.value === cycleStatus);
|
||||
|
||||
const areYearsEqual = startDate.getFullYear() === endDate.getFullYear();
|
||||
|
||||
const cycleTotalIssues =
|
||||
cycle.backlog_issues +
|
||||
cycle.unstarted_issues +
|
||||
cycle.started_issues +
|
||||
cycle.completed_issues +
|
||||
cycle.cancelled_issues;
|
||||
|
||||
const completionPercentage = (cycle.completed_issues / cycleTotalIssues) * 100;
|
||||
|
||||
const issueCount = cycle
|
||||
? cycleTotalIssues === 0
|
||||
? "0 Issue"
|
||||
: cycleTotalIssues === cycle.completed_issues
|
||||
? `${cycleTotalIssues} Issue${cycleTotalIssues > 1 ? "s" : ""}`
|
||||
: `${cycle.completed_issues}/${cycleTotalIssues} Issues`
|
||||
: "0 Issue";
|
||||
// store
|
||||
const {
|
||||
eventTracker: { setTrackElement },
|
||||
} = useApplication();
|
||||
const {
|
||||
membership: { currentProjectRole },
|
||||
} = useUser();
|
||||
const { addCycleToFavorites, removeCycleFromFavorites, getCycleById } = useCycle();
|
||||
// toast alert
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const handleCopyText = (e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
|
||||
|
||||
copyTextToClipboard(`${originURL}/${workspaceSlug}/projects/${projectId}/cycles/${cycle.id}`).then(() => {
|
||||
copyTextToClipboard(`${originURL}/${workspaceSlug}/projects/${projectId}/cycles/${cycleId}`).then(() => {
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Link Copied!",
|
||||
@ -95,7 +64,7 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = (props) => {
|
||||
e.preventDefault();
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
cycleStore.addCycleToFavorites(workspaceSlug?.toString(), projectId.toString(), cycle).catch(() => {
|
||||
addCycleToFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId).catch(() => {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
@ -108,7 +77,7 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = (props) => {
|
||||
e.preventDefault();
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
cycleStore.removeCycleFromFavorites(workspaceSlug?.toString(), projectId.toString(), cycle).catch(() => {
|
||||
removeCycleFromFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId).catch(() => {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
@ -137,14 +106,48 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = (props) => {
|
||||
|
||||
router.push({
|
||||
pathname: router.pathname,
|
||||
query: { ...query, peekCycle: cycle.id },
|
||||
query: { ...query, peekCycle: cycleId },
|
||||
});
|
||||
};
|
||||
|
||||
const cycleDetails = getCycleById(cycleId);
|
||||
|
||||
if (!cycleDetails) return null;
|
||||
|
||||
// computed
|
||||
const cycleStatus = getDateRangeStatus(cycleDetails.start_date, cycleDetails.end_date);
|
||||
const isCompleted = cycleStatus === "completed";
|
||||
const endDate = new Date(cycleDetails.end_date ?? "");
|
||||
const startDate = new Date(cycleDetails.start_date ?? "");
|
||||
const isDateValid = cycleDetails.start_date || cycleDetails.end_date;
|
||||
|
||||
const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserWorkspaceRoles.MEMBER;
|
||||
|
||||
const currentCycle = CYCLE_STATUS.find((status) => status.value === cycleStatus);
|
||||
|
||||
const areYearsEqual = startDate.getFullYear() === endDate.getFullYear();
|
||||
|
||||
const cycleTotalIssues =
|
||||
cycleDetails.backlog_issues +
|
||||
cycleDetails.unstarted_issues +
|
||||
cycleDetails.started_issues +
|
||||
cycleDetails.completed_issues +
|
||||
cycleDetails.cancelled_issues;
|
||||
|
||||
const completionPercentage = (cycleDetails.completed_issues / cycleTotalIssues) * 100;
|
||||
|
||||
const issueCount = cycleDetails
|
||||
? cycleTotalIssues === 0
|
||||
? "0 Issue"
|
||||
: cycleTotalIssues === cycleDetails.completed_issues
|
||||
? `${cycleTotalIssues} Issue${cycleTotalIssues > 1 ? "s" : ""}`
|
||||
: `${cycleDetails.completed_issues}/${cycleTotalIssues} Issues`
|
||||
: "0 Issue";
|
||||
|
||||
return (
|
||||
<div>
|
||||
<CycleCreateUpdateModal
|
||||
data={cycle}
|
||||
data={cycleDetails}
|
||||
isOpen={updateModal}
|
||||
handleClose={() => setUpdateModal(false)}
|
||||
workspaceSlug={workspaceSlug}
|
||||
@ -152,22 +155,22 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = (props) => {
|
||||
/>
|
||||
|
||||
<CycleDeleteModal
|
||||
cycle={cycle}
|
||||
cycle={cycleDetails}
|
||||
isOpen={deleteModal}
|
||||
handleClose={() => setDeleteModal(false)}
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
/>
|
||||
|
||||
<Link href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycle.id}`}>
|
||||
<Link href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycleDetails.id}`}>
|
||||
<div className="flex h-44 w-full min-w-[250px] flex-col justify-between rounded border border-custom-border-100 bg-custom-background-100 p-4 text-sm hover:shadow-md">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-3 truncate">
|
||||
<span className="flex-shrink-0">
|
||||
<CycleGroupIcon cycleGroup={cycleStatus} className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<Tooltip tooltipContent={cycle.name} position="top">
|
||||
<span className="truncate text-base font-medium">{cycle.name}</span>
|
||||
<Tooltip tooltipContent={cycleDetails.name} position="top">
|
||||
<span className="truncate text-base font-medium">{cycleDetails.name}</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@ -180,7 +183,7 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = (props) => {
|
||||
}}
|
||||
>
|
||||
{currentCycle.value === "current"
|
||||
? `${findHowManyDaysLeft(cycle.end_date ?? new Date())} ${currentCycle.label}`
|
||||
? `${findHowManyDaysLeft(cycleDetails.end_date ?? new Date())} ${currentCycle.label}`
|
||||
: `${currentCycle.label}`}
|
||||
</span>
|
||||
)}
|
||||
@ -196,11 +199,11 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = (props) => {
|
||||
<LayersIcon className="h-4 w-4 text-custom-text-300" />
|
||||
<span className="text-xs text-custom-text-300">{issueCount}</span>
|
||||
</div>
|
||||
{cycle.assignees.length > 0 && (
|
||||
<Tooltip tooltipContent={`${cycle.assignees.length} Members`}>
|
||||
{cycleDetails.assignees.length > 0 && (
|
||||
<Tooltip tooltipContent={`${cycleDetails.assignees.length} Members`}>
|
||||
<div className="flex cursor-default items-center gap-1">
|
||||
<AvatarGroup showTooltip={false}>
|
||||
{cycle.assignees.map((assignee) => (
|
||||
{cycleDetails.assignees.map((assignee) => (
|
||||
<Avatar key={assignee.id} name={assignee.display_name} src={assignee.avatar} />
|
||||
))}
|
||||
</AvatarGroup>
|
||||
@ -241,7 +244,7 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = (props) => {
|
||||
)}
|
||||
<div className="z-10 flex items-center gap-1.5">
|
||||
{isEditingAllowed &&
|
||||
(cycle.is_favorite ? (
|
||||
(cycleDetails.is_favorite ? (
|
||||
<button type="button" onClick={handleRemoveFromFavorites}>
|
||||
<Star className="h-3.5 w-3.5 fill-current text-amber-500" />
|
||||
</button>
|
||||
|
@ -4,11 +4,9 @@ import { observer } from "mobx-react-lite";
|
||||
import { useApplication } from "hooks/store";
|
||||
// components
|
||||
import { CyclePeekOverview, CyclesBoardCard } from "components/cycles";
|
||||
// types
|
||||
import { ICycle } from "types";
|
||||
|
||||
export interface ICyclesBoard {
|
||||
cycles: ICycle[];
|
||||
cycleIds: string[];
|
||||
filter: string;
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
@ -16,13 +14,13 @@ export interface ICyclesBoard {
|
||||
}
|
||||
|
||||
export const CyclesBoard: FC<ICyclesBoard> = observer((props) => {
|
||||
const { cycles, filter, workspaceSlug, projectId, peekCycle } = props;
|
||||
const { cycleIds, filter, workspaceSlug, projectId, peekCycle } = props;
|
||||
// store hooks
|
||||
const { commandPalette: commandPaletteStore } = useApplication();
|
||||
|
||||
return (
|
||||
<>
|
||||
{cycles.length > 0 ? (
|
||||
{cycleIds?.length > 0 ? (
|
||||
<div className="h-full w-full">
|
||||
<div className="flex h-full w-full justify-between">
|
||||
<div
|
||||
@ -32,8 +30,8 @@ export const CyclesBoard: FC<ICyclesBoard> = observer((props) => {
|
||||
: "lg:grid-cols-2 xl:grid-cols-3 3xl:grid-cols-4"
|
||||
} auto-rows-max transition-all `}
|
||||
>
|
||||
{cycles.map((cycle) => (
|
||||
<CyclesBoardCard key={cycle.id} workspaceSlug={workspaceSlug} projectId={projectId} cycle={cycle} />
|
||||
{cycleIds.map((cycleId) => (
|
||||
<CyclesBoardCard key={cycleId} workspaceSlug={workspaceSlug} projectId={projectId} cycleId={cycleId} />
|
||||
))}
|
||||
</div>
|
||||
<CyclePeekOverview
|
||||
|
@ -1,10 +1,8 @@
|
||||
import { FC, MouseEvent, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
// stores
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
// hooks
|
||||
import { useApplication, useCycle, useUser } from "hooks/store";
|
||||
import useToast from "hooks/use-toast";
|
||||
// components
|
||||
import { CycleCreateUpdateModal, CycleDeleteModal } from "components/cycles";
|
||||
@ -20,14 +18,12 @@ import {
|
||||
renderShortMonthDate,
|
||||
} from "helpers/date-time.helper";
|
||||
import { copyTextToClipboard } from "helpers/string.helper";
|
||||
// types
|
||||
import { ICycle } from "types";
|
||||
// constants
|
||||
import { CYCLE_STATUS } from "constants/cycle";
|
||||
import { EUserWorkspaceRoles } from "constants/workspace";
|
||||
|
||||
type TCyclesListItem = {
|
||||
cycle: ICycle;
|
||||
cycleId: string;
|
||||
handleEditCycle?: () => void;
|
||||
handleDeleteCycle?: () => void;
|
||||
handleAddToFavorites?: () => void;
|
||||
@ -37,52 +33,29 @@ type TCyclesListItem = {
|
||||
};
|
||||
|
||||
export const CyclesListItem: FC<TCyclesListItem> = (props) => {
|
||||
const { cycle, workspaceSlug, projectId } = props;
|
||||
// store
|
||||
const {
|
||||
cycle: cycleStore,
|
||||
trackEvent: { setTrackElement },
|
||||
user: userStore,
|
||||
} = useMobxStore();
|
||||
// toast
|
||||
const { setToastAlert } = useToast();
|
||||
const { cycleId, workspaceSlug, projectId } = props;
|
||||
// states
|
||||
const [updateModal, setUpdateModal] = useState(false);
|
||||
const [deleteModal, setDeleteModal] = useState(false);
|
||||
// computed
|
||||
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 { currentProjectRole } = userStore;
|
||||
const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserWorkspaceRoles.MEMBER;
|
||||
|
||||
// router
|
||||
const router = useRouter();
|
||||
|
||||
const cycleTotalIssues =
|
||||
cycle.backlog_issues +
|
||||
cycle.unstarted_issues +
|
||||
cycle.started_issues +
|
||||
cycle.completed_issues +
|
||||
cycle.cancelled_issues;
|
||||
|
||||
const renderDate = cycle.start_date || cycle.end_date;
|
||||
|
||||
const areYearsEqual = startDate.getFullYear() === endDate.getFullYear();
|
||||
|
||||
const completionPercentage = (cycle.completed_issues / cycleTotalIssues) * 100;
|
||||
|
||||
const progress = isNaN(completionPercentage) ? 0 : Math.floor(completionPercentage);
|
||||
|
||||
const currentCycle = CYCLE_STATUS.find((status) => status.value === cycleStatus);
|
||||
// store hooks
|
||||
const {
|
||||
eventTracker: { setTrackElement },
|
||||
} = useApplication();
|
||||
const {
|
||||
membership: { currentProjectRole },
|
||||
} = useUser();
|
||||
const { getCycleById, addCycleToFavorites, removeCycleFromFavorites } = useCycle();
|
||||
// toast alert
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const handleCopyText = (e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
|
||||
|
||||
copyTextToClipboard(`${originURL}/${workspaceSlug}/projects/${projectId}/cycles/${cycle.id}`).then(() => {
|
||||
copyTextToClipboard(`${originURL}/${workspaceSlug}/projects/${projectId}/cycles/${cycleId}`).then(() => {
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Link Copied!",
|
||||
@ -95,7 +68,7 @@ export const CyclesListItem: FC<TCyclesListItem> = (props) => {
|
||||
e.preventDefault();
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
cycleStore.addCycleToFavorites(workspaceSlug?.toString(), projectId.toString(), cycle).catch(() => {
|
||||
addCycleToFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId).catch(() => {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
@ -108,7 +81,7 @@ export const CyclesListItem: FC<TCyclesListItem> = (props) => {
|
||||
e.preventDefault();
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
cycleStore.removeCycleFromFavorites(workspaceSlug?.toString(), projectId.toString(), cycle).catch(() => {
|
||||
removeCycleFromFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId).catch(() => {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
@ -137,27 +110,56 @@ export const CyclesListItem: FC<TCyclesListItem> = (props) => {
|
||||
|
||||
router.push({
|
||||
pathname: router.pathname,
|
||||
query: { ...query, peekCycle: cycle.id },
|
||||
query: { ...query, peekCycle: cycleId },
|
||||
});
|
||||
};
|
||||
|
||||
const cycleDetails = getCycleById(cycleId);
|
||||
|
||||
if (!cycleDetails) return null;
|
||||
|
||||
// computed
|
||||
const cycleStatus = getDateRangeStatus(cycleDetails.start_date, cycleDetails.end_date);
|
||||
const isCompleted = cycleStatus === "completed";
|
||||
const endDate = new Date(cycleDetails.end_date ?? "");
|
||||
const startDate = new Date(cycleDetails.start_date ?? "");
|
||||
|
||||
const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserWorkspaceRoles.MEMBER;
|
||||
|
||||
const cycleTotalIssues =
|
||||
cycleDetails.backlog_issues +
|
||||
cycleDetails.unstarted_issues +
|
||||
cycleDetails.started_issues +
|
||||
cycleDetails.completed_issues +
|
||||
cycleDetails.cancelled_issues;
|
||||
|
||||
const renderDate = cycleDetails.start_date || cycleDetails.end_date;
|
||||
|
||||
const areYearsEqual = startDate.getFullYear() === endDate.getFullYear();
|
||||
|
||||
const completionPercentage = (cycleDetails.completed_issues / cycleTotalIssues) * 100;
|
||||
|
||||
const progress = isNaN(completionPercentage) ? 0 : Math.floor(completionPercentage);
|
||||
|
||||
const currentCycle = CYCLE_STATUS.find((status) => status.value === cycleStatus);
|
||||
|
||||
return (
|
||||
<>
|
||||
<CycleCreateUpdateModal
|
||||
data={cycle}
|
||||
data={cycleDetails}
|
||||
isOpen={updateModal}
|
||||
handleClose={() => setUpdateModal(false)}
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
/>
|
||||
<CycleDeleteModal
|
||||
cycle={cycle}
|
||||
cycle={cycleDetails}
|
||||
isOpen={deleteModal}
|
||||
handleClose={() => setDeleteModal(false)}
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
/>
|
||||
<Link href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycle.id}`}>
|
||||
<Link href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycleDetails.id}`}>
|
||||
<div className="group flex h-16 w-full items-center justify-between gap-5 border-b border-custom-border-100 bg-custom-background-100 px-5 py-6 text-sm hover:bg-custom-background-90">
|
||||
<div className="flex w-full items-center gap-3 truncate">
|
||||
<div className="flex items-center gap-4 truncate">
|
||||
@ -181,8 +183,8 @@ export const CyclesListItem: FC<TCyclesListItem> = (props) => {
|
||||
<span className="flex-shrink-0">
|
||||
<CycleGroupIcon cycleGroup={cycleStatus} className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<Tooltip tooltipContent={cycle.name} position="top">
|
||||
<span className="truncate text-base font-medium">{cycle.name}</span>
|
||||
<Tooltip tooltipContent={cycleDetails.name} position="top">
|
||||
<span className="truncate text-base font-medium">{cycleDetails.name}</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
@ -202,7 +204,7 @@ export const CyclesListItem: FC<TCyclesListItem> = (props) => {
|
||||
}}
|
||||
>
|
||||
{currentCycle.value === "current"
|
||||
? `${findHowManyDaysLeft(cycle.end_date ?? new Date())} ${currentCycle.label}`
|
||||
? `${findHowManyDaysLeft(cycleDetails.end_date ?? new Date())} ${currentCycle.label}`
|
||||
: `${currentCycle.label}`}
|
||||
</span>
|
||||
)}
|
||||
@ -216,11 +218,11 @@ export const CyclesListItem: FC<TCyclesListItem> = (props) => {
|
||||
</span>
|
||||
)}
|
||||
|
||||
<Tooltip tooltipContent={`${cycle.assignees.length} Members`}>
|
||||
<Tooltip tooltipContent={`${cycleDetails.assignees.length} Members`}>
|
||||
<div className="flex w-16 cursor-default items-center justify-center gap-1">
|
||||
{cycle.assignees.length > 0 ? (
|
||||
{cycleDetails.assignees.length > 0 ? (
|
||||
<AvatarGroup showTooltip={false}>
|
||||
{cycle.assignees.map((assignee) => (
|
||||
{cycleDetails.assignees.map((assignee) => (
|
||||
<Avatar key={assignee.id} name={assignee.display_name} src={assignee.avatar} />
|
||||
))}
|
||||
</AvatarGroup>
|
||||
@ -232,7 +234,7 @@ export const CyclesListItem: FC<TCyclesListItem> = (props) => {
|
||||
</div>
|
||||
</Tooltip>
|
||||
{isEditingAllowed &&
|
||||
(cycle.is_favorite ? (
|
||||
(cycleDetails.is_favorite ? (
|
||||
<button type="button" onClick={handleRemoveFromFavorites}>
|
||||
<Star className="h-3.5 w-3.5 fill-current text-amber-500" />
|
||||
</button>
|
||||
|
@ -6,18 +6,16 @@ import { useApplication } from "hooks/store";
|
||||
import { CyclePeekOverview, CyclesListItem } from "components/cycles";
|
||||
// ui
|
||||
import { Loader } from "@plane/ui";
|
||||
// types
|
||||
import { ICycle } from "types";
|
||||
|
||||
export interface ICyclesList {
|
||||
cycles: ICycle[];
|
||||
cycleIds: string[];
|
||||
filter: string;
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export const CyclesList: FC<ICyclesList> = observer((props) => {
|
||||
const { cycles, filter, workspaceSlug, projectId } = props;
|
||||
const { cycleIds, filter, workspaceSlug, projectId } = props;
|
||||
// store hooks
|
||||
const {
|
||||
commandPalette: commandPaletteStore,
|
||||
@ -26,14 +24,14 @@ export const CyclesList: FC<ICyclesList> = observer((props) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
{cycles ? (
|
||||
{cycleIds ? (
|
||||
<>
|
||||
{cycles.length > 0 ? (
|
||||
{cycleIds.length > 0 ? (
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="flex h-full w-full justify-between">
|
||||
<div className="flex h-full w-full flex-col overflow-y-auto">
|
||||
{cycles.map((cycle) => (
|
||||
<CyclesListItem cycle={cycle} workspaceSlug={workspaceSlug} projectId={projectId} />
|
||||
{cycleIds.map((cycleId) => (
|
||||
<CyclesListItem cycleId={cycleId} workspaceSlug={workspaceSlug} projectId={projectId} />
|
||||
))}
|
||||
</div>
|
||||
<CyclePeekOverview
|
||||
|
@ -1,17 +1,16 @@
|
||||
import { FC } from "react";
|
||||
import useSWR from "swr";
|
||||
import { observer } from "mobx-react-lite";
|
||||
// store
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
// hooks
|
||||
import { useCycle } from "hooks/store";
|
||||
// components
|
||||
import { CyclesBoard, CyclesList, CyclesListGanttChartView } from "components/cycles";
|
||||
// ui components
|
||||
import { Loader } from "@plane/ui";
|
||||
// types
|
||||
import { TCycleLayout } from "types";
|
||||
import { TCycleLayout, TCycleView } from "types";
|
||||
|
||||
export interface ICyclesView {
|
||||
filter: "all" | "current" | "upcoming" | "draft" | "completed" | "incomplete";
|
||||
filter: TCycleView;
|
||||
layout: TCycleLayout;
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
@ -20,31 +19,24 @@ export interface ICyclesView {
|
||||
|
||||
export const CyclesView: FC<ICyclesView> = observer((props) => {
|
||||
const { filter, layout, workspaceSlug, projectId, peekCycle } = props;
|
||||
|
||||
// store
|
||||
const { cycle: cycleStore } = useMobxStore();
|
||||
|
||||
// api call to fetch cycles list
|
||||
useSWR(
|
||||
workspaceSlug && projectId && filter ? `CYCLES_LIST_${projectId}_${filter}` : null,
|
||||
workspaceSlug && projectId && filter ? () => cycleStore.fetchCycles(workspaceSlug, projectId, filter) : null
|
||||
);
|
||||
// store hooks
|
||||
const { projectCompletedCycles, projectDraftCycles, projectUpcomingCycles, projectAllCycles } = useCycle();
|
||||
|
||||
const cyclesList =
|
||||
filter === "completed"
|
||||
? cycleStore.projectCompletedCycles
|
||||
? projectCompletedCycles
|
||||
: filter === "draft"
|
||||
? cycleStore.projectDraftCycles
|
||||
: filter === "upcoming"
|
||||
? cycleStore.projectUpcomingCycles
|
||||
: cycleStore.projectCycles;
|
||||
? projectDraftCycles
|
||||
: filter === "upcoming"
|
||||
? projectUpcomingCycles
|
||||
: projectAllCycles;
|
||||
|
||||
return (
|
||||
<>
|
||||
{layout === "list" && (
|
||||
<>
|
||||
{cyclesList ? (
|
||||
<CyclesList cycles={cyclesList} filter={filter} workspaceSlug={workspaceSlug} projectId={projectId} />
|
||||
<CyclesList cycleIds={cyclesList} filter={filter} workspaceSlug={workspaceSlug} projectId={projectId} />
|
||||
) : (
|
||||
<Loader className="space-y-4 p-8">
|
||||
<Loader.Item height="50px" />
|
||||
@ -59,7 +51,7 @@ export const CyclesView: FC<ICyclesView> = observer((props) => {
|
||||
<>
|
||||
{cyclesList ? (
|
||||
<CyclesBoard
|
||||
cycles={cyclesList}
|
||||
cycleIds={cyclesList}
|
||||
filter={filter}
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
@ -78,7 +70,7 @@ export const CyclesView: FC<ICyclesView> = observer((props) => {
|
||||
{layout === "gantt" && (
|
||||
<>
|
||||
{cyclesList ? (
|
||||
<CyclesListGanttChartView cycles={cyclesList} workspaceSlug={workspaceSlug} />
|
||||
<CyclesListGanttChartView cycleIds={cyclesList} workspaceSlug={workspaceSlug} />
|
||||
) : (
|
||||
<Loader className="space-y-4">
|
||||
<Loader.Item height="50px" />
|
||||
|
@ -1,17 +1,15 @@
|
||||
import { Fragment, useState } from "react";
|
||||
// next
|
||||
import { useRouter } from "next/router";
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
// hooks
|
||||
import { useApplication, useCycle } from "hooks/store";
|
||||
import useToast from "hooks/use-toast";
|
||||
// components
|
||||
import { Button } from "@plane/ui";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
// types
|
||||
import { ICycle } from "types";
|
||||
// mobx store
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
|
||||
interface ICycleDelete {
|
||||
cycle: ICycle;
|
||||
@ -23,56 +21,51 @@ interface ICycleDelete {
|
||||
|
||||
export const CycleDeleteModal: React.FC<ICycleDelete> = observer((props) => {
|
||||
const { isOpen, handleClose, cycle, workspaceSlug, projectId } = props;
|
||||
// store
|
||||
const {
|
||||
cycle: cycleStore,
|
||||
trackEvent: { postHogEventTracker },
|
||||
} = useMobxStore();
|
||||
// toast
|
||||
const { setToastAlert } = useToast();
|
||||
// states
|
||||
const [loader, setLoader] = useState(false);
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { cycleId, peekCycle } = router.query;
|
||||
// store hooks
|
||||
const {
|
||||
eventTracker: { postHogEventTracker },
|
||||
} = useApplication();
|
||||
const { deleteCycle } = useCycle();
|
||||
// toast alert
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const formSubmit = async () => {
|
||||
if (!cycle) return;
|
||||
|
||||
setLoader(true);
|
||||
if (cycle?.id)
|
||||
try {
|
||||
await cycleStore
|
||||
.removeCycle(workspaceSlug, projectId, cycle?.id)
|
||||
.then(() => {
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Success!",
|
||||
message: "Cycle deleted successfully.",
|
||||
});
|
||||
postHogEventTracker("CYCLE_DELETE", {
|
||||
state: "SUCCESS",
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
postHogEventTracker("CYCLE_DELETE", {
|
||||
state: "FAILED",
|
||||
});
|
||||
try {
|
||||
await deleteCycle(workspaceSlug, projectId, cycle.id)
|
||||
.then(() => {
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Success!",
|
||||
message: "Cycle deleted successfully.",
|
||||
});
|
||||
postHogEventTracker("CYCLE_DELETE", {
|
||||
state: "SUCCESS",
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
postHogEventTracker("CYCLE_DELETE", {
|
||||
state: "FAILED",
|
||||
});
|
||||
|
||||
if (cycleId || peekCycle) router.push(`/${workspaceSlug}/projects/${projectId}/cycles`);
|
||||
|
||||
handleClose();
|
||||
} catch (error) {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Warning!",
|
||||
message: "Something went wrong please try again later.",
|
||||
});
|
||||
}
|
||||
else
|
||||
|
||||
if (cycleId || peekCycle) router.push(`/${workspaceSlug}/projects/${projectId}/cycles`);
|
||||
|
||||
handleClose();
|
||||
} catch (error) {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Warning!",
|
||||
message: "Something went wrong please try again later.",
|
||||
});
|
||||
}
|
||||
|
||||
setLoader(false);
|
||||
};
|
||||
|
@ -3,7 +3,7 @@ import { useRouter } from "next/router";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { KeyedMutator } from "swr";
|
||||
// hooks
|
||||
import { useUser } from "hooks/store";
|
||||
import { useCycle, useUser } from "hooks/store";
|
||||
// services
|
||||
import { CycleService } from "services/cycle.service";
|
||||
// components
|
||||
@ -16,7 +16,7 @@ import { EUserWorkspaceRoles } from "constants/workspace";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
cycles: ICycle[];
|
||||
cycleIds: string[];
|
||||
mutateCycles?: KeyedMutator<ICycle[]>;
|
||||
};
|
||||
|
||||
@ -24,7 +24,7 @@ type Props = {
|
||||
const cycleService = new CycleService();
|
||||
|
||||
export const CyclesListGanttChartView: FC<Props> = observer((props) => {
|
||||
const { cycles, mutateCycles } = props;
|
||||
const { cycleIds, mutateCycles } = props;
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
@ -32,6 +32,7 @@ export const CyclesListGanttChartView: FC<Props> = observer((props) => {
|
||||
const {
|
||||
membership: { currentProjectRole },
|
||||
} = useUser();
|
||||
const { getCycleById } = useCycle();
|
||||
|
||||
const handleCycleUpdate = (cycle: ICycle, payload: IBlockUpdateData) => {
|
||||
if (!workspaceSlug) return;
|
||||
@ -65,18 +66,21 @@ export const CyclesListGanttChartView: FC<Props> = observer((props) => {
|
||||
cycleService.patchCycle(workspaceSlug.toString(), cycle.project, cycle.id, newPayload);
|
||||
};
|
||||
|
||||
const blockFormat = (blocks: ICycle[]) =>
|
||||
blocks && blocks.length > 0
|
||||
? blocks
|
||||
.filter((b) => b.start_date && b.end_date && new Date(b.start_date) <= new Date(b.end_date))
|
||||
.map((block) => ({
|
||||
data: block,
|
||||
id: block.id,
|
||||
sort_order: block.sort_order,
|
||||
start_date: new Date(block.start_date ?? ""),
|
||||
target_date: new Date(block.end_date ?? ""),
|
||||
}))
|
||||
: [];
|
||||
const blockFormat = (blocks: (ICycle | null)[]) => {
|
||||
if (!blocks) return [];
|
||||
|
||||
const filteredBlocks = blocks.filter((b) => b !== null && b.start_date && b.end_date);
|
||||
|
||||
const structuredBlocks = filteredBlocks.map((block) => ({
|
||||
data: block,
|
||||
id: block?.id ?? "",
|
||||
sort_order: block?.sort_order ?? 0,
|
||||
start_date: new Date(block?.start_date ?? ""),
|
||||
target_date: new Date(block?.end_date ?? ""),
|
||||
}));
|
||||
|
||||
return structuredBlocks;
|
||||
};
|
||||
|
||||
const isAllowed =
|
||||
currentProjectRole && [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER].includes(currentProjectRole);
|
||||
@ -86,7 +90,7 @@ export const CyclesListGanttChartView: FC<Props> = observer((props) => {
|
||||
<GanttChartRoot
|
||||
title="Cycles"
|
||||
loaderTitle="Cycles"
|
||||
blocks={cycles ? blockFormat(cycles) : null}
|
||||
blocks={cycleIds ? blockFormat(cycleIds.map((c) => getCycleById(c))) : null}
|
||||
blockUpdateHandler={(block, payload) => handleCycleUpdate(block, payload)}
|
||||
sidebarToRender={(props) => <CycleGanttSidebar {...props} />}
|
||||
blockToRender={(data: ICycle) => <CycleGanttBlock data={data} />}
|
||||
|
@ -3,8 +3,8 @@ import { Dialog, Transition } from "@headlessui/react";
|
||||
// services
|
||||
import { CycleService } from "services/cycle.service";
|
||||
// hooks
|
||||
import { useApplication, useCycle } from "hooks/store";
|
||||
import useToast from "hooks/use-toast";
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
// components
|
||||
import { CycleForm } from "components/cycles";
|
||||
// types
|
||||
@ -23,21 +23,21 @@ const cycleService = new CycleService();
|
||||
|
||||
export const CycleCreateUpdateModal: React.FC<CycleModalProps> = (props) => {
|
||||
const { isOpen, handleClose, data, workspaceSlug, projectId } = props;
|
||||
// store
|
||||
const {
|
||||
cycle: cycleStore,
|
||||
trackEvent: { postHogEventTracker },
|
||||
} = useMobxStore();
|
||||
// states
|
||||
const [activeProject, setActiveProject] = useState<string>(projectId);
|
||||
// toast
|
||||
// store hooks
|
||||
const {
|
||||
eventTracker: { postHogEventTracker },
|
||||
} = useApplication();
|
||||
const { createCycle, updateCycleDetails } = useCycle();
|
||||
// toast alert
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const createCycle = async (payload: Partial<ICycle>) => {
|
||||
const handleCreateCycle = async (payload: Partial<ICycle>) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
const selectedProjectId = payload.project ?? projectId.toString();
|
||||
await cycleStore
|
||||
.createCycle(workspaceSlug, selectedProjectId, payload)
|
||||
await createCycle(workspaceSlug, selectedProjectId, payload)
|
||||
.then((res) => {
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
@ -61,11 +61,11 @@ export const CycleCreateUpdateModal: React.FC<CycleModalProps> = (props) => {
|
||||
});
|
||||
};
|
||||
|
||||
const updateCycle = async (cycleId: string, payload: Partial<ICycle>) => {
|
||||
const handleUpdateCycle = async (cycleId: string, payload: Partial<ICycle>) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
const selectedProjectId = payload.project ?? projectId.toString();
|
||||
await cycleStore
|
||||
.patchCycle(workspaceSlug, selectedProjectId, cycleId, payload)
|
||||
await updateCycleDetails(workspaceSlug, selectedProjectId, cycleId, payload)
|
||||
.then(() => {
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
@ -116,8 +116,8 @@ export const CycleCreateUpdateModal: React.FC<CycleModalProps> = (props) => {
|
||||
}
|
||||
|
||||
if (isDateValid) {
|
||||
if (data) await updateCycle(data.id, payload);
|
||||
else await createCycle(payload);
|
||||
if (data) await handleUpdateCycle(data.id, payload);
|
||||
else await handleCreateCycle(payload);
|
||||
handleClose();
|
||||
} else
|
||||
setToastAlert({
|
||||
|
@ -3,11 +3,10 @@ import { useRouter } from "next/router";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Disclosure, Popover, Transition } from "@headlessui/react";
|
||||
// mobx store
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
// services
|
||||
import { CycleService } from "services/cycle.service";
|
||||
// hooks
|
||||
import { useApplication, useCycle, useUser } from "hooks/store";
|
||||
import useToast from "hooks/use-toast";
|
||||
// components
|
||||
import { SidebarProgressStats } from "components/core";
|
||||
@ -46,19 +45,21 @@ const cycleService = new CycleService();
|
||||
// TODO: refactor the whole component
|
||||
export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
const { cycleId, handleClose } = props;
|
||||
|
||||
// states
|
||||
const [cycleDeleteModal, setCycleDeleteModal] = useState(false);
|
||||
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, peekCycle } = router.query;
|
||||
|
||||
// store hooks
|
||||
const {
|
||||
cycle: cycleDetailsStore,
|
||||
trackEvent: { setTrackElement },
|
||||
user: { currentProjectRole },
|
||||
} = useMobxStore();
|
||||
eventTracker: { setTrackElement },
|
||||
} = useApplication();
|
||||
const {
|
||||
membership: { currentProjectRole },
|
||||
} = useUser();
|
||||
const { getCycleById, updateCycleDetails } = useCycle();
|
||||
|
||||
const cycleDetails = cycleDetailsStore.cycle_details[cycleId] ?? undefined;
|
||||
const cycleDetails = getCycleById(cycleId);
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
@ -74,7 +75,7 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
const submitChanges = (data: Partial<ICycle>) => {
|
||||
if (!workspaceSlug || !projectId || !cycleId) return;
|
||||
|
||||
cycleDetailsStore.patchCycle(workspaceSlug.toString(), projectId.toString(), cycleId.toString(), data);
|
||||
updateCycleDetails(workspaceSlug.toString(), projectId.toString(), cycleId.toString(), data);
|
||||
};
|
||||
|
||||
const handleCopyText = () => {
|
||||
|
@ -2,8 +2,9 @@ import React, { useState } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import useSWR from "swr";
|
||||
// mobx store
|
||||
// hooks
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
import { useCycle } from "hooks/store";
|
||||
// components
|
||||
import {
|
||||
CycleAppliedFiltersRoot,
|
||||
@ -29,12 +30,13 @@ export const CycleLayoutRoot: React.FC = observer(() => {
|
||||
projectId: string;
|
||||
cycleId: string;
|
||||
};
|
||||
|
||||
// store hooks
|
||||
const {
|
||||
cycle: cycleStore,
|
||||
cycleIssues: { loader, getIssues, fetchIssues },
|
||||
cycleIssuesFilter: { issueFilters, fetchFilters },
|
||||
} = useMobxStore();
|
||||
const { getCycleById } = useCycle();
|
||||
|
||||
useSWR(
|
||||
workspaceSlug && projectId && cycleId ? `CYCLE_ISSUES_V3_${workspaceSlug}_${projectId}_${cycleId}` : null,
|
||||
@ -48,7 +50,7 @@ export const CycleLayoutRoot: React.FC = observer(() => {
|
||||
|
||||
const activeLayout = issueFilters?.displayFilters?.layout;
|
||||
|
||||
const cycleDetails = cycleId ? cycleStore.cycle_details[cycleId.toString()] : undefined;
|
||||
const cycleDetails = cycleId ? getCycleById(cycleId) : undefined;
|
||||
const cycleStatus =
|
||||
cycleDetails?.start_date && cycleDetails?.end_date
|
||||
? getDateRangeStatus(cycleDetails?.start_date, cycleDetails?.end_date)
|
||||
|
@ -1,6 +1,11 @@
|
||||
import { GanttChartSquare, LayoutGrid, List } from "lucide-react";
|
||||
// types
|
||||
import { TCycleLayout, TCycleView } from "types";
|
||||
|
||||
export const CYCLE_TAB_LIST = [
|
||||
export const CYCLE_TAB_LIST: {
|
||||
key: TCycleView;
|
||||
name: string;
|
||||
}[] = [
|
||||
{
|
||||
key: "all",
|
||||
name: "All",
|
||||
@ -23,7 +28,11 @@ export const CYCLE_TAB_LIST = [
|
||||
},
|
||||
];
|
||||
|
||||
export const CYCLE_VIEW_LAYOUTS = [
|
||||
export const CYCLE_VIEW_LAYOUTS: {
|
||||
key: TCycleLayout;
|
||||
icon: any;
|
||||
title: string;
|
||||
}[] = [
|
||||
{
|
||||
key: "list",
|
||||
icon: List,
|
||||
|
@ -10,6 +10,7 @@ import { JoinProject } from "components/auth-screens";
|
||||
import { EmptyState } from "components/common";
|
||||
// images
|
||||
import emptyProject from "public/empty-state/project.svg";
|
||||
import { useApplication, useCycle, useModule, useProjectState, useUser } from "hooks/store";
|
||||
|
||||
interface IProjectAuthWrapper {
|
||||
children: ReactNode;
|
||||
@ -19,18 +20,22 @@ export const ProjectAuthWrapper: FC<IProjectAuthWrapper> = observer((props) => {
|
||||
const { children } = props;
|
||||
// store
|
||||
const {
|
||||
user: { fetchUserProjectInfo, projectMemberInfo, hasPermissionToProject },
|
||||
project: { fetchProjectDetails, workspaceProjects },
|
||||
projectLabel: { fetchProjectLabels },
|
||||
projectMember: { fetchProjectMembers },
|
||||
projectState: { fetchProjectStates },
|
||||
projectEstimates: { fetchProjectEstimates },
|
||||
cycle: { fetchCycles },
|
||||
module: { fetchModules },
|
||||
projectViews: { fetchAllViews },
|
||||
inbox: { fetchInboxesList, isInboxEnabled },
|
||||
commandPalette: { toggleCreateProjectModal },
|
||||
} = useMobxStore();
|
||||
const {
|
||||
commandPalette: { toggleCreateProjectModal },
|
||||
} = useApplication();
|
||||
const {
|
||||
membership: { fetchUserProjectInfo, projectMemberInfo, hasPermissionToProject },
|
||||
} = useUser();
|
||||
const { fetchAllCycles } = useCycle();
|
||||
const { fetchModules } = useModule();
|
||||
const { fetchProjectStates } = useProjectState();
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
@ -68,7 +73,7 @@ export const ProjectAuthWrapper: FC<IProjectAuthWrapper> = observer((props) => {
|
||||
// fetching project cycles
|
||||
useSWR(
|
||||
workspaceSlug && projectId ? `PROJECT_ALL_CYCLES_${workspaceSlug}_${projectId}` : null,
|
||||
workspaceSlug && projectId ? () => fetchCycles(workspaceSlug.toString(), projectId.toString(), "all") : null
|
||||
workspaceSlug && projectId ? () => fetchAllCycles(workspaceSlug.toString(), projectId.toString()) : null
|
||||
);
|
||||
// fetching project modules
|
||||
useSWR(
|
||||
@ -80,7 +85,6 @@ export const ProjectAuthWrapper: FC<IProjectAuthWrapper> = observer((props) => {
|
||||
workspaceSlug && projectId ? `PROJECT_VIEWS_${workspaceSlug}_${projectId}` : null,
|
||||
workspaceSlug && projectId ? () => fetchAllViews(workspaceSlug.toString(), projectId.toString()) : null
|
||||
);
|
||||
// TODO: fetching project pages
|
||||
// fetching project inboxes if inbox is enabled
|
||||
useSWR(
|
||||
workspaceSlug && projectId && isInboxEnabled ? `PROJECT_INBOXES_${workspaceSlug}_${projectId}` : null,
|
||||
|
@ -1,9 +1,8 @@
|
||||
import { ReactElement } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import useSWR from "swr";
|
||||
// mobx store
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
// hooks
|
||||
import { useCycle } from "hooks/store";
|
||||
import useLocalStorage from "hooks/use-local-storage";
|
||||
// layouts
|
||||
import { AppLayout } from "layouts/app-layout";
|
||||
@ -19,24 +18,23 @@ import emptyCycle from "public/empty-state/cycle.svg";
|
||||
import { NextPageWithLayout } from "types/app";
|
||||
|
||||
const CycleDetailPage: NextPageWithLayout = () => {
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, cycleId } = router.query;
|
||||
|
||||
const { cycle: cycleStore } = useMobxStore();
|
||||
// store hooks
|
||||
const { fetchCycleDetails } = useCycle();
|
||||
|
||||
const { setValue, storedValue } = useLocalStorage("cycle_sidebar_collapsed", "false");
|
||||
const isSidebarCollapsed = storedValue ? (storedValue === "true" ? true : false) : false;
|
||||
|
||||
const { error } = useSWR(
|
||||
workspaceSlug && projectId && cycleId ? `CURRENT_CYCLE_DETAILS_${cycleId.toString()}` : null,
|
||||
workspaceSlug && projectId && cycleId ? `CYCLE_DETAILS_${cycleId.toString()}` : null,
|
||||
workspaceSlug && projectId && cycleId
|
||||
? () => cycleStore.fetchCycleWithId(workspaceSlug.toString(), projectId.toString(), cycleId.toString())
|
||||
? () => fetchCycleDetails(workspaceSlug.toString(), projectId.toString(), cycleId.toString())
|
||||
: null
|
||||
);
|
||||
|
||||
const toggleSidebar = () => {
|
||||
setValue(`${!isSidebarCollapsed}`);
|
||||
};
|
||||
const toggleSidebar = () => setValue(`${!isSidebarCollapsed}`);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
@ -1,15 +1,17 @@
|
||||
import { Fragment, useCallback, useEffect, useState, ReactElement } from "react";
|
||||
import { Fragment, useCallback, useState, ReactElement } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Tab } from "@headlessui/react";
|
||||
import { Plus } from "lucide-react";
|
||||
// hooks
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
import { useCycle, useUser } from "hooks/store";
|
||||
import useLocalStorage from "hooks/use-local-storage";
|
||||
// layouts
|
||||
import { AppLayout } from "layouts/app-layout";
|
||||
// components
|
||||
import { CyclesHeader } from "components/headers";
|
||||
import { CyclesView, ActiveCycleDetails, CycleCreateUpdateModal } from "components/cycles";
|
||||
import { NewEmptyState } from "components/common/new-empty-state";
|
||||
// ui
|
||||
import { Tooltip } from "@plane/ui";
|
||||
// images
|
||||
@ -20,64 +22,37 @@ import { NextPageWithLayout } from "types/app";
|
||||
// constants
|
||||
import { CYCLE_TAB_LIST, CYCLE_VIEW_LAYOUTS } from "constants/cycle";
|
||||
import { EUserWorkspaceRoles } from "constants/workspace";
|
||||
// lib cookie
|
||||
import { setLocalStorage, getLocalStorage } from "lib/local-storage";
|
||||
import { NewEmptyState } from "components/common/new-empty-state";
|
||||
// TODO: use-local-storage hook instead of lib file.
|
||||
|
||||
const ProjectCyclesPage: NextPageWithLayout = observer(() => {
|
||||
const [createModal, setCreateModal] = useState(false);
|
||||
// store
|
||||
// store hooks
|
||||
const {
|
||||
cycle: cycleStore,
|
||||
user: { currentProjectRole },
|
||||
} = useMobxStore();
|
||||
const { projectCycles } = cycleStore;
|
||||
membership: { currentProjectRole },
|
||||
} = useUser();
|
||||
const { projectAllCycles } = useCycle();
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, peekCycle } = router.query;
|
||||
// local storage
|
||||
const { storedValue: cycleTab, setValue: setCycleTab } = useLocalStorage<TCycleView>("cycle_tab", "active");
|
||||
const { storedValue: cycleLayout, setValue: setCycleLayout } = useLocalStorage<TCycleLayout>("cycle_layout", "list");
|
||||
|
||||
const handleCurrentLayout = useCallback(
|
||||
(_layout: TCycleLayout) => {
|
||||
if (projectId) {
|
||||
setLocalStorage(`cycle_layout:${projectId}`, _layout);
|
||||
cycleStore.setCycleLayout(_layout);
|
||||
}
|
||||
setCycleLayout(_layout);
|
||||
},
|
||||
[cycleStore, projectId]
|
||||
[setCycleLayout]
|
||||
);
|
||||
|
||||
const handleCurrentView = useCallback(
|
||||
(_view: TCycleView) => {
|
||||
if (projectId) {
|
||||
setLocalStorage(`cycle_view:${projectId}`, _view);
|
||||
cycleStore.setCycleView(_view);
|
||||
if (_view === "draft" && cycleStore.cycleLayout === "gantt") {
|
||||
handleCurrentLayout("list");
|
||||
}
|
||||
}
|
||||
setCycleTab(_view);
|
||||
if (_view === "draft") handleCurrentLayout("list");
|
||||
},
|
||||
[cycleStore, projectId, handleCurrentLayout]
|
||||
[handleCurrentLayout, setCycleTab]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (projectId) {
|
||||
const _viewKey = `cycle_view:${projectId}`;
|
||||
const _viewValue = getLocalStorage(_viewKey);
|
||||
if (_viewValue && _viewValue !== cycleStore?.cycleView) cycleStore.setCycleView(_viewValue as TCycleView);
|
||||
else handleCurrentView("all");
|
||||
|
||||
const _layoutKey = `cycle_layout:${projectId}`;
|
||||
const _layoutValue = getLocalStorage(_layoutKey);
|
||||
if (_layoutValue && _layoutValue !== cycleStore?.cycleView)
|
||||
cycleStore.setCycleLayout(_layoutValue as TCycleLayout);
|
||||
else handleCurrentLayout("list");
|
||||
}
|
||||
}, [projectId, cycleStore, handleCurrentView, handleCurrentLayout]);
|
||||
|
||||
const cycleView = cycleStore?.cycleView;
|
||||
const cycleLayout = cycleStore?.cycleLayout;
|
||||
const totalCycles = projectCycles?.length ?? 0;
|
||||
const totalCycles = projectAllCycles?.length ?? 0;
|
||||
|
||||
const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserWorkspaceRoles.MEMBER;
|
||||
|
||||
@ -117,11 +92,9 @@ const ProjectCyclesPage: NextPageWithLayout = observer(() => {
|
||||
<Tab.Group
|
||||
as="div"
|
||||
className="flex h-full flex-col overflow-hidden"
|
||||
defaultIndex={CYCLE_TAB_LIST.findIndex((i) => i.key == cycleStore?.cycleView)}
|
||||
selectedIndex={CYCLE_TAB_LIST.findIndex((i) => i.key == cycleStore?.cycleView)}
|
||||
onChange={(i) => {
|
||||
handleCurrentView(CYCLE_TAB_LIST[i].key as TCycleView);
|
||||
}}
|
||||
defaultIndex={CYCLE_TAB_LIST.findIndex((i) => i.key == cycleTab)}
|
||||
selectedIndex={CYCLE_TAB_LIST.findIndex((i) => i.key == cycleTab)}
|
||||
onChange={(i) => handleCurrentView(CYCLE_TAB_LIST[i]?.key ?? "active")}
|
||||
>
|
||||
<div className="flex flex-col items-end justify-between gap-4 border-b border-custom-border-200 px-4 pb-4 sm:flex-row sm:items-center sm:px-5 sm:pb-0">
|
||||
<Tab.List as="div" className="flex items-center overflow-x-scroll">
|
||||
@ -138,26 +111,24 @@ const ProjectCyclesPage: NextPageWithLayout = observer(() => {
|
||||
</Tab>
|
||||
))}
|
||||
</Tab.List>
|
||||
{cycleStore?.cycleView != "active" && (
|
||||
{cycleTab !== "active" && (
|
||||
<div className="flex items-center gap-1 rounded bg-custom-background-80 p-1">
|
||||
{CYCLE_VIEW_LAYOUTS.map((layout) => {
|
||||
if (layout.key === "gantt" && cycleStore?.cycleView === "draft") return null;
|
||||
if (layout.key === "gantt" && cycleTab === "draft") return null;
|
||||
|
||||
return (
|
||||
<Tooltip key={layout.key} tooltipContent={layout.title}>
|
||||
<button
|
||||
type="button"
|
||||
className={`group grid h-[22px] w-7 place-items-center overflow-hidden rounded transition-all hover:bg-custom-background-100 ${
|
||||
cycleStore?.cycleLayout == layout.key
|
||||
? "bg-custom-background-100 shadow-custom-shadow-2xs"
|
||||
: ""
|
||||
cycleLayout == layout.key ? "bg-custom-background-100 shadow-custom-shadow-2xs" : ""
|
||||
}`}
|
||||
onClick={() => handleCurrentLayout(layout.key as TCycleLayout)}
|
||||
>
|
||||
<layout.icon
|
||||
strokeWidth={2}
|
||||
className={`h-3.5 w-3.5 ${
|
||||
cycleStore?.cycleLayout == layout.key ? "text-custom-text-100" : "text-custom-text-200"
|
||||
cycleLayout == layout.key ? "text-custom-text-100" : "text-custom-text-200"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
@ -170,10 +141,10 @@ const ProjectCyclesPage: NextPageWithLayout = observer(() => {
|
||||
|
||||
<Tab.Panels as={Fragment}>
|
||||
<Tab.Panel as="div" className="h-full overflow-y-auto">
|
||||
{cycleView && cycleLayout && (
|
||||
{cycleTab && cycleLayout && (
|
||||
<CyclesView
|
||||
filter={"all"}
|
||||
layout={cycleLayout as TCycleLayout}
|
||||
filter="all"
|
||||
layout={cycleLayout}
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
projectId={projectId.toString()}
|
||||
peekCycle={peekCycle?.toString()}
|
||||
@ -186,9 +157,9 @@ const ProjectCyclesPage: NextPageWithLayout = observer(() => {
|
||||
</Tab.Panel>
|
||||
|
||||
<Tab.Panel as="div" className="h-full overflow-y-auto">
|
||||
{cycleView && cycleLayout && (
|
||||
{cycleTab && cycleLayout && (
|
||||
<CyclesView
|
||||
filter={"upcoming"}
|
||||
filter="upcoming"
|
||||
layout={cycleLayout as TCycleLayout}
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
projectId={projectId.toString()}
|
||||
@ -198,9 +169,9 @@ const ProjectCyclesPage: NextPageWithLayout = observer(() => {
|
||||
</Tab.Panel>
|
||||
|
||||
<Tab.Panel as="div" className="h-full overflow-y-auto">
|
||||
{cycleView && cycleLayout && workspaceSlug && projectId && (
|
||||
{cycleTab && cycleLayout && workspaceSlug && projectId && (
|
||||
<CyclesView
|
||||
filter={"completed"}
|
||||
filter="completed"
|
||||
layout={cycleLayout as TCycleLayout}
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
projectId={projectId.toString()}
|
||||
@ -210,9 +181,9 @@ const ProjectCyclesPage: NextPageWithLayout = observer(() => {
|
||||
</Tab.Panel>
|
||||
|
||||
<Tab.Panel as="div" className="h-full overflow-y-auto">
|
||||
{cycleView && cycleLayout && workspaceSlug && projectId && (
|
||||
{cycleTab && cycleLayout && workspaceSlug && projectId && (
|
||||
<CyclesView
|
||||
filter={"draft"}
|
||||
filter="draft"
|
||||
layout={cycleLayout as TCycleLayout}
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
projectId={projectId.toString()}
|
||||
|
@ -11,7 +11,7 @@ export class CycleService extends APIService {
|
||||
super(API_BASE_URL);
|
||||
}
|
||||
|
||||
async createCycle(workspaceSlug: string, projectId: string, data: any): Promise<any> {
|
||||
async createCycle(workspaceSlug: string, projectId: string, data: any): Promise<ICycle> {
|
||||
return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/cycles/`, data)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
@ -22,8 +22,8 @@ export class CycleService extends APIService {
|
||||
async getCyclesWithParams(
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
cycleType: "all" | "current" | "upcoming" | "draft" | "completed" | "incomplete"
|
||||
): Promise<ICycle[]> {
|
||||
cycleType?: "current"
|
||||
): Promise<Record<string, ICycle>> {
|
||||
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/cycles/`, {
|
||||
params: {
|
||||
cycle_view: cycleType,
|
||||
|
@ -1,7 +1,8 @@
|
||||
import { action, computed, observable, makeObservable, runInAction } from "mobx";
|
||||
import set from "lodash/set";
|
||||
import { set, omit } from "lodash";
|
||||
import { isFuture, isPast } from "date-fns";
|
||||
// types
|
||||
import { ICycle, TCycleView, CycleDateCheckData } from "types";
|
||||
import { ICycle, CycleDateCheckData } from "types";
|
||||
// mobx
|
||||
import { RootStore } from "store/root.store";
|
||||
// services
|
||||
@ -10,40 +11,30 @@ import { IssueService } from "services/issue";
|
||||
import { CycleService } from "services/cycle.service";
|
||||
|
||||
export interface ICycleStore {
|
||||
// states
|
||||
loader: boolean;
|
||||
error: any | null;
|
||||
|
||||
cycleView: TCycleView;
|
||||
|
||||
cycleId: string | null;
|
||||
// observables
|
||||
cycleMap: {
|
||||
[projectId: string]: {
|
||||
[cycleId: string]: ICycle;
|
||||
};
|
||||
[cycleId: string]: ICycle;
|
||||
};
|
||||
cycles: {
|
||||
[projectId: string]: {
|
||||
[filterType: string]: string[];
|
||||
};
|
||||
activeCycleMap: {
|
||||
[cycleId: string]: ICycle;
|
||||
};
|
||||
|
||||
// computed
|
||||
getCycleById: (cycleId: string) => ICycle | null;
|
||||
projectCycles: string[] | null;
|
||||
projectAllCycles: string[] | null;
|
||||
projectCompletedCycles: string[] | null;
|
||||
projectUpcomingCycles: string[] | null;
|
||||
projectDraftCycles: string[] | null;
|
||||
|
||||
projectActiveCycle: string | null;
|
||||
// computed actions
|
||||
getCycleById: (cycleId: string) => ICycle | null;
|
||||
getActiveCycleById: (cycleId: string) => ICycle | null;
|
||||
// actions
|
||||
validateDate: (workspaceSlug: string, projectId: string, payload: CycleDateCheckData) => Promise<any>;
|
||||
|
||||
fetchCycles: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
params: "all" | "current" | "upcoming" | "draft" | "completed" | "incomplete"
|
||||
) => Promise<void>;
|
||||
fetchAllCycles: (workspaceSlug: string, projectId: string) => Promise<Record<string, ICycle>>;
|
||||
fetchActiveCycle: (workspaceSlug: string, projectId: string) => Promise<Record<string, ICycle>>;
|
||||
fetchCycleDetails: (workspaceSlug: string, projectId: string, cycleId: string) => Promise<ICycle>;
|
||||
|
||||
createCycle: (workspaceSlug: string, projectId: string, data: Partial<ICycle>) => Promise<ICycle>;
|
||||
updateCycleDetails: (
|
||||
workspaceSlug: string,
|
||||
@ -52,29 +43,19 @@ export interface ICycleStore {
|
||||
data: Partial<ICycle>
|
||||
) => Promise<ICycle>;
|
||||
deleteCycle: (workspaceSlug: string, projectId: string, cycleId: string) => Promise<void>;
|
||||
|
||||
addCycleToFavorites: (workspaceSlug: string, projectId: string, cycleId: string) => Promise<any>;
|
||||
removeCycleFromFavorites: (workspaceSlug: string, projectId: string, cycleId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export class CycleStore implements ICycleStore {
|
||||
// states
|
||||
loader: boolean = false;
|
||||
error: any | null = null;
|
||||
|
||||
cycleView: TCycleView = "all";
|
||||
|
||||
cycleId: string | null = null;
|
||||
// observables
|
||||
cycleMap: {
|
||||
[projectId: string]: {
|
||||
[cycleId: string]: ICycle;
|
||||
};
|
||||
[cycleId: string]: ICycle;
|
||||
} = {};
|
||||
cycles: {
|
||||
[projectId: string]: {
|
||||
[filterType: string]: string[];
|
||||
};
|
||||
} = {};
|
||||
|
||||
activeCycleMap: { [cycleId: string]: ICycle } = {};
|
||||
// root store
|
||||
rootStore;
|
||||
// services
|
||||
@ -84,29 +65,28 @@ export class CycleStore implements ICycleStore {
|
||||
|
||||
constructor(_rootStore: RootStore) {
|
||||
makeObservable(this, {
|
||||
loader: observable,
|
||||
// states
|
||||
loader: observable.ref,
|
||||
error: observable.ref,
|
||||
|
||||
cycleId: observable.ref,
|
||||
// observables
|
||||
cycleMap: observable,
|
||||
cycles: observable,
|
||||
|
||||
activeCycleMap: observable,
|
||||
// computed
|
||||
projectCycles: computed,
|
||||
projectAllCycles: computed,
|
||||
projectCompletedCycles: computed,
|
||||
projectUpcomingCycles: computed,
|
||||
projectDraftCycles: computed,
|
||||
|
||||
// actions
|
||||
projectActiveCycle: computed,
|
||||
// computed actions
|
||||
getCycleById: action,
|
||||
|
||||
fetchCycles: action,
|
||||
getActiveCycleById: action,
|
||||
// actions
|
||||
fetchAllCycles: action,
|
||||
fetchActiveCycle: action,
|
||||
fetchCycleDetails: action,
|
||||
|
||||
createCycle: action,
|
||||
updateCycleDetails: action,
|
||||
deleteCycle: action,
|
||||
|
||||
addCycleToFavorites: action,
|
||||
removeCycleFromFavorites: action,
|
||||
});
|
||||
@ -118,46 +98,86 @@ export class CycleStore implements ICycleStore {
|
||||
}
|
||||
|
||||
// computed
|
||||
get projectCycles() {
|
||||
get projectAllCycles() {
|
||||
const projectId = this.rootStore.app.router.projectId;
|
||||
|
||||
if (!projectId) return null;
|
||||
return this.cycles[projectId]?.all || null;
|
||||
|
||||
const allCycles = Object.keys(this.cycleMap ?? {}).filter(
|
||||
(cycleId) => this.cycleMap?.[cycleId]?.project === projectId
|
||||
);
|
||||
|
||||
return allCycles || null;
|
||||
}
|
||||
|
||||
get projectCompletedCycles() {
|
||||
const projectId = this.rootStore.app.router.projectId;
|
||||
const allCycles = this.projectAllCycles;
|
||||
|
||||
if (!projectId) return null;
|
||||
if (!allCycles) return null;
|
||||
|
||||
return this.cycles[projectId]?.completed || null;
|
||||
const completedCycles = allCycles.filter((cycleId) => {
|
||||
const hasEndDatePassed = isPast(new Date(this.cycleMap?.[cycleId]?.end_date ?? ""));
|
||||
|
||||
return hasEndDatePassed;
|
||||
});
|
||||
|
||||
return completedCycles || null;
|
||||
}
|
||||
|
||||
get projectUpcomingCycles() {
|
||||
const projectId = this.rootStore.app.router.projectId;
|
||||
const allCycles = this.projectAllCycles;
|
||||
|
||||
if (!projectId) return null;
|
||||
if (!allCycles) return null;
|
||||
|
||||
return this.cycles[projectId]?.upcoming || null;
|
||||
const upcomingCycles = allCycles.filter((cycleId) => {
|
||||
const isStartDateUpcoming = isFuture(new Date(this.cycleMap?.[cycleId]?.start_date ?? ""));
|
||||
|
||||
return isStartDateUpcoming;
|
||||
});
|
||||
|
||||
return upcomingCycles || null;
|
||||
}
|
||||
|
||||
get projectDraftCycles() {
|
||||
const projectId = this.rootStore.app.router.projectId;
|
||||
const allCycles = this.projectAllCycles;
|
||||
|
||||
if (!projectId) return null;
|
||||
if (!allCycles) return null;
|
||||
|
||||
return this.cycles[projectId]?.draft || null;
|
||||
const draftCycles = allCycles.filter((cycleId) => {
|
||||
const cycleDetails = this.cycleMap?.[cycleId];
|
||||
|
||||
return !cycleDetails?.start_date && !cycleDetails?.end_date;
|
||||
});
|
||||
|
||||
return draftCycles || null;
|
||||
}
|
||||
|
||||
getCycleById = (cycleId: string) => {
|
||||
get projectActiveCycle() {
|
||||
const projectId = this.rootStore.app.router.projectId;
|
||||
|
||||
if (!projectId) return null;
|
||||
|
||||
return this.cycleMap?.[projectId]?.[cycleId] || null;
|
||||
};
|
||||
const activeCycle = Object.keys(this.activeCycleMap ?? {}).find(
|
||||
(cycleId) => this.activeCycleMap?.[cycleId]?.project === projectId
|
||||
);
|
||||
|
||||
return activeCycle || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description returns cycle details by cycle id
|
||||
* @param cycleId
|
||||
* @returns
|
||||
*/
|
||||
getCycleById = (cycleId: string): ICycle | null => this.cycleMap?.[cycleId] ?? null;
|
||||
|
||||
/**
|
||||
* @description returns active cycle details by cycle id
|
||||
* @param cycleId
|
||||
* @returns
|
||||
*/
|
||||
getActiveCycleById = (cycleId: string): ICycle | null => this.activeCycleMap?.[cycleId] ?? null;
|
||||
|
||||
// actions
|
||||
validateDate = async (workspaceSlug: string, projectId: string, payload: CycleDateCheckData) => {
|
||||
try {
|
||||
const response = await this.cycleService.cycleDateCheck(workspaceSlug, projectId, payload);
|
||||
@ -168,27 +188,52 @@ export class CycleStore implements ICycleStore {
|
||||
}
|
||||
};
|
||||
|
||||
fetchCycles = async (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
params: "all" | "current" | "upcoming" | "draft" | "completed" | "incomplete"
|
||||
) => {
|
||||
fetchAllCycles = async (workspaceSlug: string, projectId: string) => {
|
||||
try {
|
||||
this.loader = true;
|
||||
this.error = null;
|
||||
|
||||
const cyclesResponse = await this.cycleService.getCyclesWithParams(workspaceSlug, projectId, params);
|
||||
const cyclesResponse = await this.cycleService.getCyclesWithParams(workspaceSlug, projectId);
|
||||
|
||||
runInAction(() => {
|
||||
set(this.cycleMap, [projectId], cyclesResponse);
|
||||
set(this.cycles, [projectId, params], Object.keys(cyclesResponse));
|
||||
Object.values(cyclesResponse).forEach((cycle) => {
|
||||
set(this.cycleMap, [cycle.id], cycle);
|
||||
});
|
||||
this.loader = false;
|
||||
this.error = null;
|
||||
});
|
||||
|
||||
return cyclesResponse;
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch project cycles in project store", error);
|
||||
this.loader = false;
|
||||
this.error = error;
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
fetchActiveCycle = async (workspaceSlug: string, projectId: string) => {
|
||||
try {
|
||||
this.loader = true;
|
||||
this.error = null;
|
||||
|
||||
const cyclesResponse = await this.cycleService.getCyclesWithParams(workspaceSlug, projectId, "current");
|
||||
|
||||
runInAction(() => {
|
||||
Object.values(cyclesResponse).forEach((cycle) => {
|
||||
set(this.activeCycleMap, [cycle.id], cycle);
|
||||
});
|
||||
this.loader = false;
|
||||
this.error = null;
|
||||
});
|
||||
|
||||
return cyclesResponse;
|
||||
} catch (error) {
|
||||
this.loader = false;
|
||||
this.error = error;
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
@ -197,7 +242,8 @@ export class CycleStore implements ICycleStore {
|
||||
const response = await this.cycleService.getCycleDetails(workspaceSlug, projectId, cycleId);
|
||||
|
||||
runInAction(() => {
|
||||
set(this.cycleMap, [projectId, response?.id], response);
|
||||
set(this.cycleMap, [response.id], { ...this.cycleMap?.[response.id], ...response });
|
||||
set(this.activeCycleMap, [response.id], { ...this.activeCycleMap?.[response.id], ...response });
|
||||
});
|
||||
|
||||
return response;
|
||||
@ -212,12 +258,10 @@ export class CycleStore implements ICycleStore {
|
||||
const response = await this.cycleService.createCycle(workspaceSlug, projectId, data);
|
||||
|
||||
runInAction(() => {
|
||||
set(this.cycleMap, [projectId, response?.id], response);
|
||||
set(this.cycleMap, [response.id], response);
|
||||
set(this.activeCycleMap, [response.id], response);
|
||||
});
|
||||
|
||||
const _currentView = this.cycleView === "active" ? "current" : this.cycleView;
|
||||
this.fetchCycles(workspaceSlug, projectId, _currentView);
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.log("Failed to create cycle from cycle store");
|
||||
@ -227,18 +271,14 @@ export class CycleStore implements ICycleStore {
|
||||
|
||||
updateCycleDetails = async (workspaceSlug: string, projectId: string, cycleId: string, data: Partial<ICycle>) => {
|
||||
try {
|
||||
const _response = await this.cycleService.patchCycle(workspaceSlug, projectId, cycleId, data);
|
||||
|
||||
const currentCycle = this.cycleMap[projectId][cycleId];
|
||||
const response = await this.cycleService.patchCycle(workspaceSlug, projectId, cycleId, data);
|
||||
|
||||
runInAction(() => {
|
||||
set(this.cycleMap, [projectId, cycleId], { ...currentCycle, ...data });
|
||||
set(this.cycleMap, [cycleId], { ...this.cycleMap?.[cycleId], ...data });
|
||||
set(this.activeCycleMap, [cycleId], { ...this.activeCycleMap?.[cycleId], ...data });
|
||||
});
|
||||
|
||||
const _currentView = this.cycleView === "active" ? "current" : this.cycleView;
|
||||
this.fetchCycles(workspaceSlug, projectId, _currentView);
|
||||
|
||||
return _response;
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.log("Failed to patch cycle from cycle store");
|
||||
throw error;
|
||||
@ -246,32 +286,36 @@ export class CycleStore implements ICycleStore {
|
||||
};
|
||||
|
||||
deleteCycle = async (workspaceSlug: string, projectId: string, cycleId: string) => {
|
||||
try {
|
||||
if (!this.cycleMap?.[projectId]?.[cycleId]) return;
|
||||
const originalCycle = this.cycleMap[cycleId];
|
||||
const originalActiveCycle = this.activeCycleMap[cycleId];
|
||||
|
||||
try {
|
||||
runInAction(() => {
|
||||
delete this.cycleMap[projectId][cycleId];
|
||||
omit(this.cycleMap, [cycleId]);
|
||||
omit(this.activeCycleMap, [cycleId]);
|
||||
});
|
||||
|
||||
const _response = await this.cycleService.deleteCycle(workspaceSlug, projectId, cycleId);
|
||||
|
||||
return _response;
|
||||
await this.cycleService.deleteCycle(workspaceSlug, projectId, cycleId);
|
||||
} catch (error) {
|
||||
console.log("Failed to delete cycle from cycle store");
|
||||
|
||||
const _currentView = this.cycleView === "active" ? "current" : this.cycleView;
|
||||
this.fetchCycles(workspaceSlug, projectId, _currentView);
|
||||
runInAction(() => {
|
||||
set(this.cycleMap, [cycleId], originalCycle);
|
||||
set(this.activeCycleMap, [cycleId], originalActiveCycle);
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
addCycleToFavorites = async (workspaceSlug: string, projectId: string, cycleId: string) => {
|
||||
try {
|
||||
const currentCycle = this.cycleMap[projectId][cycleId];
|
||||
if (currentCycle.is_favorite) return;
|
||||
const currentCycle = this.getCycleById(cycleId);
|
||||
const currentActiveCycle = this.getActiveCycleById(cycleId);
|
||||
|
||||
runInAction(() => {
|
||||
set(this.cycleMap, [projectId, cycleId, "is_favorite"], true);
|
||||
if (currentCycle) set(this.cycleMap, [cycleId, "is_favorite"], true);
|
||||
if (currentActiveCycle) set(this.activeCycleMap, [cycleId, "is_favorite"], true);
|
||||
});
|
||||
|
||||
// updating through api.
|
||||
@ -279,10 +323,12 @@ export class CycleStore implements ICycleStore {
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.log("Failed to add cycle to favorites in the cycles store", error);
|
||||
const currentCycle = this.getCycleById(cycleId);
|
||||
const currentActiveCycle = this.getActiveCycleById(cycleId);
|
||||
|
||||
runInAction(() => {
|
||||
set(this.cycleMap, [projectId, cycleId, "is_favorite"], false);
|
||||
if (currentCycle) set(this.cycleMap, [cycleId, "is_favorite"], false);
|
||||
if (currentActiveCycle) set(this.activeCycleMap, [cycleId, "is_favorite"], false);
|
||||
});
|
||||
|
||||
throw error;
|
||||
@ -291,22 +337,24 @@ export class CycleStore implements ICycleStore {
|
||||
|
||||
removeCycleFromFavorites = async (workspaceSlug: string, projectId: string, cycleId: string) => {
|
||||
try {
|
||||
const currentCycle = this.cycleMap[projectId][cycleId];
|
||||
|
||||
if (!currentCycle.is_favorite) return;
|
||||
const currentCycle = this.getCycleById(cycleId);
|
||||
const currentActiveCycle = this.getActiveCycleById(cycleId);
|
||||
|
||||
runInAction(() => {
|
||||
set(this.cycleMap, [projectId, cycleId, "is_favorite"], false);
|
||||
if (currentCycle) set(this.cycleMap, [cycleId, "is_favorite"], false);
|
||||
if (currentActiveCycle) set(this.activeCycleMap, [cycleId, "is_favorite"], false);
|
||||
});
|
||||
|
||||
const response = await this.cycleService.removeCycleFromFavorites(workspaceSlug, projectId, cycleId);
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.log("Failed to remove cycle from favorites - Cycle Store", error);
|
||||
const currentCycle = this.getCycleById(cycleId);
|
||||
const currentActiveCycle = this.getActiveCycleById(cycleId);
|
||||
|
||||
runInAction(() => {
|
||||
set(this.cycleMap, [projectId, cycleId, "is_favorite"], true);
|
||||
if (currentCycle) set(this.cycleMap, [cycleId, "is_favorite"], true);
|
||||
if (currentActiveCycle) set(this.activeCycleMap, [cycleId, "is_favorite"], true);
|
||||
});
|
||||
|
||||
throw error;
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { action, computed, observable, makeObservable, runInAction } from "mobx";
|
||||
import set from "lodash/set";
|
||||
import { set } from "lodash";
|
||||
// services
|
||||
import { ProjectService } from "services/project";
|
||||
import { ModuleService } from "services/module.service";
|
||||
|
Loading…
Reference in New Issue
Block a user