diff --git a/packages/types/src/cycles.d.ts b/packages/types/src/cycle/cycle.d.ts similarity index 94% rename from packages/types/src/cycles.d.ts rename to packages/types/src/cycle/cycle.d.ts index 25b7427f5..6d21e05b8 100644 --- a/packages/types/src/cycles.d.ts +++ b/packages/types/src/cycle/cycle.d.ts @@ -1,11 +1,7 @@ import type { TIssue, IIssueFilterOptions } from "@plane/types"; -export type TCycleView = "all" | "active" | "upcoming" | "completed" | "draft"; - export type TCycleGroups = "current" | "upcoming" | "completed" | "draft"; -export type TCycleLayout = "list" | "board" | "gantt"; - export interface ICycle { backlog_issues: number; cancelled_issues: number; diff --git a/packages/types/src/cycle/cycle_filters.d.ts b/packages/types/src/cycle/cycle_filters.d.ts new file mode 100644 index 000000000..470a20dd2 --- /dev/null +++ b/packages/types/src/cycle/cycle_filters.d.ts @@ -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; +}; diff --git a/packages/types/src/cycle/index.ts b/packages/types/src/cycle/index.ts new file mode 100644 index 000000000..d5f4ce5b0 --- /dev/null +++ b/packages/types/src/cycle/index.ts @@ -0,0 +1,2 @@ +export * from "./cycle_filters"; +export * from "./cycle"; diff --git a/packages/types/src/index.d.ts b/packages/types/src/index.d.ts index bfebd92d0..eeec266b5 100644 --- a/packages/types/src/index.d.ts +++ b/packages/types/src/index.d.ts @@ -1,6 +1,6 @@ export * from "./users"; export * from "./workspace"; -export * from "./cycles"; +export * from "./cycle"; export * from "./dashboard"; export * from "./projects"; export * from "./state"; diff --git a/packages/ui/src/icons/cycle/circle-dot-full-icon.tsx b/packages/ui/src/icons/cycle/circle-dot-full-icon.tsx index 47c90e72b..dd063e79c 100644 --- a/packages/ui/src/icons/cycle/circle-dot-full-icon.tsx +++ b/packages/ui/src/icons/cycle/circle-dot-full-icon.tsx @@ -4,7 +4,7 @@ import { ISvgIcons } from "../type"; export const CircleDotFullIcon: React.FC = ({ className = "text-current", ...rest }) => ( - + ); diff --git a/web/components/cycles/active-cycle/index.ts b/web/components/cycles/active-cycle/index.ts new file mode 100644 index 000000000..73d5d1e98 --- /dev/null +++ b/web/components/cycles/active-cycle/index.ts @@ -0,0 +1,4 @@ +export * from "./root"; +export * from "./stats"; +export * from "./upcoming-cycles-list-item"; +export * from "./upcoming-cycles-list"; diff --git a/web/components/cycles/active-cycle-details.tsx b/web/components/cycles/active-cycle/root.tsx similarity index 85% rename from web/components/cycles/active-cycle-details.tsx rename to web/components/cycles/active-cycle/root.tsx index a6457ab3c..1f33ef15c 100644 --- a/web/components/cycles/active-cycle-details.tsx +++ b/web/components/cycles/active-cycle/root.tsx @@ -3,7 +3,7 @@ import { observer } from "mobx-react-lite"; import Link from "next/link"; import useSWR from "swr"; // hooks -import { useCycle, useIssues, useMember, useProject } from "hooks/store"; +import { useCycle, useCycleFilter, useIssues, useMember, useProject } from "hooks/store"; // ui import { SingleProgressStats } from "components/core"; import { @@ -17,10 +17,11 @@ import { Avatar, CycleGroupIcon, setPromiseToast, + getButtonStyling, } from "@plane/ui"; // components import ProgressChart from "components/core/sidebar/progress-chart"; -import { ActiveCycleProgressStats } from "components/cycles"; +import { ActiveCycleProgressStats, UpcomingCyclesList } from "components/cycles"; import { StateDropdown } from "components/dropdowns"; import { EmptyState } from "components/empty-state"; // icons @@ -28,6 +29,7 @@ import { ArrowRight, CalendarCheck, CalendarDays, Star, Target } from "lucide-re // helpers import { renderFormattedDate, findHowManyDaysLeft, renderFormattedDateWithoutYear } from "helpers/date-time.helper"; import { truncateText } from "helpers/string.helper"; +import { cn } from "helpers/common.helper"; // types import { ICycle, TCycleGroups } from "@plane/types"; // constants @@ -41,30 +43,34 @@ interface IActiveCycleDetails { projectId: string; } -export const ActiveCycleDetails: React.FC = observer((props) => { +export const ActiveCycleRoot: React.FC = observer((props) => { // props const { workspaceSlug, projectId } = props; + // store hooks const { issues: { fetchActiveCycleIssues }, } = useIssues(EIssuesStoreType.CYCLE); const { - fetchActiveCycle, currentProjectActiveCycleId, + currentProjectUpcomingCycleIds, + fetchActiveCycle, getActiveCycleById, addCycleToFavorites, removeCycleFromFavorites, } = useCycle(); const { currentProjectDetails } = useProject(); const { getUserDetails } = useMember(); - + // cycle filters hook + const { updateDisplayFilters } = useCycleFilter(); + // derived values + const activeCycle = currentProjectActiveCycleId ? getActiveCycleById(currentProjectActiveCycleId) : null; + const cycleOwnerDetails = activeCycle ? getUserDetails(activeCycle.owned_by_id) : undefined; + // fetch active cycle details const { isLoading } = useSWR( workspaceSlug && projectId ? `PROJECT_ACTIVE_CYCLE_${projectId}` : null, workspaceSlug && projectId ? () => fetchActiveCycle(workspaceSlug, projectId) : null ); - - const activeCycle = currentProjectActiveCycleId ? getActiveCycleById(currentProjectActiveCycleId) : null; - const cycleOwnerDetails = activeCycle ? getUserDetails(activeCycle.owned_by_id) : undefined; - + // fetch active cycle issues const { data: activeCycleIssues } = useSWR( workspaceSlug && projectId && currentProjectActiveCycleId ? CYCLE_ISSUES_WITH_PARAMS(currentProjectActiveCycleId, { priority: "urgent,high" }) @@ -73,7 +79,7 @@ export const ActiveCycleDetails: React.FC = observer((props ? () => fetchActiveCycleIssues(workspaceSlug, projectId, currentProjectActiveCycleId) : null ); - + // show loader if active cycle is loading if (!activeCycle && isLoading) return ( @@ -81,10 +87,44 @@ export const ActiveCycleDetails: React.FC = observer((props ); - if (!activeCycle) return ; + if (!activeCycle) { + // show empty state if no active cycle is present + if (currentProjectUpcomingCycleIds?.length === 0) + return ; + // show upcoming cycles list, if present + else + return ( + <> +
+
+
No active cycle
+

+ Create new cycles to find them here or check +
+ {"'"}All{"'"} cycles tab to see all cycles or{" "} + +

+
+
+ + + ); + } const endDate = new Date(activeCycle.end_date ?? ""); const startDate = new Date(activeCycle.start_date ?? ""); + const daysLeft = findHowManyDaysLeft(activeCycle.end_date) ?? 0; + const cycleStatus = activeCycle.status.toLowerCase() as TCycleGroups; const groupedIssues: any = { backlog: activeCycle.backlog_issues, @@ -94,8 +134,6 @@ export const ActiveCycleDetails: React.FC = observer((props cancelled: activeCycle.cancelled_issues, }; - const cycleStatus = activeCycle.status.toLowerCase() as TCycleGroups; - const handleAddToFavorites = (e: MouseEvent) => { e.preventDefault(); if (!workspaceSlug || !projectId) return; @@ -148,8 +186,6 @@ export const ActiveCycleDetails: React.FC = observer((props color: group.color, })); - const daysLeft = findHowManyDaysLeft(activeCycle.end_date) ?? 0; - return (
@@ -203,27 +239,15 @@ export const ActiveCycleDetails: React.FC = observer((props
- {cycleOwnerDetails?.avatar && cycleOwnerDetails?.avatar !== "" ? ( - {cycleOwnerDetails?.display_name} - ) : ( - - {cycleOwnerDetails?.display_name.charAt(0)} - - )} + {cycleOwnerDetails?.display_name}
{activeCycle.assignee_ids.length > 0 && (
- {activeCycle.assignee_ids.map((assigne_id) => { - const member = getUserDetails(assigne_id); + {activeCycle.assignee_ids.map((assignee_id) => { + const member = getUserDetails(assignee_id); return ; })} @@ -233,7 +257,7 @@ export const ActiveCycleDetails: React.FC = observer((props
- + {activeCycle.total_issues} issues
@@ -244,9 +268,9 @@ export const ActiveCycleDetails: React.FC = observer((props - View Cycle + View cycle
@@ -287,11 +311,11 @@ export const ActiveCycleDetails: React.FC = observer((props
-
High Priority Issues
+
High priority issues
{activeCycleIssues ? ( activeCycleIssues.length > 0 ? ( - activeCycleIssues.map((issue: any) => ( + activeCycleIssues.map((issue) => ( = observer((props
{}} - projectId={projectId?.toString() ?? ""} + projectId={projectId} disabled buttonVariant="background-with-text" /> @@ -359,10 +383,10 @@ export const ActiveCycleDetails: React.FC = observer((props
- + - Pending Issues -{" "} + Pending issues-{" "} {activeCycle.total_issues - (activeCycle.completed_issues + activeCycle.cancelled_issues)}
diff --git a/web/components/cycles/active-cycle-stats.tsx b/web/components/cycles/active-cycle/stats.tsx similarity index 98% rename from web/components/cycles/active-cycle-stats.tsx rename to web/components/cycles/active-cycle/stats.tsx index 0cf7449ae..9ccd11077 100644 --- a/web/components/cycles/active-cycle-stats.tsx +++ b/web/components/cycles/active-cycle/stats.tsx @@ -134,7 +134,7 @@ export const ActiveCycleProgressStats: React.FC = ({ cycle }) => { ) : (
- There are no high priority issues present in this cycle. + There are no issues present in this cycle.
)} diff --git a/web/components/cycles/active-cycle/upcoming-cycles-list-item.tsx b/web/components/cycles/active-cycle/upcoming-cycles-list-item.tsx new file mode 100644 index 000000000..af2b02726 --- /dev/null +++ b/web/components/cycles/active-cycle/upcoming-cycles-list-item.tsx @@ -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 = 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) => { + 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) => { + 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 ( + +
{cycle.name}
+
+ {cycle.start_date && cycle.end_date && ( +
+ {renderFormattedDate(cycle.start_date)} - {renderFormattedDate(cycle.end_date)} +
+ )} + {cycle.assignee_ids?.length > 0 ? ( + + {cycle.assignee_ids?.map((assigneeId) => { + const member = getUserDetails(assigneeId); + return ; + })} + + ) : ( + + + + )} + + {cycle.is_favorite ? ( + + ) : ( + + )} + + {workspaceSlug && projectId && ( + + )} +
+ + ); +}); diff --git a/web/components/cycles/active-cycle/upcoming-cycles-list.tsx b/web/components/cycles/active-cycle/upcoming-cycles-list.tsx new file mode 100644 index 000000000..60fa9bb30 --- /dev/null +++ b/web/components/cycles/active-cycle/upcoming-cycles-list.tsx @@ -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 ( +
+
+ Upcoming cycles +
+
+ {currentProjectUpcomingCycleIds.map((cycleId) => ( + + ))} +
+
+ ); +}); diff --git a/web/components/cycles/applied-filters/date.tsx b/web/components/cycles/applied-filters/date.tsx new file mode 100644 index 000000000..0298f12d2 --- /dev/null +++ b/web/components/cycles/applied-filters/date.tsx @@ -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 = 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) => ( +
+ {getDateLabel(date)} + {editable && ( + + )} +
+ ))} + + ); +}); diff --git a/web/components/cycles/applied-filters/index.ts b/web/components/cycles/applied-filters/index.ts new file mode 100644 index 000000000..cee9ae349 --- /dev/null +++ b/web/components/cycles/applied-filters/index.ts @@ -0,0 +1,3 @@ +export * from "./date"; +export * from "./root"; +export * from "./status"; diff --git a/web/components/cycles/applied-filters/root.tsx b/web/components/cycles/applied-filters/root.tsx new file mode 100644 index 000000000..39d2ae827 --- /dev/null +++ b/web/components/cycles/applied-filters/root.tsx @@ -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 = 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 ( +
+ {Object.entries(appliedFilters).map(([key, value]) => { + const filterKey = key as keyof TCycleFilters; + + if (!value) return; + if (Array.isArray(value) && value.length === 0) return; + + return ( +
+ {replaceUnderscoreIfSnakeCase(filterKey)} +
+ {filterKey === "status" && ( + handleRemoveFilter("status", val)} + values={value} + /> + )} + {DATE_FILTERS.includes(filterKey) && ( + handleRemoveFilter(filterKey, val)} + values={value} + /> + )} + {isEditingAllowed && ( + + )} +
+
+ ); + })} + {isEditingAllowed && ( + + )} +
+ ); +}); diff --git a/web/components/cycles/applied-filters/status.tsx b/web/components/cycles/applied-filters/status.tsx new file mode 100644 index 000000000..1eb28db74 --- /dev/null +++ b/web/components/cycles/applied-filters/status.tsx @@ -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 = observer((props) => { + const { handleRemove, values, editable } = props; + + return ( + <> + {values.map((status) => { + const statusDetails = CYCLE_STATUS.find((s) => s.value === status); + return ( +
+ {statusDetails?.title} + {editable && ( + + )} +
+ ); + })} + + ); +}); diff --git a/web/components/cycles/cycles-board-card.tsx b/web/components/cycles/board/cycles-board-card.tsx similarity index 71% rename from web/components/cycles/cycles-board-card.tsx rename to web/components/cycles/board/cycles-board-card.tsx index da97f2d9d..ac95f790d 100644 --- a/web/components/cycles/cycles-board-card.tsx +++ b/web/components/cycles/board/cycles-board-card.tsx @@ -1,22 +1,12 @@ -import { FC, MouseEvent, useState } from "react"; +import { FC, MouseEvent } from "react"; import { observer } from "mobx-react"; import Link from "next/link"; import { useRouter } from "next/router"; // hooks // components -import { Info, LinkIcon, Pencil, Star, Trash2 } from "lucide-react"; -import { - Avatar, - AvatarGroup, - CustomMenu, - Tooltip, - LayersIcon, - CycleGroupIcon, - TOAST_TYPE, - setToast, - setPromiseToast, -} from "@plane/ui"; -import { CycleCreateUpdateModal, CycleDeleteModal } from "components/cycles"; +import { Info, Star } from "lucide-react"; +import { Avatar, AvatarGroup, Tooltip, LayersIcon, CycleGroupIcon, setPromiseToast } from "@plane/ui"; +import { CycleQuickActions } from "components/cycles"; // ui // icons // helpers @@ -24,7 +14,6 @@ import { CYCLE_STATUS } from "constants/cycle"; import { CYCLE_FAVORITED, CYCLE_UNFAVORITED } from "constants/event-tracker"; import { EUserWorkspaceRoles } from "constants/workspace"; import { findHowManyDaysLeft, renderFormattedDate } from "helpers/date-time.helper"; -import { copyTextToClipboard } from "helpers/string.helper"; // constants import { useEventTracker, useCycle, useUser, useMember } from "hooks/store"; //.types @@ -38,13 +27,10 @@ export interface ICyclesBoardCard { export const CyclesBoardCard: FC = observer((props) => { const { cycleId, workspaceSlug, projectId } = props; - // states - const [updateModal, setUpdateModal] = useState(false); - const [deleteModal, setDeleteModal] = useState(false); // router const router = useRouter(); // store - const { setTrackElement, captureEvent } = useEventTracker(); + const { captureEvent } = useEventTracker(); const { membership: { currentProjectRole }, } = useUser(); @@ -56,7 +42,6 @@ export const CyclesBoardCard: FC = observer((props) => { if (!cycleDetails) return null; const cycleStatus = cycleDetails.status.toLocaleLowerCase(); - const isCompleted = cycleStatus === "completed"; const endDate = new Date(cycleDetails.end_date ?? ""); const startDate = new Date(cycleDetails.start_date ?? ""); const isDateValid = cycleDetails.start_date || cycleDetails.end_date; @@ -78,24 +63,10 @@ export const CyclesBoardCard: FC = observer((props) => { ? cycleTotalIssues === 0 ? "0 Issue" : cycleTotalIssues === cycleDetails.completed_issues - ? `${cycleTotalIssues} Issue${cycleTotalIssues > 1 ? "s" : ""}` - : `${cycleDetails.completed_issues}/${cycleTotalIssues} Issues` + ? `${cycleTotalIssues} Issue${cycleTotalIssues > 1 ? "s" : ""}` + : `${cycleDetails.completed_issues}/${cycleTotalIssues} Issues` : "0 Issue"; - const handleCopyText = (e: MouseEvent) => { - 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) => { e.preventDefault(); if (!workspaceSlug || !projectId) return; @@ -152,20 +123,6 @@ export const CyclesBoardCard: FC = observer((props) => { }); }; - const handleEditCycle = (e: MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - setTrackElement("Cycles page grid layout"); - setUpdateModal(true); - }; - - const handleDeleteCycle = (e: MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - setTrackElement("Cycles page grid layout"); - setDeleteModal(true); - }; - const openCycleOverview = (e: MouseEvent) => { const { query } = router; e.preventDefault(); @@ -181,22 +138,6 @@ export const CyclesBoardCard: FC = observer((props) => { return (
- setUpdateModal(false)} - workspaceSlug={workspaceSlug} - projectId={projectId} - /> - - setDeleteModal(false)} - workspaceSlug={workspaceSlug} - projectId={projectId} - /> -
@@ -288,30 +229,8 @@ export const CyclesBoardCard: FC = observer((props) => { ))} - - {!isCompleted && isEditingAllowed && ( - <> - - - - Edit cycle - - - - - - Delete cycle - - - - )} - - - - Copy cycle link - - - + +
diff --git a/web/components/cycles/board/cycles-board-map.tsx b/web/components/cycles/board/cycles-board-map.tsx new file mode 100644 index 000000000..4218c0d1c --- /dev/null +++ b/web/components/cycles/board/cycles-board-map.tsx @@ -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) => { + const { cycleIds, peekCycle, projectId, workspaceSlug } = props; + + return ( +
+ {cycleIds.map((cycleId) => ( + + ))} +
+ ); +}; diff --git a/web/components/cycles/board/index.ts b/web/components/cycles/board/index.ts new file mode 100644 index 000000000..2e6933d99 --- /dev/null +++ b/web/components/cycles/board/index.ts @@ -0,0 +1,3 @@ +export * from "./cycles-board-card"; +export * from "./cycles-board-map"; +export * from "./root"; diff --git a/web/components/cycles/board/root.tsx b/web/components/cycles/board/root.tsx new file mode 100644 index 000000000..26154becf --- /dev/null +++ b/web/components/cycles/board/root.tsx @@ -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 = observer((props) => { + const { completedCycleIds, cycleIds, workspaceSlug, projectId, peekCycle } = props; + + return ( +
+
+
+ + {completedCycleIds.length !== 0 && ( + + + {({ open }) => ( + <> + Completed cycles ({completedCycleIds.length}) + + + )} + + + + + + )} +
+ +
+
+ ); +}); diff --git a/web/components/cycles/cycles-board.tsx b/web/components/cycles/cycles-board.tsx deleted file mode 100644 index 278d55071..000000000 --- a/web/components/cycles/cycles-board.tsx +++ /dev/null @@ -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 = observer((props) => { - const { cycleIds, filter, workspaceSlug, projectId, peekCycle } = props; - - return ( - <> - {cycleIds?.length > 0 ? ( -
-
-
- {cycleIds.map((cycleId) => ( - - ))} -
- -
-
- ) : ( - - )} - - ); -}); diff --git a/web/components/cycles/cycles-list.tsx b/web/components/cycles/cycles-list.tsx deleted file mode 100644 index f6ad64f99..000000000 --- a/web/components/cycles/cycles-list.tsx +++ /dev/null @@ -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 = observer((props) => { - const { cycleIds, filter, workspaceSlug, projectId } = props; - - return ( - <> - {cycleIds ? ( - <> - {cycleIds.length > 0 ? ( -
-
-
- {cycleIds.map((cycleId) => ( - - ))} -
- -
-
- ) : ( - - )} - - ) : ( - - - - - - )} - - ); -}); diff --git a/web/components/cycles/cycles-view-header.tsx b/web/components/cycles/cycles-view-header.tsx new file mode 100644 index 000000000..b0feede0e --- /dev/null +++ b/web/components/cycles/cycles-view-header.tsx @@ -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 = observer((props) => { + const { projectId } = props; + // states + const [isSearchOpen, setIsSearchOpen] = useState(false); + // refs + const inputRef = useRef(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) => { + if (e.key === "Escape") { + if (searchQuery && searchQuery.trim() !== "") updateSearchQuery(""); + else setIsSearchOpen(false); + } + }; + + return ( +
+ + {CYCLE_TABS_LIST.map((tab) => ( + + `border-b-2 p-4 text-sm font-medium outline-none ${ + selected ? "border-custom-primary-100 text-custom-primary-100" : "border-transparent" + }` + } + > + {tab.name} + + ))} + + {currentProjectDisplayFilters?.active_tab !== "active" && ( +
+ {!isSearchOpen && ( + + )} +
+ + updateSearchQuery(e.target.value)} + onKeyDown={handleInputKeyDown} + /> + {isSearchOpen && ( + + )} +
+ } title="Filters" placement="bottom-end"> + + +
+ {CYCLE_VIEW_LAYOUTS.map((layout) => ( + + + + ))} +
+
+ )} +
+ ); +}); diff --git a/web/components/cycles/cycles-view.tsx b/web/components/cycles/cycles-view.tsx index 745ca1bd3..447bd048c 100644 --- a/web/components/cycles/cycles-view.tsx +++ b/web/components/cycles/cycles-view.tsx @@ -1,43 +1,35 @@ import { FC } from "react"; +import Image from "next/image"; import { observer } from "mobx-react-lite"; // hooks +import { useCycle, useCycleFilter } from "hooks/store"; // components import { CyclesBoard, CyclesList, CyclesListGanttChartView } from "components/cycles"; -// ui components +// ui import { CycleModuleBoardLayout, CycleModuleListLayout, GanttLayoutLoader } from "components/ui"; -import { useCycle } from "hooks/store"; +// assets +import NameFilterImage from "public/empty-state/cycle/name-filter.svg"; +import AllFiltersImage from "public/empty-state/cycle/all-filters.svg"; // types -import { TCycleLayout, TCycleView } from "@plane/types"; +import { TCycleLayoutOptions } from "@plane/types"; export interface ICyclesView { - filter: TCycleView; - layout: TCycleLayout; + layout: TCycleLayoutOptions; workspaceSlug: string; projectId: string; peekCycle: string | undefined; } export const CyclesView: FC = observer((props) => { - const { filter, layout, workspaceSlug, projectId, peekCycle } = props; + const { layout, workspaceSlug, projectId, peekCycle } = props; // store hooks - const { - currentProjectCompletedCycleIds, - currentProjectDraftCycleIds, - currentProjectUpcomingCycleIds, - currentProjectCycleIds, - loader, - } = useCycle(); + const { getFilteredCycleIds, getFilteredCompletedCycleIds, loader } = useCycle(); + const { searchQuery } = useCycleFilter(); + // derived values + const filteredCycleIds = getFilteredCycleIds(projectId); + const filteredCompletedCycleIds = getFilteredCompletedCycleIds(projectId); - const cyclesList = - filter === "completed" - ? currentProjectCompletedCycleIds - : filter === "draft" - ? currentProjectDraftCycleIds - : filter === "upcoming" - ? currentProjectUpcomingCycleIds - : currentProjectCycleIds; - - if (loader || !cyclesList) + if (loader || !filteredCycleIds) return ( <> {layout === "list" && } @@ -46,23 +38,45 @@ export const CyclesView: FC = observer((props) => { ); + if (filteredCycleIds.length === 0 && filteredCompletedCycleIds?.length === 0) + return ( +
+
+ No matching cycles +
No matching cycles
+

+ {searchQuery.trim() === "" + ? "Remove the filters to see all cycles" + : "Remove the search criteria to see all cycles"} +

+
+
+ ); + return ( <> {layout === "list" && ( - + )} - {layout === "board" && ( )} - - {layout === "gantt" && } + {layout === "gantt" && } ); }); diff --git a/web/components/cycles/delete-modal.tsx b/web/components/cycles/delete-modal.tsx index fd7b1f356..0d1cc5921 100644 --- a/web/components/cycles/delete-modal.tsx +++ b/web/components/cycles/delete-modal.tsx @@ -103,7 +103,7 @@ export const CycleDeleteModal: React.FC = observer((props) => {
-
Delete Cycle
+
Delete cycle

@@ -118,8 +118,8 @@ export const CycleDeleteModal: React.FC = observer((props) => { Cancel -

diff --git a/web/components/cycles/dropdowns/filters/end-date.tsx b/web/components/cycles/dropdowns/filters/end-date.tsx new file mode 100644 index 000000000..10a401500 --- /dev/null +++ b/web/components/cycles/dropdowns/filters/end-date.tsx @@ -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 = 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 && ( + setIsDateFilterModalOpen(false)} + isOpen={isDateFilterModalOpen} + onSelect={(val) => handleUpdate(val)} + title="Due date" + /> + )} + 0 ? ` (${appliedFiltersCount})` : ""}`} + isPreviewEnabled={previewEnabled} + handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)} + /> + {previewEnabled && ( +
+ {filteredOptions.length > 0 ? ( + <> + {filteredOptions.map((option) => ( + handleUpdate(option.value)} + title={option.name} + multiple + /> + ))} + setIsDateFilterModalOpen(true)} title="Custom" multiple /> + + ) : ( +

No matches found

+ )} +
+ )} + + ); +}); diff --git a/web/components/cycles/dropdowns/filters/index.ts b/web/components/cycles/dropdowns/filters/index.ts new file mode 100644 index 000000000..3d097b6f0 --- /dev/null +++ b/web/components/cycles/dropdowns/filters/index.ts @@ -0,0 +1,4 @@ +export * from "./end-date"; +export * from "./root"; +export * from "./start-date"; +export * from "./status"; diff --git a/web/components/cycles/dropdowns/filters/root.tsx b/web/components/cycles/dropdowns/filters/root.tsx new file mode 100644 index 000000000..d97fcad03 --- /dev/null +++ b/web/components/cycles/dropdowns/filters/root.tsx @@ -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 = observer((props) => { + const { filters, handleFiltersUpdate } = props; + // states + const [filtersSearchQuery, setFiltersSearchQuery] = useState(""); + + return ( +
+
+
+ + setFiltersSearchQuery(e.target.value)} + autoFocus + /> + {filtersSearchQuery !== "" && ( + + )} +
+
+
+ {/* cycle status */} +
+ handleFiltersUpdate("status", val)} + searchQuery={filtersSearchQuery} + /> +
+ + {/* start date */} +
+ handleFiltersUpdate("start_date", val)} + searchQuery={filtersSearchQuery} + /> +
+ + {/* end date */} +
+ handleFiltersUpdate("end_date", val)} + searchQuery={filtersSearchQuery} + /> +
+
+
+ ); +}); diff --git a/web/components/cycles/dropdowns/filters/start-date.tsx b/web/components/cycles/dropdowns/filters/start-date.tsx new file mode 100644 index 000000000..87def7e29 --- /dev/null +++ b/web/components/cycles/dropdowns/filters/start-date.tsx @@ -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 = 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 && ( + setIsDateFilterModalOpen(false)} + isOpen={isDateFilterModalOpen} + onSelect={(val) => handleUpdate(val)} + title="Start date" + /> + )} + 0 ? ` (${appliedFiltersCount})` : ""}`} + isPreviewEnabled={previewEnabled} + handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)} + /> + {previewEnabled && ( +
+ {filteredOptions.length > 0 ? ( + <> + {filteredOptions.map((option) => ( + handleUpdate(option.value)} + title={option.name} + multiple + /> + ))} + setIsDateFilterModalOpen(true)} title="Custom" multiple /> + + ) : ( +

No matches found

+ )} +
+ )} + + ); +}); diff --git a/web/components/cycles/dropdowns/filters/status.tsx b/web/components/cycles/dropdowns/filters/status.tsx new file mode 100644 index 000000000..79e53a5c8 --- /dev/null +++ b/web/components/cycles/dropdowns/filters/status.tsx @@ -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 = 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 ( + <> + 0 ? ` (${appliedFiltersCount})` : ""}`} + isPreviewEnabled={previewEnabled} + handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)} + /> + {previewEnabled && ( +
+ {filteredOptions.length > 0 ? ( + filteredOptions.map((status) => ( + handleUpdate(status.value)} + title={status.title} + /> + )) + ) : ( +

No matches found

+ )} +
+ )} + + ); +}); diff --git a/web/components/cycles/dropdowns/index.ts b/web/components/cycles/dropdowns/index.ts new file mode 100644 index 000000000..302e3a1a6 --- /dev/null +++ b/web/components/cycles/dropdowns/index.ts @@ -0,0 +1 @@ +export * from "./filters"; diff --git a/web/components/cycles/gantt-chart/cycles-list-layout.tsx b/web/components/cycles/gantt-chart/cycles-list-layout.tsx index 521273c51..094fbea7b 100644 --- a/web/components/cycles/gantt-chart/cycles-list-layout.tsx +++ b/web/components/cycles/gantt-chart/cycles-list-layout.tsx @@ -4,8 +4,7 @@ import { useRouter } from "next/router"; // hooks import { CycleGanttBlock } from "components/cycles"; import { GanttChartRoot, IBlockUpdateData, CycleGanttSidebar } from "components/gantt-chart"; -import { EUserProjectRoles } from "constants/project"; -import { useCycle, useUser } from "hooks/store"; +import { useCycle } from "hooks/store"; // components // types import { ICycle } from "@plane/types"; @@ -22,9 +21,6 @@ export const CyclesListGanttChartView: FC = observer((props) => { const router = useRouter(); const { workspaceSlug } = router.query; // store hooks - const { - membership: { currentProjectRole }, - } = useUser(); const { getCycleById, updateCycleDetails } = useCycle(); const handleCycleUpdate = async (cycle: ICycle, data: IBlockUpdateData) => { @@ -52,9 +48,6 @@ export const CyclesListGanttChartView: FC = observer((props) => { return structuredBlocks; }; - const isAllowed = - currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole); - return (
= observer((props) => { enableBlockLeftResize={false} enableBlockRightResize={false} enableBlockMove={false} - enableReorder={isAllowed} + enableReorder={false} />
); diff --git a/web/components/cycles/index.ts b/web/components/cycles/index.ts index db5e9de9e..e37d266b7 100644 --- a/web/components/cycles/index.ts +++ b/web/components/cycles/index.ts @@ -1,17 +1,16 @@ -export * from "./cycles-view"; -export * from "./active-cycle-details"; -export * from "./active-cycle-stats"; +export * from "./active-cycle"; +export * from "./applied-filters"; +export * from "./board/"; +export * from "./dropdowns"; export * from "./gantt-chart"; +export * from "./list"; +export * from "./cycle-peek-overview"; +export * from "./cycles-view-header"; export * from "./cycles-view"; +export * from "./delete-modal"; export * from "./form"; export * from "./modal"; +export * from "./quick-actions"; export * from "./sidebar"; export * from "./transfer-issues-modal"; export * from "./transfer-issues"; -export * from "./cycles-list"; -export * from "./cycles-list-item"; -export * from "./cycles-board"; -export * from "./cycles-board-card"; -export * from "./delete-modal"; -export * from "./cycle-peek-overview"; -export * from "./cycles-list-item"; diff --git a/web/components/cycles/cycles-list-item.tsx b/web/components/cycles/list/cycles-list-item.tsx similarity index 71% rename from web/components/cycles/cycles-list-item.tsx rename to web/components/cycles/list/cycles-list-item.tsx index 9bf1866ff..90c6d5d02 100644 --- a/web/components/cycles/cycles-list-item.tsx +++ b/web/components/cycles/list/cycles-list-item.tsx @@ -1,26 +1,14 @@ -import { FC, MouseEvent, useState } from "react"; +import { FC, MouseEvent } from "react"; import { observer } from "mobx-react"; import Link from "next/link"; import { useRouter } from "next/router"; // hooks -import { Check, Info, LinkIcon, Pencil, Star, Trash2, User2 } from "lucide-react"; -import { - CustomMenu, - Tooltip, - CircularProgressIndicator, - CycleGroupIcon, - AvatarGroup, - Avatar, - TOAST_TYPE, - setToast, - setPromiseToast, -} from "@plane/ui"; -import { CycleCreateUpdateModal, CycleDeleteModal } from "components/cycles"; +import { Check, Info, Star, User2 } from "lucide-react"; +import { Tooltip, CircularProgressIndicator, CycleGroupIcon, AvatarGroup, Avatar, setPromiseToast } from "@plane/ui"; +import { CycleQuickActions } from "components/cycles"; import { CYCLE_STATUS } from "constants/cycle"; import { CYCLE_FAVORITED, CYCLE_UNFAVORITED } from "constants/event-tracker"; -import { EUserWorkspaceRoles } from "constants/workspace"; import { findHowManyDaysLeft, renderFormattedDate } from "helpers/date-time.helper"; -import { copyTextToClipboard } from "helpers/string.helper"; import { useEventTracker, useCycle, useUser, useMember } from "hooks/store"; // components // ui @@ -29,6 +17,7 @@ import { useEventTracker, useCycle, useUser, useMember } from "hooks/store"; // constants // types import { TCycleGroups } from "@plane/types"; +import { EUserProjectRoles } from "constants/project"; type TCyclesListItem = { cycleId: string; @@ -42,33 +31,16 @@ type TCyclesListItem = { export const CyclesListItem: FC = observer((props) => { const { cycleId, workspaceSlug, projectId } = props; - // states - const [updateModal, setUpdateModal] = useState(false); - const [deleteModal, setDeleteModal] = useState(false); // router const router = useRouter(); // store hooks - const { setTrackElement, captureEvent } = useEventTracker(); + const { captureEvent } = useEventTracker(); const { membership: { currentProjectRole }, } = useUser(); const { getCycleById, addCycleToFavorites, removeCycleFromFavorites } = useCycle(); const { getUserDetails } = useMember(); - const handleCopyText = (e: MouseEvent) => { - 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) => { e.preventDefault(); if (!workspaceSlug || !projectId) return; @@ -125,20 +97,6 @@ export const CyclesListItem: FC = observer((props) => { }); }; - const handleEditCycle = (e: MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - setTrackElement("Cycles page list layout"); - setUpdateModal(true); - }; - - const handleDeleteCycle = (e: MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - setTrackElement("Cycles page list layout"); - setDeleteModal(true); - }; - const openCycleOverview = (e: MouseEvent) => { const { query } = router; e.preventDefault(); @@ -161,7 +119,7 @@ export const CyclesListItem: FC = observer((props) => { const endDate = new Date(cycleDetails.end_date ?? ""); const startDate = new Date(cycleDetails.start_date ?? ""); - const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserWorkspaceRoles.MEMBER; + const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; const cycleTotalIssues = cycleDetails.backlog_issues + @@ -184,20 +142,6 @@ export const CyclesListItem: FC = observer((props) => { return ( <> - setUpdateModal(false)} - workspaceSlug={workspaceSlug} - projectId={projectId} - /> - setDeleteModal(false)} - workspaceSlug={workspaceSlug} - projectId={projectId} - />
@@ -246,7 +190,7 @@ export const CyclesListItem: FC = observer((props) => {
)}
-
+
{renderDate && `${renderFormattedDate(startDate) ?? `_ _`} - ${renderFormattedDate(endDate) ?? `_ _`}`}
@@ -256,8 +200,8 @@ export const CyclesListItem: FC = observer((props) => {
{cycleDetails.assignee_ids?.length > 0 ? ( - {cycleDetails.assignee_ids?.map((assigne_id) => { - const member = getUserDetails(assigne_id); + {cycleDetails.assignee_ids?.map((assignee_id) => { + const member = getUserDetails(assignee_id); return ; })} @@ -281,30 +225,7 @@ export const CyclesListItem: FC = observer((props) => { )} - - {!isCompleted && isEditingAllowed && ( - <> - - - - Edit cycle - - - - - - Delete cycle - - - - )} - - - - Copy cycle link - - - + )}
diff --git a/web/components/cycles/list/cycles-list-map.tsx b/web/components/cycles/list/cycles-list-map.tsx new file mode 100644 index 000000000..c07b204b1 --- /dev/null +++ b/web/components/cycles/list/cycles-list-map.tsx @@ -0,0 +1,20 @@ +// components +import { CyclesListItem } from "components/cycles"; + +type Props = { + cycleIds: string[]; + projectId: string; + workspaceSlug: string; +}; + +export const CyclesListMap: React.FC = (props) => { + const { cycleIds, projectId, workspaceSlug } = props; + + return ( + <> + {cycleIds.map((cycleId) => ( + + ))} + + ); +}; diff --git a/web/components/cycles/list/index.ts b/web/components/cycles/list/index.ts new file mode 100644 index 000000000..46a3557d7 --- /dev/null +++ b/web/components/cycles/list/index.ts @@ -0,0 +1,3 @@ +export * from "./cycles-list-item"; +export * from "./cycles-list-map"; +export * from "./root"; diff --git a/web/components/cycles/list/root.tsx b/web/components/cycles/list/root.tsx new file mode 100644 index 000000000..27488d238 --- /dev/null +++ b/web/components/cycles/list/root.tsx @@ -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 = observer((props) => { + const { completedCycleIds, cycleIds, workspaceSlug, projectId } = props; + + return ( +
+
+
+ + {completedCycleIds.length !== 0 && ( + + + {({ open }) => ( + <> + Completed cycles ({completedCycleIds.length}) + + + )} + + + + + + )} +
+ +
+
+ ); +}); diff --git a/web/components/cycles/modal.tsx b/web/components/cycles/modal.tsx index 2d1640ec9..3f57fc204 100644 --- a/web/components/cycles/modal.tsx +++ b/web/components/cycles/modal.tsx @@ -11,7 +11,7 @@ import { CycleService } from "services/cycle.service"; // components // ui // types -import type { CycleDateCheckData, ICycle, TCycleView } from "@plane/types"; +import type { CycleDateCheckData, ICycle, TCycleTabOptions } from "@plane/types"; // constants type CycleModalProps = { @@ -34,7 +34,7 @@ export const CycleCreateUpdateModal: React.FC = (props) => { const { workspaceProjectIds } = useProject(); const { createCycle, updateCycleDetails } = useCycle(); - const { setValue: setCycleTab } = useLocalStorage("cycle_tab", "active"); + const { setValue: setCycleTab } = useLocalStorage("cycle_tab", "active"); const handleCreateCycle = async (payload: Partial) => { if (!workspaceSlug || !projectId) return; diff --git a/web/components/cycles/quick-actions.tsx b/web/components/cycles/quick-actions.tsx new file mode 100644 index 000000000..f1c930ccb --- /dev/null +++ b/web/components/cycles/quick-actions.tsx @@ -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 = 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) => { + 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) => { + e.preventDefault(); + e.stopPropagation(); + setTrackElement("Cycles page list layout"); + setUpdateModal(true); + }; + + const handleDeleteCycle = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + setTrackElement("Cycles page list layout"); + setDeleteModal(true); + }; + + return ( + <> + {cycleDetails && ( +
+ setUpdateModal(false)} + workspaceSlug={workspaceSlug} + projectId={projectId} + /> + setDeleteModal(false)} + workspaceSlug={workspaceSlug} + projectId={projectId} + /> +
+ )} + + {!isCompleted && isEditingAllowed && ( + <> + + + + Edit cycle + + + + + + Delete cycle + + + + )} + + + + Copy cycle link + + + + + ); +}); diff --git a/web/components/gantt-chart/chart/header.tsx b/web/components/gantt-chart/chart/header.tsx index fe35c9e52..b4dcd6a62 100644 --- a/web/components/gantt-chart/chart/header.tsx +++ b/web/components/gantt-chart/chart/header.tsx @@ -25,7 +25,7 @@ export const GanttChartHeader: React.FC = observer((props) => { const { currentView } = useGanttChart(); return ( -
+
{title}
{blocks ? `${blocks.length} ${loaderTitle}` : "Loading..."}
diff --git a/web/components/headers/cycles.tsx b/web/components/headers/cycles.tsx index 22637147f..6f019f3bd 100644 --- a/web/components/headers/cycles.tsx +++ b/web/components/headers/cycles.tsx @@ -13,7 +13,7 @@ import { CYCLE_VIEW_LAYOUTS } from "constants/cycle"; import { EUserProjectRoles } from "constants/project"; import { useApplication, useEventTracker, useProject, useUser } from "hooks/store"; import useLocalStorage from "hooks/use-local-storage"; -import { TCycleLayout } from "@plane/types"; +import { TCycleLayoutOptions } from "@plane/types"; import { ProjectLogo } from "components/project"; export const CyclesHeader: FC = observer(() => { @@ -33,10 +33,10 @@ export const CyclesHeader: FC = observer(() => { const canUserCreateCycle = currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole); - const { setValue: setCycleLayout } = useLocalStorage("cycle_layout", "list"); + const { setValue: setCycleLayout } = useLocalStorage("cycle_layout", "list"); const handleCurrentLayout = useCallback( - (_layout: TCycleLayout) => { + (_layout: TCycleLayoutOptions) => { setCycleLayout(_layout); }, [setCycleLayout] @@ -109,7 +109,7 @@ export const CyclesHeader: FC = observer(() => { key={layout.key} onClick={() => { // handleLayoutChange(ISSUE_LAYOUTS[index].key); - handleCurrentLayout(layout.key as TCycleLayout); + handleCurrentLayout(layout.key as TCycleLayoutOptions); }} className="flex items-center gap-2" > diff --git a/web/components/issues/issue-layouts/filters/applied-filters/filters-list.tsx b/web/components/issues/issue-layouts/filters/applied-filters/filters-list.tsx index 10ad265f3..467258273 100644 --- a/web/components/issues/issue-layouts/filters/applied-filters/filters-list.tsx +++ b/web/components/issues/issue-layouts/filters/applied-filters/filters-list.tsx @@ -55,6 +55,7 @@ export const AppliedFiltersList: React.FC = observer((props) => { const filterKey = key as keyof IIssueFilterOptions; if (!value) return; + if (Array.isArray(value) && value.length === 0) return; return (
= (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(null); const [popperElement, setPopperElement] = useState(null); @@ -44,6 +45,7 @@ export const FiltersDropdown: React.FC = (props) => { ref={setReferenceElement} variant="neutral-primary" size="sm" + prependIcon={icon} appendIcon={ } @@ -64,9 +66,9 @@ export const FiltersDropdown: React.FC = (props) => { leaveFrom="opacity-100 translate-y-0" leaveTo="opacity-0 translate-y-1" > - +
{ + 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; +}; diff --git a/web/helpers/filter.helper.ts b/web/helpers/filter.helper.ts index d31a25b3d..3c34fa9da 100644 --- a/web/helpers/filter.helper.ts +++ b/web/helpers/filter.helper.ts @@ -1,3 +1,4 @@ +import { differenceInCalendarDays } from "date-fns"; // types import { IIssueFilterOptions } from "@plane/types"; @@ -13,3 +14,29 @@ export const calculateTotalFilters = (filters: IIssueFilterOptions): number => ) .reduce((curr, prev) => curr + prev, 0) : 0; + +/** + * @description checks if the date satisfies the filter + * @param {Date} date + * @param {string} filter + * @returns {boolean} + */ +export const satisfiesDateFilter = (date: Date, filter: string): boolean => { + const [value, operator, from] = filter.split(";"); + + if (!from) { + if (operator === "after") return date >= new Date(value); + if (operator === "before") return date <= new Date(value); + } + + if (from === "fromnow") { + if (operator === "after") { + if (value === "1_weeks") return differenceInCalendarDays(date, new Date()) >= 7; + if (value === "2_weeks") return differenceInCalendarDays(date, new Date()) >= 14; + if (value === "1_months") return differenceInCalendarDays(date, new Date()) >= 30; + if (value === "2_months") return differenceInCalendarDays(date, new Date()) >= 60; + } + } + + return false; +}; diff --git a/web/hooks/store/index.ts b/web/hooks/store/index.ts index ff036a529..3ec5c97bf 100644 --- a/web/hooks/store/index.ts +++ b/web/hooks/store/index.ts @@ -1,7 +1,8 @@ export * from "./use-application"; -export * from "./use-event-tracker"; export * from "./use-calendar-view"; +export * from "./use-cycle-filter"; export * from "./use-cycle"; +export * from "./use-event-tracker"; export * from "./use-dashboard"; export * from "./use-estimate"; export * from "./use-global-view"; diff --git a/web/hooks/store/use-cycle-filter.ts b/web/hooks/store/use-cycle-filter.ts new file mode 100644 index 000000000..50c37508b --- /dev/null +++ b/web/hooks/store/use-cycle-filter.ts @@ -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; +}; diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/cycles/index.tsx b/web/pages/[workspaceSlug]/projects/[projectId]/cycles/index.tsx index a22e252f2..fa9008d2f 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/cycles/index.tsx +++ b/web/pages/[workspaceSlug]/projects/[projectId]/cycles/index.tsx @@ -1,28 +1,35 @@ -import { Fragment, useCallback, useState, ReactElement } from "react"; +import { Fragment, useState, ReactElement } from "react"; import { observer } from "mobx-react-lite"; import { useRouter } from "next/router"; import { Tab } from "@headlessui/react"; // hooks -import { useEventTracker, useCycle, useProject } from "hooks/store"; -import useLocalStorage from "hooks/use-local-storage"; +import { useEventTracker, useCycle, useProject, useCycleFilter } from "hooks/store"; // layouts import { AppLayout } from "layouts/app-layout"; // components import { PageHead } from "components/core"; import { CyclesHeader } from "components/headers"; -import { CyclesView, ActiveCycleDetails, CycleCreateUpdateModal } from "components/cycles"; +import { + CyclesView, + CycleCreateUpdateModal, + CyclesViewHeader, + CycleAppliedFiltersList, + ActiveCycleRoot, +} from "components/cycles"; import { EmptyState } from "components/empty-state"; -import { CycleModuleBoardLayout, CycleModuleListLayout, GanttLayoutLoader } from "components/ui"; // ui -import { Tooltip } from "@plane/ui"; +import { CycleModuleBoardLayout, CycleModuleListLayout, GanttLayoutLoader } from "components/ui"; +// helpers +import { calculateTotalFilters } from "helpers/filter.helper"; // types import { NextPageWithLayout } from "lib/types"; -import { TCycleView, TCycleLayout } from "@plane/types"; +import { TCycleFilters } from "@plane/types"; // constants -import { CYCLE_TAB_LIST, CYCLE_VIEW_LAYOUTS } from "constants/cycle"; +import { CYCLE_TABS_LIST } from "constants/cycle"; import { EmptyStateType } from "constants/empty-state"; const ProjectCyclesPage: NextPageWithLayout = observer(() => { + // states const [createModal, setCreateModal] = useState(false); // store hooks const { setTrackElement } = useEventTracker(); @@ -31,28 +38,26 @@ const ProjectCyclesPage: NextPageWithLayout = observer(() => { // router const router = useRouter(); const { workspaceSlug, projectId, peekCycle } = router.query; - // local storage - const { storedValue: cycleTab, setValue: setCycleTab } = useLocalStorage("cycle_tab", "active"); - const { storedValue: cycleLayout, setValue: setCycleLayout } = useLocalStorage("cycle_layout", "list"); + // cycle filters hook + const { clearAllFilters, currentProjectDisplayFilters, currentProjectFilters, updateDisplayFilters, updateFilters } = + useCycleFilter(); // derived values const totalCycles = currentProjectCycleIds?.length ?? 0; const project = projectId ? getProjectById(projectId?.toString()) : undefined; const pageTitle = project?.name ? `${project?.name} - Cycles` : undefined; + // selected display filters + const cycleTab = currentProjectDisplayFilters?.active_tab; + const cycleLayout = currentProjectDisplayFilters?.layout; - const handleCurrentLayout = useCallback( - (_layout: TCycleLayout) => { - setCycleLayout(_layout); - }, - [setCycleLayout] - ); + const handleRemoveFilter = (key: keyof TCycleFilters, value: string | null) => { + if (!projectId) return; + let newValues = currentProjectFilters?.[key] ?? []; - const handleCurrentView = useCallback( - (_view: TCycleView) => { - setCycleTab(_view); - if (_view === "draft") handleCurrentLayout("list"); - }, - [handleCurrentLayout, setCycleTab] - ); + if (!value) newValues = []; + else newValues = newValues.filter((val) => val !== value); + + updateFilters(projectId.toString(), { [key]: newValues }); + }; if (!workspaceSlug || !projectId) return null; @@ -89,101 +94,35 @@ const ProjectCyclesPage: NextPageWithLayout = observer(() => { i.key == cycleTab)} - selectedIndex={CYCLE_TAB_LIST.findIndex((i) => i.key == cycleTab)} - onChange={(i) => handleCurrentView(CYCLE_TAB_LIST[i]?.key ?? "active")} + defaultIndex={CYCLE_TABS_LIST.findIndex((i) => i.key == cycleTab)} + selectedIndex={CYCLE_TABS_LIST.findIndex((i) => i.key == cycleTab)} + onChange={(i) => { + if (!projectId) return; + const tab = CYCLE_TABS_LIST[i]; + if (!tab) return; + updateDisplayFilters(projectId.toString(), { + active_tab: tab.key, + }); + }} > -
- - {CYCLE_TAB_LIST.map((tab) => ( - - `border-b-2 p-4 text-sm font-medium outline-none ${ - selected ? "border-custom-primary-100 text-custom-primary-100" : "border-transparent" - }` - } - > - {tab.name} - - ))} - -
- {cycleTab !== "active" && ( -
- {CYCLE_VIEW_LAYOUTS.map((layout) => { - if (layout.key === "gantt" && cycleTab === "draft") return null; - - return ( - - - - ); - })} -
- )} + + {calculateTotalFilters(currentProjectFilters ?? {}) !== 0 && ( +
+ clearAllFilters(projectId.toString())} + handleRemoveFilter={handleRemoveFilter} + />
-
- + )} - - {cycleTab && cycleLayout && ( - - )} - - - + - {cycleTab && cycleLayout && ( - )} - - - - {cycleTab && cycleLayout && workspaceSlug && projectId && ( - - )} - - - - {cycleTab && cycleLayout && workspaceSlug && projectId && ( - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/public/empty-state/cycle/name-filter.svg b/web/public/empty-state/cycle/name-filter.svg new file mode 100644 index 000000000..168611119 --- /dev/null +++ b/web/public/empty-state/cycle/name-filter.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/store/cycle.store.ts b/web/store/cycle.store.ts index aea87033e..71cb8f924 100644 --- a/web/store/cycle.store.ts +++ b/web/store/cycle.store.ts @@ -11,9 +11,10 @@ import { IssueService } from "services/issue"; import { ProjectService } from "services/project"; import { RootStore } from "store/root.store"; import { ICycle, CycleDateCheckData } from "@plane/types"; +import { orderCycles, shouldFilterCycle } from "helpers/cycle.helper"; export interface ICycleStore { - //Loaders + // loaders loader: boolean; // observables fetchedMap: Record; @@ -27,6 +28,8 @@ export interface ICycleStore { currentProjectDraftCycleIds: string[] | null; currentProjectActiveCycleId: string | null; // computed actions + getFilteredCycleIds: (projectId: string) => string[] | null; + getFilteredCompletedCycleIds: (projectId: string) => string[] | null; getCycleById: (cycleId: string) => ICycle | null; getCycleNameById: (cycleId: string) => string | undefined; getActiveCycleById: (cycleId: string) => ICycle | null; @@ -183,6 +186,49 @@ export class CycleStore implements ICycleStore { return activeCycle || null; } + /** + * @description returns filtered cycle ids based on display filters and filters + * @param {TCycleDisplayFilters} displayFilters + * @param {TCycleFilters} filters + * @returns {string[] | null} + */ + getFilteredCycleIds = computedFn((projectId: string) => { + const filters = this.rootStore.cycleFilter.getFiltersByProjectId(projectId); + const searchQuery = this.rootStore.cycleFilter.searchQuery; + if (!this.fetchedMap[projectId]) return null; + let cycles = Object.values(this.cycleMap ?? {}).filter( + (c) => + c.project_id === projectId && + c.name.toLowerCase().includes(searchQuery.toLowerCase()) && + shouldFilterCycle(c, filters ?? {}) + ); + cycles = orderCycles(cycles); + const cycleIds = cycles.map((c) => c.id); + return cycleIds; + }); + + /** + * @description returns filtered cycle ids based on display filters and filters + * @param {TCycleDisplayFilters} displayFilters + * @param {TCycleFilters} filters + * @returns {string[] | null} + */ + getFilteredCompletedCycleIds = computedFn((projectId: string) => { + const filters = this.rootStore.cycleFilter.getFiltersByProjectId(projectId); + const searchQuery = this.rootStore.cycleFilter.searchQuery; + if (!this.fetchedMap[projectId]) return null; + let cycles = Object.values(this.cycleMap ?? {}).filter( + (c) => + c.project_id === projectId && + c.status.toLowerCase() === "completed" && + c.name.toLowerCase().includes(searchQuery.toLowerCase()) && + shouldFilterCycle(c, filters ?? {}) + ); + cycles = sortBy(cycles, [(c) => !c.start_date]); + const cycleIds = cycles.map((c) => c.id); + return cycleIds; + }); + /** * @description returns cycle details by cycle id * @param cycleId diff --git a/web/store/cycle_filter.store.ts b/web/store/cycle_filter.store.ts new file mode 100644 index 000000000..064ea4a4e --- /dev/null +++ b/web/store/cycle_filter.store.ts @@ -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; + filters: Record; + 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 = {}; + filters: Record = {}; + 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] = {}; + }); + }; +} diff --git a/web/store/root.store.ts b/web/store/root.store.ts index 298cd532e..0390d7ce2 100644 --- a/web/store/root.store.ts +++ b/web/store/root.store.ts @@ -18,6 +18,7 @@ import { IStateStore, StateStore } from "./state.store"; import { IUserRootStore, UserRootStore } from "./user"; import { IWorkspaceRootStore, WorkspaceRootStore } from "./workspace"; import { IProjectPageStore, ProjectPageStore } from "./project-page.store"; +import { CycleFilterStore, ICycleFilterStore } from "./cycle_filter.store"; enableStaticRendering(typeof window === "undefined"); @@ -29,6 +30,7 @@ export class RootStore { projectRoot: IProjectRootStore; memberRoot: IMemberRootStore; cycle: ICycleStore; + cycleFilter: ICycleFilterStore; module: IModuleStore; projectView: IProjectViewStore; globalView: IGlobalViewStore; @@ -50,6 +52,7 @@ export class RootStore { this.memberRoot = new MemberRootStore(this); // independent stores this.cycle = new CycleStore(this); + this.cycleFilter = new CycleFilterStore(this); this.module = new ModulesStore(this); this.projectView = new ProjectViewStore(this); this.globalView = new GlobalViewStore(this); @@ -69,6 +72,7 @@ export class RootStore { this.memberRoot = new MemberRootStore(this); // independent stores this.cycle = new CycleStore(this); + this.cycleFilter = new CycleFilterStore(this); this.module = new ModulesStore(this); this.projectView = new ProjectViewStore(this); this.globalView = new GlobalViewStore(this);