[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"; import type { TIssue, IIssueFilterOptions } from "@plane/types";
export type TCycleView = "all" | "active" | "upcoming" | "completed" | "draft";
export type TCycleGroups = "current" | "upcoming" | "completed" | "draft"; export type TCycleGroups = "current" | "upcoming" | "completed" | "draft";
export type TCycleLayout = "list" | "board" | "gantt";
export interface ICycle { export interface ICycle {
backlog_issues: number; backlog_issues: number;
cancelled_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 "./users";
export * from "./workspace"; export * from "./workspace";
export * from "./cycles"; export * from "./cycle";
export * from "./dashboard"; export * from "./dashboard";
export * from "./projects"; export * from "./projects";
export * from "./state"; export * from "./state";

View File

@ -4,7 +4,7 @@ import { ISvgIcons } from "../type";
export const CircleDotFullIcon: React.FC<ISvgIcons> = ({ className = "text-current", ...rest }) => ( 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}> <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" /> <circle cx="8.33333" cy="8.33333" r="4.33333" fill="currentColor" />
</svg> </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 Link from "next/link";
import useSWR from "swr"; import useSWR from "swr";
// hooks // hooks
import { useCycle, useIssues, useMember, useProject } from "hooks/store"; import { useCycle, useCycleFilter, useIssues, useMember, useProject } from "hooks/store";
// ui // ui
import { SingleProgressStats } from "components/core"; import { SingleProgressStats } from "components/core";
import { import {
@ -17,10 +17,11 @@ import {
Avatar, Avatar,
CycleGroupIcon, CycleGroupIcon,
setPromiseToast, setPromiseToast,
getButtonStyling,
} from "@plane/ui"; } from "@plane/ui";
// components // components
import ProgressChart from "components/core/sidebar/progress-chart"; 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 { StateDropdown } from "components/dropdowns";
import { EmptyState } from "components/empty-state"; import { EmptyState } from "components/empty-state";
// icons // icons
@ -28,6 +29,7 @@ import { ArrowRight, CalendarCheck, CalendarDays, Star, Target } from "lucide-re
// helpers // helpers
import { renderFormattedDate, findHowManyDaysLeft, renderFormattedDateWithoutYear } from "helpers/date-time.helper"; import { renderFormattedDate, findHowManyDaysLeft, renderFormattedDateWithoutYear } from "helpers/date-time.helper";
import { truncateText } from "helpers/string.helper"; import { truncateText } from "helpers/string.helper";
import { cn } from "helpers/common.helper";
// types // types
import { ICycle, TCycleGroups } from "@plane/types"; import { ICycle, TCycleGroups } from "@plane/types";
// constants // constants
@ -41,30 +43,34 @@ interface IActiveCycleDetails {
projectId: string; projectId: string;
} }
export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props) => { export const ActiveCycleRoot: React.FC<IActiveCycleDetails> = observer((props) => {
// props // props
const { workspaceSlug, projectId } = props; const { workspaceSlug, projectId } = props;
// store hooks
const { const {
issues: { fetchActiveCycleIssues }, issues: { fetchActiveCycleIssues },
} = useIssues(EIssuesStoreType.CYCLE); } = useIssues(EIssuesStoreType.CYCLE);
const { const {
fetchActiveCycle,
currentProjectActiveCycleId, currentProjectActiveCycleId,
currentProjectUpcomingCycleIds,
fetchActiveCycle,
getActiveCycleById, getActiveCycleById,
addCycleToFavorites, addCycleToFavorites,
removeCycleFromFavorites, removeCycleFromFavorites,
} = useCycle(); } = useCycle();
const { currentProjectDetails } = useProject(); const { currentProjectDetails } = useProject();
const { getUserDetails } = useMember(); 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( const { isLoading } = useSWR(
workspaceSlug && projectId ? `PROJECT_ACTIVE_CYCLE_${projectId}` : null, workspaceSlug && projectId ? `PROJECT_ACTIVE_CYCLE_${projectId}` : null,
workspaceSlug && projectId ? () => fetchActiveCycle(workspaceSlug, projectId) : null workspaceSlug && projectId ? () => fetchActiveCycle(workspaceSlug, projectId) : null
); );
// fetch active cycle issues
const activeCycle = currentProjectActiveCycleId ? getActiveCycleById(currentProjectActiveCycleId) : null;
const cycleOwnerDetails = activeCycle ? getUserDetails(activeCycle.owned_by_id) : undefined;
const { data: activeCycleIssues } = useSWR( const { data: activeCycleIssues } = useSWR(
workspaceSlug && projectId && currentProjectActiveCycleId workspaceSlug && projectId && currentProjectActiveCycleId
? CYCLE_ISSUES_WITH_PARAMS(currentProjectActiveCycleId, { priority: "urgent,high" }) ? CYCLE_ISSUES_WITH_PARAMS(currentProjectActiveCycleId, { priority: "urgent,high" })
@ -73,7 +79,7 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
? () => fetchActiveCycleIssues(workspaceSlug, projectId, currentProjectActiveCycleId) ? () => fetchActiveCycleIssues(workspaceSlug, projectId, currentProjectActiveCycleId)
: null : null
); );
// show loader if active cycle is loading
if (!activeCycle && isLoading) if (!activeCycle && isLoading)
return ( return (
<Loader> <Loader>
@ -81,10 +87,44 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
</Loader> </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 endDate = new Date(activeCycle.end_date ?? "");
const startDate = new Date(activeCycle.start_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 = { const groupedIssues: any = {
backlog: activeCycle.backlog_issues, backlog: activeCycle.backlog_issues,
@ -94,8 +134,6 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
cancelled: activeCycle.cancelled_issues, cancelled: activeCycle.cancelled_issues,
}; };
const cycleStatus = activeCycle.status.toLowerCase() as TCycleGroups;
const handleAddToFavorites = (e: MouseEvent<HTMLButtonElement>) => { const handleAddToFavorites = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault(); e.preventDefault();
if (!workspaceSlug || !projectId) return; if (!workspaceSlug || !projectId) return;
@ -148,8 +186,6 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
color: group.color, color: group.color,
})); }));
const daysLeft = findHowManyDaysLeft(activeCycle.end_date) ?? 0;
return ( return (
<div className="grid-row-2 grid divide-y rounded-[10px] border border-custom-border-200 bg-custom-background-100 shadow"> <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"> <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-4">
<div className="flex items-center gap-2.5 text-custom-text-200"> <div className="flex items-center gap-2.5 text-custom-text-200">
{cycleOwnerDetails?.avatar && cycleOwnerDetails?.avatar !== "" ? ( <Avatar src={cycleOwnerDetails?.avatar} name={cycleOwnerDetails?.display_name} />
<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>
)}
<span className="text-custom-text-200">{cycleOwnerDetails?.display_name}</span> <span className="text-custom-text-200">{cycleOwnerDetails?.display_name}</span>
</div> </div>
{activeCycle.assignee_ids.length > 0 && ( {activeCycle.assignee_ids.length > 0 && (
<div className="flex items-center gap-1 text-custom-text-200"> <div className="flex items-center gap-1 text-custom-text-200">
<AvatarGroup> <AvatarGroup>
{activeCycle.assignee_ids.map((assigne_id) => { {activeCycle.assignee_ids.map((assignee_id) => {
const member = getUserDetails(assigne_id); const member = getUserDetails(assignee_id);
return <Avatar key={member?.id} name={member?.display_name} src={member?.avatar} />; return <Avatar key={member?.id} name={member?.display_name} src={member?.avatar} />;
})} })}
</AvatarGroup> </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 items-center gap-4 text-custom-text-200">
<div className="flex gap-2"> <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 {activeCycle.total_issues} issues
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -244,9 +268,9 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
<Link <Link
href={`/${workspaceSlug}/projects/${projectId}/cycles/${activeCycle.id}`} 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> </Link>
</div> </div>
</div> </div>
@ -287,11 +311,11 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
</div> </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="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="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"> <div className="flex h-full flex-col gap-2.5 overflow-y-scroll rounded-md">
{activeCycleIssues ? ( {activeCycleIssues ? (
activeCycleIssues.length > 0 ? ( activeCycleIssues.length > 0 ? (
activeCycleIssues.map((issue: any) => ( activeCycleIssues.map((issue) => (
<Link <Link
key={issue.id} key={issue.id}
href={`/${workspaceSlug}/projects/${projectId}/issues/${issue.id}`} href={`/${workspaceSlug}/projects/${projectId}/issues/${issue.id}`}
@ -314,9 +338,9 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
</div> </div>
<div className="flex flex-shrink-0 items-center gap-1.5"> <div className="flex flex-shrink-0 items-center gap-1.5">
<StateDropdown <StateDropdown
value={issue.state_id ?? undefined} value={issue.state_id}
onChange={() => {}} onChange={() => {}}
projectId={projectId?.toString() ?? ""} projectId={projectId}
disabled disabled
buttonVariant="background-with-text" buttonVariant="background-with-text"
/> />
@ -359,10 +383,10 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<span> <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>
<span> <span>
Pending Issues -{" "} Pending issues-{" "}
{activeCycle.total_issues - (activeCycle.completed_issues + activeCycle.cancelled_issues)} {activeCycle.total_issues - (activeCycle.completed_issues + activeCycle.cancelled_issues)}
</span> </span>
</div> </div>

View File

@ -134,7 +134,7 @@ export const ActiveCycleProgressStats: React.FC<Props> = ({ cycle }) => {
</Tab.Panels> </Tab.Panels>
) : ( ) : (
<div className="mt-4 grid place-items-center text-center text-sm text-custom-text-200"> <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> </div>
)} )}
</Tab.Group> </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 { observer } from "mobx-react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
// hooks // hooks
// components // components
import { Info, LinkIcon, Pencil, Star, Trash2 } from "lucide-react"; import { Info, Star } from "lucide-react";
import { import { Avatar, AvatarGroup, Tooltip, LayersIcon, CycleGroupIcon, setPromiseToast } from "@plane/ui";
Avatar, import { CycleQuickActions } from "components/cycles";
AvatarGroup,
CustomMenu,
Tooltip,
LayersIcon,
CycleGroupIcon,
TOAST_TYPE,
setToast,
setPromiseToast,
} from "@plane/ui";
import { CycleCreateUpdateModal, CycleDeleteModal } from "components/cycles";
// ui // ui
// icons // icons
// helpers // helpers
@ -24,7 +14,6 @@ import { CYCLE_STATUS } from "constants/cycle";
import { CYCLE_FAVORITED, CYCLE_UNFAVORITED } from "constants/event-tracker"; import { CYCLE_FAVORITED, CYCLE_UNFAVORITED } from "constants/event-tracker";
import { EUserWorkspaceRoles } from "constants/workspace"; import { EUserWorkspaceRoles } from "constants/workspace";
import { findHowManyDaysLeft, renderFormattedDate } from "helpers/date-time.helper"; import { findHowManyDaysLeft, renderFormattedDate } from "helpers/date-time.helper";
import { copyTextToClipboard } from "helpers/string.helper";
// constants // constants
import { useEventTracker, useCycle, useUser, useMember } from "hooks/store"; import { useEventTracker, useCycle, useUser, useMember } from "hooks/store";
//.types //.types
@ -38,13 +27,10 @@ export interface ICyclesBoardCard {
export const CyclesBoardCard: FC<ICyclesBoardCard> = observer((props) => { export const CyclesBoardCard: FC<ICyclesBoardCard> = observer((props) => {
const { cycleId, workspaceSlug, projectId } = props; const { cycleId, workspaceSlug, projectId } = props;
// states
const [updateModal, setUpdateModal] = useState(false);
const [deleteModal, setDeleteModal] = useState(false);
// router // router
const router = useRouter(); const router = useRouter();
// store // store
const { setTrackElement, captureEvent } = useEventTracker(); const { captureEvent } = useEventTracker();
const { const {
membership: { currentProjectRole }, membership: { currentProjectRole },
} = useUser(); } = useUser();
@ -56,7 +42,6 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = observer((props) => {
if (!cycleDetails) return null; if (!cycleDetails) return null;
const cycleStatus = cycleDetails.status.toLocaleLowerCase(); const cycleStatus = cycleDetails.status.toLocaleLowerCase();
const isCompleted = cycleStatus === "completed";
const endDate = new Date(cycleDetails.end_date ?? ""); const endDate = new Date(cycleDetails.end_date ?? "");
const startDate = new Date(cycleDetails.start_date ?? ""); const startDate = new Date(cycleDetails.start_date ?? "");
const isDateValid = cycleDetails.start_date || cycleDetails.end_date; const isDateValid = cycleDetails.start_date || cycleDetails.end_date;
@ -78,24 +63,10 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = observer((props) => {
? cycleTotalIssues === 0 ? cycleTotalIssues === 0
? "0 Issue" ? "0 Issue"
: cycleTotalIssues === cycleDetails.completed_issues : cycleTotalIssues === cycleDetails.completed_issues
? `${cycleTotalIssues} Issue${cycleTotalIssues > 1 ? "s" : ""}` ? `${cycleTotalIssues} Issue${cycleTotalIssues > 1 ? "s" : ""}`
: `${cycleDetails.completed_issues}/${cycleTotalIssues} Issues` : `${cycleDetails.completed_issues}/${cycleTotalIssues} Issues`
: "0 Issue"; : "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>) => { const handleAddToFavorites = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault(); e.preventDefault();
if (!workspaceSlug || !projectId) return; 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 openCycleOverview = (e: MouseEvent<HTMLButtonElement>) => {
const { query } = router; const { query } = router;
e.preventDefault(); e.preventDefault();
@ -181,22 +138,6 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = observer((props) => {
return ( return (
<div> <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}`}> <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 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"> <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" /> <Star className="h-3.5 w-3.5 text-custom-text-200" />
</button> </button>
))} ))}
<CustomMenu ellipsis className="z-10">
{!isCompleted && isEditingAllowed && ( <CycleQuickActions cycleId={cycleId} projectId={projectId} workspaceSlug={workspaceSlug} />
<>
<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>
</div> </div>
</div> </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 { FC } from "react";
import Image from "next/image";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
// hooks // hooks
import { useCycle, useCycleFilter } from "hooks/store";
// components // components
import { CyclesBoard, CyclesList, CyclesListGanttChartView } from "components/cycles"; import { CyclesBoard, CyclesList, CyclesListGanttChartView } from "components/cycles";
// ui components // ui
import { CycleModuleBoardLayout, CycleModuleListLayout, GanttLayoutLoader } from "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 // types
import { TCycleLayout, TCycleView } from "@plane/types"; import { TCycleLayoutOptions } from "@plane/types";
export interface ICyclesView { export interface ICyclesView {
filter: TCycleView; layout: TCycleLayoutOptions;
layout: TCycleLayout;
workspaceSlug: string; workspaceSlug: string;
projectId: string; projectId: string;
peekCycle: string | undefined; peekCycle: string | undefined;
} }
export const CyclesView: FC<ICyclesView> = observer((props) => { export const CyclesView: FC<ICyclesView> = observer((props) => {
const { filter, layout, workspaceSlug, projectId, peekCycle } = props; const { layout, workspaceSlug, projectId, peekCycle } = props;
// store hooks // store hooks
const { const { getFilteredCycleIds, getFilteredCompletedCycleIds, loader } = useCycle();
currentProjectCompletedCycleIds, const { searchQuery } = useCycleFilter();
currentProjectDraftCycleIds, // derived values
currentProjectUpcomingCycleIds, const filteredCycleIds = getFilteredCycleIds(projectId);
currentProjectCycleIds, const filteredCompletedCycleIds = getFilteredCompletedCycleIds(projectId);
loader,
} = useCycle();
const cyclesList = if (loader || !filteredCycleIds)
filter === "completed"
? currentProjectCompletedCycleIds
: filter === "draft"
? currentProjectDraftCycleIds
: filter === "upcoming"
? currentProjectUpcomingCycleIds
: currentProjectCycleIds;
if (loader || !cyclesList)
return ( return (
<> <>
{layout === "list" && <CycleModuleListLayout />} {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 ( return (
<> <>
{layout === "list" && ( {layout === "list" && (
<CyclesList cycleIds={cyclesList} filter={filter} workspaceSlug={workspaceSlug} projectId={projectId} /> <CyclesList
completedCycleIds={filteredCompletedCycleIds ?? []}
cycleIds={filteredCycleIds}
workspaceSlug={workspaceSlug}
projectId={projectId}
/>
)} )}
{layout === "board" && ( {layout === "board" && (
<CyclesBoard <CyclesBoard
cycleIds={cyclesList} completedCycleIds={filteredCompletedCycleIds ?? []}
filter={filter} cycleIds={filteredCycleIds}
workspaceSlug={workspaceSlug} workspaceSlug={workspaceSlug}
projectId={projectId} projectId={projectId}
peekCycle={peekCycle} peekCycle={peekCycle}
/> />
)} )}
{layout === "gantt" && <CyclesListGanttChartView cycleIds={filteredCycleIds} workspaceSlug={workspaceSlug} />}
{layout === "gantt" && <CyclesListGanttChartView cycleIds={cyclesList} 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"> <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" /> <AlertTriangle width={16} strokeWidth={2} className="text-red-600" />
</div> </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> </div>
<span> <span>
<p className="text-sm text-custom-text-200"> <p className="text-sm text-custom-text-200">
@ -118,8 +118,8 @@ export const CycleDeleteModal: React.FC<ICycleDelete> = observer((props) => {
Cancel Cancel
</Button> </Button>
<Button variant="danger" size="sm" tabIndex={1} onClick={formSubmit}> <Button variant="danger" size="sm" tabIndex={1} onClick={formSubmit} loading={loader}>
{loader ? "Deleting..." : "Delete Cycle"} {loader ? "Deleting" : "Delete"}
</Button> </Button>
</div> </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 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 // hooks
import { CycleGanttBlock } from "components/cycles"; import { CycleGanttBlock } from "components/cycles";
import { GanttChartRoot, IBlockUpdateData, CycleGanttSidebar } from "components/gantt-chart"; import { GanttChartRoot, IBlockUpdateData, CycleGanttSidebar } from "components/gantt-chart";
import { EUserProjectRoles } from "constants/project"; import { useCycle } from "hooks/store";
import { useCycle, useUser } from "hooks/store";
// components // components
// types // types
import { ICycle } from "@plane/types"; import { ICycle } from "@plane/types";
@ -22,9 +21,6 @@ export const CyclesListGanttChartView: FC<Props> = observer((props) => {
const router = useRouter(); const router = useRouter();
const { workspaceSlug } = router.query; const { workspaceSlug } = router.query;
// store hooks // store hooks
const {
membership: { currentProjectRole },
} = useUser();
const { getCycleById, updateCycleDetails } = useCycle(); const { getCycleById, updateCycleDetails } = useCycle();
const handleCycleUpdate = async (cycle: ICycle, data: IBlockUpdateData) => { const handleCycleUpdate = async (cycle: ICycle, data: IBlockUpdateData) => {
@ -52,9 +48,6 @@ export const CyclesListGanttChartView: FC<Props> = observer((props) => {
return structuredBlocks; return structuredBlocks;
}; };
const isAllowed =
currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole);
return ( return (
<div className="h-full w-full overflow-y-auto"> <div className="h-full w-full overflow-y-auto">
<GanttChartRoot <GanttChartRoot
@ -67,7 +60,7 @@ export const CyclesListGanttChartView: FC<Props> = observer((props) => {
enableBlockLeftResize={false} enableBlockLeftResize={false}
enableBlockRightResize={false} enableBlockRightResize={false}
enableBlockMove={false} enableBlockMove={false}
enableReorder={isAllowed} enableReorder={false}
/> />
</div> </div>
); );

View File

@ -1,17 +1,16 @@
export * from "./cycles-view"; export * from "./active-cycle";
export * from "./active-cycle-details"; export * from "./applied-filters";
export * from "./active-cycle-stats"; export * from "./board/";
export * from "./dropdowns";
export * from "./gantt-chart"; export * from "./gantt-chart";
export * from "./list";
export * from "./cycle-peek-overview";
export * from "./cycles-view-header";
export * from "./cycles-view"; export * from "./cycles-view";
export * from "./delete-modal";
export * from "./form"; export * from "./form";
export * from "./modal"; export * from "./modal";
export * from "./quick-actions";
export * from "./sidebar"; export * from "./sidebar";
export * from "./transfer-issues-modal"; export * from "./transfer-issues-modal";
export * from "./transfer-issues"; 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 { observer } from "mobx-react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
// hooks // hooks
import { Check, Info, LinkIcon, Pencil, Star, Trash2, User2 } from "lucide-react"; import { Check, Info, Star, User2 } from "lucide-react";
import { import { Tooltip, CircularProgressIndicator, CycleGroupIcon, AvatarGroup, Avatar, setPromiseToast } from "@plane/ui";
CustomMenu, import { CycleQuickActions } from "components/cycles";
Tooltip,
CircularProgressIndicator,
CycleGroupIcon,
AvatarGroup,
Avatar,
TOAST_TYPE,
setToast,
setPromiseToast,
} from "@plane/ui";
import { CycleCreateUpdateModal, CycleDeleteModal } from "components/cycles";
import { CYCLE_STATUS } from "constants/cycle"; import { CYCLE_STATUS } from "constants/cycle";
import { CYCLE_FAVORITED, CYCLE_UNFAVORITED } from "constants/event-tracker"; import { CYCLE_FAVORITED, CYCLE_UNFAVORITED } from "constants/event-tracker";
import { EUserWorkspaceRoles } from "constants/workspace";
import { findHowManyDaysLeft, renderFormattedDate } from "helpers/date-time.helper"; import { findHowManyDaysLeft, renderFormattedDate } from "helpers/date-time.helper";
import { copyTextToClipboard } from "helpers/string.helper";
import { useEventTracker, useCycle, useUser, useMember } from "hooks/store"; import { useEventTracker, useCycle, useUser, useMember } from "hooks/store";
// components // components
// ui // ui
@ -29,6 +17,7 @@ import { useEventTracker, useCycle, useUser, useMember } from "hooks/store";
// constants // constants
// types // types
import { TCycleGroups } from "@plane/types"; import { TCycleGroups } from "@plane/types";
import { EUserProjectRoles } from "constants/project";
type TCyclesListItem = { type TCyclesListItem = {
cycleId: string; cycleId: string;
@ -42,33 +31,16 @@ type TCyclesListItem = {
export const CyclesListItem: FC<TCyclesListItem> = observer((props) => { export const CyclesListItem: FC<TCyclesListItem> = observer((props) => {
const { cycleId, workspaceSlug, projectId } = props; const { cycleId, workspaceSlug, projectId } = props;
// states
const [updateModal, setUpdateModal] = useState(false);
const [deleteModal, setDeleteModal] = useState(false);
// router // router
const router = useRouter(); const router = useRouter();
// store hooks // store hooks
const { setTrackElement, captureEvent } = useEventTracker(); const { captureEvent } = useEventTracker();
const { const {
membership: { currentProjectRole }, membership: { currentProjectRole },
} = useUser(); } = useUser();
const { getCycleById, addCycleToFavorites, removeCycleFromFavorites } = useCycle(); const { getCycleById, addCycleToFavorites, removeCycleFromFavorites } = useCycle();
const { getUserDetails } = useMember(); 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>) => { const handleAddToFavorites = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault(); e.preventDefault();
if (!workspaceSlug || !projectId) return; 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 openCycleOverview = (e: MouseEvent<HTMLButtonElement>) => {
const { query } = router; const { query } = router;
e.preventDefault(); e.preventDefault();
@ -161,7 +119,7 @@ export const CyclesListItem: FC<TCyclesListItem> = observer((props) => {
const endDate = new Date(cycleDetails.end_date ?? ""); const endDate = new Date(cycleDetails.end_date ?? "");
const startDate = new Date(cycleDetails.start_date ?? ""); const startDate = new Date(cycleDetails.start_date ?? "");
const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserWorkspaceRoles.MEMBER; const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER;
const cycleTotalIssues = const cycleTotalIssues =
cycleDetails.backlog_issues + cycleDetails.backlog_issues +
@ -184,20 +142,6 @@ export const CyclesListItem: FC<TCyclesListItem> = observer((props) => {
return ( 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}`}> <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="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"> <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> </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"> <div className="text-xs text-custom-text-300">
{renderDate && `${renderFormattedDate(startDate) ?? `_ _`} - ${renderFormattedDate(endDate) ?? `_ _`}`} {renderDate && `${renderFormattedDate(startDate) ?? `_ _`} - ${renderFormattedDate(endDate) ?? `_ _`}`}
</div> </div>
@ -256,8 +200,8 @@ export const CyclesListItem: FC<TCyclesListItem> = observer((props) => {
<div className="flex w-10 cursor-default items-center justify-center"> <div className="flex w-10 cursor-default items-center justify-center">
{cycleDetails.assignee_ids?.length > 0 ? ( {cycleDetails.assignee_ids?.length > 0 ? (
<AvatarGroup showTooltip={false}> <AvatarGroup showTooltip={false}>
{cycleDetails.assignee_ids?.map((assigne_id) => { {cycleDetails.assignee_ids?.map((assignee_id) => {
const member = getUserDetails(assigne_id); const member = getUserDetails(assignee_id);
return <Avatar key={member?.id} name={member?.display_name} src={member?.avatar} />; return <Avatar key={member?.id} name={member?.display_name} src={member?.avatar} />;
})} })}
</AvatarGroup> </AvatarGroup>
@ -281,30 +225,7 @@ export const CyclesListItem: FC<TCyclesListItem> = observer((props) => {
</button> </button>
)} )}
<CustomMenu ellipsis> <CycleQuickActions cycleId={cycleId} projectId={projectId} workspaceSlug={workspaceSlug} />
{!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>
</> </>
)} )}
</div> </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 // components
// ui // ui
// types // types
import type { CycleDateCheckData, ICycle, TCycleView } from "@plane/types"; import type { CycleDateCheckData, ICycle, TCycleTabOptions } from "@plane/types";
// constants // constants
type CycleModalProps = { type CycleModalProps = {
@ -34,7 +34,7 @@ export const CycleCreateUpdateModal: React.FC<CycleModalProps> = (props) => {
const { workspaceProjectIds } = useProject(); const { workspaceProjectIds } = useProject();
const { createCycle, updateCycleDetails } = useCycle(); 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>) => { const handleCreateCycle = async (payload: Partial<ICycle>) => {
if (!workspaceSlug || !projectId) return; 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(); const { currentView } = useGanttChart();
return ( 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="flex items-center gap-2 text-lg font-medium">{title}</div>
<div className="ml-auto"> <div className="ml-auto">
<div className="ml-auto text-sm font-medium">{blocks ? `${blocks.length} ${loaderTitle}` : "Loading..."}</div> <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 { EUserProjectRoles } from "constants/project";
import { useApplication, useEventTracker, useProject, useUser } from "hooks/store"; import { useApplication, useEventTracker, useProject, useUser } from "hooks/store";
import useLocalStorage from "hooks/use-local-storage"; import useLocalStorage from "hooks/use-local-storage";
import { TCycleLayout } from "@plane/types"; import { TCycleLayoutOptions } from "@plane/types";
import { ProjectLogo } from "components/project"; import { ProjectLogo } from "components/project";
export const CyclesHeader: FC = observer(() => { export const CyclesHeader: FC = observer(() => {
@ -33,10 +33,10 @@ export const CyclesHeader: FC = observer(() => {
const canUserCreateCycle = const canUserCreateCycle =
currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole); 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( const handleCurrentLayout = useCallback(
(_layout: TCycleLayout) => { (_layout: TCycleLayoutOptions) => {
setCycleLayout(_layout); setCycleLayout(_layout);
}, },
[setCycleLayout] [setCycleLayout]
@ -109,7 +109,7 @@ export const CyclesHeader: FC = observer(() => {
key={layout.key} key={layout.key}
onClick={() => { onClick={() => {
// handleLayoutChange(ISSUE_LAYOUTS[index].key); // handleLayoutChange(ISSUE_LAYOUTS[index].key);
handleCurrentLayout(layout.key as TCycleLayout); handleCurrentLayout(layout.key as TCycleLayoutOptions);
}} }}
className="flex items-center gap-2" 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; const filterKey = key as keyof IIssueFilterOptions;
if (!value) return; if (!value) return;
if (Array.isArray(value) && value.length === 0) return;
return ( return (
<div <div

View File

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

View File

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

View File

@ -7,7 +7,7 @@ export interface EmptyStateDetails {
description?: string; description?: string;
path?: string; path?: string;
primaryButton?: { primaryButton?: {
icon?: any; icon?: React.ReactNode;
text: string; text: string;
comicBox?: { comicBox?: {
title?: string; title?: string;
@ -15,7 +15,7 @@ export interface EmptyStateDetails {
}; };
}; };
secondaryButton?: { secondaryButton?: {
icon?: any; icon?: React.ReactNode;
text: string; text: string;
comicBox?: { comicBox?: {
title?: string; title?: string;
@ -51,9 +51,7 @@ export enum EmptyStateType {
PROJECT_CYCLES = "project-cycles", PROJECT_CYCLES = "project-cycles",
PROJECT_CYCLE_NO_ISSUES = "project-cycle-no-issues", PROJECT_CYCLE_NO_ISSUES = "project-cycle-no-issues",
PROJECT_CYCLE_ACTIVE = "project-cycle-active", PROJECT_CYCLE_ACTIVE = "project-cycle-active",
PROJECT_CYCLE_UPCOMING = "project-cycle-upcoming", PROJECT_CYCLE_ALL = "project-cycle-all",
PROJECT_CYCLE_COMPLETED = "project-cycle-completed",
PROJECT_CYCLE_DRAFT = "project-cycle-draft",
PROJECT_EMPTY_FILTER = "project-empty-filter", PROJECT_EMPTY_FILTER = "project-empty-filter",
PROJECT_ARCHIVED_EMPTY_FILTER = "project-archived-empty-filter", PROJECT_ARCHIVED_EMPTY_FILTER = "project-archived-empty-filter",
PROJECT_DRAFT_EMPTY_FILTER = "project-draft-empty-filter", PROJECT_DRAFT_EMPTY_FILTER = "project-draft-empty-filter",
@ -288,28 +286,17 @@ const emptyStateDetails = {
}, },
"project-cycle-active": { "project-cycle-active": {
key: "project-cycle-active", key: "project-cycle-active",
title: "No active cycles", title: "No active cycle",
description: 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.", "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", path: "/empty-state/cycle/active",
}, },
"project-cycle-upcoming": { "project-cycle-all": {
key: "project-cycle-upcoming", key: "project-cycle-all",
title: "No upcoming cycles", title: "No cycles",
description: "Upcoming cycles on deck! Just add dates to cycles in draft, and they'll show up right here.", description:
path: "/empty-state/cycle/upcoming", "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-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",
}, },
// empty filters // empty filters
"project-empty-filter": { "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 // types
import { IIssueFilterOptions } from "@plane/types"; import { IIssueFilterOptions } from "@plane/types";
@ -13,3 +14,29 @@ export const calculateTotalFilters = (filters: IIssueFilterOptions): number =>
) )
.reduce((curr, prev) => curr + prev, 0) .reduce((curr, prev) => curr + prev, 0)
: 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-application";
export * from "./use-event-tracker";
export * from "./use-calendar-view"; export * from "./use-calendar-view";
export * from "./use-cycle-filter";
export * from "./use-cycle"; export * from "./use-cycle";
export * from "./use-event-tracker";
export * from "./use-dashboard"; export * from "./use-dashboard";
export * from "./use-estimate"; export * from "./use-estimate";
export * from "./use-global-view"; 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 { observer } from "mobx-react-lite";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { Tab } from "@headlessui/react"; import { Tab } from "@headlessui/react";
// hooks // hooks
import { useEventTracker, useCycle, useProject } from "hooks/store"; import { useEventTracker, useCycle, useProject, useCycleFilter } from "hooks/store";
import useLocalStorage from "hooks/use-local-storage";
// layouts // layouts
import { AppLayout } from "layouts/app-layout"; import { AppLayout } from "layouts/app-layout";
// components // components
import { PageHead } from "components/core"; import { PageHead } from "components/core";
import { CyclesHeader } from "components/headers"; 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 { EmptyState } from "components/empty-state";
import { CycleModuleBoardLayout, CycleModuleListLayout, GanttLayoutLoader } from "components/ui";
// ui // ui
import { Tooltip } from "@plane/ui"; import { CycleModuleBoardLayout, CycleModuleListLayout, GanttLayoutLoader } from "components/ui";
// helpers
import { calculateTotalFilters } from "helpers/filter.helper";
// types // types
import { NextPageWithLayout } from "lib/types"; import { NextPageWithLayout } from "lib/types";
import { TCycleView, TCycleLayout } from "@plane/types"; import { TCycleFilters } from "@plane/types";
// constants // constants
import { CYCLE_TAB_LIST, CYCLE_VIEW_LAYOUTS } from "constants/cycle"; import { CYCLE_TABS_LIST } from "constants/cycle";
import { EmptyStateType } from "constants/empty-state"; import { EmptyStateType } from "constants/empty-state";
const ProjectCyclesPage: NextPageWithLayout = observer(() => { const ProjectCyclesPage: NextPageWithLayout = observer(() => {
// states
const [createModal, setCreateModal] = useState(false); const [createModal, setCreateModal] = useState(false);
// store hooks // store hooks
const { setTrackElement } = useEventTracker(); const { setTrackElement } = useEventTracker();
@ -31,28 +38,26 @@ const ProjectCyclesPage: NextPageWithLayout = observer(() => {
// router // router
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId, peekCycle } = router.query; const { workspaceSlug, projectId, peekCycle } = router.query;
// local storage // cycle filters hook
const { storedValue: cycleTab, setValue: setCycleTab } = useLocalStorage<TCycleView>("cycle_tab", "active"); const { clearAllFilters, currentProjectDisplayFilters, currentProjectFilters, updateDisplayFilters, updateFilters } =
const { storedValue: cycleLayout, setValue: setCycleLayout } = useLocalStorage<TCycleLayout>("cycle_layout", "list"); useCycleFilter();
// derived values // derived values
const totalCycles = currentProjectCycleIds?.length ?? 0; const totalCycles = currentProjectCycleIds?.length ?? 0;
const project = projectId ? getProjectById(projectId?.toString()) : undefined; const project = projectId ? getProjectById(projectId?.toString()) : undefined;
const pageTitle = project?.name ? `${project?.name} - Cycles` : undefined; const pageTitle = project?.name ? `${project?.name} - Cycles` : undefined;
// selected display filters
const cycleTab = currentProjectDisplayFilters?.active_tab;
const cycleLayout = currentProjectDisplayFilters?.layout;
const handleCurrentLayout = useCallback( const handleRemoveFilter = (key: keyof TCycleFilters, value: string | null) => {
(_layout: TCycleLayout) => { if (!projectId) return;
setCycleLayout(_layout); let newValues = currentProjectFilters?.[key] ?? [];
},
[setCycleLayout]
);
const handleCurrentView = useCallback( if (!value) newValues = [];
(_view: TCycleView) => { else newValues = newValues.filter((val) => val !== value);
setCycleTab(_view);
if (_view === "draft") handleCurrentLayout("list"); updateFilters(projectId.toString(), { [key]: newValues });
}, };
[handleCurrentLayout, setCycleTab]
);
if (!workspaceSlug || !projectId) return null; if (!workspaceSlug || !projectId) return null;
@ -89,101 +94,35 @@ const ProjectCyclesPage: NextPageWithLayout = observer(() => {
<Tab.Group <Tab.Group
as="div" as="div"
className="flex h-full flex-col overflow-hidden" className="flex h-full flex-col overflow-hidden"
defaultIndex={CYCLE_TAB_LIST.findIndex((i) => i.key == cycleTab)} defaultIndex={CYCLE_TABS_LIST.findIndex((i) => i.key == cycleTab)}
selectedIndex={CYCLE_TAB_LIST.findIndex((i) => i.key == cycleTab)} selectedIndex={CYCLE_TABS_LIST.findIndex((i) => i.key == cycleTab)}
onChange={(i) => handleCurrentView(CYCLE_TAB_LIST[i]?.key ?? "active")} 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"> <CyclesViewHeader projectId={projectId.toString()} />
<Tab.List as="div" className="flex items-center overflow-x-scroll"> {calculateTotalFilters(currentProjectFilters ?? {}) !== 0 && (
{CYCLE_TAB_LIST.map((tab) => ( <div className="border-b border-custom-border-200 px-5 py-3">
<Tab <CycleAppliedFiltersList
key={tab.key} appliedFilters={currentProjectFilters ?? {}}
className={({ selected }) => handleClearAllFilters={() => clearAllFilters(projectId.toString())}
`border-b-2 p-4 text-sm font-medium outline-none ${ handleRemoveFilter={handleRemoveFilter}
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"
}`}
/>
</button>
</Tooltip>
);
})}
</div>
)}
</div> </div>
</div> )}
<Tab.Panels as={Fragment}> <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"> <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>
<Tab.Panel as="div" className="h-full overflow-y-auto"> <Tab.Panel as="div" className="h-full overflow-y-auto">
{cycleTab && cycleLayout && ( {cycleTab && cycleLayout && (
<CyclesView <CyclesView
filter="upcoming" layout={cycleLayout}
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}
workspaceSlug={workspaceSlug.toString()} workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()} projectId={projectId.toString()}
peekCycle={peekCycle?.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 { ProjectService } from "services/project";
import { RootStore } from "store/root.store"; import { RootStore } from "store/root.store";
import { ICycle, CycleDateCheckData } from "@plane/types"; import { ICycle, CycleDateCheckData } from "@plane/types";
import { orderCycles, shouldFilterCycle } from "helpers/cycle.helper";
export interface ICycleStore { export interface ICycleStore {
//Loaders // loaders
loader: boolean; loader: boolean;
// observables // observables
fetchedMap: Record<string, boolean>; fetchedMap: Record<string, boolean>;
@ -27,6 +28,8 @@ export interface ICycleStore {
currentProjectDraftCycleIds: string[] | null; currentProjectDraftCycleIds: string[] | null;
currentProjectActiveCycleId: string | null; currentProjectActiveCycleId: string | null;
// computed actions // computed actions
getFilteredCycleIds: (projectId: string) => string[] | null;
getFilteredCompletedCycleIds: (projectId: string) => string[] | null;
getCycleById: (cycleId: string) => ICycle | null; getCycleById: (cycleId: string) => ICycle | null;
getCycleNameById: (cycleId: string) => string | undefined; getCycleNameById: (cycleId: string) => string | undefined;
getActiveCycleById: (cycleId: string) => ICycle | null; getActiveCycleById: (cycleId: string) => ICycle | null;
@ -183,6 +186,49 @@ export class CycleStore implements ICycleStore {
return activeCycle || null; 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 * @description returns cycle details by cycle id
* @param cycleId * @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 { IUserRootStore, UserRootStore } from "./user";
import { IWorkspaceRootStore, WorkspaceRootStore } from "./workspace"; import { IWorkspaceRootStore, WorkspaceRootStore } from "./workspace";
import { IProjectPageStore, ProjectPageStore } from "./project-page.store"; import { IProjectPageStore, ProjectPageStore } from "./project-page.store";
import { CycleFilterStore, ICycleFilterStore } from "./cycle_filter.store";
enableStaticRendering(typeof window === "undefined"); enableStaticRendering(typeof window === "undefined");
@ -29,6 +30,7 @@ export class RootStore {
projectRoot: IProjectRootStore; projectRoot: IProjectRootStore;
memberRoot: IMemberRootStore; memberRoot: IMemberRootStore;
cycle: ICycleStore; cycle: ICycleStore;
cycleFilter: ICycleFilterStore;
module: IModuleStore; module: IModuleStore;
projectView: IProjectViewStore; projectView: IProjectViewStore;
globalView: IGlobalViewStore; globalView: IGlobalViewStore;
@ -50,6 +52,7 @@ export class RootStore {
this.memberRoot = new MemberRootStore(this); this.memberRoot = new MemberRootStore(this);
// independent stores // independent stores
this.cycle = new CycleStore(this); this.cycle = new CycleStore(this);
this.cycleFilter = new CycleFilterStore(this);
this.module = new ModulesStore(this); this.module = new ModulesStore(this);
this.projectView = new ProjectViewStore(this); this.projectView = new ProjectViewStore(this);
this.globalView = new GlobalViewStore(this); this.globalView = new GlobalViewStore(this);
@ -69,6 +72,7 @@ export class RootStore {
this.memberRoot = new MemberRootStore(this); this.memberRoot = new MemberRootStore(this);
// independent stores // independent stores
this.cycle = new CycleStore(this); this.cycle = new CycleStore(this);
this.cycleFilter = new CycleFilterStore(this);
this.module = new ModulesStore(this); this.module = new ModulesStore(this);
this.projectView = new ProjectViewStore(this); this.projectView = new ProjectViewStore(this);
this.globalView = new GlobalViewStore(this); this.globalView = new GlobalViewStore(this);