[WEB-1298] chore: project cycle revamp (#4454)

* chore: cycle endpoint changes

* chore: completed cycle icon updated

* chore: project cycle list revamp and code refactor

* chore: cycle page improvement

* chore: added created by in retrieve endopoint

* fix: build error

* chore: cycle list page disclosure button improvement

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
This commit is contained in:
Anmol Singh Bhatia 2024-05-14 19:22:08 +05:30 committed by GitHub
parent febf19ccc0
commit ab6f1ef780
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 230 additions and 288 deletions

View File

@ -241,6 +241,7 @@ class CycleViewSet(BaseViewSet):
"backlog_issues", "backlog_issues",
"assignee_ids", "assignee_ids",
"status", "status",
"created_by"
) )
if data: if data:
@ -365,6 +366,7 @@ class CycleViewSet(BaseViewSet):
"backlog_issues", "backlog_issues",
"assignee_ids", "assignee_ids",
"status", "status",
"created_by",
) )
return Response(data, status=status.HTTP_200_OK) return Response(data, status=status.HTTP_200_OK)
@ -564,6 +566,7 @@ class CycleViewSet(BaseViewSet):
"backlog_issues", "backlog_issues",
"assignee_ids", "assignee_ids",
"status", "status",
"created_by",
) )
.first() .first()
) )

View File

@ -3,8 +3,8 @@ import * as React from "react";
import { ISvgIcons } from "../type"; 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 24 24" 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" strokeLinecap="round" /> <circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" strokeLinecap="round" />
<circle cx="8.33333" cy="8.33333" r="4.33333" fill="currentColor" /> <circle cx="12" cy="12" r="6.25" fill="currentColor" stroke-width="0.5" />
</svg> </svg>
); );

View File

@ -3,6 +3,7 @@ import { observer } from "mobx-react";
import Link from "next/link"; import Link from "next/link";
import useSWR from "swr"; import useSWR from "swr";
import { CalendarCheck } from "lucide-react"; import { CalendarCheck } from "lucide-react";
// headless ui
import { Tab } from "@headlessui/react"; import { Tab } from "@headlessui/react";
// types // types
import { ICycle, TIssue } from "@plane/types"; import { ICycle, TIssue } from "@plane/types";
@ -60,7 +61,7 @@ export const ActiveCycleStats: FC<ActiveCycleStatsProps> = observer((props) => {
const cycleIssues = activeCycleIssues ?? []; const cycleIssues = activeCycleIssues ?? [];
return ( return (
<div className="flex flex-col gap-4 p-4 min-h-[17rem] overflow-hidden col-span-1 lg:col-span-2 xl:col-span-1 border border-custom-border-200 rounded-lg"> <div className="flex flex-col gap-4 p-4 min-h-[17rem] overflow-hidden bg-custom-background-100 col-span-1 lg:col-span-2 xl:col-span-1 border border-custom-border-200 rounded-lg">
<Tab.Group <Tab.Group
as={Fragment} as={Fragment}
defaultIndex={currentValue(tab)} defaultIndex={currentValue(tab)}

View File

@ -15,7 +15,7 @@ export const ActiveCycleProductivity: FC<ActiveCycleProductivityProps> = (props)
const { cycle } = props; const { cycle } = props;
return ( return (
<div className="flex flex-col justify-center min-h-[17rem] gap-5 py-4 px-3.5 border border-custom-border-200 rounded-lg"> <div className="flex flex-col justify-center min-h-[17rem] gap-5 py-4 px-3.5 bg-custom-background-100 border border-custom-border-200 rounded-lg">
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<h3 className="text-base text-custom-text-300 font-semibold">Issue burndown</h3> <h3 className="text-base text-custom-text-300 font-semibold">Issue burndown</h3>
</div> </div>

View File

@ -31,7 +31,7 @@ export const ActiveCycleProgress: FC<ActiveCycleProgressProps> = (props) => {
}; };
return ( return (
<div className="flex flex-col min-h-[17rem] gap-5 py-4 px-3.5 border border-custom-border-200 rounded-lg"> <div className="flex flex-col min-h-[17rem] gap-5 py-4 px-3.5 bg-custom-background-100 border border-custom-border-200 rounded-lg">
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<h3 className="text-base text-custom-text-300 font-semibold">Progress</h3> <h3 className="text-base text-custom-text-300 font-semibold">Progress</h3>

View File

@ -1,20 +1,21 @@
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import useSWR from "swr"; import useSWR from "swr";
// ui // ui
import { Disclosure } from "@headlessui/react";
import { Loader } from "@plane/ui"; import { Loader } from "@plane/ui";
// components // components
import { import {
ActiveCycleHeader,
ActiveCycleProductivity, ActiveCycleProductivity,
ActiveCycleProgress, ActiveCycleProgress,
ActiveCycleStats, ActiveCycleStats,
UpcomingCyclesList, CycleListGroupHeader,
CyclesListItem,
} from "@/components/cycles"; } from "@/components/cycles";
import { EmptyState } from "@/components/empty-state"; import { EmptyState } from "@/components/empty-state";
// constants // constants
import { EmptyStateType } from "@/constants/empty-state"; import { EmptyStateType } from "@/constants/empty-state";
// hooks // hooks
import { useCycle, useCycleFilter } from "@/hooks/store"; import { useCycle } from "@/hooks/store";
interface IActiveCycleDetails { interface IActiveCycleDetails {
workspaceSlug: string; workspaceSlug: string;
@ -25,10 +26,7 @@ export const ActiveCycleRoot: React.FC<IActiveCycleDetails> = observer((props) =
// props // props
const { workspaceSlug, projectId } = props; const { workspaceSlug, projectId } = props;
// store hooks // store hooks
const { fetchActiveCycle, currentProjectActiveCycleId, currentProjectUpcomingCycleIds, getActiveCycleById } = const { fetchActiveCycle, currentProjectActiveCycleId, getActiveCycleById } = useCycle();
useCycle();
// cycle filters hook
const { updateDisplayFilters } = useCycleFilter();
// derived values // derived values
const activeCycle = currentProjectActiveCycleId ? getActiveCycleById(currentProjectActiveCycleId) : null; const activeCycle = currentProjectActiveCycleId ? getActiveCycleById(currentProjectActiveCycleId) : null;
// fetch active cycle details // fetch active cycle details
@ -37,11 +35,6 @@ export const ActiveCycleRoot: React.FC<IActiveCycleDetails> = observer((props) =
workspaceSlug && projectId ? () => fetchActiveCycle(workspaceSlug, projectId) : null workspaceSlug && projectId ? () => fetchActiveCycle(workspaceSlug, projectId) : null
); );
const handleEmptyStateAction = () =>
updateDisplayFilters(projectId, {
active_tab: "all",
});
// show loader if active cycle is loading // show loader if active cycle is loading
if (!activeCycle && isLoading) if (!activeCycle && isLoading)
return ( return (
@ -50,43 +43,40 @@ export const ActiveCycleRoot: React.FC<IActiveCycleDetails> = observer((props) =
</Loader> </Loader>
); );
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 ( return (
<> <>
<div className="mb-6 grid h-52 w-full place-items-center"> <Disclosure as="div" className="flex flex-shrink-0 flex-col" defaultOpen>
<div className="text-center"> {({ open }) => (
<h5 className="mb-1 text-xl font-medium">No active cycle</h5>
<p className="text-base text-custom-text-400">
Create new cycles to find them here or check
<br />
{"'"}All{"'"} cycles tab to see all cycles or{" "}
<button type="button" className="font-medium text-custom-primary-100" onClick={handleEmptyStateAction}>
click here
</button>
</p>
</div>
</div>
<UpcomingCyclesList handleEmptyStateAction={handleEmptyStateAction} />
</>
);
}
return (
<> <>
<div className="flex flex-col gap-4"> <Disclosure.Button className="sticky top-0 z-[2] w-full flex-shrink-0 border-b border-custom-border-200 bg-custom-background-90 px-7 py-1 cursor-pointer">
<ActiveCycleHeader cycle={activeCycle} workspaceSlug={workspaceSlug} projectId={projectId} /> <CycleListGroupHeader title="Active cycle" type="current" isExpanded={open} />
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2 xl:grid-cols-3"> </Disclosure.Button>
<Disclosure.Panel>
{!activeCycle ? (
<EmptyState type={EmptyStateType.PROJECT_CYCLE_ACTIVE} size="sm" />
) : (
<div className="flex flex-col bg-custom-background-90 border-b">
{currentProjectActiveCycleId && (
<CyclesListItem
key={currentProjectActiveCycleId}
cycleId={currentProjectActiveCycleId}
workspaceSlug={workspaceSlug}
projectId={projectId}
/>
)}
<div className="bg-custom-background-90 py-6 px-8">
<div className="grid grid-cols-1 bg-custom-background-90 gap-3 lg:grid-cols-2 xl:grid-cols-3">
<ActiveCycleProgress cycle={activeCycle} /> <ActiveCycleProgress cycle={activeCycle} />
<ActiveCycleProductivity cycle={activeCycle} /> <ActiveCycleProductivity cycle={activeCycle} />
<ActiveCycleStats cycle={activeCycle} workspaceSlug={workspaceSlug} projectId={projectId} /> <ActiveCycleStats cycle={activeCycle} workspaceSlug={workspaceSlug} projectId={projectId} />
</div> </div>
</div> </div>
{currentProjectUpcomingCycleIds && <UpcomingCyclesList handleEmptyStateAction={handleEmptyStateAction} />} </div>
)}
</Disclosure.Panel>
</>
)}
</Disclosure>
</> </>
); );
}); });

View File

@ -2,24 +2,17 @@ import { useCallback, useRef, useState } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
// icons // icons
import { ListFilter, Search, X } from "lucide-react"; import { ListFilter, Search, X } from "lucide-react";
// headless ui
import { Tab } from "@headlessui/react";
// types // types
import { TCycleFilters } from "@plane/types"; import { TCycleFilters } from "@plane/types";
// ui
import { Tooltip } from "@plane/ui";
// components // components
import { CycleFiltersSelection } from "@/components/cycles"; import { CycleFiltersSelection } from "@/components/cycles";
import { FiltersDropdown } from "@/components/issues"; import { FiltersDropdown } from "@/components/issues";
// constants
import { CYCLE_TABS_LIST, CYCLE_VIEW_LAYOUTS } from "@/constants/cycle";
// helpers // helpers
import { cn } from "@/helpers/common.helper"; import { cn } from "@/helpers/common.helper";
import { calculateTotalFilters } from "@/helpers/filter.helper"; import { calculateTotalFilters } from "@/helpers/filter.helper";
// hooks // hooks
import { useCycleFilter } from "@/hooks/store"; import { useCycleFilter } from "@/hooks/store";
import useOutsideClickDetector from "@/hooks/use-outside-click-detector"; import useOutsideClickDetector from "@/hooks/use-outside-click-detector";
import { usePlatformOS } from "@/hooks/use-platform-os";
type Props = { type Props = {
projectId: string; projectId: string;
@ -30,23 +23,13 @@ export const CyclesViewHeader: React.FC<Props> = observer((props) => {
// refs // refs
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
// hooks // hooks
const { const { currentProjectFilters, searchQuery, updateFilters, updateSearchQuery } = useCycleFilter();
currentProjectDisplayFilters,
currentProjectFilters,
searchQuery,
updateDisplayFilters,
updateFilters,
updateSearchQuery,
} = useCycleFilter();
const { isMobile } = usePlatformOS();
// states // states
const [isSearchOpen, setIsSearchOpen] = useState(searchQuery !== "" ? true : false); const [isSearchOpen, setIsSearchOpen] = useState(searchQuery !== "" ? true : false);
// outside click detector hook // outside click detector hook
useOutsideClickDetector(inputRef, () => { useOutsideClickDetector(inputRef, () => {
if (isSearchOpen && searchQuery.trim() === "") setIsSearchOpen(false); if (isSearchOpen && searchQuery.trim() === "") setIsSearchOpen(false);
}); });
// derived values
const activeLayout = currentProjectDisplayFilters?.layout ?? "list";
const handleFilters = useCallback( const handleFilters = useCallback(
(key: keyof TCycleFilters, value: string | string[]) => { (key: keyof TCycleFilters, value: string | string[]) => {
@ -81,23 +64,7 @@ export const CyclesViewHeader: React.FC<Props> = observer((props) => {
const isFiltersApplied = calculateTotalFilters(currentProjectFilters ?? {}) !== 0; const isFiltersApplied = calculateTotalFilters(currentProjectFilters ?? {}) !== 0;
return ( return (
<div className="h-[50px] flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 border-b border-custom-border-200 px-6 sm:pb-0"> <div className="flex items-center gap-3">
<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 && ( {!isSearchOpen && (
<button <button
type="button" type="button"
@ -148,32 +115,6 @@ export const CyclesViewHeader: React.FC<Props> = observer((props) => {
> >
<CycleFiltersSelection filters={currentProjectFilters ?? {}} handleFiltersUpdate={handleFilters} /> <CycleFiltersSelection filters={currentProjectFilters ?? {}} handleFiltersUpdate={handleFilters} />
</FiltersDropdown> </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} isMobile={isMobile}>
<button
type="button"
className={`group grid h-[22px] w-7 place-items-center overflow-hidden rounded transition-all hover:bg-custom-background-100 ${
activeLayout == 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 ${
activeLayout == layout.key ? "text-custom-text-100" : "text-custom-text-200"
}`}
/>
</button>
</Tooltip>
))}
</div>
</div>
)}
</div> </div>
); );
}); });

View File

@ -1,12 +1,10 @@
import { FC } from "react"; import { FC } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import Image from "next/image"; import Image from "next/image";
// types
import { TCycleLayoutOptions } from "@plane/types";
// components // components
import { CyclesBoard, CyclesList, CyclesListGanttChartView } from "@/components/cycles"; import { CyclesList } from "@/components/cycles";
// ui // ui
import { CycleModuleBoardLayout, CycleModuleListLayout, GanttLayoutLoader } from "@/components/ui"; import { CycleModuleListLayout } from "@/components/ui";
// hooks // hooks
import { useCycle, useCycleFilter } from "@/hooks/store"; import { useCycle, useCycleFilter } from "@/hooks/store";
// assets // assets
@ -14,30 +12,24 @@ import AllFiltersImage from "public/empty-state/cycle/all-filters.svg";
import NameFilterImage from "public/empty-state/cycle/name-filter.svg"; import NameFilterImage from "public/empty-state/cycle/name-filter.svg";
export interface ICyclesView { export interface ICyclesView {
layout: TCycleLayoutOptions;
workspaceSlug: string; workspaceSlug: string;
projectId: string; projectId: string;
peekCycle: string | undefined;
} }
export const CyclesView: FC<ICyclesView> = observer((props) => { export const CyclesView: FC<ICyclesView> = observer((props) => {
const { layout, workspaceSlug, projectId, peekCycle } = props; const { workspaceSlug, projectId } = props;
// store hooks // store hooks
const { getFilteredCycleIds, getFilteredCompletedCycleIds, loader } = useCycle(); const { getFilteredCycleIds, getFilteredCompletedCycleIds, loader, currentProjectActiveCycleId } = useCycle();
const { searchQuery } = useCycleFilter(); const { searchQuery } = useCycleFilter();
// derived values // derived values
const filteredCycleIds = getFilteredCycleIds(projectId, layout === "gantt"); const filteredCycleIds = getFilteredCycleIds(projectId, false);
const filteredCompletedCycleIds = getFilteredCompletedCycleIds(projectId); const filteredCompletedCycleIds = getFilteredCompletedCycleIds(projectId);
const filteredUpcomingCycleIds = (filteredCycleIds ?? []).filter(
if (loader || !filteredCycleIds) (cycleId) => cycleId !== currentProjectActiveCycleId
return (
<>
{layout === "list" && <CycleModuleListLayout />}
{layout === "board" && <CycleModuleBoardLayout />}
{layout === "gantt" && <GanttLayoutLoader />}
</>
); );
if (loader || !filteredCycleIds) return <CycleModuleListLayout />;
if (filteredCycleIds.length === 0 && filteredCompletedCycleIds?.length === 0) if (filteredCycleIds.length === 0 && filteredCompletedCycleIds?.length === 0)
return ( return (
<div className="grid h-full w-full place-items-center"> <div className="grid h-full w-full place-items-center">
@ -59,24 +51,13 @@ export const CyclesView: FC<ICyclesView> = observer((props) => {
return ( return (
<> <>
{layout === "list" && (
<CyclesList <CyclesList
completedCycleIds={filteredCompletedCycleIds ?? []} completedCycleIds={filteredCompletedCycleIds ?? []}
upcomingCycleIds={filteredUpcomingCycleIds}
cycleIds={filteredCycleIds} cycleIds={filteredCycleIds}
workspaceSlug={workspaceSlug} workspaceSlug={workspaceSlug}
projectId={projectId} projectId={projectId}
/> />
)}
{layout === "board" && (
<CyclesBoard
completedCycleIds={filteredCompletedCycleIds ?? []}
cycleIds={filteredCycleIds}
workspaceSlug={workspaceSlug}
projectId={projectId}
peekCycle={peekCycle}
/>
)}
{layout === "gantt" && <CyclesListGanttChartView cycleIds={filteredCycleIds} workspaceSlug={workspaceSlug} />}
</> </>
); );
}); });

View File

@ -14,6 +14,7 @@ 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-view-header";
// archived cycles // archived cycles
export * from "./archived-cycles"; export * from "./archived-cycles";

View File

@ -0,0 +1,39 @@
import React, { FC } from "react";
import { ChevronDown } from "lucide-react";
// types
import { TCycleGroups } from "@plane/types";
// icons
import { CycleGroupIcon } from "@plane/ui";
// helpers
import { cn } from "@/helpers/common.helper";
type Props = {
type: TCycleGroups;
title: string;
count?: number;
showCount?: boolean;
isExpanded?: boolean;
};
export const CycleListGroupHeader: FC<Props> = (props) => {
const { type, title, count, showCount = false, isExpanded = false } = props;
return (
<div className="relative flex items-center justify-between w-full gap-5 py-1.5">
<div className="flex items-center gap-5 flex-shrink-0">
<div className="flex h-5 w-5 flex-shrink-0 items-center justify-center overflow-hidden rounded-sm">
<CycleGroupIcon cycleGroup={type} className="h-5 w-5" />
</div>
<div className="relative flex w-full flex-row items-center gap-1 overflow-hidden">
<div className="inline-block line-clamp-1 truncate font-medium text-custom-text-100">{title}</div>
{showCount && <div className="pl-2 text-sm font-medium text-custom-text-300">{`${count ?? "0"}`}</div>}
</div>
</div>
<ChevronDown
className={cn("h-4 w-4 text-custom-sidebar-text-300 duration-300 ", {
"rotate-180": isExpanded,
})}
/>
</div>
);
};

View File

@ -8,6 +8,7 @@ import { Avatar, AvatarGroup, Tooltip, setPromiseToast } from "@plane/ui";
// components // components
import { FavoriteStar } from "@/components/core"; import { FavoriteStar } from "@/components/core";
import { CycleQuickActions } from "@/components/cycles"; import { CycleQuickActions } from "@/components/cycles";
import { ButtonAvatars } from "@/components/dropdowns/member/avatar";
// constants // constants
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";
@ -104,6 +105,8 @@ export const CycleListItemAction: FC<Props> = observer((props) => {
}); });
}; };
const createdByDetails = cycleDetails.created_by ? getUserDetails(cycleDetails.created_by) : undefined;
return ( return (
<> <>
{renderDate && ( {renderDate && (
@ -130,6 +133,9 @@ export const CycleListItemAction: FC<Props> = observer((props) => {
</div> </div>
)} )}
{/* created by */}
{createdByDetails && <ButtonAvatars showTooltip={false} userIds={createdByDetails?.id} />}
<Tooltip tooltipContent={`${cycleDetails.assignee_ids?.length} Members`} isMobile={isMobile}> <Tooltip tooltipContent={`${cycleDetails.assignee_ids?.length} Members`} isMobile={isMobile}>
<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 && cycleDetails.assignee_ids?.length > 0 ? ( {cycleDetails.assignee_ids && cycleDetails.assignee_ids?.length > 0 ? (

View File

@ -2,3 +2,4 @@ export * from "./cycles-list-item";
export * from "./cycles-list-map"; export * from "./cycles-list-map";
export * from "./root"; export * from "./root";
export * from "./cycle-list-item-action"; export * from "./cycle-list-item-action";
export * from "./cycle-list-group-header";

View File

@ -1,15 +1,13 @@
import { FC } from "react"; import { FC } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { ChevronRight } from "lucide-react";
import { Disclosure } from "@headlessui/react"; import { Disclosure } from "@headlessui/react";
// components // components
import { ListLayout } from "@/components/core/list"; import { ListLayout } from "@/components/core/list";
import { CyclePeekOverview, CyclesListMap } from "@/components/cycles"; import { ActiveCycleRoot, CycleListGroupHeader, CyclePeekOverview, CyclesListMap } from "@/components/cycles";
// helpers
import { cn } from "@/helpers/common.helper";
export interface ICyclesList { export interface ICyclesList {
completedCycleIds: string[]; completedCycleIds: string[];
upcomingCycleIds?: string[] | undefined;
cycleIds: string[]; cycleIds: string[];
workspaceSlug: string; workspaceSlug: string;
projectId: string; projectId: string;
@ -17,39 +15,62 @@ export interface ICyclesList {
} }
export const CyclesList: FC<ICyclesList> = observer((props) => { export const CyclesList: FC<ICyclesList> = observer((props) => {
const { completedCycleIds, cycleIds, workspaceSlug, projectId, isArchived = false } = props; const { completedCycleIds, upcomingCycleIds, cycleIds, workspaceSlug, projectId, isArchived = false } = props;
return ( return (
<div className="h-full overflow-y-auto">
<div className="flex h-full w-full justify-between "> <div className="flex h-full w-full justify-between ">
<ListLayout> <ListLayout>
<CyclesListMap {isArchived ? (
cycleIds={cycleIds} <>
projectId={projectId} <CyclesListMap cycleIds={cycleIds} projectId={projectId} workspaceSlug={workspaceSlug} />
workspaceSlug={workspaceSlug} </>
/> ) : (
{completedCycleIds.length !== 0 && ( <>
<Disclosure as="div" className="py-8 pl-3 space-y-4"> <ActiveCycleRoot workspaceSlug={workspaceSlug.toString()} projectId={projectId.toString()} />
<Disclosure.Button className="bg-custom-background-80 font-semibold text-sm py-1 px-2 rounded ml-5 flex items-center gap-1">
{upcomingCycleIds && (
<Disclosure as="div" className="flex flex-shrink-0 flex-col" defaultOpen>
{({ open }) => ( {({ open }) => (
<> <>
Completed cycles ({completedCycleIds.length}) <Disclosure.Button className="sticky top-0 z-[2] w-full flex-shrink-0 border-b border-custom-border-200 bg-custom-background-90 px-7 py-1 cursor-pointer">
<ChevronRight <CycleListGroupHeader
className={cn("h-3 w-3 transition-all", { title="Upcoming cycle"
"rotate-90": open, type="upcoming"
})} count={upcomingCycleIds.length}
showCount
isExpanded={open}
/> />
</Disclosure.Button>
<Disclosure.Panel>
<CyclesListMap cycleIds={upcomingCycleIds} projectId={projectId} workspaceSlug={workspaceSlug} />
</Disclosure.Panel>
</> </>
)} )}
</Disclosure>
)}
<Disclosure as="div" className="flex flex-shrink-0 flex-col pb-7">
{({ open }) => (
<>
<Disclosure.Button className="sticky top-0 z-[2] w-full flex-shrink-0 border-b border-custom-border-200 bg-custom-background-90 px-7 py-1 cursor-pointer">
<CycleListGroupHeader
title="Completed cycle"
type="completed"
count={completedCycleIds.length}
showCount
isExpanded={open}
/>
</Disclosure.Button> </Disclosure.Button>
<Disclosure.Panel> <Disclosure.Panel>
<CyclesListMap cycleIds={completedCycleIds} projectId={projectId} workspaceSlug={workspaceSlug} /> <CyclesListMap cycleIds={completedCycleIds} projectId={projectId} workspaceSlug={workspaceSlug} />
</Disclosure.Panel> </Disclosure.Panel>
</>
)}
</Disclosure> </Disclosure>
</>
)} )}
</ListLayout> </ListLayout>
<CyclePeekOverview projectId={projectId} workspaceSlug={workspaceSlug} isArchived={isArchived} /> <CyclePeekOverview projectId={projectId} workspaceSlug={workspaceSlug} isArchived={isArchived} />
</div> </div>
</div>
); );
}); });

View File

@ -5,6 +5,7 @@ import { useRouter } from "next/router";
import { Breadcrumbs, Button, ContrastIcon } from "@plane/ui"; import { Breadcrumbs, Button, ContrastIcon } from "@plane/ui";
// components // components
import { BreadcrumbLink } from "@/components/common"; import { BreadcrumbLink } from "@/components/common";
import { CyclesViewHeader } from "@/components/cycles";
import { ProjectLogo } from "@/components/project"; import { ProjectLogo } from "@/components/project";
// constants // constants
import { EUserProjectRoles } from "@/constants/project"; import { EUserProjectRoles } from "@/constants/project";
@ -54,8 +55,9 @@ export const CyclesHeader: FC = observer(() => {
</Breadcrumbs> </Breadcrumbs>
</div> </div>
</div> </div>
{canUserCreateCycle && ( {canUserCreateCycle && currentProjectDetails && (
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<CyclesViewHeader projectId={currentProjectDetails.id} />
<Button <Button
variant="primary" variant="primary"
size="sm" size="sm"

View File

@ -1,33 +1,25 @@
import { Fragment, useState, ReactElement } from "react"; import { useState, ReactElement } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { Tab } from "@headlessui/react"; // types
import { TCycleFilters } from "@plane/types"; import { TCycleFilters } from "@plane/types";
// hooks // components
import { PageHead } from "@/components/core"; import { PageHead } from "@/components/core";
import { import { CyclesView, CycleCreateUpdateModal, CycleAppliedFiltersList } from "@/components/cycles";
CyclesView,
CycleCreateUpdateModal,
CyclesViewHeader,
CycleAppliedFiltersList,
ActiveCycleRoot,
} from "@/components/cycles";
import CyclesListMobileHeader from "@/components/cycles/cycles-list-mobile-header"; import CyclesListMobileHeader from "@/components/cycles/cycles-list-mobile-header";
import { EmptyState } from "@/components/empty-state"; import { EmptyState } from "@/components/empty-state";
import { CyclesHeader } from "@/components/headers"; import { CyclesHeader } from "@/components/headers";
import { CycleModuleBoardLayout, CycleModuleListLayout, GanttLayoutLoader } from "@/components/ui"; import { CycleModuleListLayout } from "@/components/ui";
import { CYCLE_TABS_LIST } from "@/constants/cycle"; // constants
import { EmptyStateType } from "@/constants/empty-state"; import { EmptyStateType } from "@/constants/empty-state";
// helpers
import { calculateTotalFilters } from "@/helpers/filter.helper"; import { calculateTotalFilters } from "@/helpers/filter.helper";
// hooks
import { useEventTracker, useCycle, useProject, useCycleFilter } from "@/hooks/store"; import { useEventTracker, useCycle, useProject, useCycleFilter } from "@/hooks/store";
// layouts // layouts
import { AppLayout } from "@/layouts/app-layout"; import { AppLayout } from "@/layouts/app-layout";
// components
// ui
// helpers
// types // types
import { NextPageWithLayout } from "@/lib/types"; import { NextPageWithLayout } from "@/lib/types";
// constants
const ProjectCyclesPage: NextPageWithLayout = observer(() => { const ProjectCyclesPage: NextPageWithLayout = observer(() => {
// states // states
@ -38,17 +30,13 @@ const ProjectCyclesPage: NextPageWithLayout = observer(() => {
const { getProjectById, currentProjectDetails } = useProject(); const { getProjectById, currentProjectDetails } = useProject();
// router // router
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId, peekCycle } = router.query; const { workspaceSlug, projectId } = router.query;
// cycle filters hook // cycle filters hook
const { clearAllFilters, currentProjectDisplayFilters, currentProjectFilters, updateDisplayFilters, updateFilters } = const { clearAllFilters, currentProjectFilters, updateFilters } = useCycleFilter();
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 ?? "list";
const handleRemoveFilter = (key: keyof TCycleFilters, value: string | null) => { const handleRemoveFilter = (key: keyof TCycleFilters, value: string | null) => {
if (!projectId) return; if (!projectId) return;
@ -73,14 +61,7 @@ const ProjectCyclesPage: NextPageWithLayout = observer(() => {
</div> </div>
); );
if (loader) if (loader) return <CycleModuleListLayout />;
return (
<>
{cycleLayout === "list" && <CycleModuleListLayout />}
{cycleLayout === "board" && <CycleModuleBoardLayout />}
{cycleLayout === "gantt" && <GanttLayoutLoader />}
</>
);
return ( return (
<> <>
@ -103,21 +84,7 @@ const ProjectCyclesPage: NextPageWithLayout = observer(() => {
/> />
</div> </div>
) : ( ) : (
<Tab.Group <>
as="div"
className="flex h-full flex-col overflow-hidden"
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,
});
}}
>
<CyclesViewHeader projectId={projectId.toString()} />
{calculateTotalFilters(currentProjectFilters ?? {}) !== 0 && ( {calculateTotalFilters(currentProjectFilters ?? {}) !== 0 && (
<div className="border-b border-custom-border-200 px-5 py-3"> <div className="border-b border-custom-border-200 px-5 py-3">
<CycleAppliedFiltersList <CycleAppliedFiltersList
@ -127,20 +94,9 @@ const ProjectCyclesPage: NextPageWithLayout = observer(() => {
/> />
</div> </div>
)} )}
<Tab.Panels as={Fragment}>
<Tab.Panel as="div" className="h-full space-y-5 overflow-y-auto p-4 sm:p-5"> <CyclesView workspaceSlug={workspaceSlug.toString()} projectId={projectId.toString()} />
<ActiveCycleRoot workspaceSlug={workspaceSlug.toString()} projectId={projectId.toString()} /> </>
</Tab.Panel>
<Tab.Panel as="div" className="h-full overflow-y-auto">
<CyclesView
layout={cycleLayout}
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
peekCycle={peekCycle?.toString()}
/>
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
)} )}
</div> </div>
</> </>