diff --git a/apiserver/plane/api/views/cycle.py b/apiserver/plane/api/views/cycle.py index bb2796bf69..637d713c31 100644 --- a/apiserver/plane/api/views/cycle.py +++ b/apiserver/plane/api/views/cycle.py @@ -481,7 +481,7 @@ class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView): .distinct() ) - def list(self, request, slug, project_id): + def get(self, request, slug, project_id): return self.paginate( request=request, queryset=(self.get_queryset()), diff --git a/apiserver/plane/api/views/module.py b/apiserver/plane/api/views/module.py index 88bb1b05e1..643221dcab 100644 --- a/apiserver/plane/api/views/module.py +++ b/apiserver/plane/api/views/module.py @@ -553,7 +553,7 @@ class ModuleArchiveUnarchiveAPIEndpoint(BaseAPIView): .order_by(self.kwargs.get("order_by", "-created_at")) ) - def list(self, request, slug, project_id): + def get(self, request, slug, project_id): return self.paginate( request=request, queryset=(self.get_queryset()), diff --git a/apiserver/plane/app/views/cycle/base.py b/apiserver/plane/app/views/cycle/base.py index f63f1570fa..4e57464e1e 100644 --- a/apiserver/plane/app/views/cycle/base.py +++ b/apiserver/plane/app/views/cycle/base.py @@ -182,6 +182,9 @@ class CycleViewSet(WebhookMixin, BaseViewSet): distinct=True, filter=~Q( issue_cycle__issue__assignees__id__isnull=True + ) + & Q( + issue_cycle__issue__assignees__member_project__is_active=True ), ), Value([], output_field=ArrayField(UUIDField())), @@ -713,10 +716,8 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView): project_id=self.kwargs.get("project_id"), workspace__slug=self.kwargs.get("slug"), ) - return self.filter_queryset( - super() - .get_queryset() - .filter(workspace__slug=self.kwargs.get("slug")) + return ( + Cycle.objects.filter(workspace__slug=self.kwargs.get("slug")) .filter(project_id=self.kwargs.get("project_id")) .filter(archived_at__isnull=False) .filter( @@ -818,6 +819,9 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView): distinct=True, filter=~Q( issue_cycle__issue__assignees__id__isnull=True + ) + & Q( + issue_cycle__issue__assignees__member_project__is_active=True ), ), Value([], output_field=ArrayField(UUIDField())), @@ -827,7 +831,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView): .distinct() ) - def list(self, request, slug, project_id): + def get(self, request, slug, project_id): queryset = ( self.get_queryset() .annotate( @@ -865,6 +869,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView): "backlog_issues", "assignee_ids", "status", + "archived_at", ) ).order_by("-is_favorite", "-created_at") return Response(queryset, status=status.HTTP_200_OK) diff --git a/apiserver/plane/app/views/cycle/issue.py b/apiserver/plane/app/views/cycle/issue.py index d2a7795dab..2a5505dd05 100644 --- a/apiserver/plane/app/views/cycle/issue.py +++ b/apiserver/plane/app/views/cycle/issue.py @@ -143,7 +143,8 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet): ArrayAgg( "assignees__id", distinct=True, - filter=~Q(assignees__id__isnull=True), + filter=~Q(assignees__id__isnull=True) + & Q(assignees__member_project__is_active=True), ), Value([], output_field=ArrayField(UUIDField())), ), diff --git a/apiserver/plane/app/views/dashboard/base.py b/apiserver/plane/app/views/dashboard/base.py index 508f81f21d..33b3cf9d5b 100644 --- a/apiserver/plane/app/views/dashboard/base.py +++ b/apiserver/plane/app/views/dashboard/base.py @@ -149,7 +149,8 @@ def dashboard_assigned_issues(self, request, slug): ArrayAgg( "assignees__id", distinct=True, - filter=~Q(assignees__id__isnull=True), + filter=~Q(assignees__id__isnull=True) + & Q(assignees__member_project__is_active=True), ), Value([], output_field=ArrayField(UUIDField())), ), @@ -303,7 +304,8 @@ def dashboard_created_issues(self, request, slug): ArrayAgg( "assignees__id", distinct=True, - filter=~Q(assignees__id__isnull=True), + filter=~Q(assignees__id__isnull=True) + & Q(assignees__member_project__is_active=True), ), Value([], output_field=ArrayField(UUIDField())), ), diff --git a/apiserver/plane/app/views/inbox/base.py b/apiserver/plane/app/views/inbox/base.py index fb3b9227f2..710aa10a22 100644 --- a/apiserver/plane/app/views/inbox/base.py +++ b/apiserver/plane/app/views/inbox/base.py @@ -146,7 +146,8 @@ class InboxIssueViewSet(BaseViewSet): ArrayAgg( "assignees__id", distinct=True, - filter=~Q(assignees__id__isnull=True), + filter=~Q(assignees__id__isnull=True) + & Q(assignees__member_project__is_active=True), ), Value([], output_field=ArrayField(UUIDField())), ), diff --git a/apiserver/plane/app/views/issue/archive.py b/apiserver/plane/app/views/issue/archive.py index 540715a241..d9274ae4fa 100644 --- a/apiserver/plane/app/views/issue/archive.py +++ b/apiserver/plane/app/views/issue/archive.py @@ -105,7 +105,8 @@ class IssueArchiveViewSet(BaseViewSet): ArrayAgg( "assignees__id", distinct=True, - filter=~Q(assignees__id__isnull=True), + filter=~Q(assignees__id__isnull=True) + & Q(assignees__member_project__is_active=True), ), Value([], output_field=ArrayField(UUIDField())), ), diff --git a/apiserver/plane/app/views/issue/base.py b/apiserver/plane/app/views/issue/base.py index f1b4c76278..a27f52c748 100644 --- a/apiserver/plane/app/views/issue/base.py +++ b/apiserver/plane/app/views/issue/base.py @@ -52,6 +52,7 @@ from plane.db.models import ( from plane.bgtasks.issue_activites_task import issue_activity from plane.utils.issue_filters import issue_filters + class IssueListEndpoint(BaseAPIView): permission_classes = [ @@ -114,7 +115,8 @@ class IssueListEndpoint(BaseAPIView): ArrayAgg( "assignees__id", distinct=True, - filter=~Q(assignees__id__isnull=True), + filter=~Q(assignees__id__isnull=True) + & Q(assignees__member_project__is_active=True), ), Value([], output_field=ArrayField(UUIDField())), ), @@ -308,7 +310,8 @@ class IssueViewSet(WebhookMixin, BaseViewSet): ArrayAgg( "assignees__id", distinct=True, - filter=~Q(assignees__id__isnull=True), + filter=~Q(assignees__id__isnull=True) + & Q(assignees__member_project__is_active=True), ), Value([], output_field=ArrayField(UUIDField())), ), diff --git a/apiserver/plane/app/views/issue/draft.py b/apiserver/plane/app/views/issue/draft.py index db6b5b9fb9..e1c6962d89 100644 --- a/apiserver/plane/app/views/issue/draft.py +++ b/apiserver/plane/app/views/issue/draft.py @@ -101,7 +101,8 @@ class IssueDraftViewSet(BaseViewSet): ArrayAgg( "assignees__id", distinct=True, - filter=~Q(assignees__id__isnull=True), + filter=~Q(assignees__id__isnull=True) + & Q(assignees__member_project__is_active=True), ), Value([], output_field=ArrayField(UUIDField())), ), diff --git a/apiserver/plane/app/views/issue/sub_issue.py b/apiserver/plane/app/views/issue/sub_issue.py index 6ec4a2de1f..da479e0e99 100644 --- a/apiserver/plane/app/views/issue/sub_issue.py +++ b/apiserver/plane/app/views/issue/sub_issue.py @@ -83,7 +83,8 @@ class SubIssuesEndpoint(BaseAPIView): ArrayAgg( "assignees__id", distinct=True, - filter=~Q(assignees__id__isnull=True), + filter=~Q(assignees__id__isnull=True) + & Q(assignees__member_project__is_active=True), ), Value([], output_field=ArrayField(UUIDField())), ), diff --git a/apiserver/plane/app/views/module/base.py b/apiserver/plane/app/views/module/base.py index 2e4b1024da..39dbcb751e 100644 --- a/apiserver/plane/app/views/module/base.py +++ b/apiserver/plane/app/views/module/base.py @@ -498,10 +498,7 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView): workspace__slug=self.kwargs.get("slug"), ) return ( - super() - .get_queryset() - .filter(project_id=self.kwargs.get("project_id")) - .filter(workspace__slug=self.kwargs.get("slug")) + Module.objects.filter(workspace__slug=self.kwargs.get("slug")) .filter(archived_at__isnull=False) .annotate(is_favorite=Exists(favorite_subquery)) .select_related("project") @@ -594,7 +591,7 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView): .order_by("-is_favorite", "-created_at") ) - def list(self, request, slug, project_id): + def get(self, request, slug, project_id): queryset = self.get_queryset() modules = queryset.values( # Required fields "id", @@ -624,6 +621,7 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView): "backlog_issues", "created_at", "updated_at", + "archived_at" ) return Response(modules, status=status.HTTP_200_OK) diff --git a/apiserver/plane/app/views/module/issue.py b/apiserver/plane/app/views/module/issue.py index cfa8ee478c..d264333407 100644 --- a/apiserver/plane/app/views/module/issue.py +++ b/apiserver/plane/app/views/module/issue.py @@ -93,7 +93,8 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet): ArrayAgg( "assignees__id", distinct=True, - filter=~Q(assignees__id__isnull=True), + filter=~Q(assignees__id__isnull=True) + & Q(assignees__member_project__is_active=True), ), Value([], output_field=ArrayField(UUIDField())), ), diff --git a/apiserver/plane/app/views/view/base.py b/apiserver/plane/app/views/view/base.py index e2fc29aac1..45e7bd29cf 100644 --- a/apiserver/plane/app/views/view/base.py +++ b/apiserver/plane/app/views/view/base.py @@ -125,7 +125,8 @@ class GlobalViewIssuesViewSet(BaseViewSet): ArrayAgg( "assignees__id", distinct=True, - filter=~Q(assignees__id__isnull=True), + filter=~Q(assignees__id__isnull=True) + & Q(assignees__member_project__is_active=True), ), Value([], output_field=ArrayField(UUIDField())), ), diff --git a/apiserver/plane/app/views/workspace/user.py b/apiserver/plane/app/views/workspace/user.py index fe495de6c5..94a22a1a7f 100644 --- a/apiserver/plane/app/views/workspace/user.py +++ b/apiserver/plane/app/views/workspace/user.py @@ -165,7 +165,8 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView): ArrayAgg( "assignees__id", distinct=True, - filter=~Q(assignees__id__isnull=True), + filter=~Q(assignees__id__isnull=True) + & Q(assignees__member_project__is_active=True), ), Value([], output_field=ArrayField(UUIDField())), ), diff --git a/packages/types/src/cycle/cycle.d.ts b/packages/types/src/cycle/cycle.d.ts index c41ab279b9..30724706b4 100644 --- a/packages/types/src/cycle/cycle.d.ts +++ b/packages/types/src/cycle/cycle.d.ts @@ -31,6 +31,7 @@ export interface ICycle { unstarted_issues: number; updated_at: Date; updated_by: string; + archived_at: string | null; assignee_ids: string[]; view_props: { filters: IIssueFilterOptions; diff --git a/packages/types/src/cycle/cycle_filters.d.ts b/packages/types/src/cycle/cycle_filters.d.ts index 470a20dd27..38f8a7549b 100644 --- a/packages/types/src/cycle/cycle_filters.d.ts +++ b/packages/types/src/cycle/cycle_filters.d.ts @@ -13,6 +13,11 @@ export type TCycleFilters = { status?: string[] | null; }; +export type TCycleFiltersByState = { + default: TCycleFilters; + archived: TCycleFilters; +}; + export type TCycleStoredFilters = { display_filters?: TCycleDisplayFilters; filters?: TCycleFilters; diff --git a/packages/types/src/module/module_filters.d.ts b/packages/types/src/module/module_filters.d.ts index 10d56c3289..297c8046cd 100644 --- a/packages/types/src/module/module_filters.d.ts +++ b/packages/types/src/module/module_filters.d.ts @@ -26,6 +26,11 @@ export type TModuleFilters = { target_date?: string[] | null; }; +export type TModuleFiltersByState = { + default: TModuleFilters; + archived: TModuleFilters; +}; + export type TModuleStoredFilters = { display_filters?: TModuleDisplayFilters; filters?: TModuleFilters; diff --git a/packages/types/src/module/modules.d.ts b/packages/types/src/module/modules.d.ts index 0af293e507..7ba2c3b418 100644 --- a/packages/types/src/module/modules.d.ts +++ b/packages/types/src/module/modules.d.ts @@ -39,6 +39,7 @@ export interface IModule { unstarted_issues: number; updated_at: Date; updated_by: string; + archived_at: string | null; view_props: { filters: IIssueFilterOptions; }; diff --git a/web/components/archives/archive-tabs-list.tsx b/web/components/archives/archive-tabs-list.tsx new file mode 100644 index 0000000000..57d1c36a1d --- /dev/null +++ b/web/components/archives/archive-tabs-list.tsx @@ -0,0 +1,43 @@ +import { FC } from "react"; +import { observer } from "mobx-react"; +import Link from "next/link"; +import { useRouter } from "next/router"; +// constants +import { ARCHIVES_TAB_LIST } from "@/constants/archives"; +// hooks +import { useProject } from "@/hooks/store"; + +export const ArchiveTabsList: FC = observer(() => { + // router + const router = useRouter(); + const { workspaceSlug, projectId } = router.query; + const activeTab = router.pathname.split("/").pop(); + // store hooks + const { getProjectById } = useProject(); + + // derived values + if (!projectId) return null; + const projectDetails = getProjectById(projectId?.toString()); + if (!projectDetails) return null; + + return ( + <> + {ARCHIVES_TAB_LIST.map( + (tab) => + tab.shouldRender(projectDetails) && ( + + + {tab.label} + + + ) + )} + + ); +}); diff --git a/web/components/archives/index.ts b/web/components/archives/index.ts new file mode 100644 index 0000000000..4b519fca03 --- /dev/null +++ b/web/components/archives/index.ts @@ -0,0 +1 @@ +export * from "./archive-tabs-list"; diff --git a/web/components/core/sidebar/links-list.tsx b/web/components/core/sidebar/links-list.tsx index 9556eb1aa9..83db67c347 100644 --- a/web/components/core/sidebar/links-list.tsx +++ b/web/components/core/sidebar/links-list.tsx @@ -16,12 +16,13 @@ type Props = { handleDeleteLink: (linkId: string) => void; handleEditLink: (link: ILinkDetails) => void; userAuth: UserAuth; + disabled?: boolean; }; -export const LinksList: React.FC = observer(({ links, handleDeleteLink, handleEditLink, userAuth }) => { +export const LinksList: React.FC = observer(({ links, handleDeleteLink, handleEditLink, userAuth, disabled }) => { const { getUserDetails } = useMember(); const { isMobile } = usePlatformOS(); - const isNotAllowed = userAuth.isGuest || userAuth.isViewer; + const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disabled; const copyToClipboard = (text: string) => { navigator.clipboard.writeText(text); diff --git a/web/components/cycles/archived-cycles/header.tsx b/web/components/cycles/archived-cycles/header.tsx new file mode 100644 index 0000000000..267c873885 --- /dev/null +++ b/web/components/cycles/archived-cycles/header.tsx @@ -0,0 +1,123 @@ +import { FC, useCallback, useRef, useState } from "react"; +import { observer } from "mobx-react-lite"; +import { useRouter } from "next/router"; +// icons +import { ListFilter, Search, X } from "lucide-react"; +// types +import type { TCycleFilters } from "@plane/types"; +// components +import { ArchiveTabsList } from "@/components/archives"; +import { CycleFiltersSelection } from "@/components/cycles"; +import { FiltersDropdown } from "@/components/issues"; +// helpers +import { cn } from "@/helpers/common.helper"; +// hooks +import { useCycleFilter } from "@/hooks/store"; +import useOutsideClickDetector from "@/hooks/use-outside-click-detector"; + +export const ArchivedCyclesHeader: FC = observer(() => { + // router + const router = useRouter(); + const { projectId } = router.query; + // refs + const inputRef = useRef(null); + // hooks + const { currentProjectArchivedFilters, archivedCyclesSearchQuery, updateFilters, updateArchivedCyclesSearchQuery } = + useCycleFilter(); + // states + const [isSearchOpen, setIsSearchOpen] = useState(archivedCyclesSearchQuery !== "" ? true : false); + // outside click detector hook + useOutsideClickDetector(inputRef, () => { + if (isSearchOpen && archivedCyclesSearchQuery.trim() === "") setIsSearchOpen(false); + }); + + const handleFilters = useCallback( + (key: keyof TCycleFilters, value: string | string[]) => { + if (!projectId) return; + + const newValues = currentProjectArchivedFilters?.[key] ?? []; + + if (Array.isArray(value)) + value.forEach((val) => { + if (!newValues.includes(val)) newValues.push(val); + }); + else { + if (currentProjectArchivedFilters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1); + else newValues.push(value); + } + + updateFilters(projectId.toString(), { [key]: newValues }, "archived"); + }, + [currentProjectArchivedFilters, projectId, updateFilters] + ); + + const handleInputKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Escape") { + if (archivedCyclesSearchQuery && archivedCyclesSearchQuery.trim() !== "") updateArchivedCyclesSearchQuery(""); + else { + setIsSearchOpen(false); + inputRef.current?.blur(); + } + } + }; + + return ( +
+
+ +
+ {/* filter options */} +
+ {!isSearchOpen && ( + + )} +
+ + updateArchivedCyclesSearchQuery(e.target.value)} + onKeyDown={handleInputKeyDown} + /> + {isSearchOpen && ( + + )} +
+ } title="Filters" placement="bottom-end"> + + +
+
+ ); +}); diff --git a/web/components/cycles/archived-cycles/index.ts b/web/components/cycles/archived-cycles/index.ts new file mode 100644 index 0000000000..f59f0954ef --- /dev/null +++ b/web/components/cycles/archived-cycles/index.ts @@ -0,0 +1,4 @@ +export * from "./root"; +export * from "./view"; +export * from "./header"; +export * from "./modal"; diff --git a/web/components/cycles/archived-cycles/modal.tsx b/web/components/cycles/archived-cycles/modal.tsx new file mode 100644 index 0000000000..a9b421351b --- /dev/null +++ b/web/components/cycles/archived-cycles/modal.tsx @@ -0,0 +1,104 @@ +import { useState, Fragment } from "react"; +import { useRouter } from "next/router"; +import { Dialog, Transition } from "@headlessui/react"; +// ui +import { Button, TOAST_TYPE, setToast } from "@plane/ui"; +// hooks +import { useCycle } from "@/hooks/store"; + +type Props = { + workspaceSlug: string; + projectId: string; + cycleId: string; + handleClose: () => void; + isOpen: boolean; + onSubmit?: () => Promise; +}; + +export const ArchiveCycleModal: React.FC = (props) => { + const { workspaceSlug, projectId, cycleId, isOpen, handleClose } = props; + // router + const router = useRouter(); + // states + const [isArchiving, setIsArchiving] = useState(false); + // store hooks + const { getCycleNameById, archiveCycle } = useCycle(); + + const cycleName = getCycleNameById(cycleId); + + const onClose = () => { + setIsArchiving(false); + handleClose(); + }; + + const handleArchiveIssue = async () => { + setIsArchiving(true); + await archiveCycle(workspaceSlug, projectId, cycleId) + .then(() => { + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Archive success", + message: "Your archives can be found in project archives.", + }); + onClose(); + router.push(`/${workspaceSlug}/projects/${projectId}/archives/cycles?peekCycle=${cycleId}`); + }) + .catch(() => + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: "Cycle could not be archived. Please try again.", + }) + ) + .finally(() => setIsArchiving(false)); + }; + + return ( + + + +
+ + +
+
+ + +
+

Archive cycle {cycleName}

+

+ Are you sure you want to archive the cycle? All your archives can be restored later. +

+
+ + +
+
+
+
+
+
+
+
+ ); +}; diff --git a/web/components/cycles/archived-cycles/root.tsx b/web/components/cycles/archived-cycles/root.tsx new file mode 100644 index 0000000000..4d47c8f34e --- /dev/null +++ b/web/components/cycles/archived-cycles/root.tsx @@ -0,0 +1,77 @@ +import React from "react"; +import { observer } from "mobx-react-lite"; +import { useRouter } from "next/router"; +import useSWR from "swr"; +// types +import { TCycleFilters } from "@plane/types"; +// components +import { ArchivedCyclesView, CycleAppliedFiltersList } from "@/components/cycles"; +import { EmptyState } from "@/components/empty-state"; +import { CycleModuleListLayout } from "@/components/ui"; +// constants +import { EmptyStateType } from "@/constants/empty-state"; +// helpers +import { calculateTotalFilters } from "@/helpers/filter.helper"; +// hooks +import { useCycle, useCycleFilter } from "@/hooks/store"; + +export const ArchivedCycleLayoutRoot: React.FC = observer(() => { + // router + const router = useRouter(); + const { workspaceSlug, projectId } = router.query; + // hooks + const { fetchArchivedCycles, currentProjectArchivedCycleIds, loader } = useCycle(); + // cycle filters hook + const { clearAllFilters, currentProjectArchivedFilters, updateFilters } = useCycleFilter(); + // derived values + const totalArchivedCycles = currentProjectArchivedCycleIds?.length ?? 0; + + useSWR( + workspaceSlug && projectId ? `ARCHIVED_CYCLES_${workspaceSlug.toString()}_${projectId.toString()}` : null, + async () => { + if (workspaceSlug && projectId) { + await fetchArchivedCycles(workspaceSlug.toString(), projectId.toString()); + } + }, + { revalidateIfStale: false, revalidateOnFocus: false } + ); + + const handleRemoveFilter = (key: keyof TCycleFilters, value: string | null) => { + if (!projectId) return; + let newValues = currentProjectArchivedFilters?.[key] ?? []; + + if (!value) newValues = []; + else newValues = newValues.filter((val) => val !== value); + + updateFilters(projectId.toString(), { [key]: newValues }, "archived"); + }; + + if (!workspaceSlug || !projectId) return <>; + + if (loader || !currentProjectArchivedCycleIds) { + return ; + } + + return ( + <> + {calculateTotalFilters(currentProjectArchivedFilters ?? {}) !== 0 && ( +
+ clearAllFilters(projectId.toString(), "archived")} + handleRemoveFilter={handleRemoveFilter} + /> +
+ )} + {totalArchivedCycles === 0 ? ( +
+ +
+ ) : ( +
+ +
+ )} + + ); +}); diff --git a/web/components/cycles/archived-cycles/view.tsx b/web/components/cycles/archived-cycles/view.tsx new file mode 100644 index 0000000000..ed86a56b44 --- /dev/null +++ b/web/components/cycles/archived-cycles/view.tsx @@ -0,0 +1,57 @@ +import { FC } from "react"; +import { observer } from "mobx-react-lite"; +import Image from "next/image"; +// components +import { CyclesList } from "@/components/cycles"; +// ui +import { CycleModuleListLayout } from "@/components/ui"; +// hooks +import { useCycle, useCycleFilter } from "@/hooks/store"; +// assets +import AllFiltersImage from "@/public/empty-state/cycle/all-filters.svg"; +import NameFilterImage from "@/public/empty-state/cycle/name-filter.svg"; + +export interface IArchivedCyclesView { + workspaceSlug: string; + projectId: string; +} + +export const ArchivedCyclesView: FC = observer((props) => { + const { workspaceSlug, projectId } = props; + // store hooks + const { getFilteredArchivedCycleIds, loader } = useCycle(); + const { archivedCyclesSearchQuery } = useCycleFilter(); + // derived values + const filteredArchivedCycleIds = getFilteredArchivedCycleIds(projectId); + + if (loader || !filteredArchivedCycleIds) return ; + + if (filteredArchivedCycleIds.length === 0) + return ( +
+
+ No matching cycles +
No matching cycles
+

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

+
+
+ ); + + return ( + + ); +}); diff --git a/web/components/cycles/cycle-peek-overview.tsx b/web/components/cycles/cycle-peek-overview.tsx index 4b88d8d7bf..8409c06fe3 100644 --- a/web/components/cycles/cycle-peek-overview.tsx +++ b/web/components/cycles/cycle-peek-overview.tsx @@ -9,9 +9,10 @@ import { CycleDetailsSidebar } from "./sidebar"; type Props = { projectId: string; workspaceSlug: string; + isArchived?: boolean; }; -export const CyclePeekOverview: React.FC = observer(({ projectId, workspaceSlug }) => { +export const CyclePeekOverview: React.FC = observer(({ projectId, workspaceSlug, isArchived = false }) => { // router const router = useRouter(); const { peekCycle } = router.query; @@ -29,9 +30,9 @@ export const CyclePeekOverview: React.FC = observer(({ projectId, workspa }; useEffect(() => { - if (!peekCycle) return; + if (!peekCycle || isArchived) return; fetchCycleDetails(workspaceSlug, projectId, peekCycle.toString()); - }, [fetchCycleDetails, peekCycle, projectId, workspaceSlug]); + }, [fetchCycleDetails, isArchived, peekCycle, projectId, workspaceSlug]); return ( <> @@ -44,7 +45,11 @@ export const CyclePeekOverview: React.FC = observer(({ projectId, workspa "0px 1px 4px 0px rgba(0, 0, 0, 0.06), 0px 2px 4px 0px rgba(16, 24, 40, 0.06), 0px 1px 8px -1px rgba(16, 24, 40, 0.06)", }} > - + )} diff --git a/web/components/cycles/cycles-view-header.tsx b/web/components/cycles/cycles-view-header.tsx index c687c965ed..aad650dd66 100644 --- a/web/components/cycles/cycles-view-header.tsx +++ b/web/components/cycles/cycles-view-header.tsx @@ -2,21 +2,21 @@ import { useCallback, useRef, useState } from "react"; import { observer } from "mobx-react"; import { ListFilter, Search, X } from "lucide-react"; import { Tab } from "@headlessui/react"; +// types import { TCycleFilters } from "@plane/types"; -// hooks +// ui import { Tooltip } from "@plane/ui"; +// components import { CycleFiltersSelection } from "@/components/cycles"; import { FiltersDropdown } from "@/components/issues"; +// constants import { CYCLE_TABS_LIST, CYCLE_VIEW_LAYOUTS } from "@/constants/cycle"; +// helpers import { cn } from "@/helpers/common.helper"; +// hooks import { useCycleFilter } from "@/hooks/store"; import useOutsideClickDetector from "@/hooks/use-outside-click-detector"; import { usePlatformOS } from "@/hooks/use-platform-os"; -// components -// ui -// helpers -// types -// constants type Props = { projectId: string; @@ -24,8 +24,6 @@ type Props = { export const CyclesViewHeader: React.FC = observer((props) => { const { projectId } = props; - // states - const [isSearchOpen, setIsSearchOpen] = useState(false); // refs const inputRef = useRef(null); // hooks @@ -38,6 +36,8 @@ export const CyclesViewHeader: React.FC = observer((props) => { updateSearchQuery, } = useCycleFilter(); const { isMobile } = usePlatformOS(); + // states + const [isSearchOpen, setIsSearchOpen] = useState(searchQuery !== "" ? true : false); // outside click detector hook useOutsideClickDetector(inputRef, () => { if (isSearchOpen && searchQuery.trim() === "") setIsSearchOpen(false); diff --git a/web/components/cycles/dropdowns/filters/root.tsx b/web/components/cycles/dropdowns/filters/root.tsx index 768b8a5dc8..57e9ec90c4 100644 --- a/web/components/cycles/dropdowns/filters/root.tsx +++ b/web/components/cycles/dropdowns/filters/root.tsx @@ -9,10 +9,11 @@ import { FilterEndDate, FilterStartDate, FilterStatus } from "@/components/cycle type Props = { filters: TCycleFilters; handleFiltersUpdate: (key: keyof TCycleFilters, value: string | string[]) => void; + isArchived?: boolean; }; export const CycleFiltersSelection: React.FC = observer((props) => { - const { filters, handleFiltersUpdate } = props; + const { filters, handleFiltersUpdate, isArchived = false } = props; // states const [filtersSearchQuery, setFiltersSearchQuery] = useState(""); @@ -38,13 +39,15 @@ export const CycleFiltersSelection: React.FC = observer((props) => {
{/* cycle status */} -
- handleFiltersUpdate("status", val)} - searchQuery={filtersSearchQuery} - /> -
+ {!isArchived && ( +
+ handleFiltersUpdate("status", val)} + searchQuery={filtersSearchQuery} + /> +
+ )} {/* start date */}
diff --git a/web/components/cycles/form.tsx b/web/components/cycles/form.tsx index 27b9720440..f8092f8d06 100644 --- a/web/components/cycles/form.tsx +++ b/web/components/cycles/form.tsx @@ -113,7 +113,7 @@ export const CycleForm: React.FC = (props) => { id="cycle_description" name="description" placeholder="Description..." - className="!h-24 w-full resize-none text-sm" + className="w-full text-sm resize-none min-h-24" hasError={Boolean(errors?.description)} value={value} onChange={onChange} diff --git a/web/components/cycles/index.ts b/web/components/cycles/index.ts index 972d03474e..bc713b5df3 100644 --- a/web/components/cycles/index.ts +++ b/web/components/cycles/index.ts @@ -16,3 +16,6 @@ export * from "./quick-actions"; export * from "./sidebar"; export * from "./transfer-issues-modal"; export * from "./transfer-issues"; + +// archived cycles +export * from "./archived-cycles"; diff --git a/web/components/cycles/list/cycles-list-item.tsx b/web/components/cycles/list/cycles-list-item.tsx index 6e81da3c7b..a418f9b047 100644 --- a/web/components/cycles/list/cycles-list-item.tsx +++ b/web/components/cycles/list/cycles-list-item.tsx @@ -2,27 +2,21 @@ import { FC, MouseEvent } from "react"; import { observer } from "mobx-react"; import Link from "next/link"; import { useRouter } from "next/router"; -// hooks -import { Check, Info, Star, User2 } from "lucide-react"; -import type { TCycleGroups } from "@plane/types"; -import { Tooltip, CircularProgressIndicator, CycleGroupIcon, AvatarGroup, Avatar, setPromiseToast } from "@plane/ui"; -import { CycleQuickActions } from "@/components/cycles"; -// components -// import { CycleCreateUpdateModal, CycleDeleteModal } from "@/components/cycles"; -// ui // icons -// helpers +import { Check, Info, Star, User2 } from "lucide-react"; +// types +import type { TCycleGroups } from "@plane/types"; +// ui +import { Tooltip, CircularProgressIndicator, CycleGroupIcon, AvatarGroup, Avatar, setPromiseToast } from "@plane/ui"; +// components +import { CycleQuickActions } from "@/components/cycles"; // constants import { CYCLE_STATUS } from "@/constants/cycle"; import { CYCLE_FAVORITED, CYCLE_UNFAVORITED } from "@/constants/event-tracker"; -// components -// ui -// icons -// helpers -// constants -// types import { EUserProjectRoles } from "@/constants/project"; +// helpers import { findHowManyDaysLeft, getDate, renderFormattedDate } from "@/helpers/date-time.helper"; +// hooks import { useEventTracker, useCycle, useUser, useMember } from "@/hooks/store"; import { usePlatformOS } from "@/hooks/use-platform-os"; @@ -34,10 +28,11 @@ type TCyclesListItem = { handleRemoveFromFavorites?: () => void; workspaceSlug: string; projectId: string; + isArchived?: boolean; }; export const CyclesListItem: FC = observer((props) => { - const { cycleId, workspaceSlug, projectId } = props; + const { cycleId, workspaceSlug, projectId, isArchived } = props; // router const router = useRouter(); // hooks @@ -106,7 +101,7 @@ export const CyclesListItem: FC = observer((props) => { }); }; - const openCycleOverview = (e: MouseEvent) => { + const openCycleOverview = (e: MouseEvent) => { const { query } = router; e.preventDefault(); e.stopPropagation(); @@ -151,7 +146,14 @@ export const CyclesListItem: FC = observer((props) => { return ( <> - + { + if (isArchived) { + openCycleOverview(e); + } + }} + >
@@ -221,21 +223,23 @@ export const CyclesListItem: FC = observer((props) => {
- {isEditingAllowed && ( - <> - {cycleDetails.is_favorite ? ( - - ) : ( - - )} - - - - )} + {isEditingAllowed && + !isArchived && + (cycleDetails.is_favorite ? ( + + ) : ( + + ))} +
diff --git a/web/components/cycles/list/cycles-list-map.tsx b/web/components/cycles/list/cycles-list-map.tsx index 004c66fcac..7a99f5ab73 100644 --- a/web/components/cycles/list/cycles-list-map.tsx +++ b/web/components/cycles/list/cycles-list-map.tsx @@ -5,15 +5,22 @@ type Props = { cycleIds: string[]; projectId: string; workspaceSlug: string; + isArchived?: boolean; }; export const CyclesListMap: React.FC = (props) => { - const { cycleIds, projectId, workspaceSlug } = props; + const { cycleIds, projectId, workspaceSlug, isArchived } = props; return ( <> {cycleIds.map((cycleId) => ( - + ))} ); diff --git a/web/components/cycles/list/root.tsx b/web/components/cycles/list/root.tsx index 904daa1d9b..ef05228eea 100644 --- a/web/components/cycles/list/root.tsx +++ b/web/components/cycles/list/root.tsx @@ -12,16 +12,22 @@ export interface ICyclesList { cycleIds: string[]; workspaceSlug: string; projectId: string; + isArchived?: boolean; } export const CyclesList: FC = observer((props) => { - const { completedCycleIds, cycleIds, workspaceSlug, projectId } = props; + const { completedCycleIds, cycleIds, workspaceSlug, projectId, isArchived = false } = props; return (
- + {completedCycleIds.length !== 0 && ( @@ -37,12 +43,17 @@ export const CyclesList: FC = observer((props) => { )} - + )}
- +
); diff --git a/web/components/cycles/quick-actions.tsx b/web/components/cycles/quick-actions.tsx index eebd28a9f4..215f07beff 100644 --- a/web/components/cycles/quick-actions.tsx +++ b/web/components/cycles/quick-actions.tsx @@ -1,34 +1,40 @@ import { useState } from "react"; import { observer } from "mobx-react"; -import { LinkIcon, Pencil, Trash2 } from "lucide-react"; -// hooks -// components -import { CustomMenu, TOAST_TYPE, setToast } from "@plane/ui"; -import { CycleCreateUpdateModal, CycleDeleteModal } from "@/components/cycles"; +import { useRouter } from "next/router"; +// icons +import { ArchiveRestoreIcon, LinkIcon, Pencil, Trash2 } from "lucide-react"; // ui -// helpers -import { EUserProjectRoles } from "@/constants/project"; -import { copyUrlToClipboard } from "@/helpers/string.helper"; +import { ArchiveIcon, CustomMenu, TOAST_TYPE, setToast } from "@plane/ui"; +// components +import { ArchiveCycleModal, CycleCreateUpdateModal, CycleDeleteModal } from "@/components/cycles"; // constants +import { EUserProjectRoles } from "@/constants/project"; +// helpers +import { copyUrlToClipboard } from "@/helpers/string.helper"; +// hooks import { useCycle, useEventTracker, useUser } from "@/hooks/store"; type Props = { cycleId: string; projectId: string; workspaceSlug: string; + isArchived?: boolean; }; export const CycleQuickActions: React.FC = observer((props) => { - const { cycleId, projectId, workspaceSlug } = props; + const { cycleId, projectId, workspaceSlug, isArchived } = props; + // router + const router = useRouter(); // states const [updateModal, setUpdateModal] = useState(false); + const [archiveCycleModal, setArchiveCycleModal] = useState(false); const [deleteModal, setDeleteModal] = useState(false); // store hooks const { setTrackElement } = useEventTracker(); const { membership: { currentWorkspaceAllProjectsRole }, } = useUser(); - const { getCycleById } = useCycle(); + const { getCycleById, restoreCycle } = useCycle(); // derived values const cycleDetails = getCycleById(cycleId); const isCompleted = cycleDetails?.status.toLowerCase() === "completed"; @@ -56,6 +62,33 @@ export const CycleQuickActions: React.FC = observer((props) => { setUpdateModal(true); }; + const handleArchiveCycle = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + setArchiveCycleModal(true); + }; + + const handleRestoreCycle = async (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + await restoreCycle(workspaceSlug, projectId, cycleId) + .then(() => { + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Restore success", + message: "Your cycle can be found in project cycles.", + }); + router.push(`/${workspaceSlug}/projects/${projectId}/cycles/${cycleId}`); + }) + .catch(() => + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: "Cycle could not be restored. Please try again.", + }) + ); + }; + const handleDeleteCycle = (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); @@ -74,6 +107,13 @@ export const CycleQuickActions: React.FC = observer((props) => { workspaceSlug={workspaceSlug} projectId={projectId} /> + setArchiveCycleModal(false)} + /> = observer((props) => {
)} + {!isCompleted && isEditingAllowed && !isArchived && ( + + + + Edit cycle + + + )} + {isEditingAllowed && !isArchived && ( + + {isCompleted ? ( +
+ + Archive cycle +
+ ) : ( +
+ +
+

Archive cycle

+

+ Only completed cycle
can be archived. +

+
+
+ )} +
+ )} + {isEditingAllowed && isArchived && ( + + + + Restore cycle + + + )} + {!isArchived && ( + + + + Copy cycle link + + + )} {!isCompleted && isEditingAllowed && ( - <> - - - - Edit cycle - - +
Delete cycle - +
)} - - - - Copy cycle link - -
); diff --git a/web/components/cycles/sidebar.tsx b/web/components/cycles/sidebar.tsx index e142b95ec5..e333564ee7 100644 --- a/web/components/cycles/sidebar.tsx +++ b/web/components/cycles/sidebar.tsx @@ -3,33 +3,43 @@ import isEmpty from "lodash/isEmpty"; import { observer } from "mobx-react-lite"; import { useRouter } from "next/router"; import { Controller, useForm } from "react-hook-form"; -import { ChevronDown, LinkIcon, Trash2, UserCircle2, AlertCircle, ChevronRight, CalendarClock } from "lucide-react"; -import { Disclosure, Transition } from "@headlessui/react"; // icons +import { + ArchiveRestoreIcon, + ChevronDown, + LinkIcon, + Trash2, + UserCircle2, + AlertCircle, + ChevronRight, + CalendarClock, +} from "lucide-react"; +import { Disclosure, Transition } from "@headlessui/react"; +// types import { ICycle } from "@plane/types"; // ui -import { Avatar, CustomMenu, Loader, LayersIcon, TOAST_TYPE, setToast } from "@plane/ui"; +import { Avatar, ArchiveIcon, CustomMenu, Loader, LayersIcon, TOAST_TYPE, setToast, TextArea } from "@plane/ui"; // components import { SidebarProgressStats } from "@/components/core"; import ProgressChart from "@/components/core/sidebar/progress-chart"; -import { CycleDeleteModal } from "@/components/cycles/delete-modal"; +import { ArchiveCycleModal, CycleDeleteModal } from "@/components/cycles"; import { DateRangeDropdown } from "@/components/dropdowns"; // constants import { CYCLE_STATUS } from "@/constants/cycle"; import { CYCLE_UPDATED } from "@/constants/event-tracker"; import { EUserWorkspaceRoles } from "@/constants/workspace"; // helpers -// hooks import { findHowManyDaysLeft, getDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper"; import { copyUrlToClipboard } from "@/helpers/string.helper"; +// hooks import { useEventTracker, useCycle, useUser, useMember } from "@/hooks/store"; // services import { CycleService } from "@/services/cycle.service"; -// types type Props = { cycleId: string; handleClose: () => void; + isArchived?: boolean; }; const defaultValues: Partial = { @@ -42,8 +52,9 @@ const cycleService = new CycleService(); // TODO: refactor the whole component export const CycleDetailsSidebar: React.FC = observer((props) => { - const { cycleId, handleClose } = props; + const { cycleId, handleClose, isArchived } = props; // states + const [archiveCycleModal, setArchiveCycleModal] = useState(false); const [cycleDeleteModal, setCycleDeleteModal] = useState(false); // router const router = useRouter(); @@ -53,7 +64,7 @@ export const CycleDetailsSidebar: React.FC = observer((props) => { const { membership: { currentProjectRole }, } = useUser(); - const { getCycleById, updateCycleDetails } = useCycle(); + const { getCycleById, updateCycleDetails, restoreCycle } = useCycle(); const { getUserDetails } = useMember(); // derived values const cycleDetails = getCycleById(cycleId); @@ -108,6 +119,27 @@ export const CycleDetailsSidebar: React.FC = observer((props) => { }); }; + const handleRestoreCycle = async () => { + if (!workspaceSlug || !projectId) return; + + await restoreCycle(workspaceSlug.toString(), projectId.toString(), cycleId) + .then(() => { + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Restore success", + message: "Your cycle can be found in project cycles.", + }); + router.push(`/${workspaceSlug.toString()}/projects/${projectId.toString()}/cycles/${cycleId}`); + }) + .catch(() => + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: "Cycle could not be restored. Please try again.", + }) + ); + }; + useEffect(() => { if (cycleDetails) reset({ @@ -219,8 +251,8 @@ export const CycleDetailsSidebar: React.FC = observer((props) => { ? "0 Issue" : `${cycleDetails.progress_snapshot.completed_issues}/${cycleDetails.progress_snapshot.total_issues}` : cycleDetails.total_issues === 0 - ? "0 Issue" - : `${cycleDetails.completed_issues}/${cycleDetails.total_issues}`; + ? "0 Issue" + : `${cycleDetails.completed_issues}/${cycleDetails.total_issues}`; const daysLeft = findHowManyDaysLeft(cycleDetails.end_date); @@ -229,13 +261,22 @@ export const CycleDetailsSidebar: React.FC = observer((props) => { return (
{cycleDetails && workspaceSlug && projectId && ( - setCycleDeleteModal(false)} - workspaceSlug={workspaceSlug.toString()} - projectId={projectId.toString()} - /> + <> + setArchiveCycleModal(false)} + /> + setCycleDeleteModal(false)} + workspaceSlug={workspaceSlug.toString()} + projectId={projectId.toString()} + /> + )} <> @@ -249,22 +290,54 @@ export const CycleDetailsSidebar: React.FC = observer((props) => {
- - {!isCompleted && isEditingAllowed && ( + {!isArchived && ( + + )} + {isEditingAllowed && ( - { - setTrackElement("CYCLE_PAGE_SIDEBAR"); - setCycleDeleteModal(true); - }} - > - - - Delete cycle - - + {!isArchived && ( + setArchiveCycleModal(true)} disabled={!isCompleted}> + {isCompleted ? ( +
+ + Archive cycle +
+ ) : ( +
+ +
+

Archive cycle

+

+ Only completed cycle
can be archived. +

+
+
+ )} +
+ )} + {isArchived && ( + + + + Restore cycle + + + )} + {!isCompleted && ( + { + setTrackElement("CYCLE_PAGE_SIDEBAR"); + setCycleDeleteModal(true); + }} + > + + + Delete cycle + + + )}
)}
@@ -290,9 +363,11 @@ export const CycleDetailsSidebar: React.FC = observer((props) => { {cycleDetails.description && ( - - {cycleDetails.description} - +