mirror of
https://github.com/makeplane/plane
synced 2024-06-14 14:31:34 +00:00
[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:
parent
4b30339a59
commit
535731141f
@ -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;
|
19
packages/types/src/cycle/cycle_filters.d.ts
vendored
Normal file
19
packages/types/src/cycle/cycle_filters.d.ts
vendored
Normal 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;
|
||||||
|
};
|
2
packages/types/src/cycle/index.ts
Normal file
2
packages/types/src/cycle/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from "./cycle_filters";
|
||||||
|
export * from "./cycle";
|
2
packages/types/src/index.d.ts
vendored
2
packages/types/src/index.d.ts
vendored
@ -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";
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
|
4
web/components/cycles/active-cycle/index.ts
Normal file
4
web/components/cycles/active-cycle/index.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export * from "./root";
|
||||||
|
export * from "./stats";
|
||||||
|
export * from "./upcoming-cycles-list-item";
|
||||||
|
export * from "./upcoming-cycles-list";
|
@ -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>
|
@ -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>
|
135
web/components/cycles/active-cycle/upcoming-cycles-list-item.tsx
Normal file
135
web/components/cycles/active-cycle/upcoming-cycles-list-item.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
});
|
25
web/components/cycles/active-cycle/upcoming-cycles-list.tsx
Normal file
25
web/components/cycles/active-cycle/upcoming-cycles-list.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
});
|
55
web/components/cycles/applied-filters/date.tsx
Normal file
55
web/components/cycles/applied-filters/date.tsx
Normal 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>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
3
web/components/cycles/applied-filters/index.ts
Normal file
3
web/components/cycles/applied-filters/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export * from "./date";
|
||||||
|
export * from "./root";
|
||||||
|
export * from "./status";
|
90
web/components/cycles/applied-filters/root.tsx
Normal file
90
web/components/cycles/applied-filters/root.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
});
|
43
web/components/cycles/applied-filters/status.tsx
Normal file
43
web/components/cycles/applied-filters/status.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
@ -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;
|
||||||
@ -82,20 +67,6 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = observer((props) => {
|
|||||||
: `${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>
|
25
web/components/cycles/board/cycles-board-map.tsx
Normal file
25
web/components/cycles/board/cycles-board-map.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
3
web/components/cycles/board/index.ts
Normal file
3
web/components/cycles/board/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export * from "./cycles-board-card";
|
||||||
|
export * from "./cycles-board-map";
|
||||||
|
export * from "./root";
|
60
web/components/cycles/board/root.tsx
Normal file
60
web/components/cycles/board/root.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
});
|
@ -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" />
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
});
|
|
@ -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>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
});
|
|
164
web/components/cycles/cycles-view-header.tsx
Normal file
164
web/components/cycles/cycles-view-header.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
});
|
@ -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} />}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -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>
|
||||||
|
63
web/components/cycles/dropdowns/filters/end-date.tsx
Normal file
63
web/components/cycles/dropdowns/filters/end-date.tsx
Normal 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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
4
web/components/cycles/dropdowns/filters/index.ts
Normal file
4
web/components/cycles/dropdowns/filters/index.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export * from "./end-date";
|
||||||
|
export * from "./root";
|
||||||
|
export * from "./start-date";
|
||||||
|
export * from "./status";
|
69
web/components/cycles/dropdowns/filters/root.tsx
Normal file
69
web/components/cycles/dropdowns/filters/root.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
});
|
63
web/components/cycles/dropdowns/filters/start-date.tsx
Normal file
63
web/components/cycles/dropdowns/filters/start-date.tsx
Normal 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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
49
web/components/cycles/dropdowns/filters/status.tsx
Normal file
49
web/components/cycles/dropdowns/filters/status.tsx
Normal 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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
1
web/components/cycles/dropdowns/index.ts
Normal file
1
web/components/cycles/dropdowns/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from "./filters";
|
@ -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>
|
||||||
);
|
);
|
||||||
|
@ -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";
|
|
||||||
|
@ -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>
|
20
web/components/cycles/list/cycles-list-map.tsx
Normal file
20
web/components/cycles/list/cycles-list-map.tsx
Normal 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} />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
3
web/components/cycles/list/index.ts
Normal file
3
web/components/cycles/list/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export * from "./cycles-list-item";
|
||||||
|
export * from "./cycles-list-map";
|
||||||
|
export * from "./root";
|
49
web/components/cycles/list/root.tsx
Normal file
49
web/components/cycles/list/root.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
});
|
@ -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;
|
||||||
|
112
web/components/cycles/quick-actions.tsx
Normal file
112
web/components/cycles/quick-actions.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
@ -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>
|
||||||
|
@ -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"
|
||||||
>
|
>
|
||||||
|
@ -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
|
||||||
|
@ -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}
|
||||||
|
@ -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",
|
||||||
|
@ -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": {
|
||||||
|
59
web/helpers/cycle.helper.ts
Normal file
59
web/helpers/cycle.helper.ts
Normal 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;
|
||||||
|
};
|
@ -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;
|
||||||
|
};
|
||||||
|
@ -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";
|
||||||
|
11
web/hooks/store/use-cycle-filter.ts
Normal file
11
web/hooks/store/use-cycle-filter.ts
Normal 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;
|
||||||
|
};
|
@ -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()}
|
||||||
|
42
web/public/empty-state/cycle/all-filters.svg
Normal file
42
web/public/empty-state/cycle/all-filters.svg
Normal 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 |
41
web/public/empty-state/cycle/name-filter.svg
Normal file
41
web/public/empty-state/cycle/name-filter.svg
Normal 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 |
@ -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
|
||||||
|
145
web/store/cycle_filter.store.ts
Normal file
145
web/store/cycle_filter.store.ts
Normal 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] = {};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
@ -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);
|
||||||
|
Loading…
Reference in New Issue
Block a user