[WEB-682] feat: cycles list filtering and searching (#3910)

* chore: implemented cycles list filters and ordering

* chore: active cycle tab updated

* refactor: cycles folder structure

* fix: name search inout auto-focus

* fix: cycles ordering

* refactor: move cycle filters logic to mobx store from local storage

* chore: show completed cycles in a disclosure

* chore: added completed cycles count

* refactor: cycles mapping logic

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
This commit is contained in:
Aaryan Khandelwal 2024-03-11 21:00:05 +05:30 committed by GitHub
parent 4b30339a59
commit 535731141f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
53 changed files with 1666 additions and 545 deletions

View File

@ -1,11 +1,7 @@
import type { TIssue, IIssueFilterOptions } from "@plane/types";
export type TCycleView = "all" | "active" | "upcoming" | "completed" | "draft";
export type TCycleGroups = "current" | "upcoming" | "completed" | "draft";
export type TCycleLayout = "list" | "board" | "gantt";
export interface ICycle {
backlog_issues: number;
cancelled_issues: number;

View File

@ -0,0 +1,19 @@
export type TCycleTabOptions = "active" | "all";
export type TCycleLayoutOptions = "list" | "board" | "gantt";
export type TCycleDisplayFilters = {
active_tab?: TCycleTabOptions;
layout?: TCycleLayoutOptions;
};
export type TCycleFilters = {
end_date?: string[] | null;
start_date?: string[] | null;
status?: string[] | null;
};
export type TCycleStoredFilters = {
display_filters?: TCycleDisplayFilters;
filters?: TCycleFilters;
};

View File

@ -0,0 +1,2 @@
export * from "./cycle_filters";
export * from "./cycle";

View File

@ -1,6 +1,6 @@
export * from "./users";
export * from "./workspace";
export * from "./cycles";
export * from "./cycle";
export * from "./dashboard";
export * from "./projects";
export * from "./state";

View File

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

View File

@ -0,0 +1,4 @@
export * from "./root";
export * from "./stats";
export * from "./upcoming-cycles-list-item";
export * from "./upcoming-cycles-list";

View File

@ -3,7 +3,7 @@ import { observer } from "mobx-react-lite";
import Link from "next/link";
import useSWR from "swr";
// hooks
import { useCycle, useIssues, useMember, useProject } from "hooks/store";
import { useCycle, useCycleFilter, useIssues, useMember, useProject } from "hooks/store";
// ui
import { SingleProgressStats } from "components/core";
import {
@ -17,10 +17,11 @@ import {
Avatar,
CycleGroupIcon,
setPromiseToast,
getButtonStyling,
} from "@plane/ui";
// components
import ProgressChart from "components/core/sidebar/progress-chart";
import { ActiveCycleProgressStats } from "components/cycles";
import { ActiveCycleProgressStats, UpcomingCyclesList } from "components/cycles";
import { StateDropdown } from "components/dropdowns";
import { EmptyState } from "components/empty-state";
// icons
@ -28,6 +29,7 @@ import { ArrowRight, CalendarCheck, CalendarDays, Star, Target } from "lucide-re
// helpers
import { renderFormattedDate, findHowManyDaysLeft, renderFormattedDateWithoutYear } from "helpers/date-time.helper";
import { truncateText } from "helpers/string.helper";
import { cn } from "helpers/common.helper";
// types
import { ICycle, TCycleGroups } from "@plane/types";
// constants
@ -41,30 +43,34 @@ interface IActiveCycleDetails {
projectId: string;
}
export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props) => {
export const ActiveCycleRoot: React.FC<IActiveCycleDetails> = observer((props) => {
// props
const { workspaceSlug, projectId } = props;
// store hooks
const {
issues: { fetchActiveCycleIssues },
} = useIssues(EIssuesStoreType.CYCLE);
const {
fetchActiveCycle,
currentProjectActiveCycleId,
currentProjectUpcomingCycleIds,
fetchActiveCycle,
getActiveCycleById,
addCycleToFavorites,
removeCycleFromFavorites,
} = useCycle();
const { currentProjectDetails } = useProject();
const { getUserDetails } = useMember();
// cycle filters hook
const { updateDisplayFilters } = useCycleFilter();
// derived values
const activeCycle = currentProjectActiveCycleId ? getActiveCycleById(currentProjectActiveCycleId) : null;
const cycleOwnerDetails = activeCycle ? getUserDetails(activeCycle.owned_by_id) : undefined;
// fetch active cycle details
const { isLoading } = useSWR(
workspaceSlug && projectId ? `PROJECT_ACTIVE_CYCLE_${projectId}` : null,
workspaceSlug && projectId ? () => fetchActiveCycle(workspaceSlug, projectId) : null
);
const activeCycle = currentProjectActiveCycleId ? getActiveCycleById(currentProjectActiveCycleId) : null;
const cycleOwnerDetails = activeCycle ? getUserDetails(activeCycle.owned_by_id) : undefined;
// fetch active cycle issues
const { data: activeCycleIssues } = useSWR(
workspaceSlug && projectId && currentProjectActiveCycleId
? CYCLE_ISSUES_WITH_PARAMS(currentProjectActiveCycleId, { priority: "urgent,high" })
@ -73,7 +79,7 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
? () => fetchActiveCycleIssues(workspaceSlug, projectId, currentProjectActiveCycleId)
: null
);
// show loader if active cycle is loading
if (!activeCycle && isLoading)
return (
<Loader>
@ -81,10 +87,44 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
</Loader>
);
if (!activeCycle) return <EmptyState type={EmptyStateType.PROJECT_CYCLE_ACTIVE} size="sm" />;
if (!activeCycle) {
// show empty state if no active cycle is present
if (currentProjectUpcomingCycleIds?.length === 0)
return <EmptyState type={EmptyStateType.PROJECT_CYCLE_ACTIVE} size="sm" />;
// show upcoming cycles list, if present
else
return (
<>
<div className="h-52 w-full grid place-items-center mb-6">
<div className="text-center">
<h5 className="text-xl font-medium mb-1">No active cycle</h5>
<p className="text-custom-text-400 text-base">
Create new cycles to find them here or check
<br />
{"'"}All{"'"} cycles tab to see all cycles or{" "}
<button
type="button"
className="text-custom-primary-100 font-medium"
onClick={() =>
updateDisplayFilters(projectId, {
active_tab: "all",
})
}
>
click here
</button>
</p>
</div>
</div>
<UpcomingCyclesList />
</>
);
}
const endDate = new Date(activeCycle.end_date ?? "");
const startDate = new Date(activeCycle.start_date ?? "");
const daysLeft = findHowManyDaysLeft(activeCycle.end_date) ?? 0;
const cycleStatus = activeCycle.status.toLowerCase() as TCycleGroups;
const groupedIssues: any = {
backlog: activeCycle.backlog_issues,
@ -94,8 +134,6 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
cancelled: activeCycle.cancelled_issues,
};
const cycleStatus = activeCycle.status.toLowerCase() as TCycleGroups;
const handleAddToFavorites = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
if (!workspaceSlug || !projectId) return;
@ -148,8 +186,6 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
color: group.color,
}));
const daysLeft = findHowManyDaysLeft(activeCycle.end_date) ?? 0;
return (
<div className="grid-row-2 grid divide-y rounded-[10px] border border-custom-border-200 bg-custom-background-100 shadow">
<div className="grid grid-cols-1 divide-y border-custom-border-200 lg:grid-cols-3 lg:divide-x lg:divide-y-0">
@ -203,27 +239,15 @@ 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">
{cycleOwnerDetails?.avatar && cycleOwnerDetails?.avatar !== "" ? (
<img
src={cycleOwnerDetails?.avatar}
height={16}
width={16}
className="rounded-full"
alt={cycleOwnerDetails?.display_name}
/>
) : (
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-custom-background-100 capitalize">
{cycleOwnerDetails?.display_name.charAt(0)}
</span>
)}
<Avatar src={cycleOwnerDetails?.avatar} name={cycleOwnerDetails?.display_name} />
<span className="text-custom-text-200">{cycleOwnerDetails?.display_name}</span>
</div>
{activeCycle.assignee_ids.length > 0 && (
<div className="flex items-center gap-1 text-custom-text-200">
<AvatarGroup>
{activeCycle.assignee_ids.map((assigne_id) => {
const member = getUserDetails(assigne_id);
{activeCycle.assignee_ids.map((assignee_id) => {
const member = getUserDetails(assignee_id);
return <Avatar key={member?.id} name={member?.display_name} src={member?.avatar} />;
})}
</AvatarGroup>
@ -233,7 +257,7 @@ 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" />
<LayersIcon className="h-3.5 w-3.5 flex-shrink-0" />
{activeCycle.total_issues} issues
</div>
<div className="flex items-center gap-2">
@ -244,9 +268,9 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
<Link
href={`/${workspaceSlug}/projects/${projectId}/cycles/${activeCycle.id}`}
className="w-min text-nowrap rounded-md bg-custom-primary px-4 py-2 text-center text-sm font-medium text-white hover:bg-custom-primary/90"
className={cn(getButtonStyling("primary", "lg"), "w-min whitespace-nowrap")}
>
View Cycle
View cycle
</Link>
</div>
</div>
@ -287,11 +311,11 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
</div>
<div className="grid grid-cols-1 divide-y border-custom-border-200 lg:grid-cols-2 lg:divide-x lg:divide-y-0">
<div className="flex max-h-60 flex-col gap-3 overflow-hidden p-4">
<div className="text-custom-primary">High Priority Issues</div>
<div className="text-custom-primary">High priority issues</div>
<div className="flex h-full flex-col gap-2.5 overflow-y-scroll rounded-md">
{activeCycleIssues ? (
activeCycleIssues.length > 0 ? (
activeCycleIssues.map((issue: any) => (
activeCycleIssues.map((issue) => (
<Link
key={issue.id}
href={`/${workspaceSlug}/projects/${projectId}/issues/${issue.id}`}
@ -314,9 +338,9 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
</div>
<div className="flex flex-shrink-0 items-center gap-1.5">
<StateDropdown
value={issue.state_id ?? undefined}
value={issue.state_id}
onChange={() => {}}
projectId={projectId?.toString() ?? ""}
projectId={projectId}
disabled
buttonVariant="background-with-text"
/>
@ -359,10 +383,10 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
</div>
<div className="flex items-center gap-1">
<span>
<LayersIcon className="h-5 w-5 flex-shrink-0 text-custom-text-200" />
<LayersIcon className="h-3.5 w-3.5 flex-shrink-0 text-custom-text-200" />
</span>
<span>
Pending Issues -{" "}
Pending issues-{" "}
{activeCycle.total_issues - (activeCycle.completed_issues + activeCycle.cancelled_issues)}
</span>
</div>

View File

@ -134,7 +134,7 @@ export const ActiveCycleProgressStats: React.FC<Props> = ({ cycle }) => {
</Tab.Panels>
) : (
<div className="mt-4 grid place-items-center text-center text-sm text-custom-text-200">
There are no high priority issues present in this cycle.
There are no issues present in this cycle.
</div>
)}
</Tab.Group>

View File

@ -0,0 +1,135 @@
import Link from "next/link";
import { useRouter } from "next/router";
import { observer } from "mobx-react";
import { Star, User2 } from "lucide-react";
// hooks
import { useCycle, useEventTracker, useMember } from "hooks/store";
// components
import { CycleQuickActions } from "components/cycles";
// ui
import { Avatar, AvatarGroup, setPromiseToast } from "@plane/ui";
// helpers
import { renderFormattedDate } from "helpers/date-time.helper";
// constants
import { CYCLE_FAVORITED, CYCLE_UNFAVORITED } from "constants/event-tracker";
type Props = {
cycleId: string;
};
export const UpcomingCycleListItem: React.FC<Props> = observer((props) => {
const { cycleId } = props;
// router
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
// store hooks
const { captureEvent } = useEventTracker();
const { addCycleToFavorites, getCycleById, removeCycleFromFavorites } = useCycle();
const { getUserDetails } = useMember();
// derived values
const cycle = getCycleById(cycleId);
const handleAddToFavorites = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
if (!workspaceSlug || !projectId) return;
const addToFavoritePromise = addCycleToFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId).then(
() => {
captureEvent(CYCLE_FAVORITED, {
cycle_id: cycleId,
element: "List layout",
state: "SUCCESS",
});
}
);
setPromiseToast(addToFavoritePromise, {
loading: "Adding cycle to favorites...",
success: {
title: "Success!",
message: () => "Cycle added to favorites.",
},
error: {
title: "Error!",
message: () => "Couldn't add the cycle to favorites. Please try again.",
},
});
};
const handleRemoveFromFavorites = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
if (!workspaceSlug || !projectId) return;
const removeFromFavoritePromise = removeCycleFromFavorites(
workspaceSlug?.toString(),
projectId.toString(),
cycleId
).then(() => {
captureEvent(CYCLE_UNFAVORITED, {
cycle_id: cycleId,
element: "List layout",
state: "SUCCESS",
});
});
setPromiseToast(removeFromFavoritePromise, {
loading: "Removing cycle from favorites...",
success: {
title: "Success!",
message: () => "Cycle removed from favorites.",
},
error: {
title: "Error!",
message: () => "Couldn't remove the cycle from favorites. Please try again.",
},
});
};
if (!cycle) return null;
return (
<Link
href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycleId}`}
className="py-5 px-2 flex items-center justify-between gap-2 hover:bg-custom-background-90"
>
<h6 className="font-medium text-base">{cycle.name}</h6>
<div className="flex items-center gap-4">
{cycle.start_date && cycle.end_date && (
<div className="text-xs text-custom-text-300">
{renderFormattedDate(cycle.start_date)} - {renderFormattedDate(cycle.end_date)}
</div>
)}
{cycle.assignee_ids?.length > 0 ? (
<AvatarGroup showTooltip={false}>
{cycle.assignee_ids?.map((assigneeId) => {
const member = getUserDetails(assigneeId);
return <Avatar key={member?.id} name={member?.display_name} src={member?.avatar} />;
})}
</AvatarGroup>
) : (
<span className="flex h-5 w-5 items-end justify-center rounded-full border border-dashed border-custom-text-400 bg-custom-background-80">
<User2 className="h-4 w-4 text-custom-text-400" />
</span>
)}
{cycle.is_favorite ? (
<button type="button" onClick={handleRemoveFromFavorites}>
<Star className="h-3.5 w-3.5 fill-current text-amber-500" />
</button>
) : (
<button type="button" onClick={handleAddToFavorites}>
<Star className="h-3.5 w-3.5 text-custom-text-200" />
</button>
)}
{workspaceSlug && projectId && (
<CycleQuickActions
cycleId={cycleId}
projectId={projectId.toString()}
workspaceSlug={workspaceSlug.toString()}
/>
)}
</div>
</Link>
);
});

View File

@ -0,0 +1,25 @@
import { observer } from "mobx-react";
// hooks
import { useCycle } from "hooks/store";
// components
import { UpcomingCycleListItem } from "components/cycles";
export const UpcomingCyclesList = observer(() => {
// store hooks
const { currentProjectUpcomingCycleIds } = useCycle();
if (!currentProjectUpcomingCycleIds) return null;
return (
<div>
<div className="bg-custom-background-80 font-semibold text-sm py-1 px-2 rounded inline-block">
Upcoming cycles
</div>
<div className="mt-2 divide-y-[0.5px] divide-custom-border-200 border-b-[0.5px] border-custom-border-200">
{currentProjectUpcomingCycleIds.map((cycleId) => (
<UpcomingCycleListItem key={cycleId} cycleId={cycleId} />
))}
</div>
</div>
);
});

View File

@ -0,0 +1,55 @@
import { observer } from "mobx-react-lite";
import { X } from "lucide-react";
// helpers
import { renderFormattedDate } from "helpers/date-time.helper";
import { capitalizeFirstLetter } from "helpers/string.helper";
// constants
import { DATE_FILTER_OPTIONS } from "constants/filters";
type Props = {
editable: boolean | undefined;
handleRemove: (val: string) => void;
values: string[];
};
export const AppliedDateFilters: React.FC<Props> = observer((props) => {
const { editable, handleRemove, values } = props;
const getDateLabel = (value: string): string => {
let dateLabel = "";
const dateDetails = DATE_FILTER_OPTIONS.find((d) => d.value === value);
if (dateDetails) dateLabel = dateDetails.name;
else {
const dateParts = value.split(";");
if (dateParts.length === 2) {
const [date, time] = dateParts;
dateLabel = `${capitalizeFirstLetter(time)} ${renderFormattedDate(date)}`;
}
}
return dateLabel;
};
return (
<>
{values.map((date) => (
<div key={date} className="flex items-center gap-1 rounded bg-custom-background-80 p-1 text-xs">
<span className="normal-case">{getDateLabel(date)}</span>
{editable && (
<button
type="button"
className="grid place-items-center text-custom-text-300 hover:text-custom-text-200"
onClick={() => handleRemove(date)}
>
<X size={10} strokeWidth={2} />
</button>
)}
</div>
))}
</>
);
});

View File

@ -0,0 +1,3 @@
export * from "./date";
export * from "./root";
export * from "./status";

View File

@ -0,0 +1,90 @@
import { observer } from "mobx-react-lite";
import { X } from "lucide-react";
// hooks
import { useUser } from "hooks/store";
// components
import { AppliedDateFilters, AppliedStatusFilters } from "components/cycles";
// helpers
import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper";
// types
import { TCycleFilters } from "@plane/types";
// constants
import { EUserProjectRoles } from "constants/project";
type Props = {
appliedFilters: TCycleFilters;
handleClearAllFilters: () => void;
handleRemoveFilter: (key: keyof TCycleFilters, value: string | null) => void;
alwaysAllowEditing?: boolean;
};
const DATE_FILTERS = ["start_date", "end_date"];
export const CycleAppliedFiltersList: React.FC<Props> = observer((props) => {
const { appliedFilters, handleClearAllFilters, handleRemoveFilter, alwaysAllowEditing } = props;
// store hooks
const {
membership: { currentProjectRole },
} = useUser();
if (!appliedFilters) return null;
if (Object.keys(appliedFilters).length === 0) return null;
const isEditingAllowed = alwaysAllowEditing || (currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER);
return (
<div className="flex flex-wrap items-stretch gap-2 bg-custom-background-100">
{Object.entries(appliedFilters).map(([key, value]) => {
const filterKey = key as keyof TCycleFilters;
if (!value) return;
if (Array.isArray(value) && value.length === 0) return;
return (
<div
key={filterKey}
className="flex flex-wrap items-center gap-2 rounded-md border border-custom-border-200 px-2 py-1 capitalize"
>
<span className="text-xs text-custom-text-300">{replaceUnderscoreIfSnakeCase(filterKey)}</span>
<div className="flex flex-wrap items-center gap-1">
{filterKey === "status" && (
<AppliedStatusFilters
editable={isEditingAllowed}
handleRemove={(val) => handleRemoveFilter("status", val)}
values={value}
/>
)}
{DATE_FILTERS.includes(filterKey) && (
<AppliedDateFilters
editable={isEditingAllowed}
handleRemove={(val) => handleRemoveFilter(filterKey, val)}
values={value}
/>
)}
{isEditingAllowed && (
<button
type="button"
className="grid place-items-center text-custom-text-300 hover:text-custom-text-200"
onClick={() => handleRemoveFilter(filterKey, null)}
>
<X size={12} strokeWidth={2} />
</button>
)}
</div>
</div>
);
})}
{isEditingAllowed && (
<button
type="button"
onClick={handleClearAllFilters}
className="flex items-center gap-2 rounded-md border border-custom-border-200 px-2 py-1 text-xs text-custom-text-300 hover:text-custom-text-200"
>
Clear all
<X size={12} strokeWidth={2} />
</button>
)}
</div>
);
});

View File

@ -0,0 +1,43 @@
import { observer } from "mobx-react-lite";
import { X } from "lucide-react";
import { CYCLE_STATUS } from "constants/cycle";
import { cn } from "helpers/common.helper";
type Props = {
handleRemove: (val: string) => void;
values: string[];
editable: boolean | undefined;
};
export const AppliedStatusFilters: React.FC<Props> = observer((props) => {
const { handleRemove, values, editable } = props;
return (
<>
{values.map((status) => {
const statusDetails = CYCLE_STATUS.find((s) => s.value === status);
return (
<div
key={status}
className={cn(
"flex items-center gap-1 rounded p-1 text-xs",
statusDetails?.bgColor,
statusDetails?.textColor
)}
>
{statusDetails?.title}
{editable && (
<button
type="button"
className="grid place-items-center text-custom-text-300 hover:text-custom-text-200"
onClick={() => handleRemove(status)}
>
<X size={10} strokeWidth={2} />
</button>
)}
</div>
);
})}
</>
);
});

View File

@ -1,22 +1,12 @@
import { FC, MouseEvent, useState } from "react";
import { FC, MouseEvent } from "react";
import { observer } from "mobx-react";
import Link from "next/link";
import { useRouter } from "next/router";
// hooks
// components
import { Info, LinkIcon, Pencil, Star, Trash2 } from "lucide-react";
import {
Avatar,
AvatarGroup,
CustomMenu,
Tooltip,
LayersIcon,
CycleGroupIcon,
TOAST_TYPE,
setToast,
setPromiseToast,
} from "@plane/ui";
import { CycleCreateUpdateModal, CycleDeleteModal } from "components/cycles";
import { Info, Star } from "lucide-react";
import { Avatar, AvatarGroup, Tooltip, LayersIcon, CycleGroupIcon, setPromiseToast } from "@plane/ui";
import { CycleQuickActions } from "components/cycles";
// ui
// icons
// helpers
@ -24,7 +14,6 @@ import { CYCLE_STATUS } from "constants/cycle";
import { CYCLE_FAVORITED, CYCLE_UNFAVORITED } from "constants/event-tracker";
import { EUserWorkspaceRoles } from "constants/workspace";
import { findHowManyDaysLeft, renderFormattedDate } from "helpers/date-time.helper";
import { copyTextToClipboard } from "helpers/string.helper";
// constants
import { useEventTracker, useCycle, useUser, useMember } from "hooks/store";
//.types
@ -38,13 +27,10 @@ export interface ICyclesBoardCard {
export const CyclesBoardCard: FC<ICyclesBoardCard> = observer((props) => {
const { cycleId, workspaceSlug, projectId } = props;
// states
const [updateModal, setUpdateModal] = useState(false);
const [deleteModal, setDeleteModal] = useState(false);
// router
const router = useRouter();
// store
const { setTrackElement, captureEvent } = useEventTracker();
const { captureEvent } = useEventTracker();
const {
membership: { currentProjectRole },
} = useUser();
@ -56,7 +42,6 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = observer((props) => {
if (!cycleDetails) return null;
const cycleStatus = cycleDetails.status.toLocaleLowerCase();
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;
@ -82,20 +67,6 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = observer((props) => {
: `${cycleDetails.completed_issues}/${cycleTotalIssues} Issues`
: "0 Issue";
const handleCopyText = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
copyTextToClipboard(`${originURL}/${workspaceSlug}/projects/${projectId}/cycles/${cycleId}`).then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Link Copied!",
message: "Cycle link copied to clipboard.",
});
});
};
const handleAddToFavorites = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
if (!workspaceSlug || !projectId) return;
@ -152,20 +123,6 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = observer((props) => {
});
};
const handleEditCycle = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setTrackElement("Cycles page grid layout");
setUpdateModal(true);
};
const handleDeleteCycle = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setTrackElement("Cycles page grid layout");
setDeleteModal(true);
};
const openCycleOverview = (e: MouseEvent<HTMLButtonElement>) => {
const { query } = router;
e.preventDefault();
@ -181,22 +138,6 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = observer((props) => {
return (
<div>
<CycleCreateUpdateModal
data={cycleDetails}
isOpen={updateModal}
handleClose={() => setUpdateModal(false)}
workspaceSlug={workspaceSlug}
projectId={projectId}
/>
<CycleDeleteModal
cycle={cycleDetails}
isOpen={deleteModal}
handleClose={() => setDeleteModal(false)}
workspaceSlug={workspaceSlug}
projectId={projectId}
/>
<Link href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycleDetails.id}`}>
<div className="flex h-44 w-full 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">
@ -288,30 +229,8 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = observer((props) => {
<Star className="h-3.5 w-3.5 text-custom-text-200" />
</button>
))}
<CustomMenu ellipsis className="z-10">
{!isCompleted && isEditingAllowed && (
<>
<CustomMenu.MenuItem onClick={handleEditCycle}>
<span className="flex items-center justify-start gap-2">
<Pencil className="h-3 w-3" />
<span>Edit cycle</span>
</span>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={handleDeleteCycle}>
<span className="flex items-center justify-start gap-2">
<Trash2 className="h-3 w-3" />
<span>Delete cycle</span>
</span>
</CustomMenu.MenuItem>
</>
)}
<CustomMenu.MenuItem onClick={handleCopyText}>
<span className="flex items-center justify-start gap-2">
<LinkIcon className="h-3 w-3" />
<span>Copy cycle link</span>
</span>
</CustomMenu.MenuItem>
</CustomMenu>
<CycleQuickActions cycleId={cycleId} projectId={projectId} workspaceSlug={workspaceSlug} />
</div>
</div>
</div>

View File

@ -0,0 +1,25 @@
// components
import { CyclesBoardCard } from "components/cycles";
type Props = {
cycleIds: string[];
peekCycle: string | undefined;
projectId: string;
workspaceSlug: string;
};
export const CyclesBoardMap: React.FC<Props> = (props) => {
const { cycleIds, peekCycle, projectId, workspaceSlug } = props;
return (
<div
className={`w-full grid grid-cols-1 gap-6 ${
peekCycle ? "lg:grid-cols-1 xl:grid-cols-2 3xl:grid-cols-3" : "lg:grid-cols-2 xl:grid-cols-3 3xl:grid-cols-4"
} auto-rows-max transition-all`}
>
{cycleIds.map((cycleId) => (
<CyclesBoardCard key={cycleId} workspaceSlug={workspaceSlug} projectId={projectId} cycleId={cycleId} />
))}
</div>
);
};

View File

@ -0,0 +1,3 @@
export * from "./cycles-board-card";
export * from "./cycles-board-map";
export * from "./root";

View File

@ -0,0 +1,60 @@
import { FC } from "react";
import { observer } from "mobx-react-lite";
import { Disclosure } from "@headlessui/react";
import { ChevronRight } from "lucide-react";
// components
import { CyclePeekOverview, CyclesBoardMap } from "components/cycles";
// helpers
import { cn } from "helpers/common.helper";
export interface ICyclesBoard {
completedCycleIds: string[];
cycleIds: string[];
workspaceSlug: string;
projectId: string;
peekCycle: string | undefined;
}
export const CyclesBoard: FC<ICyclesBoard> = observer((props) => {
const { completedCycleIds, cycleIds, workspaceSlug, projectId, peekCycle } = props;
return (
<div className="h-full w-full">
<div className="flex h-full w-full justify-between">
<div className="h-full w-full flex flex-col p-8 space-y-8 vertical-scrollbar scrollbar-lg">
<CyclesBoardMap
cycleIds={cycleIds}
peekCycle={peekCycle}
projectId={projectId}
workspaceSlug={workspaceSlug}
/>
{completedCycleIds.length !== 0 && (
<Disclosure as="div" className="space-y-4">
<Disclosure.Button className="bg-custom-background-80 font-semibold text-sm py-1 px-2 rounded flex items-center gap-1">
{({ open }) => (
<>
Completed cycles ({completedCycleIds.length})
<ChevronRight
className={cn("h-3 w-3 transition-all", {
"rotate-90": open,
})}
/>
</>
)}
</Disclosure.Button>
<Disclosure.Panel>
<CyclesBoardMap
cycleIds={completedCycleIds}
peekCycle={peekCycle}
projectId={projectId}
workspaceSlug={workspaceSlug}
/>
</Disclosure.Panel>
</Disclosure>
)}
</div>
<CyclePeekOverview projectId={projectId} workspaceSlug={workspaceSlug} />
</div>
</div>
);
});

View File

@ -1,47 +0,0 @@
import { FC } from "react";
import { observer } from "mobx-react-lite";
// components
import { CyclePeekOverview, CyclesBoardCard } from "components/cycles";
import { EmptyState } from "components/empty-state";
// constants
import { EMPTY_STATE_DETAILS } from "constants/empty-state";
export interface ICyclesBoard {
cycleIds: string[];
filter: string;
workspaceSlug: string;
projectId: string;
peekCycle: string | undefined;
}
export const CyclesBoard: FC<ICyclesBoard> = observer((props) => {
const { cycleIds, filter, workspaceSlug, projectId, peekCycle } = props;
return (
<>
{cycleIds?.length > 0 ? (
<div className="h-full w-full">
<div className="flex h-full w-full justify-between">
<div
className={`grid h-full w-full grid-cols-1 gap-6 overflow-y-auto p-8 ${
peekCycle
? "lg:grid-cols-1 xl:grid-cols-2 3xl:grid-cols-3"
: "lg:grid-cols-2 xl:grid-cols-3 3xl:grid-cols-4"
} auto-rows-max transition-all vertical-scrollbar scrollbar-lg`}
>
{cycleIds.map((cycleId) => (
<CyclesBoardCard key={cycleId} workspaceSlug={workspaceSlug} projectId={projectId} cycleId={cycleId} />
))}
</div>
<CyclePeekOverview
projectId={projectId?.toString() ?? ""}
workspaceSlug={workspaceSlug?.toString() ?? ""}
/>
</div>
</div>
) : (
<EmptyState type={`project-cycle-${filter}` as keyof typeof EMPTY_STATE_DETAILS} size="sm" />
)}
</>
);
});

View File

@ -1,57 +0,0 @@
import { FC } from "react";
import { observer } from "mobx-react-lite";
// components
import { CyclePeekOverview, CyclesListItem } from "components/cycles";
import { EmptyState } from "components/empty-state";
// ui
import { Loader } from "@plane/ui";
// constants
import { EMPTY_STATE_DETAILS } from "constants/empty-state";
export interface ICyclesList {
cycleIds: string[];
filter: string;
workspaceSlug: string;
projectId: string;
}
export const CyclesList: FC<ICyclesList> = observer((props) => {
const { cycleIds, filter, workspaceSlug, projectId } = props;
return (
<>
{cycleIds ? (
<>
{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 vertical-scrollbar scrollbar-lg">
{cycleIds.map((cycleId) => (
<CyclesListItem
key={cycleId}
cycleId={cycleId}
workspaceSlug={workspaceSlug}
projectId={projectId}
/>
))}
</div>
<CyclePeekOverview
projectId={projectId?.toString() ?? ""}
workspaceSlug={workspaceSlug?.toString() ?? ""}
/>
</div>
</div>
) : (
<EmptyState type={`project-cycle-${filter}` as keyof typeof EMPTY_STATE_DETAILS} size="sm" />
)}
</>
) : (
<Loader className="space-y-4">
<Loader.Item height="50px" />
<Loader.Item height="50px" />
<Loader.Item height="50px" />
</Loader>
)}
</>
);
});

View File

@ -0,0 +1,164 @@
import { useCallback, useRef, useState } from "react";
import { observer } from "mobx-react";
import { Tab } from "@headlessui/react";
import { ListFilter, Search, X } from "lucide-react";
// hooks
import { useCycleFilter } from "hooks/store";
import useOutsideClickDetector from "hooks/use-outside-click-detector";
// components
import { CycleFiltersSelection } from "components/cycles";
import { FiltersDropdown } from "components/issues";
// ui
import { Tooltip } from "@plane/ui";
// helpers
import { cn } from "helpers/common.helper";
// types
import { TCycleFilters } from "@plane/types";
// constants
import { CYCLE_TABS_LIST, CYCLE_VIEW_LAYOUTS } from "constants/cycle";
type Props = {
projectId: string;
};
export const CyclesViewHeader: React.FC<Props> = observer((props) => {
const { projectId } = props;
// states
const [isSearchOpen, setIsSearchOpen] = useState(false);
// refs
const inputRef = useRef<HTMLInputElement>(null);
// hooks
const {
currentProjectDisplayFilters,
currentProjectFilters,
searchQuery,
updateDisplayFilters,
updateFilters,
updateSearchQuery,
} = useCycleFilter();
// outside click detector hook
useOutsideClickDetector(inputRef, () => {
if (isSearchOpen && searchQuery.trim() === "") setIsSearchOpen(false);
});
const handleFilters = useCallback(
(key: keyof TCycleFilters, value: string | string[]) => {
const newValues = currentProjectFilters?.[key] ?? [];
if (Array.isArray(value))
value.forEach((val) => {
if (!newValues.includes(val)) newValues.push(val);
});
else {
if (currentProjectFilters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
else newValues.push(value);
}
updateFilters(projectId, { [key]: newValues });
},
[currentProjectFilters, projectId, updateFilters]
);
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Escape") {
if (searchQuery && searchQuery.trim() !== "") updateSearchQuery("");
else setIsSearchOpen(false);
}
};
return (
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 border-b border-custom-border-200 px-4 sm:px-5 sm:pb-0">
<Tab.List as="div" className="flex items-center overflow-x-scroll">
{CYCLE_TABS_LIST.map((tab) => (
<Tab
key={tab.key}
className={({ selected }) =>
`border-b-2 p-4 text-sm font-medium outline-none ${
selected ? "border-custom-primary-100 text-custom-primary-100" : "border-transparent"
}`
}
>
{tab.name}
</Tab>
))}
</Tab.List>
{currentProjectDisplayFilters?.active_tab !== "active" && (
<div className="hidden h-full sm:flex items-center gap-3 self-end">
{!isSearchOpen && (
<button
type="button"
className="-mr-5 p-2 hover:bg-custom-background-80 rounded text-custom-text-400 grid place-items-center"
onClick={() => {
setIsSearchOpen(true);
inputRef.current?.focus();
}}
>
<Search className="h-3.5 w-3.5" />
</button>
)}
<div
className={cn(
"ml-auto flex items-center justify-start gap-1 rounded-md border border-transparent bg-custom-background-100 text-custom-text-400 w-0 transition-[width] ease-linear overflow-hidden opacity-0",
{
"w-64 px-2.5 py-1.5 border-custom-border-200 opacity-100": isSearchOpen,
}
)}
>
<Search className="h-3.5 w-3.5" />
<input
ref={inputRef}
className="w-full max-w-[234px] border-none bg-transparent text-sm text-custom-text-100 focus:outline-none"
placeholder="Search"
value={searchQuery}
onChange={(e) => updateSearchQuery(e.target.value)}
onKeyDown={handleInputKeyDown}
/>
{isSearchOpen && (
<button
type="button"
className="grid place-items-center"
onClick={() => {
updateSearchQuery("");
setIsSearchOpen(false);
}}
>
<X className="h-3 w-3" />
</button>
)}
</div>
<FiltersDropdown icon={<ListFilter className="h-3 w-3" />} title="Filters" placement="bottom-end">
<CycleFiltersSelection filters={currentProjectFilters ?? {}} handleFiltersUpdate={handleFilters} />
</FiltersDropdown>
<div className="flex items-center gap-1 rounded bg-custom-background-80 p-1">
{CYCLE_VIEW_LAYOUTS.map((layout) => (
<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 ${
currentProjectDisplayFilters?.layout == layout.key
? "bg-custom-background-100 shadow-custom-shadow-2xs"
: ""
}`}
onClick={() =>
updateDisplayFilters(projectId, {
layout: layout.key,
})
}
>
<layout.icon
strokeWidth={2}
className={`h-3.5 w-3.5 ${
currentProjectDisplayFilters?.layout == layout.key
? "text-custom-text-100"
: "text-custom-text-200"
}`}
/>
</button>
</Tooltip>
))}
</div>
</div>
)}
</div>
);
});

View File

@ -1,43 +1,35 @@
import { FC } from "react";
import Image from "next/image";
import { observer } from "mobx-react-lite";
// hooks
import { useCycle, useCycleFilter } from "hooks/store";
// components
import { CyclesBoard, CyclesList, CyclesListGanttChartView } from "components/cycles";
// ui components
// ui
import { CycleModuleBoardLayout, CycleModuleListLayout, GanttLayoutLoader } from "components/ui";
import { useCycle } from "hooks/store";
// assets
import NameFilterImage from "public/empty-state/cycle/name-filter.svg";
import AllFiltersImage from "public/empty-state/cycle/all-filters.svg";
// types
import { TCycleLayout, TCycleView } from "@plane/types";
import { TCycleLayoutOptions } from "@plane/types";
export interface ICyclesView {
filter: TCycleView;
layout: TCycleLayout;
layout: TCycleLayoutOptions;
workspaceSlug: string;
projectId: string;
peekCycle: string | undefined;
}
export const CyclesView: FC<ICyclesView> = observer((props) => {
const { filter, layout, workspaceSlug, projectId, peekCycle } = props;
const { layout, workspaceSlug, projectId, peekCycle } = props;
// store hooks
const {
currentProjectCompletedCycleIds,
currentProjectDraftCycleIds,
currentProjectUpcomingCycleIds,
currentProjectCycleIds,
loader,
} = useCycle();
const { getFilteredCycleIds, getFilteredCompletedCycleIds, loader } = useCycle();
const { searchQuery } = useCycleFilter();
// derived values
const filteredCycleIds = getFilteredCycleIds(projectId);
const filteredCompletedCycleIds = getFilteredCompletedCycleIds(projectId);
const cyclesList =
filter === "completed"
? currentProjectCompletedCycleIds
: filter === "draft"
? currentProjectDraftCycleIds
: filter === "upcoming"
? currentProjectUpcomingCycleIds
: currentProjectCycleIds;
if (loader || !cyclesList)
if (loader || !filteredCycleIds)
return (
<>
{layout === "list" && <CycleModuleListLayout />}
@ -46,23 +38,45 @@ export const CyclesView: FC<ICyclesView> = observer((props) => {
</>
);
if (filteredCycleIds.length === 0 && filteredCompletedCycleIds?.length === 0)
return (
<div className="h-full w-full grid place-items-center">
<div className="text-center">
<Image
src={searchQuery.trim() === "" ? AllFiltersImage : NameFilterImage}
className="h-36 sm:h-48 w-36 sm:w-48 mx-auto"
alt="No matching cycles"
/>
<h5 className="text-xl font-medium mt-7 mb-1">No matching cycles</h5>
<p className="text-custom-text-400 text-base">
{searchQuery.trim() === ""
? "Remove the filters to see all cycles"
: "Remove the search criteria to see all cycles"}
</p>
</div>
</div>
);
return (
<>
{layout === "list" && (
<CyclesList cycleIds={cyclesList} filter={filter} workspaceSlug={workspaceSlug} projectId={projectId} />
<CyclesList
completedCycleIds={filteredCompletedCycleIds ?? []}
cycleIds={filteredCycleIds}
workspaceSlug={workspaceSlug}
projectId={projectId}
/>
)}
{layout === "board" && (
<CyclesBoard
cycleIds={cyclesList}
filter={filter}
completedCycleIds={filteredCompletedCycleIds ?? []}
cycleIds={filteredCycleIds}
workspaceSlug={workspaceSlug}
projectId={projectId}
peekCycle={peekCycle}
/>
)}
{layout === "gantt" && <CyclesListGanttChartView cycleIds={cyclesList} workspaceSlug={workspaceSlug} />}
{layout === "gantt" && <CyclesListGanttChartView cycleIds={filteredCycleIds} workspaceSlug={workspaceSlug} />}
</>
);
});

View File

@ -103,7 +103,7 @@ export const CycleDeleteModal: React.FC<ICycleDelete> = observer((props) => {
<div className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-red-500/20">
<AlertTriangle width={16} strokeWidth={2} className="text-red-600" />
</div>
<div className="text-xl font-medium 2xl:text-2xl">Delete Cycle</div>
<div className="text-xl font-medium 2xl:text-2xl">Delete cycle</div>
</div>
<span>
<p className="text-sm text-custom-text-200">
@ -118,8 +118,8 @@ export const CycleDeleteModal: React.FC<ICycleDelete> = observer((props) => {
Cancel
</Button>
<Button variant="danger" size="sm" tabIndex={1} onClick={formSubmit}>
{loader ? "Deleting..." : "Delete Cycle"}
<Button variant="danger" size="sm" tabIndex={1} onClick={formSubmit} loading={loader}>
{loader ? "Deleting" : "Delete"}
</Button>
</div>
</div>

View File

@ -0,0 +1,63 @@
import React, { useState } from "react";
import { observer } from "mobx-react-lite";
// components
import { DateFilterModal } from "components/core";
import { FilterHeader, FilterOption } from "components/issues";
// constants
import { DATE_FILTER_OPTIONS } from "constants/filters";
type Props = {
appliedFilters: string[] | null;
handleUpdate: (val: string | string[]) => void;
searchQuery: string;
};
export const FilterEndDate: React.FC<Props> = observer((props) => {
const { appliedFilters, handleUpdate, searchQuery } = props;
const [previewEnabled, setPreviewEnabled] = useState(true);
const [isDateFilterModalOpen, setIsDateFilterModalOpen] = useState(false);
const appliedFiltersCount = appliedFilters?.length ?? 0;
const filteredOptions = DATE_FILTER_OPTIONS.filter((d) => d.name.toLowerCase().includes(searchQuery.toLowerCase()));
return (
<>
{isDateFilterModalOpen && (
<DateFilterModal
handleClose={() => setIsDateFilterModalOpen(false)}
isOpen={isDateFilterModalOpen}
onSelect={(val) => handleUpdate(val)}
title="Due date"
/>
)}
<FilterHeader
title={`Due date${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`}
isPreviewEnabled={previewEnabled}
handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)}
/>
{previewEnabled && (
<div>
{filteredOptions.length > 0 ? (
<>
{filteredOptions.map((option) => (
<FilterOption
key={option.value}
isChecked={appliedFilters?.includes(option.value) ? true : false}
onClick={() => handleUpdate(option.value)}
title={option.name}
multiple
/>
))}
<FilterOption isChecked={false} onClick={() => setIsDateFilterModalOpen(true)} title="Custom" multiple />
</>
) : (
<p className="text-xs italic text-custom-text-400">No matches found</p>
)}
</div>
)}
</>
);
});

View File

@ -0,0 +1,4 @@
export * from "./end-date";
export * from "./root";
export * from "./start-date";
export * from "./status";

View File

@ -0,0 +1,69 @@
import { useState } from "react";
import { observer } from "mobx-react-lite";
import { Search, X } from "lucide-react";
// components
import { FilterEndDate, FilterStartDate, FilterStatus } from "components/cycles";
// types
import { TCycleFilters, TCycleGroups } from "@plane/types";
type Props = {
filters: TCycleFilters;
handleFiltersUpdate: (key: keyof TCycleFilters, value: string | string[]) => void;
};
export const CycleFiltersSelection: React.FC<Props> = observer((props) => {
const { filters, handleFiltersUpdate } = props;
// states
const [filtersSearchQuery, setFiltersSearchQuery] = useState("");
return (
<div className="flex h-full w-full flex-col overflow-hidden">
<div className="bg-custom-background-100 p-2.5 pb-0">
<div className="flex items-center gap-1.5 rounded border-[0.5px] border-custom-border-200 bg-custom-background-90 px-1.5 py-1 text-xs">
<Search className="text-custom-text-400" size={12} strokeWidth={2} />
<input
type="text"
className="w-full bg-custom-background-90 outline-none placeholder:text-custom-text-400"
placeholder="Search"
value={filtersSearchQuery}
onChange={(e) => setFiltersSearchQuery(e.target.value)}
autoFocus
/>
{filtersSearchQuery !== "" && (
<button type="button" className="grid place-items-center" onClick={() => setFiltersSearchQuery("")}>
<X className="text-custom-text-300" size={12} strokeWidth={2} />
</button>
)}
</div>
</div>
<div className="h-full w-full divide-y divide-custom-border-200 overflow-y-auto px-2.5 vertical-scrollbar scrollbar-sm">
{/* cycle status */}
<div className="py-2">
<FilterStatus
appliedFilters={(filters.status as TCycleGroups[]) ?? null}
handleUpdate={(val) => handleFiltersUpdate("status", val)}
searchQuery={filtersSearchQuery}
/>
</div>
{/* start date */}
<div className="py-2">
<FilterStartDate
appliedFilters={filters.start_date ?? null}
handleUpdate={(val) => handleFiltersUpdate("start_date", val)}
searchQuery={filtersSearchQuery}
/>
</div>
{/* end date */}
<div className="py-2">
<FilterEndDate
appliedFilters={filters.end_date ?? null}
handleUpdate={(val) => handleFiltersUpdate("end_date", val)}
searchQuery={filtersSearchQuery}
/>
</div>
</div>
</div>
);
});

View File

@ -0,0 +1,63 @@
import React, { useState } from "react";
import { observer } from "mobx-react-lite";
// components
import { DateFilterModal } from "components/core";
import { FilterHeader, FilterOption } from "components/issues";
// constants
import { DATE_FILTER_OPTIONS } from "constants/filters";
type Props = {
appliedFilters: string[] | null;
handleUpdate: (val: string | string[]) => void;
searchQuery: string;
};
export const FilterStartDate: React.FC<Props> = observer((props) => {
const { appliedFilters, handleUpdate, searchQuery } = props;
const [previewEnabled, setPreviewEnabled] = useState(true);
const [isDateFilterModalOpen, setIsDateFilterModalOpen] = useState(false);
const appliedFiltersCount = appliedFilters?.length ?? 0;
const filteredOptions = DATE_FILTER_OPTIONS.filter((d) => d.name.toLowerCase().includes(searchQuery.toLowerCase()));
return (
<>
{isDateFilterModalOpen && (
<DateFilterModal
handleClose={() => setIsDateFilterModalOpen(false)}
isOpen={isDateFilterModalOpen}
onSelect={(val) => handleUpdate(val)}
title="Start date"
/>
)}
<FilterHeader
title={`Start date${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`}
isPreviewEnabled={previewEnabled}
handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)}
/>
{previewEnabled && (
<div>
{filteredOptions.length > 0 ? (
<>
{filteredOptions.map((option) => (
<FilterOption
key={option.value}
isChecked={appliedFilters?.includes(option.value) ? true : false}
onClick={() => handleUpdate(option.value)}
title={option.name}
multiple
/>
))}
<FilterOption isChecked={false} onClick={() => setIsDateFilterModalOpen(true)} title="Custom" multiple />
</>
) : (
<p className="text-xs italic text-custom-text-400">No matches found</p>
)}
</div>
)}
</>
);
});

View File

@ -0,0 +1,49 @@
import React, { useState } from "react";
import { observer } from "mobx-react-lite";
// components
import { FilterHeader, FilterOption } from "components/issues";
// types
import { TCycleGroups } from "@plane/types";
// constants
import { CYCLE_STATUS } from "constants/cycle";
type Props = {
appliedFilters: TCycleGroups[] | null;
handleUpdate: (val: string) => void;
searchQuery: string;
};
export const FilterStatus: React.FC<Props> = observer((props) => {
const { appliedFilters, handleUpdate, searchQuery } = props;
// states
const [previewEnabled, setPreviewEnabled] = useState(true);
const appliedFiltersCount = appliedFilters?.length ?? 0;
const filteredOptions = CYCLE_STATUS.filter((p) => p.value.includes(searchQuery.toLowerCase()));
return (
<>
<FilterHeader
title={`Status of the cycle${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`}
isPreviewEnabled={previewEnabled}
handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)}
/>
{previewEnabled && (
<div>
{filteredOptions.length > 0 ? (
filteredOptions.map((status) => (
<FilterOption
key={status.value}
isChecked={appliedFilters?.includes(status.value) ? true : false}
onClick={() => handleUpdate(status.value)}
title={status.title}
/>
))
) : (
<p className="text-xs italic text-custom-text-400">No matches found</p>
)}
</div>
)}
</>
);
});

View File

@ -0,0 +1 @@
export * from "./filters";

View File

@ -4,8 +4,7 @@ import { useRouter } from "next/router";
// hooks
import { CycleGanttBlock } from "components/cycles";
import { GanttChartRoot, IBlockUpdateData, CycleGanttSidebar } from "components/gantt-chart";
import { EUserProjectRoles } from "constants/project";
import { useCycle, useUser } from "hooks/store";
import { useCycle } from "hooks/store";
// components
// types
import { ICycle } from "@plane/types";
@ -22,9 +21,6 @@ export const CyclesListGanttChartView: FC<Props> = observer((props) => {
const router = useRouter();
const { workspaceSlug } = router.query;
// store hooks
const {
membership: { currentProjectRole },
} = useUser();
const { getCycleById, updateCycleDetails } = useCycle();
const handleCycleUpdate = async (cycle: ICycle, data: IBlockUpdateData) => {
@ -52,9 +48,6 @@ export const CyclesListGanttChartView: FC<Props> = observer((props) => {
return structuredBlocks;
};
const isAllowed =
currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole);
return (
<div className="h-full w-full overflow-y-auto">
<GanttChartRoot
@ -67,7 +60,7 @@ export const CyclesListGanttChartView: FC<Props> = observer((props) => {
enableBlockLeftResize={false}
enableBlockRightResize={false}
enableBlockMove={false}
enableReorder={isAllowed}
enableReorder={false}
/>
</div>
);

View File

@ -1,17 +1,16 @@
export * from "./cycles-view";
export * from "./active-cycle-details";
export * from "./active-cycle-stats";
export * from "./active-cycle";
export * from "./applied-filters";
export * from "./board/";
export * from "./dropdowns";
export * from "./gantt-chart";
export * from "./list";
export * from "./cycle-peek-overview";
export * from "./cycles-view-header";
export * from "./cycles-view";
export * from "./delete-modal";
export * from "./form";
export * from "./modal";
export * from "./quick-actions";
export * from "./sidebar";
export * from "./transfer-issues-modal";
export * from "./transfer-issues";
export * from "./cycles-list";
export * from "./cycles-list-item";
export * from "./cycles-board";
export * from "./cycles-board-card";
export * from "./delete-modal";
export * from "./cycle-peek-overview";
export * from "./cycles-list-item";

View File

@ -1,26 +1,14 @@
import { FC, MouseEvent, useState } from "react";
import { FC, MouseEvent } from "react";
import { observer } from "mobx-react";
import Link from "next/link";
import { useRouter } from "next/router";
// hooks
import { Check, Info, LinkIcon, Pencil, Star, Trash2, User2 } from "lucide-react";
import {
CustomMenu,
Tooltip,
CircularProgressIndicator,
CycleGroupIcon,
AvatarGroup,
Avatar,
TOAST_TYPE,
setToast,
setPromiseToast,
} from "@plane/ui";
import { CycleCreateUpdateModal, CycleDeleteModal } from "components/cycles";
import { Check, Info, Star, User2 } from "lucide-react";
import { Tooltip, CircularProgressIndicator, CycleGroupIcon, AvatarGroup, Avatar, setPromiseToast } from "@plane/ui";
import { CycleQuickActions } from "components/cycles";
import { CYCLE_STATUS } from "constants/cycle";
import { CYCLE_FAVORITED, CYCLE_UNFAVORITED } from "constants/event-tracker";
import { EUserWorkspaceRoles } from "constants/workspace";
import { findHowManyDaysLeft, renderFormattedDate } from "helpers/date-time.helper";
import { copyTextToClipboard } from "helpers/string.helper";
import { useEventTracker, useCycle, useUser, useMember } from "hooks/store";
// components
// ui
@ -29,6 +17,7 @@ import { useEventTracker, useCycle, useUser, useMember } from "hooks/store";
// constants
// types
import { TCycleGroups } from "@plane/types";
import { EUserProjectRoles } from "constants/project";
type TCyclesListItem = {
cycleId: string;
@ -42,33 +31,16 @@ type TCyclesListItem = {
export const CyclesListItem: FC<TCyclesListItem> = observer((props) => {
const { cycleId, workspaceSlug, projectId } = props;
// states
const [updateModal, setUpdateModal] = useState(false);
const [deleteModal, setDeleteModal] = useState(false);
// router
const router = useRouter();
// store hooks
const { setTrackElement, captureEvent } = useEventTracker();
const { captureEvent } = useEventTracker();
const {
membership: { currentProjectRole },
} = useUser();
const { getCycleById, addCycleToFavorites, removeCycleFromFavorites } = useCycle();
const { getUserDetails } = useMember();
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/${cycleId}`).then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Link Copied!",
message: "Cycle link copied to clipboard.",
});
});
};
const handleAddToFavorites = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
if (!workspaceSlug || !projectId) return;
@ -125,20 +97,6 @@ export const CyclesListItem: FC<TCyclesListItem> = observer((props) => {
});
};
const handleEditCycle = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setTrackElement("Cycles page list layout");
setUpdateModal(true);
};
const handleDeleteCycle = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setTrackElement("Cycles page list layout");
setDeleteModal(true);
};
const openCycleOverview = (e: MouseEvent<HTMLButtonElement>) => {
const { query } = router;
e.preventDefault();
@ -161,7 +119,7 @@ export const CyclesListItem: FC<TCyclesListItem> = observer((props) => {
const endDate = new Date(cycleDetails.end_date ?? "");
const startDate = new Date(cycleDetails.start_date ?? "");
const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserWorkspaceRoles.MEMBER;
const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER;
const cycleTotalIssues =
cycleDetails.backlog_issues +
@ -184,20 +142,6 @@ export const CyclesListItem: FC<TCyclesListItem> = observer((props) => {
return (
<>
<CycleCreateUpdateModal
data={cycleDetails}
isOpen={updateModal}
handleClose={() => setUpdateModal(false)}
workspaceSlug={workspaceSlug}
projectId={projectId}
/>
<CycleDeleteModal
cycle={cycleDetails}
isOpen={deleteModal}
handleClose={() => setDeleteModal(false)}
workspaceSlug={workspaceSlug}
projectId={projectId}
/>
<Link href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycleDetails.id}`}>
<div className="group flex w-full flex-col 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 md:flex-row">
<div className="relative flex w-full items-center justify-between gap-3 overflow-hidden">
@ -246,7 +190,7 @@ export const CyclesListItem: FC<TCyclesListItem> = observer((props) => {
</div>
)}
</div>
<div className="relative flex w-full flex-shrink-0 items-center justify-between gap-2.5 overflow-hidden md:w-auto md:flex-shrink-0 md:justify-end ">
<div className="relative flex w-full flex-shrink-0 items-center justify-between gap-2.5 overflow-hidden md:w-auto md:flex-shrink-0 md:justify-end">
<div className="text-xs text-custom-text-300">
{renderDate && `${renderFormattedDate(startDate) ?? `_ _`} - ${renderFormattedDate(endDate) ?? `_ _`}`}
</div>
@ -256,8 +200,8 @@ export const CyclesListItem: FC<TCyclesListItem> = observer((props) => {
<div className="flex w-10 cursor-default items-center justify-center">
{cycleDetails.assignee_ids?.length > 0 ? (
<AvatarGroup showTooltip={false}>
{cycleDetails.assignee_ids?.map((assigne_id) => {
const member = getUserDetails(assigne_id);
{cycleDetails.assignee_ids?.map((assignee_id) => {
const member = getUserDetails(assignee_id);
return <Avatar key={member?.id} name={member?.display_name} src={member?.avatar} />;
})}
</AvatarGroup>
@ -281,30 +225,7 @@ export const CyclesListItem: FC<TCyclesListItem> = observer((props) => {
</button>
)}
<CustomMenu ellipsis>
{!isCompleted && isEditingAllowed && (
<>
<CustomMenu.MenuItem onClick={handleEditCycle}>
<span className="flex items-center justify-start gap-2">
<Pencil className="h-3 w-3" />
<span>Edit cycle</span>
</span>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={handleDeleteCycle}>
<span className="flex items-center justify-start gap-2">
<Trash2 className="h-3 w-3" />
<span>Delete cycle</span>
</span>
</CustomMenu.MenuItem>
</>
)}
<CustomMenu.MenuItem onClick={handleCopyText}>
<span className="flex items-center justify-start gap-2">
<LinkIcon className="h-3 w-3" />
<span>Copy cycle link</span>
</span>
</CustomMenu.MenuItem>
</CustomMenu>
<CycleQuickActions cycleId={cycleId} projectId={projectId} workspaceSlug={workspaceSlug} />
</>
)}
</div>

View File

@ -0,0 +1,20 @@
// components
import { CyclesListItem } from "components/cycles";
type Props = {
cycleIds: string[];
projectId: string;
workspaceSlug: string;
};
export const CyclesListMap: React.FC<Props> = (props) => {
const { cycleIds, projectId, workspaceSlug } = props;
return (
<>
{cycleIds.map((cycleId) => (
<CyclesListItem key={cycleId} cycleId={cycleId} workspaceSlug={workspaceSlug} projectId={projectId} />
))}
</>
);
};

View File

@ -0,0 +1,3 @@
export * from "./cycles-list-item";
export * from "./cycles-list-map";
export * from "./root";

View File

@ -0,0 +1,49 @@
import { FC } from "react";
import { observer } from "mobx-react-lite";
import { Disclosure } from "@headlessui/react";
import { ChevronRight } from "lucide-react";
// components
import { CyclePeekOverview, CyclesListMap } from "components/cycles";
// helpers
import { cn } from "helpers/common.helper";
export interface ICyclesList {
completedCycleIds: string[];
cycleIds: string[];
workspaceSlug: string;
projectId: string;
}
export const CyclesList: FC<ICyclesList> = observer((props) => {
const { completedCycleIds, cycleIds, workspaceSlug, projectId } = props;
return (
<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 vertical-scrollbar scrollbar-lg">
<CyclesListMap cycleIds={cycleIds} projectId={projectId} workspaceSlug={workspaceSlug} />
{completedCycleIds.length !== 0 && (
<Disclosure as="div" className="mt-4 space-y-4">
<Disclosure.Button className="bg-custom-background-80 font-semibold text-sm py-1 px-2 rounded ml-5 flex items-center gap-1">
{({ open }) => (
<>
Completed cycles ({completedCycleIds.length})
<ChevronRight
className={cn("h-3 w-3 transition-all", {
"rotate-90": open,
})}
/>
</>
)}
</Disclosure.Button>
<Disclosure.Panel>
<CyclesListMap cycleIds={completedCycleIds} projectId={projectId} workspaceSlug={workspaceSlug} />
</Disclosure.Panel>
</Disclosure>
)}
</div>
<CyclePeekOverview projectId={projectId} workspaceSlug={workspaceSlug} />
</div>
</div>
);
});

View File

@ -11,7 +11,7 @@ import { CycleService } from "services/cycle.service";
// components
// ui
// types
import type { CycleDateCheckData, ICycle, TCycleView } from "@plane/types";
import type { CycleDateCheckData, ICycle, TCycleTabOptions } from "@plane/types";
// constants
type CycleModalProps = {
@ -34,7 +34,7 @@ export const CycleCreateUpdateModal: React.FC<CycleModalProps> = (props) => {
const { workspaceProjectIds } = useProject();
const { createCycle, updateCycleDetails } = useCycle();
const { setValue: setCycleTab } = useLocalStorage<TCycleView>("cycle_tab", "active");
const { setValue: setCycleTab } = useLocalStorage<TCycleTabOptions>("cycle_tab", "active");
const handleCreateCycle = async (payload: Partial<ICycle>) => {
if (!workspaceSlug || !projectId) return;

View File

@ -0,0 +1,112 @@
import { useState } from "react";
import { observer } from "mobx-react";
import { LinkIcon, Pencil, Trash2 } from "lucide-react";
// hooks
import { useCycle, useEventTracker, useUser } from "hooks/store";
// components
import { CycleCreateUpdateModal, CycleDeleteModal } from "components/cycles";
// ui
import { CustomMenu, TOAST_TYPE, setToast } from "@plane/ui";
// helpers
import { copyUrlToClipboard } from "helpers/string.helper";
// constants
import { EUserProjectRoles } from "constants/project";
type Props = {
cycleId: string;
projectId: string;
workspaceSlug: string;
};
export const CycleQuickActions: React.FC<Props> = observer((props) => {
const { cycleId, projectId, workspaceSlug } = props;
// states
const [updateModal, setUpdateModal] = useState(false);
const [deleteModal, setDeleteModal] = useState(false);
// store hooks
const { setTrackElement } = useEventTracker();
const {
membership: { currentWorkspaceAllProjectsRole },
} = useUser();
const { getCycleById } = useCycle();
// derived values
const cycleDetails = getCycleById(cycleId);
const isCompleted = cycleDetails?.status.toLowerCase() === "completed";
// auth
const isEditingAllowed =
!!currentWorkspaceAllProjectsRole && currentWorkspaceAllProjectsRole[projectId] >= EUserProjectRoles.MEMBER;
const handleCopyText = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
copyUrlToClipboard(`${workspaceSlug}/projects/${projectId}/cycles/${cycleId}`).then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Link Copied!",
message: "Cycle link copied to clipboard.",
});
});
};
const handleEditCycle = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setTrackElement("Cycles page list layout");
setUpdateModal(true);
};
const handleDeleteCycle = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setTrackElement("Cycles page list layout");
setDeleteModal(true);
};
return (
<>
{cycleDetails && (
<div className="fixed">
<CycleCreateUpdateModal
data={cycleDetails}
isOpen={updateModal}
handleClose={() => setUpdateModal(false)}
workspaceSlug={workspaceSlug}
projectId={projectId}
/>
<CycleDeleteModal
cycle={cycleDetails}
isOpen={deleteModal}
handleClose={() => setDeleteModal(false)}
workspaceSlug={workspaceSlug}
projectId={projectId}
/>
</div>
)}
<CustomMenu ellipsis placement="bottom-end">
{!isCompleted && isEditingAllowed && (
<>
<CustomMenu.MenuItem onClick={handleEditCycle}>
<span className="flex items-center justify-start gap-2">
<Pencil className="h-3 w-3" />
<span>Edit cycle</span>
</span>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={handleDeleteCycle}>
<span className="flex items-center justify-start gap-2">
<Trash2 className="h-3 w-3" />
<span>Delete cycle</span>
</span>
</CustomMenu.MenuItem>
</>
)}
<CustomMenu.MenuItem onClick={handleCopyText}>
<span className="flex items-center justify-start gap-2">
<LinkIcon className="h-3 w-3" />
<span>Copy cycle link</span>
</span>
</CustomMenu.MenuItem>
</CustomMenu>
</>
);
});

View File

@ -25,7 +25,7 @@ export const GanttChartHeader: React.FC<Props> = observer((props) => {
const { currentView } = useGanttChart();
return (
<div className="relative flex w-full flex-shrink-0 flex-wrap items-center gap-2 whitespace-nowrap px-2.5 py-2 z-10">
<div className="relative flex w-full flex-shrink-0 flex-wrap items-center gap-2 whitespace-nowrap px-2.5 py-2">
<div className="flex items-center gap-2 text-lg font-medium">{title}</div>
<div className="ml-auto">
<div className="ml-auto text-sm font-medium">{blocks ? `${blocks.length} ${loaderTitle}` : "Loading..."}</div>

View File

@ -13,7 +13,7 @@ import { CYCLE_VIEW_LAYOUTS } from "constants/cycle";
import { EUserProjectRoles } from "constants/project";
import { useApplication, useEventTracker, useProject, useUser } from "hooks/store";
import useLocalStorage from "hooks/use-local-storage";
import { TCycleLayout } from "@plane/types";
import { TCycleLayoutOptions } from "@plane/types";
import { ProjectLogo } from "components/project";
export const CyclesHeader: FC = observer(() => {
@ -33,10 +33,10 @@ export const CyclesHeader: FC = observer(() => {
const canUserCreateCycle =
currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole);
const { setValue: setCycleLayout } = useLocalStorage<TCycleLayout>("cycle_layout", "list");
const { setValue: setCycleLayout } = useLocalStorage<TCycleLayoutOptions>("cycle_layout", "list");
const handleCurrentLayout = useCallback(
(_layout: TCycleLayout) => {
(_layout: TCycleLayoutOptions) => {
setCycleLayout(_layout);
},
[setCycleLayout]
@ -109,7 +109,7 @@ export const CyclesHeader: FC = observer(() => {
key={layout.key}
onClick={() => {
// handleLayoutChange(ISSUE_LAYOUTS[index].key);
handleCurrentLayout(layout.key as TCycleLayout);
handleCurrentLayout(layout.key as TCycleLayoutOptions);
}}
className="flex items-center gap-2"
>

View File

@ -55,6 +55,7 @@ export const AppliedFiltersList: React.FC<Props> = observer((props) => {
const filterKey = key as keyof IIssueFilterOptions;
if (!value) return;
if (Array.isArray(value) && value.length === 0) return;
return (
<div

View File

@ -9,6 +9,7 @@ import { Button } from "@plane/ui";
type Props = {
children: React.ReactNode;
icon?: React.ReactNode;
title?: string;
placement?: Placement;
disabled?: boolean;
@ -17,7 +18,7 @@ type Props = {
};
export const FiltersDropdown: React.FC<Props> = (props) => {
const { children, title = "Dropdown", placement, disabled = false, tabIndex, menuButton } = props;
const { children, icon, title = "Dropdown", placement, disabled = false, tabIndex, menuButton } = props;
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
@ -44,6 +45,7 @@ export const FiltersDropdown: React.FC<Props> = (props) => {
ref={setReferenceElement}
variant="neutral-primary"
size="sm"
prependIcon={icon}
appendIcon={
<ChevronUp className={`transition-all ${open ? "" : "rotate-180"}`} size={14} strokeWidth={2} />
}
@ -64,9 +66,9 @@ export const FiltersDropdown: React.FC<Props> = (props) => {
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel>
<Popover.Panel className="fixed z-10">
<div
className="z-10 overflow-hidden rounded border border-custom-border-200 bg-custom-background-100 shadow-custom-shadow-rg"
className="overflow-hidden rounded border border-custom-border-200 bg-custom-background-100 shadow-custom-shadow-rg my-1"
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}

View File

@ -11,36 +11,24 @@ import {
} from "lucide-react";
// types
import { TCycleLayout, TCycleView } from "@plane/types";
import { TCycleLayoutOptions, TCycleTabOptions } from "@plane/types";
export const CYCLE_TAB_LIST: {
key: TCycleView;
export const CYCLE_TABS_LIST: {
key: TCycleTabOptions;
name: string;
}[] = [
{
key: "all",
name: "All",
},
{
key: "active",
name: "Active",
},
{
key: "upcoming",
name: "Upcoming",
},
{
key: "completed",
name: "Completed",
},
{
key: "draft",
name: "Drafts",
key: "all",
name: "All",
},
];
export const CYCLE_VIEW_LAYOUTS: {
key: TCycleLayout;
key: TCycleLayoutOptions;
icon: any;
title: string;
}[] = [
@ -64,6 +52,7 @@ export const CYCLE_VIEW_LAYOUTS: {
export const CYCLE_STATUS: {
label: string;
value: "current" | "upcoming" | "completed" | "draft";
title: string;
color: string;
textColor: string;
bgColor: string;
@ -71,6 +60,7 @@ export const CYCLE_STATUS: {
{
label: "day left",
value: "current",
title: "Active",
color: "#F59E0B",
textColor: "text-amber-500",
bgColor: "bg-amber-50",
@ -78,6 +68,7 @@ export const CYCLE_STATUS: {
{
label: "Yet to start",
value: "upcoming",
title: "Yet to start",
color: "#3F76FF",
textColor: "text-blue-500",
bgColor: "bg-indigo-50",
@ -85,6 +76,7 @@ export const CYCLE_STATUS: {
{
label: "Completed",
value: "completed",
title: "Completed",
color: "#16A34A",
textColor: "text-green-600",
bgColor: "bg-green-50",
@ -92,6 +84,7 @@ export const CYCLE_STATUS: {
{
label: "Draft",
value: "draft",
title: "Draft",
color: "#525252",
textColor: "text-custom-text-300",
bgColor: "bg-custom-background-90",

View File

@ -7,7 +7,7 @@ export interface EmptyStateDetails {
description?: string;
path?: string;
primaryButton?: {
icon?: any;
icon?: React.ReactNode;
text: string;
comicBox?: {
title?: string;
@ -15,7 +15,7 @@ export interface EmptyStateDetails {
};
};
secondaryButton?: {
icon?: any;
icon?: React.ReactNode;
text: string;
comicBox?: {
title?: string;
@ -51,9 +51,7 @@ export enum EmptyStateType {
PROJECT_CYCLES = "project-cycles",
PROJECT_CYCLE_NO_ISSUES = "project-cycle-no-issues",
PROJECT_CYCLE_ACTIVE = "project-cycle-active",
PROJECT_CYCLE_UPCOMING = "project-cycle-upcoming",
PROJECT_CYCLE_COMPLETED = "project-cycle-completed",
PROJECT_CYCLE_DRAFT = "project-cycle-draft",
PROJECT_CYCLE_ALL = "project-cycle-all",
PROJECT_EMPTY_FILTER = "project-empty-filter",
PROJECT_ARCHIVED_EMPTY_FILTER = "project-archived-empty-filter",
PROJECT_DRAFT_EMPTY_FILTER = "project-draft-empty-filter",
@ -288,28 +286,17 @@ const emptyStateDetails = {
},
"project-cycle-active": {
key: "project-cycle-active",
title: "No active cycles",
title: "No active cycle",
description:
"An active cycle includes any period that encompasses today's date within its range. Find the progress and details of the active cycle here.",
path: "/empty-state/cycle/active",
},
"project-cycle-upcoming": {
key: "project-cycle-upcoming",
title: "No upcoming cycles",
description: "Upcoming cycles on deck! Just add dates to cycles in draft, and they'll show up right here.",
path: "/empty-state/cycle/upcoming",
},
"project-cycle-completed": {
key: "project-cycle-completed",
title: "No completed cycles",
description: "Any cycle with a past due date is considered completed. Explore all completed cycles here.",
path: "/empty-state/cycle/completed",
},
"project-cycle-draft": {
key: "project-cycle-draft",
title: "No draft cycles",
description: "No dates added in cycles? Find them here as drafts.",
path: "/empty-state/cycle/draft",
"project-cycle-all": {
key: "project-cycle-all",
title: "No cycles",
description:
"An active cycle includes any period that encompasses today's date within its range. Find the progress and details of the active cycle here.",
path: "/empty-state/cycle/active",
},
// empty filters
"project-empty-filter": {

View File

@ -0,0 +1,59 @@
import sortBy from "lodash/sortBy";
// helpers
import { satisfiesDateFilter } from "helpers/filter.helper";
// types
import { ICycle, TCycleFilters } from "@plane/types";
/**
* @description orders cycles based on their status
* @param {ICycle[]} cycles
* @returns {ICycle[]}
*/
export const orderCycles = (cycles: ICycle[]): ICycle[] => {
if (cycles.length === 0) return [];
const STATUS_ORDER: {
[key: string]: number;
} = {
current: 1,
upcoming: 2,
draft: 3,
};
let filteredCycles = cycles.filter((c) => c.status.toLowerCase() !== "completed");
filteredCycles = sortBy(filteredCycles, [
(c) => STATUS_ORDER[c.status.toLowerCase()],
(c) => (c.status.toLowerCase() === "upcoming" ? c.start_date : c.name.toLowerCase()),
]);
return filteredCycles;
};
/**
* @description filters cycles based on the filter
* @param {ICycle} cycle
* @param {TCycleFilters} filter
* @returns {boolean}
*/
export const shouldFilterCycle = (cycle: ICycle, filter: TCycleFilters): boolean => {
let fallsInFilters = true;
Object.keys(filter).forEach((key) => {
const filterKey = key as keyof TCycleFilters;
if (filterKey === "status" && filter.status && filter.status.length > 0)
fallsInFilters = fallsInFilters && filter.status.includes(cycle.status.toLowerCase());
if (filterKey === "start_date" && filter.start_date && filter.start_date.length > 0) {
filter.start_date.forEach((dateFilter) => {
fallsInFilters =
fallsInFilters && !!cycle.start_date && satisfiesDateFilter(new Date(cycle.start_date), dateFilter);
});
}
if (filterKey === "end_date" && filter.end_date && filter.end_date.length > 0) {
filter.end_date.forEach((dateFilter) => {
fallsInFilters =
fallsInFilters && !!cycle.end_date && satisfiesDateFilter(new Date(cycle.end_date), dateFilter);
});
}
});
return fallsInFilters;
};

View File

@ -1,3 +1,4 @@
import { differenceInCalendarDays } from "date-fns";
// types
import { IIssueFilterOptions } from "@plane/types";
@ -13,3 +14,29 @@ export const calculateTotalFilters = (filters: IIssueFilterOptions): number =>
)
.reduce((curr, prev) => curr + prev, 0)
: 0;
/**
* @description checks if the date satisfies the filter
* @param {Date} date
* @param {string} filter
* @returns {boolean}
*/
export const satisfiesDateFilter = (date: Date, filter: string): boolean => {
const [value, operator, from] = filter.split(";");
if (!from) {
if (operator === "after") return date >= new Date(value);
if (operator === "before") return date <= new Date(value);
}
if (from === "fromnow") {
if (operator === "after") {
if (value === "1_weeks") return differenceInCalendarDays(date, new Date()) >= 7;
if (value === "2_weeks") return differenceInCalendarDays(date, new Date()) >= 14;
if (value === "1_months") return differenceInCalendarDays(date, new Date()) >= 30;
if (value === "2_months") return differenceInCalendarDays(date, new Date()) >= 60;
}
}
return false;
};

View File

@ -1,7 +1,8 @@
export * from "./use-application";
export * from "./use-event-tracker";
export * from "./use-calendar-view";
export * from "./use-cycle-filter";
export * from "./use-cycle";
export * from "./use-event-tracker";
export * from "./use-dashboard";
export * from "./use-estimate";
export * from "./use-global-view";

View File

@ -0,0 +1,11 @@
import { useContext } from "react";
// mobx store
import { StoreContext } from "contexts/store-context";
// types
import { ICycleFilterStore } from "store/cycle_filter.store";
export const useCycleFilter = (): ICycleFilterStore => {
const context = useContext(StoreContext);
if (context === undefined) throw new Error("useCycleFilter must be used within StoreProvider");
return context.cycleFilter;
};

View File

@ -1,28 +1,35 @@
import { Fragment, useCallback, useState, ReactElement } from "react";
import { Fragment, useState, ReactElement } from "react";
import { observer } from "mobx-react-lite";
import { useRouter } from "next/router";
import { Tab } from "@headlessui/react";
// hooks
import { useEventTracker, useCycle, useProject } from "hooks/store";
import useLocalStorage from "hooks/use-local-storage";
import { useEventTracker, useCycle, useProject, useCycleFilter } from "hooks/store";
// layouts
import { AppLayout } from "layouts/app-layout";
// components
import { PageHead } from "components/core";
import { CyclesHeader } from "components/headers";
import { CyclesView, ActiveCycleDetails, CycleCreateUpdateModal } from "components/cycles";
import {
CyclesView,
CycleCreateUpdateModal,
CyclesViewHeader,
CycleAppliedFiltersList,
ActiveCycleRoot,
} from "components/cycles";
import { EmptyState } from "components/empty-state";
import { CycleModuleBoardLayout, CycleModuleListLayout, GanttLayoutLoader } from "components/ui";
// ui
import { Tooltip } from "@plane/ui";
import { CycleModuleBoardLayout, CycleModuleListLayout, GanttLayoutLoader } from "components/ui";
// helpers
import { calculateTotalFilters } from "helpers/filter.helper";
// types
import { NextPageWithLayout } from "lib/types";
import { TCycleView, TCycleLayout } from "@plane/types";
import { TCycleFilters } from "@plane/types";
// constants
import { CYCLE_TAB_LIST, CYCLE_VIEW_LAYOUTS } from "constants/cycle";
import { CYCLE_TABS_LIST } from "constants/cycle";
import { EmptyStateType } from "constants/empty-state";
const ProjectCyclesPage: NextPageWithLayout = observer(() => {
// states
const [createModal, setCreateModal] = useState(false);
// store hooks
const { setTrackElement } = useEventTracker();
@ -31,28 +38,26 @@ const ProjectCyclesPage: NextPageWithLayout = observer(() => {
// 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");
// cycle filters hook
const { clearAllFilters, currentProjectDisplayFilters, currentProjectFilters, updateDisplayFilters, updateFilters } =
useCycleFilter();
// derived values
const totalCycles = currentProjectCycleIds?.length ?? 0;
const project = projectId ? getProjectById(projectId?.toString()) : undefined;
const pageTitle = project?.name ? `${project?.name} - Cycles` : undefined;
// selected display filters
const cycleTab = currentProjectDisplayFilters?.active_tab;
const cycleLayout = currentProjectDisplayFilters?.layout;
const handleCurrentLayout = useCallback(
(_layout: TCycleLayout) => {
setCycleLayout(_layout);
},
[setCycleLayout]
);
const handleRemoveFilter = (key: keyof TCycleFilters, value: string | null) => {
if (!projectId) return;
let newValues = currentProjectFilters?.[key] ?? [];
const handleCurrentView = useCallback(
(_view: TCycleView) => {
setCycleTab(_view);
if (_view === "draft") handleCurrentLayout("list");
},
[handleCurrentLayout, setCycleTab]
);
if (!value) newValues = [];
else newValues = newValues.filter((val) => val !== value);
updateFilters(projectId.toString(), { [key]: newValues });
};
if (!workspaceSlug || !projectId) return null;
@ -89,101 +94,35 @@ const ProjectCyclesPage: NextPageWithLayout = observer(() => {
<Tab.Group
as="div"
className="flex h-full flex-col overflow-hidden"
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")}
defaultIndex={CYCLE_TABS_LIST.findIndex((i) => i.key == cycleTab)}
selectedIndex={CYCLE_TABS_LIST.findIndex((i) => i.key == cycleTab)}
onChange={(i) => {
if (!projectId) return;
const tab = CYCLE_TABS_LIST[i];
if (!tab) return;
updateDisplayFilters(projectId.toString(), {
active_tab: tab.key,
});
}}
>
<div className="flex flex-col items-start justify-between gap-4 border-b border-custom-border-200 px-4 sm:flex-row sm:items-center sm:px-5 sm:pb-0">
<Tab.List as="div" className="flex items-center overflow-x-scroll">
{CYCLE_TAB_LIST.map((tab) => (
<Tab
key={tab.key}
className={({ selected }) =>
`border-b-2 p-4 text-sm font-medium outline-none ${
selected ? "border-custom-primary-100 text-custom-primary-100" : "border-transparent"
}`
}
>
{tab.name}
</Tab>
))}
</Tab.List>
<div className="hidden sm:block">
{cycleTab !== "active" && (
<div className="flex items-center self-end sm:self-center md:self-center lg:self-center gap-1 rounded bg-custom-background-80 p-1">
{CYCLE_VIEW_LAYOUTS.map((layout) => {
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 ${
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 ${
cycleLayout == layout.key ? "text-custom-text-100" : "text-custom-text-200"
}`}
<CyclesViewHeader projectId={projectId.toString()} />
{calculateTotalFilters(currentProjectFilters ?? {}) !== 0 && (
<div className="border-b border-custom-border-200 px-5 py-3">
<CycleAppliedFiltersList
appliedFilters={currentProjectFilters ?? {}}
handleClearAllFilters={() => clearAllFilters(projectId.toString())}
handleRemoveFilter={handleRemoveFilter}
/>
</button>
</Tooltip>
);
})}
</div>
)}
</div>
</div>
<Tab.Panels as={Fragment}>
<Tab.Panel as="div" className="h-full overflow-y-auto">
{cycleTab && cycleLayout && (
<CyclesView
filter="all"
layout={cycleLayout}
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
peekCycle={peekCycle?.toString()}
/>
)}
</Tab.Panel>
<Tab.Panel as="div" className="h-full space-y-5 overflow-y-auto p-4 sm:p-5">
<ActiveCycleDetails workspaceSlug={workspaceSlug.toString()} projectId={projectId.toString()} />
<ActiveCycleRoot workspaceSlug={workspaceSlug.toString()} projectId={projectId.toString()} />
</Tab.Panel>
<Tab.Panel as="div" className="h-full overflow-y-auto">
{cycleTab && cycleLayout && (
<CyclesView
filter="upcoming"
layout={cycleLayout as TCycleLayout}
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
peekCycle={peekCycle?.toString()}
/>
)}
</Tab.Panel>
<Tab.Panel as="div" className="h-full overflow-y-auto">
{cycleTab && cycleLayout && workspaceSlug && projectId && (
<CyclesView
filter="completed"
layout={cycleLayout as TCycleLayout}
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
peekCycle={peekCycle?.toString()}
/>
)}
</Tab.Panel>
<Tab.Panel as="div" className="h-full overflow-y-auto">
{cycleTab && cycleLayout && workspaceSlug && projectId && (
<CyclesView
filter="draft"
layout={cycleLayout as TCycleLayout}
layout={cycleLayout}
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
peekCycle={peekCycle?.toString()}

View File

@ -0,0 +1,42 @@
<svg width="205" height="217" viewBox="0 0 205 217" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="102.5" cy="102.5" r="102.5" fill="url(#paint0_linear_7901_142254)"/>
<path d="M102 165.5C136.518 165.5 164.5 137.518 164.5 103C164.5 68.4822 136.518 40.5 102 40.5C67.4822 40.5 39.5 68.4822 39.5 103C39.5 137.518 67.4822 165.5 102 165.5Z" stroke="#80838D" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M102 140.5C111.946 140.5 121.484 136.549 128.517 129.517C135.549 122.484 139.5 112.946 139.5 103C139.5 93.0544 135.549 83.5161 128.517 76.4835C121.484 69.4509 111.946 65.5 102 65.5V140.5Z" fill="#80838D" stroke="#80838D" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<g filter="url(#filter0_ddd_7901_142254)">
<circle cx="102.754" cy="173.828" r="31" fill="#3A5BC7"/>
<path d="M91.75 166.5H114.25" stroke="#F9F9FB" stroke-width="3.57143" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M96.75 174H109.25" stroke="#F9F9FB" stroke-width="3.57143" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M100.5 181.5H105.5" stroke="#F9F9FB" stroke-width="3.57143" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<filter id="filter0_ddd_7901_142254" x="63.7539" y="137.828" width="78" height="79" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feMorphology radius="2" operator="erode" in="SourceAlpha" result="effect1_dropShadow_7901_142254"/>
<feOffset dy="2"/>
<feGaussianBlur stdDeviation="1.5"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.24 0 0 0 0.051 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_7901_142254"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feMorphology radius="4" operator="erode" in="SourceAlpha" result="effect2_dropShadow_7901_142254"/>
<feOffset dy="3"/>
<feGaussianBlur stdDeviation="6"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.055 0"/>
<feBlend mode="normal" in2="effect1_dropShadow_7901_142254" result="effect2_dropShadow_7901_142254"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feMorphology radius="8" operator="erode" in="SourceAlpha" result="effect3_dropShadow_7901_142254"/>
<feOffset dy="4"/>
<feGaussianBlur stdDeviation="8"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.078 0"/>
<feBlend mode="normal" in2="effect2_dropShadow_7901_142254" result="effect3_dropShadow_7901_142254"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect3_dropShadow_7901_142254" result="shape"/>
</filter>
<linearGradient id="paint0_linear_7901_142254" x1="102.5" y1="0" x2="102.5" y2="207.52" gradientUnits="userSpaceOnUse">
<stop stop-color="#F7F7F7"/>
<stop offset="1" stop-color="#F8F9FA" stop-opacity="0"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

@ -0,0 +1,41 @@
<svg width="205" height="217" viewBox="0 0 205 217" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="102.5" cy="102.5" r="102.5" fill="url(#paint0_linear_7901_142341)"/>
<path d="M103 165.5C137.518 165.5 165.5 137.518 165.5 103C165.5 68.4822 137.518 40.5 103 40.5C68.4822 40.5 40.5 68.4822 40.5 103C40.5 137.518 68.4822 165.5 103 165.5Z" stroke="#80838D" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M103 140.5C112.946 140.5 122.484 136.549 129.517 129.517C136.549 122.484 140.5 112.946 140.5 103C140.5 93.0544 136.549 83.5161 129.517 76.4835C122.484 69.4509 112.946 65.5 103 65.5V140.5Z" fill="#80838D" stroke="#80838D" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<g filter="url(#filter0_ddd_7901_142341)">
<circle cx="102.754" cy="173.828" r="31" fill="#3A5BC7"/>
<path d="M101.075 185.585C108.494 185.585 114.508 179.571 114.508 172.152C114.508 164.733 108.494 158.719 101.075 158.719C93.6559 158.719 87.6416 164.733 87.6416 172.152C87.6416 179.571 93.6559 185.585 101.075 185.585Z" stroke="white" stroke-width="2.52049" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M117.866 188.939L110.646 181.719" stroke="white" stroke-width="2.52049" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<filter id="filter0_ddd_7901_142341" x="63.7539" y="137.828" width="78" height="79" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feMorphology radius="2" operator="erode" in="SourceAlpha" result="effect1_dropShadow_7901_142341"/>
<feOffset dy="2"/>
<feGaussianBlur stdDeviation="1.5"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.24 0 0 0 0.051 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_7901_142341"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feMorphology radius="4" operator="erode" in="SourceAlpha" result="effect2_dropShadow_7901_142341"/>
<feOffset dy="3"/>
<feGaussianBlur stdDeviation="6"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.055 0"/>
<feBlend mode="normal" in2="effect1_dropShadow_7901_142341" result="effect2_dropShadow_7901_142341"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feMorphology radius="8" operator="erode" in="SourceAlpha" result="effect3_dropShadow_7901_142341"/>
<feOffset dy="4"/>
<feGaussianBlur stdDeviation="8"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.078 0"/>
<feBlend mode="normal" in2="effect2_dropShadow_7901_142341" result="effect3_dropShadow_7901_142341"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect3_dropShadow_7901_142341" result="shape"/>
</filter>
<linearGradient id="paint0_linear_7901_142341" x1="102.5" y1="0" x2="102.5" y2="207.52" gradientUnits="userSpaceOnUse">
<stop stop-color="#F7F7F7"/>
<stop offset="1" stop-color="#F8F9FA" stop-opacity="0"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

@ -11,9 +11,10 @@ import { IssueService } from "services/issue";
import { ProjectService } from "services/project";
import { RootStore } from "store/root.store";
import { ICycle, CycleDateCheckData } from "@plane/types";
import { orderCycles, shouldFilterCycle } from "helpers/cycle.helper";
export interface ICycleStore {
//Loaders
// loaders
loader: boolean;
// observables
fetchedMap: Record<string, boolean>;
@ -27,6 +28,8 @@ export interface ICycleStore {
currentProjectDraftCycleIds: string[] | null;
currentProjectActiveCycleId: string | null;
// computed actions
getFilteredCycleIds: (projectId: string) => string[] | null;
getFilteredCompletedCycleIds: (projectId: string) => string[] | null;
getCycleById: (cycleId: string) => ICycle | null;
getCycleNameById: (cycleId: string) => string | undefined;
getActiveCycleById: (cycleId: string) => ICycle | null;
@ -183,6 +186,49 @@ export class CycleStore implements ICycleStore {
return activeCycle || null;
}
/**
* @description returns filtered cycle ids based on display filters and filters
* @param {TCycleDisplayFilters} displayFilters
* @param {TCycleFilters} filters
* @returns {string[] | null}
*/
getFilteredCycleIds = computedFn((projectId: string) => {
const filters = this.rootStore.cycleFilter.getFiltersByProjectId(projectId);
const searchQuery = this.rootStore.cycleFilter.searchQuery;
if (!this.fetchedMap[projectId]) return null;
let cycles = Object.values(this.cycleMap ?? {}).filter(
(c) =>
c.project_id === projectId &&
c.name.toLowerCase().includes(searchQuery.toLowerCase()) &&
shouldFilterCycle(c, filters ?? {})
);
cycles = orderCycles(cycles);
const cycleIds = cycles.map((c) => c.id);
return cycleIds;
});
/**
* @description returns filtered cycle ids based on display filters and filters
* @param {TCycleDisplayFilters} displayFilters
* @param {TCycleFilters} filters
* @returns {string[] | null}
*/
getFilteredCompletedCycleIds = computedFn((projectId: string) => {
const filters = this.rootStore.cycleFilter.getFiltersByProjectId(projectId);
const searchQuery = this.rootStore.cycleFilter.searchQuery;
if (!this.fetchedMap[projectId]) return null;
let cycles = Object.values(this.cycleMap ?? {}).filter(
(c) =>
c.project_id === projectId &&
c.status.toLowerCase() === "completed" &&
c.name.toLowerCase().includes(searchQuery.toLowerCase()) &&
shouldFilterCycle(c, filters ?? {})
);
cycles = sortBy(cycles, [(c) => !c.start_date]);
const cycleIds = cycles.map((c) => c.id);
return cycleIds;
});
/**
* @description returns cycle details by cycle id
* @param cycleId

View File

@ -0,0 +1,145 @@
import { action, computed, observable, makeObservable, runInAction, autorun } from "mobx";
import { computedFn } from "mobx-utils";
import set from "lodash/set";
// types
import { RootStore } from "store/root.store";
import { TCycleDisplayFilters, TCycleFilters } from "@plane/types";
export interface ICycleFilterStore {
// observables
displayFilters: Record<string, TCycleDisplayFilters>;
filters: Record<string, TCycleFilters>;
searchQuery: string;
// computed
currentProjectDisplayFilters: TCycleDisplayFilters | undefined;
currentProjectFilters: TCycleFilters | undefined;
// computed functions
getDisplayFiltersByProjectId: (projectId: string) => TCycleDisplayFilters | undefined;
getFiltersByProjectId: (projectId: string) => TCycleFilters | undefined;
// actions
updateDisplayFilters: (projectId: string, displayFilters: TCycleDisplayFilters) => void;
updateFilters: (projectId: string, filters: TCycleFilters) => void;
updateSearchQuery: (query: string) => void;
clearAllFilters: (projectId: string) => void;
}
export class CycleFilterStore implements ICycleFilterStore {
// observables
displayFilters: Record<string, TCycleDisplayFilters> = {};
filters: Record<string, TCycleFilters> = {};
searchQuery: string = "";
// root store
rootStore: RootStore;
constructor(_rootStore: RootStore) {
makeObservable(this, {
// observables
displayFilters: observable,
filters: observable,
searchQuery: observable.ref,
// computed
currentProjectDisplayFilters: computed,
currentProjectFilters: computed,
// actions
updateDisplayFilters: action,
updateFilters: action,
updateSearchQuery: action,
clearAllFilters: action,
});
// root store
this.rootStore = _rootStore;
// initialize display filters of the current project
autorun(() => {
const projectId = this.rootStore.app.router.projectId;
if (!projectId) return;
this.initProjectCycleFilters(projectId);
});
}
/**
* @description get display filters of the current project
*/
get currentProjectDisplayFilters() {
const projectId = this.rootStore.app.router.projectId;
if (!projectId) return;
return this.displayFilters[projectId];
}
/**
* @description get filters of the current project
*/
get currentProjectFilters() {
const projectId = this.rootStore.app.router.projectId;
if (!projectId) return;
return this.filters[projectId];
}
/**
* @description get display filters of a project by projectId
* @param {string} projectId
*/
getDisplayFiltersByProjectId = computedFn((projectId: string) => this.displayFilters[projectId]);
/**
* @description get filters of a project by projectId
* @param {string} projectId
*/
getFiltersByProjectId = computedFn((projectId: string) => this.filters[projectId]);
/**
* @description initialize display filters and filters of a project
* @param {string} projectId
*/
initProjectCycleFilters = (projectId: string) => {
const displayFilters = this.getDisplayFiltersByProjectId(projectId);
runInAction(() => {
this.displayFilters[projectId] = {
active_tab: displayFilters?.active_tab || "active",
layout: displayFilters?.layout || "list",
};
this.filters[projectId] = {};
});
};
/**
* @description update display filters of a project
* @param {string} projectId
* @param {TCycleDisplayFilters} displayFilters
*/
updateDisplayFilters = (projectId: string, displayFilters: TCycleDisplayFilters) => {
runInAction(() => {
Object.keys(displayFilters).forEach((key) => {
set(this.displayFilters, [projectId, key], displayFilters[key as keyof TCycleDisplayFilters]);
});
});
};
/**
* @description update filters of a project
* @param {string} projectId
* @param {TCycleFilters} filters
*/
updateFilters = (projectId: string, filters: TCycleFilters) => {
runInAction(() => {
Object.keys(filters).forEach((key) => {
set(this.filters, [projectId, key], filters[key as keyof TCycleFilters]);
});
});
};
/**
* @description update search query
* @param {string} query
*/
updateSearchQuery = (query: string) => (this.searchQuery = query);
/**
* @description clear all filters of a project
* @param {string} projectId
*/
clearAllFilters = (projectId: string) => {
runInAction(() => {
this.filters[projectId] = {};
});
};
}

View File

@ -18,6 +18,7 @@ import { IStateStore, StateStore } from "./state.store";
import { IUserRootStore, UserRootStore } from "./user";
import { IWorkspaceRootStore, WorkspaceRootStore } from "./workspace";
import { IProjectPageStore, ProjectPageStore } from "./project-page.store";
import { CycleFilterStore, ICycleFilterStore } from "./cycle_filter.store";
enableStaticRendering(typeof window === "undefined");
@ -29,6 +30,7 @@ export class RootStore {
projectRoot: IProjectRootStore;
memberRoot: IMemberRootStore;
cycle: ICycleStore;
cycleFilter: ICycleFilterStore;
module: IModuleStore;
projectView: IProjectViewStore;
globalView: IGlobalViewStore;
@ -50,6 +52,7 @@ export class RootStore {
this.memberRoot = new MemberRootStore(this);
// independent stores
this.cycle = new CycleStore(this);
this.cycleFilter = new CycleFilterStore(this);
this.module = new ModulesStore(this);
this.projectView = new ProjectViewStore(this);
this.globalView = new GlobalViewStore(this);
@ -69,6 +72,7 @@ export class RootStore {
this.memberRoot = new MemberRootStore(this);
// independent stores
this.cycle = new CycleStore(this);
this.cycleFilter = new CycleFilterStore(this);
this.module = new ModulesStore(this);
this.projectView = new ProjectViewStore(this);
this.globalView = new GlobalViewStore(this);