diff --git a/apiserver/plane/api/views/cycle.py b/apiserver/plane/api/views/cycle.py index bb2796bf690..637d713c319 100644 --- a/apiserver/plane/api/views/cycle.py +++ b/apiserver/plane/api/views/cycle.py @@ -481,7 +481,7 @@ def get_queryset(self): .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 88bb1b05e16..643221dcabe 100644 --- a/apiserver/plane/api/views/module.py +++ b/apiserver/plane/api/views/module.py @@ -553,7 +553,7 @@ def get_queryset(self): .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 778072d9ffe..6809efbe6a4 100644 --- a/apiserver/plane/app/views/cycle/base.py +++ b/apiserver/plane/app/views/cycle/base.py @@ -714,10 +714,8 @@ def get_queryset(self): 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( @@ -831,7 +829,7 @@ def get_queryset(self): .distinct() ) - def list(self, request, slug, project_id): + def get(self, request, slug, project_id): queryset = ( self.get_queryset() .annotate( @@ -869,6 +867,7 @@ def list(self, request, slug, project_id): "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/module/base.py b/apiserver/plane/app/views/module/base.py index 2e4b1024daa..39dbcb751ec 100644 --- a/apiserver/plane/app/views/module/base.py +++ b/apiserver/plane/app/views/module/base.py @@ -498,10 +498,7 @@ def get_queryset(self): 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 @@ def get_queryset(self): .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 @@ def list(self, request, slug, project_id): "backlog_issues", "created_at", "updated_at", + "archived_at" ) return Response(modules, status=status.HTTP_200_OK) diff --git a/packages/types/src/cycle/cycle.d.ts b/packages/types/src/cycle/cycle.d.ts index c41ab279b9b..30724706b4a 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 470a20dd275..38f8a7549b3 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 10d56c32896..297c8046cdc 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 0af293e5070..7ba2c3b4181 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 00000000000..57d1c36a1dd --- /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 00000000000..4b519fca038 --- /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 9556eb1aa9a..83db67c3472 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 00000000000..267c873885f --- /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 00000000000..f59f0954ef1 --- /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 00000000000..a9b421351bf --- /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 00000000000..4d47c8f34e5 --- /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 00000000000..ed86a56b44b --- /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 4b88d8d7bf6..8409c06fe36 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 c687c965edc..aad650dd66b 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 768b8a5dc83..57e9ec90c4e 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/index.ts b/web/components/cycles/index.ts index e37d266b74d..b1b718175eb 100644 --- a/web/components/cycles/index.ts +++ b/web/components/cycles/index.ts @@ -14,3 +14,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 6e81da3c7bd..a418f9b0475 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 +// icons 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"; -import { CycleQuickActions } from "@/components/cycles"; // components -// import { CycleCreateUpdateModal, CycleDeleteModal } from "@/components/cycles"; -// ui -// icons -// helpers +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 004c66fcac0..7a99f5ab736 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 904daa1d9b7..ef05228eea2 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 eebd28a9f4c..215f07beff2 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 { 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"; -// constants +// 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 ed6fe4b7557..e333564ee7f 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, TextArea } 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({ @@ -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 + + + )}
)}
@@ -331,6 +404,7 @@ export const CycleDetailsSidebar: React.FC = observer((props) => { to: "End date", }} required={cycleDetails.status !== "draft"} + disabled={isArchived} /> )} /> diff --git a/web/components/dropdowns/date-range.tsx b/web/components/dropdowns/date-range.tsx index 0ab33636d0a..8ae5726a81b 100644 --- a/web/components/dropdowns/date-range.tsx +++ b/web/components/dropdowns/date-range.tsx @@ -149,6 +149,7 @@ export const DateRangeDropdown: React.FC = (props) => { if (!isOpen) handleKeyDown(e); } else handleKeyDown(e); }} + disabled={disabled} > )} diff --git a/web/components/modules/archived-modules/header.tsx b/web/components/modules/archived-modules/header.tsx new file mode 100644 index 00000000000..f72d35f4ef2 --- /dev/null +++ b/web/components/modules/archived-modules/header.tsx @@ -0,0 +1,147 @@ +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 { TModuleFilters } from "@plane/types"; +// components +import { ArchiveTabsList } from "@/components/archives"; +import { FiltersDropdown } from "@/components/issues"; +import { ModuleFiltersSelection, ModuleOrderByDropdown } from "@/components/modules"; +// helpers +import { cn } from "@/helpers/common.helper"; +// hooks +import { useMember, useModuleFilter } from "@/hooks/store"; +import useOutsideClickDetector from "@/hooks/use-outside-click-detector"; + +export const ArchivedModulesHeader: FC = observer(() => { + // router + const router = useRouter(); + const { projectId } = router.query; + // refs + const inputRef = useRef(null); + // hooks + const { + currentProjectArchivedFilters, + currentProjectDisplayFilters, + archivedModulesSearchQuery, + updateFilters, + updateDisplayFilters, + updateArchivedModulesSearchQuery, + } = useModuleFilter(); + const { + workspace: { workspaceMemberIds }, + } = useMember(); + // states + const [isSearchOpen, setIsSearchOpen] = useState(archivedModulesSearchQuery !== "" ? true : false); + // outside click detector hook + useOutsideClickDetector(inputRef, () => { + if (isSearchOpen && archivedModulesSearchQuery.trim() === "") setIsSearchOpen(false); + }); + + const handleFilters = useCallback( + (key: keyof TModuleFilters, 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 (archivedModulesSearchQuery && archivedModulesSearchQuery.trim() !== "") updateArchivedModulesSearchQuery(""); + else { + setIsSearchOpen(false); + inputRef.current?.blur(); + } + } + }; + + return ( +
+
+ +
+ {/* filter options */} +
+ {!isSearchOpen && ( + + )} +
+ + updateArchivedModulesSearchQuery(e.target.value)} + onKeyDown={handleInputKeyDown} + /> + {isSearchOpen && ( + + )} +
+ { + if (!projectId || val === currentProjectDisplayFilters?.order_by) return; + updateDisplayFilters(projectId.toString(), { + order_by: val, + }); + }} + /> + } title="Filters" placement="bottom-end"> + { + if (!projectId) return; + updateDisplayFilters(projectId.toString(), val); + }} + handleFiltersUpdate={handleFilters} + memberIds={workspaceMemberIds ?? undefined} + isArchived + /> + +
+
+ ); +}); diff --git a/web/components/modules/archived-modules/index.ts b/web/components/modules/archived-modules/index.ts new file mode 100644 index 00000000000..f59f0954ef1 --- /dev/null +++ b/web/components/modules/archived-modules/index.ts @@ -0,0 +1,4 @@ +export * from "./root"; +export * from "./view"; +export * from "./header"; +export * from "./modal"; diff --git a/web/components/modules/archived-modules/modal.tsx b/web/components/modules/archived-modules/modal.tsx new file mode 100644 index 00000000000..f34aff26040 --- /dev/null +++ b/web/components/modules/archived-modules/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 { useModule } from "@/hooks/store"; + +type Props = { + workspaceSlug: string; + projectId: string; + moduleId: string; + handleClose: () => void; + isOpen: boolean; + onSubmit?: () => Promise; +}; + +export const ArchiveModuleModal: React.FC = (props) => { + const { workspaceSlug, projectId, moduleId, isOpen, handleClose } = props; + // router + const router = useRouter(); + // states + const [isArchiving, setIsArchiving] = useState(false); + // store hooks + const { getModuleNameById, archiveModule } = useModule(); + + const moduleName = getModuleNameById(moduleId); + + const onClose = () => { + setIsArchiving(false); + handleClose(); + }; + + const handleArchiveIssue = async () => { + setIsArchiving(true); + await archiveModule(workspaceSlug, projectId, moduleId) + .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/modules?peekModule=${moduleId}`); + }) + .catch(() => + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: "Module could not be archived. Please try again.", + }) + ) + .finally(() => setIsArchiving(false)); + }; + + return ( + + + +
+ + +
+
+ + +
+

Archive module {moduleName}

+

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

+
+ + +
+
+
+
+
+
+
+
+ ); +}; diff --git a/web/components/modules/archived-modules/root.tsx b/web/components/modules/archived-modules/root.tsx new file mode 100644 index 00000000000..d697f15b55b --- /dev/null +++ b/web/components/modules/archived-modules/root.tsx @@ -0,0 +1,81 @@ +import React, { useCallback } from "react"; +import { observer } from "mobx-react-lite"; +import { useRouter } from "next/router"; +import useSWR from "swr"; +// types +import { TModuleFilters } from "@plane/types"; +// components +import { EmptyState } from "@/components/empty-state"; +import { ArchivedModulesView, ModuleAppliedFiltersList } from "@/components/modules"; +import { CycleModuleListLayout } from "@/components/ui"; +// constants +import { EmptyStateType } from "@/constants/empty-state"; +// helpers +import { calculateTotalFilters } from "@/helpers/filter.helper"; +// hooks +import { useModule, useModuleFilter } from "@/hooks/store"; + +export const ArchivedModuleLayoutRoot: React.FC = observer(() => { + // router + const router = useRouter(); + const { workspaceSlug, projectId } = router.query; + // hooks + const { fetchArchivedModules, projectArchivedModuleIds, loader } = useModule(); + // module filters hook + const { clearAllFilters, currentProjectArchivedFilters, updateFilters } = useModuleFilter(); + // derived values + const totalArchivedModules = projectArchivedModuleIds?.length ?? 0; + + useSWR( + workspaceSlug && projectId ? `ARCHIVED_MODULES_${workspaceSlug.toString()}_${projectId.toString()}` : null, + async () => { + if (workspaceSlug && projectId) { + await fetchArchivedModules(workspaceSlug.toString(), projectId.toString()); + } + }, + { revalidateIfStale: false, revalidateOnFocus: false } + ); + + const handleRemoveFilter = useCallback( + (key: keyof TModuleFilters, 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"); + }, + [currentProjectArchivedFilters, projectId, updateFilters] + ); + + if (!workspaceSlug || !projectId) return <>; + + if (loader || !projectArchivedModuleIds) { + return ; + } + + return ( + <> + {calculateTotalFilters(currentProjectArchivedFilters ?? {}) !== 0 && ( +
+ clearAllFilters(projectId.toString(), "archived")} + handleRemoveFilter={handleRemoveFilter} + alwaysAllowEditing + /> +
+ )} + {totalArchivedModules === 0 ? ( +
+ +
+ ) : ( +
+ +
+ )} + + ); +}); diff --git a/web/components/modules/archived-modules/view.tsx b/web/components/modules/archived-modules/view.tsx new file mode 100644 index 00000000000..56dbd0135ce --- /dev/null +++ b/web/components/modules/archived-modules/view.tsx @@ -0,0 +1,64 @@ +import { FC } from "react"; +import { observer } from "mobx-react-lite"; +import Image from "next/image"; +// components +import { ModuleListItem, ModulePeekOverview } from "@/components/modules"; +// ui +import { CycleModuleListLayout } from "@/components/ui"; +// hooks +import { useModule, useModuleFilter } from "@/hooks/store"; +// assets +import AllFiltersImage from "@/public/empty-state/module/all-filters.svg"; +import NameFilterImage from "@/public/empty-state/module/name-filter.svg"; + +export interface IArchivedModulesView { + workspaceSlug: string; + projectId: string; +} + +export const ArchivedModulesView: FC = observer((props) => { + const { workspaceSlug, projectId } = props; + // store hooks + const { getFilteredArchivedModuleIds, loader } = useModule(); + const { archivedModulesSearchQuery } = useModuleFilter(); + // derived values + const filteredArchivedModuleIds = getFilteredArchivedModuleIds(projectId); + + if (loader || !filteredArchivedModuleIds) return ; + + if (filteredArchivedModuleIds.length === 0) + return ( +
+
+ No matching modules +
No matching modules
+

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

+
+
+ ); + + return ( +
+
+
+ {filteredArchivedModuleIds.map((moduleId) => ( + + ))} +
+ +
+
+ ); +}); diff --git a/web/components/modules/dropdowns/filters/root.tsx b/web/components/modules/dropdowns/filters/root.tsx index 346a03c6f54..7be60a5548a 100644 --- a/web/components/modules/dropdowns/filters/root.tsx +++ b/web/components/modules/dropdowns/filters/root.tsx @@ -14,10 +14,18 @@ type Props = { handleDisplayFiltersUpdate: (updatedDisplayProperties: Partial) => void; handleFiltersUpdate: (key: keyof TModuleFilters, value: string | string[]) => void; memberIds?: string[] | undefined; + isArchived?: boolean; }; export const ModuleFiltersSelection: React.FC = observer((props) => { - const { displayFilters, filters, handleDisplayFiltersUpdate, handleFiltersUpdate, memberIds } = props; + const { + displayFilters, + filters, + handleDisplayFiltersUpdate, + handleFiltersUpdate, + memberIds, + isArchived = false, + } = props; // states const [filtersSearchQuery, setFiltersSearchQuery] = useState(""); @@ -42,26 +50,30 @@ export const ModuleFiltersSelection: React.FC = observer((props) => {
-
- - handleDisplayFiltersUpdate({ - favorites: !displayFilters.favorites, - }) - } - title="Favorites" - /> -
+ {!isArchived && ( +
+ + handleDisplayFiltersUpdate({ + favorites: !displayFilters.favorites, + }) + } + title="Favorites" + /> +
+ )} {/* status */} -
- handleFiltersUpdate("status", val)} - searchQuery={filtersSearchQuery} - /> -
+ {!isArchived && ( +
+ handleFiltersUpdate("status", val)} + searchQuery={filtersSearchQuery} + /> +
+ )} {/* lead */}
diff --git a/web/components/modules/index.ts b/web/components/modules/index.ts index 7bda973fa5a..957726e7d02 100644 --- a/web/components/modules/index.ts +++ b/web/components/modules/index.ts @@ -11,3 +11,7 @@ export * from "./sidebar"; export * from "./module-card-item"; export * from "./module-list-item"; export * from "./module-peek-overview"; +export * from "./quick-actions"; + +// archived modules +export * from "./archived-modules"; diff --git a/web/components/modules/module-card-item.tsx b/web/components/modules/module-card-item.tsx index b12b10fe834..ff228c82e60 100644 --- a/web/components/modules/module-card-item.tsx +++ b/web/components/modules/module-card-item.tsx @@ -1,19 +1,19 @@ -import React, { useState } from "react"; +import React from "react"; import { observer } from "mobx-react-lite"; import Link from "next/link"; import { useRouter } from "next/router"; -import { Info, LinkIcon, Pencil, Star, Trash2 } from "lucide-react"; +// icons +import { Info, Star } from "lucide-react"; // ui -import { Avatar, AvatarGroup, CustomMenu, LayersIcon, Tooltip, TOAST_TYPE, setToast, setPromiseToast } from "@plane/ui"; +import { Avatar, AvatarGroup, LayersIcon, Tooltip, setPromiseToast } from "@plane/ui"; // components -import { CreateUpdateModuleModal, DeleteModuleModal } from "@/components/modules"; +import { ModuleQuickActions } from "@/components/modules"; // constants import { MODULE_FAVORITED, MODULE_UNFAVORITED } from "@/constants/event-tracker"; import { MODULE_STATUS } from "@/constants/module"; import { EUserProjectRoles } from "@/constants/project"; // helpers import { getDate, renderFormattedDate } from "@/helpers/date-time.helper"; -import { copyUrlToClipboard } from "@/helpers/string.helper"; // hooks import { useEventTracker, useMember, useModule, useUser } from "@/hooks/store"; import { usePlatformOS } from "@/hooks/use-platform-os"; @@ -24,9 +24,6 @@ type Props = { export const ModuleCardItem: React.FC = observer((props) => { const { moduleId } = props; - // states - const [editModal, setEditModal] = useState(false); - const [deleteModal, setDeleteModal] = useState(false); // router const router = useRouter(); const { workspaceSlug, projectId } = router.query; @@ -36,7 +33,7 @@ export const ModuleCardItem: React.FC = observer((props) => { } = useUser(); const { getModuleById, addModuleToFavorites, removeModuleFromFavorites } = useModule(); const { getUserDetails } = useMember(); - const { setTrackElement, captureEvent } = useEventTracker(); + const { captureEvent } = useEventTracker(); // derived values const moduleDetails = getModuleById(moduleId); const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; @@ -99,32 +96,6 @@ export const ModuleCardItem: React.FC = observer((props) => { }); }; - const handleCopyText = (e: React.MouseEvent) => { - e.stopPropagation(); - e.preventDefault(); - copyUrlToClipboard(`${workspaceSlug}/projects/${projectId}/modules/${moduleId}`).then(() => { - setToast({ - type: TOAST_TYPE.SUCCESS, - title: "Link Copied!", - message: "Module link copied to clipboard.", - }); - }); - }; - - const handleEditModule = (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - setTrackElement("Modules page grid layout"); - setEditModal(true); - }; - - const handleDeleteModule = (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - setTrackElement("Modules page grid layout"); - setDeleteModal(true); - }; - const openModuleOverview = (e: React.MouseEvent) => { e.stopPropagation(); e.preventDefault(); @@ -160,142 +131,112 @@ export const ModuleCardItem: React.FC = observer((props) => { ? !moduleTotalIssues || moduleTotalIssues === 0 ? "0 Issue" : moduleTotalIssues === moduleDetails.completed_issues - ? `${moduleTotalIssues} Issue${moduleTotalIssues > 1 ? "s" : ""}` - : `${moduleDetails.completed_issues}/${moduleTotalIssues} Issues` + ? `${moduleTotalIssues} Issue${moduleTotalIssues > 1 ? "s" : ""}` + : `${moduleDetails.completed_issues}/${moduleTotalIssues} Issues` : "0 Issue"; return ( - <> - {workspaceSlug && projectId && ( - setEditModal(false)} - data={moduleDetails} - projectId={projectId.toString()} - workspaceSlug={workspaceSlug.toString()} - /> - )} - setDeleteModal(false)} /> - -
-
-
- - {moduleDetails.name} - -
- {moduleStatus && ( - - {moduleStatus.label} - - )} - -
+ +
+
+
+ + {moduleDetails.name} + +
+ {moduleStatus && ( + + {moduleStatus.label} + + )} +
+
-
-
-
- - {issueCount ?? "0 Issue"} -
- {moduleDetails.member_ids?.length > 0 && ( - -
- - {moduleDetails.member_ids.map((member_id) => { - const member = getUserDetails(member_id); - return ; - })} - -
-
- )} +
+
+
+ + {issueCount ?? "0 Issue"}
+ {moduleDetails.member_ids?.length > 0 && ( + +
+ + {moduleDetails.member_ids.map((member_id) => { + const member = getUserDetails(member_id); + return ; + })} + +
+
+ )} +
- -
+ +
+
-
-
+ />
- - -
- {isDateValid ? ( - <> - - {renderFormattedDate(startDate) ?? "_ _"} - {renderFormattedDate(endDate) ?? "_ _"} - - - ) : ( - No due date +
+ + +
+ {isDateValid ? ( + <> + + {renderFormattedDate(startDate) ?? "_ _"} - {renderFormattedDate(endDate) ?? "_ _"} + + + ) : ( + No due date + )} + +
+ {isEditingAllowed && + (moduleDetails.is_favorite ? ( + + ) : ( + + ))} + {workspaceSlug && projectId && ( + )} - -
- {isEditingAllowed && - (moduleDetails.is_favorite ? ( - - ) : ( - - ))} - - - {isEditingAllowed && ( - <> - - - - Edit module - - - - - - Delete module - - - - )} - - - - Copy module link - - - -
- - +
+ ); }); diff --git a/web/components/modules/module-list-item.tsx b/web/components/modules/module-list-item.tsx index c317b78d1b8..3fd630f2979 100644 --- a/web/components/modules/module-list-item.tsx +++ b/web/components/modules/module-list-item.tsx @@ -1,44 +1,30 @@ -import React, { useState } from "react"; +import React from "react"; import { observer } from "mobx-react-lite"; import Link from "next/link"; import { useRouter } from "next/router"; -import { Check, Info, LinkIcon, Pencil, Star, Trash2, User2 } from "lucide-react"; +// icons +import { Check, Info, Star, User2 } from "lucide-react"; // ui -import { - Avatar, - AvatarGroup, - CircularProgressIndicator, - CustomMenu, - Tooltip, - TOAST_TYPE, - setToast, - setPromiseToast, -} from "@plane/ui"; -import { CreateUpdateModuleModal, DeleteModuleModal } from "@/components/modules"; -import { MODULE_FAVORITED, MODULE_UNFAVORITED } from "@/constants/event-tracker"; -// helpers +import { Avatar, AvatarGroup, CircularProgressIndicator, Tooltip, setPromiseToast } from "@plane/ui"; +// components +import { ModuleQuickActions } from "@/components/modules"; // constants +import { MODULE_FAVORITED, MODULE_UNFAVORITED } from "@/constants/event-tracker"; import { MODULE_STATUS } from "@/constants/module"; import { EUserProjectRoles } from "@/constants/project"; +// helpers import { getDate, renderFormattedDate } from "@/helpers/date-time.helper"; -import { copyUrlToClipboard } from "@/helpers/string.helper"; // hooks import { useModule, useUser, useEventTracker, useMember } from "@/hooks/store"; import { usePlatformOS } from "@/hooks/use-platform-os"; -// components -// ui -// helpers -// constants type Props = { moduleId: string; + isArchived?: boolean; }; export const ModuleListItem: React.FC = observer((props) => { - const { moduleId } = props; - // states - const [editModal, setEditModal] = useState(false); - const [deleteModal, setDeleteModal] = useState(false); + const { moduleId, isArchived = false } = props; // router const router = useRouter(); const { workspaceSlug, projectId } = router.query; @@ -48,7 +34,7 @@ export const ModuleListItem: React.FC = observer((props) => { } = useUser(); const { getModuleById, addModuleToFavorites, removeModuleFromFavorites } = useModule(); const { getUserDetails } = useMember(); - const { setTrackElement, captureEvent } = useEventTracker(); + const { captureEvent } = useEventTracker(); // derived values const moduleDetails = getModuleById(moduleId); const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; @@ -111,33 +97,7 @@ export const ModuleListItem: React.FC = observer((props) => { }); }; - const handleCopyText = (e: React.MouseEvent) => { - e.stopPropagation(); - e.preventDefault(); - copyUrlToClipboard(`${workspaceSlug}/projects/${projectId}/modules/${moduleId}`).then(() => { - setToast({ - type: TOAST_TYPE.SUCCESS, - title: "Link Copied!", - message: "Module link copied to clipboard.", - }); - }); - }; - - const handleEditModule = (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - setTrackElement("Modules page list layout"); - setEditModal(true); - }; - - const handleDeleteModule = (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - setTrackElement("Modules page list layout"); - setDeleteModal(true); - }; - - const openModuleOverview = (e: React.MouseEvent) => { + const openModuleOverview = (e: React.MouseEvent) => { e.stopPropagation(); e.preventDefault(); const { query } = router; @@ -167,126 +127,105 @@ export const ModuleListItem: React.FC = observer((props) => { const completedModuleCheck = moduleDetails.status === "completed"; return ( - <> - {workspaceSlug && projectId && ( - setEditModal(false)} - data={moduleDetails} - projectId={projectId.toString()} - workspaceSlug={workspaceSlug.toString()} - /> - )} - setDeleteModal(false)} /> - -
-
-
-
- - - {completedModuleCheck ? ( - progress === 100 ? ( - - ) : ( - {`!`} - ) - ) : progress === 100 ? ( + { + if (isArchived) { + openModuleOverview(e); + } + }} + > +
+
+
+
+ + + {completedModuleCheck ? ( + progress === 100 ? ( ) : ( - {`${progress}%`} - )} - - - - {moduleDetails.name} - -
- -
-
- {moduleStatus && ( - - {moduleStatus.label} - - )} -
-
- -
-
- {renderDate && ( - - {renderFormattedDate(startDate) ?? "_ _"} - {renderFormattedDate(endDate) ?? "_ _"} - - )} -
- -
- -
- {moduleDetails.member_ids.length > 0 ? ( - - {moduleDetails.member_ids.map((member_id) => { - const member = getUserDetails(member_id); - return ; - })} - + {`!`} + ) + ) : progress === 100 ? ( + ) : ( - - - + {`${progress}%`} )} -
+ + + + {moduleDetails.name} +
+ +
+
+ {moduleStatus && ( + + {moduleStatus.label} + + )} +
+
- {isEditingAllowed && - (moduleDetails.is_favorite ? ( - - ) : ( - - ))} +
+
+ {renderDate && ( + + {renderFormattedDate(startDate) ?? "_ _"} - {renderFormattedDate(endDate) ?? "_ _"} + + )} +
- - {isEditingAllowed && ( - <> - - - - Edit module - - - - - - Delete module - - - - )} - - - - Copy module link +
+ +
+ {moduleDetails.member_ids.length > 0 ? ( + + {moduleDetails.member_ids.map((member_id) => { + const member = getUserDetails(member_id); + return ; + })} + + ) : ( + + - - -
+ )} +
+ + + {isEditingAllowed && + !isArchived && + (moduleDetails.is_favorite ? ( + + ) : ( + + ))} + {workspaceSlug && projectId && ( + + )}
- - +
+ ); }); diff --git a/web/components/modules/module-peek-overview.tsx b/web/components/modules/module-peek-overview.tsx index f455a825d58..ba470a6499f 100644 --- a/web/components/modules/module-peek-overview.tsx +++ b/web/components/modules/module-peek-overview.tsx @@ -9,9 +9,10 @@ import { ModuleDetailsSidebar } from "./sidebar"; type Props = { projectId: string; workspaceSlug: string; + isArchived?: boolean; }; -export const ModulePeekOverview: React.FC = observer(({ projectId, workspaceSlug }) => { +export const ModulePeekOverview: React.FC = observer(({ projectId, workspaceSlug, isArchived = false }) => { // router const router = useRouter(); const { peekModule } = router.query; @@ -29,10 +30,10 @@ export const ModulePeekOverview: React.FC = observer(({ projectId, worksp }; useEffect(() => { - if (!peekModule) return; + if (!peekModule || isArchived) return; fetchModuleDetails(workspaceSlug, projectId, peekModule.toString()); - }, [fetchModuleDetails, peekModule, projectId, workspaceSlug]); + }, [fetchModuleDetails, isArchived, peekModule, projectId, workspaceSlug]); return ( <> @@ -45,7 +46,11 @@ export const ModulePeekOverview: React.FC = observer(({ projectId, worksp "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/modules/quick-actions.tsx b/web/components/modules/quick-actions.tsx new file mode 100644 index 00000000000..b7972d61240 --- /dev/null +++ b/web/components/modules/quick-actions.tsx @@ -0,0 +1,179 @@ +import { useState } from "react"; +import { observer } from "mobx-react"; +import { useRouter } from "next/router"; +// icons +import { ArchiveRestoreIcon, LinkIcon, Pencil, Trash2 } from "lucide-react"; +// ui +import { ArchiveIcon, CustomMenu, TOAST_TYPE, setToast } from "@plane/ui"; +// components +import { ArchiveModuleModal, CreateUpdateModuleModal, DeleteModuleModal } from "@/components/modules"; +// constants +import { EUserProjectRoles } from "@/constants/project"; +// helpers +import { copyUrlToClipboard } from "@/helpers/string.helper"; +// hooks +import { useModule, useEventTracker, useUser } from "@/hooks/store"; + +type Props = { + moduleId: string; + projectId: string; + workspaceSlug: string; + isArchived?: boolean; +}; + +export const ModuleQuickActions: React.FC = observer((props) => { + const { moduleId, projectId, workspaceSlug, isArchived } = props; + // router + const router = useRouter(); + // states + const [editModal, setEditModal] = useState(false); + const [archiveModuleModal, setArchiveModuleModal] = useState(false); + const [deleteModal, setDeleteModal] = useState(false); + // store hooks + const { setTrackElement } = useEventTracker(); + const { + membership: { currentWorkspaceAllProjectsRole }, + } = useUser(); + const { getModuleById, restoreModule } = useModule(); + // derived values + const moduleDetails = getModuleById(moduleId); + // auth + const isEditingAllowed = + !!currentWorkspaceAllProjectsRole && currentWorkspaceAllProjectsRole[projectId] >= EUserProjectRoles.MEMBER; + + const moduleState = moduleDetails?.status.toLocaleLowerCase(); + const isInArchivableGroup = !!moduleState && ["completed", "cancelled"].includes(moduleState); + + const handleCopyText = (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + copyUrlToClipboard(`${workspaceSlug}/projects/${projectId}/modules/${moduleId}`).then(() => { + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Link Copied!", + message: "Module link copied to clipboard.", + }); + }); + }; + + const handleEditModule = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + setTrackElement("Modules page list layout"); + setEditModal(true); + }; + + const handleArchiveModule = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + setArchiveModuleModal(true); + }; + + const handleRestoreModule = async (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + await restoreModule(workspaceSlug, projectId, moduleId) + .then(() => { + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Restore success", + message: "Your module can be found in project modules.", + }); + router.push(`/${workspaceSlug}/projects/${projectId}/modules/${moduleId}`); + }) + .catch(() => + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: "Module could not be restored. Please try again.", + }) + ); + }; + + const handleDeleteModule = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + setTrackElement("Modules page list layout"); + setDeleteModal(true); + }; + + return ( + <> + {moduleDetails && ( +
+ setEditModal(false)} + data={moduleDetails} + projectId={projectId} + workspaceSlug={workspaceSlug} + /> + setArchiveModuleModal(false)} + /> + setDeleteModal(false)} /> +
+ )} + + {isEditingAllowed && !isArchived && ( + + + + Edit module + + + )} + {isEditingAllowed && !isArchived && ( + + {isInArchivableGroup ? ( +
+ + Archive module +
+ ) : ( +
+ +
+

Archive module

+

+ Only completed or cancelled
module can be archived. +

+
+
+ )} +
+ )} + {isEditingAllowed && isArchived && ( + + + + Restore module + + + )} + {!isArchived && ( + + + + Copy module link + + + )} + {isEditingAllowed && ( +
+ + + + Delete module + + +
+ )} +
+ + ); +}); diff --git a/web/components/modules/sidebar.tsx b/web/components/modules/sidebar.tsx index 4ed37d3449f..38618023dcc 100644 --- a/web/components/modules/sidebar.tsx +++ b/web/components/modules/sidebar.tsx @@ -4,6 +4,7 @@ import { useRouter } from "next/router"; import { Controller, useForm } from "react-hook-form"; import { AlertCircle, + ArchiveRestoreIcon, CalendarClock, ChevronDown, ChevronRight, @@ -25,13 +26,14 @@ import { UserGroupIcon, TOAST_TYPE, setToast, + ArchiveIcon, TextArea, } from "@plane/ui"; // components import { LinkModal, LinksList, SidebarProgressStats } from "@/components/core"; import ProgressChart from "@/components/core/sidebar/progress-chart"; import { DateRangeDropdown, MemberDropdown } from "@/components/dropdowns"; -import { DeleteModuleModal } from "@/components/modules"; +import { ArchiveModuleModal, DeleteModuleModal } from "@/components/modules"; // constant import { MODULE_LINK_CREATED, @@ -59,13 +61,15 @@ const defaultValues: Partial = { type Props = { moduleId: string; handleClose: () => void; + isArchived?: boolean; }; // TODO: refactor this component export const ModuleDetailsSidebar: React.FC = observer((props) => { - const { moduleId, handleClose } = props; + const { moduleId, handleClose, isArchived } = props; // states const [moduleDeleteModal, setModuleDeleteModal] = useState(false); + const [archiveModuleModal, setArchiveModuleModal] = useState(false); const [moduleLinkModal, setModuleLinkModal] = useState(false); const [selectedLinkToUpdate, setSelectedLinkToUpdate] = useState(null); // router @@ -75,10 +79,14 @@ export const ModuleDetailsSidebar: React.FC = observer((props) => { const { membership: { currentProjectRole }, } = useUser(); - const { getModuleById, updateModuleDetails, createModuleLink, updateModuleLink, deleteModuleLink } = useModule(); + const { getModuleById, updateModuleDetails, createModuleLink, updateModuleLink, deleteModuleLink, restoreModule } = + useModule(); const { setTrackElement, captureModuleEvent, captureEvent } = useEventTracker(); const moduleDetails = getModuleById(moduleId); + const moduleState = moduleDetails?.status.toLocaleLowerCase(); + const isInArchivableGroup = !!moduleState && ["completed", "cancelled"].includes(moduleState); + const { reset, control } = useForm({ defaultValues, }); @@ -206,6 +214,30 @@ export const ModuleDetailsSidebar: React.FC = observer((props) => { }); }; + const handleRestoreModule = async (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + if (!workspaceSlug || !projectId || !moduleId) return; + + await restoreModule(workspaceSlug.toString(), projectId.toString(), moduleId) + .then(() => { + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Restore success", + message: "Your module can be found in project modules.", + }); + router.push(`/${workspaceSlug}/projects/${projectId}/modules/${moduleId}`); + }) + .catch(() => + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: "Module could not be restored. Please try again.", + }) + ); + }; + useEffect(() => { if (moduleDetails) reset({ @@ -262,8 +294,16 @@ export const ModuleDetailsSidebar: React.FC = observer((props) => { createIssueLink={handleCreateLink} updateIssueLink={handleUpdateLink} /> + {workspaceSlug && projectId && ( + setArchiveModuleModal(false)} + /> + )} setModuleDeleteModal(false)} data={moduleDetails} /> - <>
@@ -275,11 +315,41 @@ export const ModuleDetailsSidebar: React.FC = observer((props) => {
- + {!isArchived && ( + + )} {isEditingAllowed && ( + {!isArchived && ( + setArchiveModuleModal(true)} disabled={!isInArchivableGroup}> + {isInArchivableGroup ? ( +
+ + Archive module +
+ ) : ( +
+ +
+

Archive module

+

+ Only completed or cancelled
module can be archived. +

+
+
+ )} +
+ )} + {isArchived && ( + + + + Restore module + + + )} { setTrackElement("Module peek-overview"); @@ -306,7 +376,7 @@ export const ModuleDetailsSidebar: React.FC = observer((props) => { customButton={ = observer((props) => { onChange={(value: any) => { submitChanges({ status: value }); }} - disabled={!isEditingAllowed} + disabled={!isEditingAllowed || isArchived} > {MODULE_STATUS.map((status) => ( @@ -379,6 +449,7 @@ export const ModuleDetailsSidebar: React.FC = observer((props) => { from: "Start date", to: "Target date", }} + disabled={isArchived} /> ); }} @@ -408,6 +479,7 @@ export const ModuleDetailsSidebar: React.FC = observer((props) => { multiple={false} buttonVariant="background-with-text" placeholder="Lead" + disabled={isArchived} />
)} @@ -432,7 +504,7 @@ export const ModuleDetailsSidebar: React.FC = observer((props) => { projectId={projectId?.toString() ?? ""} buttonVariant={value && value?.length > 0 ? "transparent-without-text" : "background-with-text"} buttonClassName={value && value.length > 0 ? "hover:bg-transparent px-0" : ""} - disabled={!isEditingAllowed} + disabled={!isEditingAllowed || isArchived} />
)} @@ -556,7 +628,7 @@ export const ModuleDetailsSidebar: React.FC = observer((props) => {
{currentProjectRole && moduleDetails.link_module && moduleDetails.link_module.length > 0 ? ( <> - {isEditingAllowed && ( + {isEditingAllowed && !isArchived && (
- + {isEditingAllowed && !isArchived && ( + + )}
)}
diff --git a/web/components/notifications/notification-card.tsx b/web/components/notifications/notification-card.tsx index c7af0824cc0..4089e9108b8 100644 --- a/web/components/notifications/notification-card.tsx +++ b/web/components/notifications/notification-card.tsx @@ -16,12 +16,12 @@ import { NOTIFICATION_SNOOZED, } from "@/constants/event-tracker"; import { snoozeOptions } from "@/constants/notification"; +// helper +import { calculateTimeAgo, renderFormattedTime, renderFormattedDate, getDate } from "@/helpers/date-time.helper"; +import { replaceUnderscoreIfSnakeCase, truncateText, stripAndTruncateHTML } from "@/helpers/string.helper"; // hooks import { useEventTracker } from "@/hooks/store"; import { usePlatformOS } from "@/hooks/use-platform-os"; -// helper -import { calculateTimeAgo, renderFormattedTime, renderFormattedDate, getDate } from "helpers/date-time.helper"; -import { replaceUnderscoreIfSnakeCase, truncateText, stripAndTruncateHTML } from "helpers/string.helper"; type NotificationCardProps = { @@ -137,8 +137,8 @@ export const NotificationCard: React.FC = (props) => { closePopover(); }} href={`/${workspaceSlug}/projects/${notification.project}/${ - notificationField === "archived_at" ? "archived-issues" : "issues" - }/${notification.data.issue.id}`} + notificationField === "archived_at" ? "archives/" : "" + }issues/${notification.data.issue.id}`} className={`group relative flex w-full cursor-pointer items-center gap-4 p-3 pl-6 ${ notification.read_at === null ? "bg-custom-primary-70/5" : "hover:bg-custom-background-200" }`} @@ -184,12 +184,12 @@ export const NotificationCard: React.FC = (props) => { {notificationField === "comment" ? "commented" : notificationField === "archived_at" - ? notification.data.issue_activity.new_value === "restore" - ? "restored the issue" - : "archived the issue" - : notificationField === "None" - ? null - : replaceUnderscoreIfSnakeCase(notificationField)}{" "} + ? notification.data.issue_activity.new_value === "restore" + ? "restored the issue" + : "archived the issue" + : notificationField === "None" + ? null + : replaceUnderscoreIfSnakeCase(notificationField)}{" "} {!["comment", "archived_at", "None"].includes(notificationField) ? "to" : ""} {" "} diff --git a/web/components/project/sidebar-list-item.tsx b/web/components/project/sidebar-list-item.tsx index 9a50786792a..bb1d6ad17a2 100644 --- a/web/components/project/sidebar-list-item.tsx +++ b/web/components/project/sidebar-list-item.tsx @@ -282,13 +282,6 @@ export const ProjectSidebarListItem: React.FC = observer((props) => { )} - - - - Copy project link - - - {/* publish project settings */} {isAdmin && ( setPublishModal(true)}> @@ -300,16 +293,6 @@ export const ProjectSidebarListItem: React.FC = observer((props) => {
)} - {!isViewerOrGuest && ( - - -
- - Archived issues -
- -
- )}
@@ -318,6 +301,23 @@ export const ProjectSidebarListItem: React.FC = observer((props) => {
+ + + + Copy link + + + + {!isViewerOrGuest && ( + + +
+ + Archives +
+ +
+ )}
diff --git a/web/constants/archives.ts b/web/constants/archives.ts new file mode 100644 index 00000000000..9130a981a17 --- /dev/null +++ b/web/constants/archives.ts @@ -0,0 +1,50 @@ +// types +import { IProject } from "@plane/types"; +// icons +import { ContrastIcon, DiceIcon, LayersIcon } from "@plane/ui"; + +export const ARCHIVES_TAB_LIST: { + key: string; + label: string; + shouldRender: (projectDetails: IProject) => boolean; +}[] = [ + { + key: "issues", + label: "Issues", + shouldRender: () => true, + }, + { + key: "cycles", + label: "Cycles", + shouldRender: (projectDetails) => projectDetails.cycle_view, + }, + { + key: "modules", + label: "Modules", + shouldRender: (projectDetails) => projectDetails.module_view, + }, +]; + +export const PROJECT_ARCHIVES_BREADCRUMB_LIST: { + [key: string]: { + label: string; + href: string; + icon: React.FC & { className?: string }>; + }; +} = { + issues: { + label: "Issues", + href: "/issues", + icon: LayersIcon, + }, + cycles: { + label: "Cycles", + href: "/cycles", + icon: ContrastIcon, + }, + modules: { + label: "Modules", + href: "/modules", + icon: DiceIcon, + }, +}; diff --git a/web/constants/empty-state.ts b/web/constants/empty-state.ts index 587f58cee11..8be4a52e2ce 100644 --- a/web/constants/empty-state.ts +++ b/web/constants/empty-state.ts @@ -51,6 +51,7 @@ export enum EmptyStateType { PROJECT_CYCLE_ACTIVE = "project-cycle-active", PROJECT_CYCLE_ALL = "project-cycle-all", PROJECT_CYCLE_COMPLETED_NO_ISSUES = "project-cycle-completed-no-issues", + PROJECT_ARCHIVED_NO_CYCLES = "project-archived-no-cycles", PROJECT_EMPTY_FILTER = "project-empty-filter", PROJECT_ARCHIVED_EMPTY_FILTER = "project-archived-empty-filter", PROJECT_DRAFT_EMPTY_FILTER = "project-draft-empty-filter", @@ -62,6 +63,7 @@ export enum EmptyStateType { MEMBERS_EMPTY_SEARCH = "members-empty-search", PROJECT_MODULE_ISSUES = "project-module-issues", PROJECT_MODULE = "project-module", + PROJECT_ARCHIVED_NO_MODULES = "project-archived-no-modules", PROJECT_VIEW = "project-view", PROJECT_PAGE = "project-page", PROJECT_PAGE_ALL = "project-page-all", @@ -308,6 +310,12 @@ const emptyStateDetails = { "No issues in the cycle. Issues are either transferred or hidden. To see hidden issues if any, update your display properties accordingly.", path: "/empty-state/cycle/completed-no-issues", }, + [EmptyStateType.PROJECT_ARCHIVED_NO_CYCLES]: { + key: EmptyStateType.PROJECT_ARCHIVED_NO_CYCLES, + title: "No archived cycles yet", + description: "To tidy up your project, archive completed cycles. Find them here once archived.", + path: "/empty-state/archived/empty-cycles", + }, [EmptyStateType.PROJECT_CYCLE_ALL]: { key: EmptyStateType.PROJECT_CYCLE_ALL, title: "No cycles", @@ -368,7 +376,7 @@ const emptyStateDetails = { key: EmptyStateType.PROJECT_ARCHIVED_NO_ISSUES, title: "No archived issues yet", description: - "Archived issues help you remove issues you completed or cancelled from focus. You can set automation to auto archive issues and find them here.", + "Manually or through automation, you can archive issues that are completed or cancelled. Find them here once archived.", path: "/empty-state/archived/empty-issues", primaryButton: { text: "Set automation", @@ -432,6 +440,12 @@ const emptyStateDetails = { accessType: "project", access: EUserProjectRoles.MEMBER, }, + [EmptyStateType.PROJECT_ARCHIVED_NO_MODULES]: { + key: EmptyStateType.PROJECT_ARCHIVED_NO_MODULES, + title: "No archived Modules yet", + description: "To tidy up your project, archive completed or cancelled modules. Find them here once archived.", + path: "/empty-state/archived/empty-modules", + }, // project views [EmptyStateType.PROJECT_VIEW]: { key: EmptyStateType.PROJECT_VIEW, diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/archives/cycles/index.tsx b/web/pages/[workspaceSlug]/projects/[projectId]/archives/cycles/index.tsx new file mode 100644 index 00000000000..bcc406fa087 --- /dev/null +++ b/web/pages/[workspaceSlug]/projects/[projectId]/archives/cycles/index.tsx @@ -0,0 +1,44 @@ +import { ReactElement } from "react"; +import { observer } from "mobx-react"; +import { useRouter } from "next/router"; +// components +import { PageHead } from "@/components/core"; +import { ArchivedCycleLayoutRoot, ArchivedCyclesHeader } from "@/components/cycles"; +import { ProjectArchivesHeader } from "@/components/headers"; +// hooks +import { useProject } from "@/hooks/store"; +// layouts +import { AppLayout } from "@/layouts/app-layout"; +// types +import { NextPageWithLayout } from "@/lib/types"; + +const ProjectArchivedCyclesPage: NextPageWithLayout = observer(() => { + // router + const router = useRouter(); + const { projectId } = router.query; + // store hooks + const { getProjectById } = useProject(); + // derived values + const project = projectId ? getProjectById(projectId.toString()) : undefined; + const pageTitle = project?.name && `${project?.name} - Archived cycles`; + + return ( + <> + +
+ + +
+ + ); +}); + +ProjectArchivedCyclesPage.getLayout = function getLayout(page: ReactElement) { + return ( + } withProjectWrapper> + {page} + + ); +}; + +export default ProjectArchivedCyclesPage; diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/archived-issues/[archivedIssueId].tsx b/web/pages/[workspaceSlug]/projects/[projectId]/archives/issues/[archivedIssueId].tsx similarity index 96% rename from web/pages/[workspaceSlug]/projects/[projectId]/archived-issues/[archivedIssueId].tsx rename to web/pages/[workspaceSlug]/projects/[projectId]/archives/issues/[archivedIssueId].tsx index 7d1b380f2a6..12b0c83917d 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/archived-issues/[archivedIssueId].tsx +++ b/web/pages/[workspaceSlug]/projects/[projectId]/archives/issues/[archivedIssueId].tsx @@ -2,23 +2,23 @@ import { useState, ReactElement } from "react"; import { observer } from "mobx-react"; import { useRouter } from "next/router"; import useSWR from "swr"; -// hooks -import { RotateCcw } from "lucide-react"; +// icons +import { ArchiveRestoreIcon } from "lucide-react"; +// ui import { ArchiveIcon, Button, Loader, TOAST_TYPE, setToast } from "@plane/ui"; +// components import { PageHead } from "@/components/core"; import { ProjectArchivedIssueDetailsHeader } from "@/components/headers"; import { IssueDetailRoot } from "@/components/issues"; +// constants import { EIssuesStoreType } from "@/constants/issue"; import { EUserProjectRoles } from "@/constants/project"; +// hooks import { useIssueDetail, useIssues, useProject, useUser } from "@/hooks/store"; // layouts import { AppLayout } from "@/layouts/app-layout"; -// components -// ui -// icons // types import { NextPageWithLayout } from "@/lib/types"; -// constants const ArchivedIssueDetailsPage: NextPageWithLayout = observer(() => { // router @@ -112,7 +112,7 @@ const ArchivedIssueDetailsPage: NextPageWithLayout = observer(() => { {issue?.archived_at && canRestoreIssue && (
- +

This issue has been archived.

diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/archived-issues/index.tsx b/web/pages/[workspaceSlug]/projects/[projectId]/archives/issues/index.tsx similarity index 72% rename from web/pages/[workspaceSlug]/projects/[projectId]/archived-issues/index.tsx rename to web/pages/[workspaceSlug]/projects/[projectId]/archives/issues/index.tsx index 5c2ca094509..b2c08e0a3e6 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/archived-issues/index.tsx +++ b/web/pages/[workspaceSlug]/projects/[projectId]/archives/issues/index.tsx @@ -1,17 +1,16 @@ import { ReactElement } from "react"; import { observer } from "mobx-react"; import { useRouter } from "next/router"; -// layouts +// components import { PageHead } from "@/components/core"; -import { ProjectArchivedIssuesHeader } from "@/components/headers"; -import { ArchivedIssueLayoutRoot } from "@/components/issues"; +import { ProjectArchivesHeader } from "@/components/headers"; +import { ArchivedIssueLayoutRoot, ArchivedIssuesHeader } from "@/components/issues"; +// hooks import { useProject } from "@/hooks/store"; +// layouts import { AppLayout } from "@/layouts/app-layout"; -// contexts -// components // types import { NextPageWithLayout } from "@/lib/types"; -// hooks const ProjectArchivedIssuesPage: NextPageWithLayout = observer(() => { // router @@ -26,14 +25,17 @@ const ProjectArchivedIssuesPage: NextPageWithLayout = observer(() => { return ( <> - +
+ + +
); }); ProjectArchivedIssuesPage.getLayout = function getLayout(page: ReactElement) { return ( - } withProjectWrapper> + } withProjectWrapper> {page} ); diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/archives/modules/index.tsx b/web/pages/[workspaceSlug]/projects/[projectId]/archives/modules/index.tsx new file mode 100644 index 00000000000..1e346098e67 --- /dev/null +++ b/web/pages/[workspaceSlug]/projects/[projectId]/archives/modules/index.tsx @@ -0,0 +1,44 @@ +import { ReactElement } from "react"; +import { observer } from "mobx-react"; +import { useRouter } from "next/router"; +// components +import { PageHead } from "@/components/core"; +import { ProjectArchivesHeader } from "@/components/headers"; +import { ArchivedModuleLayoutRoot, ArchivedModulesHeader } from "@/components/modules"; +// hooks +import { useProject } from "@/hooks/store"; +// layouts +import { AppLayout } from "@/layouts/app-layout"; +// types +import { NextPageWithLayout } from "@/lib/types"; + +const ProjectArchivedModulesPage: NextPageWithLayout = observer(() => { + // router + const router = useRouter(); + const { projectId } = router.query; + // store hooks + const { getProjectById } = useProject(); + // derived values + const project = projectId ? getProjectById(projectId.toString()) : undefined; + const pageTitle = project?.name && `${project?.name} - Archived modules`; + + return ( + <> + +
+ + +
+ + ); +}); + +ProjectArchivedModulesPage.getLayout = function getLayout(page: ReactElement) { + return ( + } withProjectWrapper> + {page} + + ); +}; + +export default ProjectArchivedModulesPage; diff --git a/web/public/empty-state/archived/empty-cycles-dark.webp b/web/public/empty-state/archived/empty-cycles-dark.webp new file mode 100644 index 00000000000..872fb2fca19 Binary files /dev/null and b/web/public/empty-state/archived/empty-cycles-dark.webp differ diff --git a/web/public/empty-state/archived/empty-cycles-light.webp b/web/public/empty-state/archived/empty-cycles-light.webp new file mode 100644 index 00000000000..2db1dc4b780 Binary files /dev/null and b/web/public/empty-state/archived/empty-cycles-light.webp differ diff --git a/web/public/empty-state/archived/empty-issues-dark.webp b/web/public/empty-state/archived/empty-issues-dark.webp index 264488cbf63..01abef1fee1 100644 Binary files a/web/public/empty-state/archived/empty-issues-dark.webp and b/web/public/empty-state/archived/empty-issues-dark.webp differ diff --git a/web/public/empty-state/archived/empty-issues-light.webp b/web/public/empty-state/archived/empty-issues-light.webp index 602f0b14f0f..f010155fc99 100644 Binary files a/web/public/empty-state/archived/empty-issues-light.webp and b/web/public/empty-state/archived/empty-issues-light.webp differ diff --git a/web/public/empty-state/archived/empty-modules-dark.webp b/web/public/empty-state/archived/empty-modules-dark.webp new file mode 100644 index 00000000000..e34bf373738 Binary files /dev/null and b/web/public/empty-state/archived/empty-modules-dark.webp differ diff --git a/web/public/empty-state/archived/empty-modules-light.webp b/web/public/empty-state/archived/empty-modules-light.webp new file mode 100644 index 00000000000..5caf2ea9039 Binary files /dev/null and b/web/public/empty-state/archived/empty-modules-light.webp differ diff --git a/web/services/cycle_archive.service.ts b/web/services/cycle_archive.service.ts new file mode 100644 index 00000000000..6ea3aeb40de --- /dev/null +++ b/web/services/cycle_archive.service.ts @@ -0,0 +1,42 @@ +// type +import { ICycle } from "@plane/types"; +// helpers +import { API_BASE_URL } from "@/helpers/common.helper"; +// services +import { APIService } from "@/services/api.service"; + +export class CycleArchiveService extends APIService { + constructor() { + super(API_BASE_URL); + } + + async getArchivedCycles(workspaceSlug: string, projectId: string): Promise { + return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/archived-cycles/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async archiveCycle( + workspaceSlug: string, + projectId: string, + cycleId: string + ): Promise<{ + archived_at: string; + }> { + return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/cycles/${cycleId}/archive/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async restoreCycle(workspaceSlug: string, projectId: string, cycleId: string): Promise { + return this.delete(`/api/workspaces/${workspaceSlug}/projects/${projectId}/cycles/${cycleId}/archive/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } +} diff --git a/web/services/module_archive.service.ts b/web/services/module_archive.service.ts new file mode 100644 index 00000000000..3b8f0582771 --- /dev/null +++ b/web/services/module_archive.service.ts @@ -0,0 +1,42 @@ +// type +import { IModule } from "@plane/types"; +// helpers +import { API_BASE_URL } from "@/helpers/common.helper"; +// services +import { APIService } from "@/services/api.service"; + +export class ModuleArchiveService extends APIService { + constructor() { + super(API_BASE_URL); + } + + async getArchivedModules(workspaceSlug: string, projectId: string): Promise { + return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/archived-modules/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async archiveModule( + workspaceSlug: string, + projectId: string, + moduleId: string + ): Promise<{ + archived_at: string; + }> { + return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/modules/${moduleId}/archive/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async restoreModule(workspaceSlug: string, projectId: string, moduleId: string): Promise { + return this.delete(`/api/workspaces/${workspaceSlug}/projects/${projectId}/modules/${moduleId}/archive/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } +} diff --git a/web/store/cycle.store.ts b/web/store/cycle.store.ts index deaaf253061..ac90f9e5eed 100644 --- a/web/store/cycle.store.ts +++ b/web/store/cycle.store.ts @@ -3,17 +3,18 @@ import set from "lodash/set"; import sortBy from "lodash/sortBy"; import { action, computed, observable, makeObservable, runInAction } from "mobx"; import { computedFn } from "mobx-utils"; +// types +import { ICycle, CycleDateCheckData } from "@plane/types"; // helpers -import { getDate } from "@/helpers/date-time.helper"; import { orderCycles, shouldFilterCycle } from "@/helpers/cycle.helper"; +import { getDate } from "@/helpers/date-time.helper"; // services import { CycleService } from "@/services/cycle.service"; +import { CycleArchiveService } from "@/services/cycle_archive.service"; import { IssueService } from "@/services/issue"; import { ProjectService } from "@/services/project"; -// mobx +// store import { RootStore } from "@/store/root.store"; -// types -import { ICycle, CycleDateCheckData } from "@plane/types"; export interface ICycleStore { // loaders @@ -29,9 +30,11 @@ export interface ICycleStore { currentProjectIncompleteCycleIds: string[] | null; currentProjectDraftCycleIds: string[] | null; currentProjectActiveCycleId: string | null; + currentProjectArchivedCycleIds: string[] | null; // computed actions getFilteredCycleIds: (projectId: string) => string[] | null; getFilteredCompletedCycleIds: (projectId: string) => string[] | null; + getFilteredArchivedCycleIds: (projectId: string) => string[] | null; getCycleById: (cycleId: string) => ICycle | null; getCycleNameById: (cycleId: string) => string | undefined; getActiveCycleById: (cycleId: string) => ICycle | null; @@ -42,6 +45,7 @@ export interface ICycleStore { fetchWorkspaceCycles: (workspaceSlug: string) => Promise; fetchAllCycles: (workspaceSlug: string, projectId: string) => Promise; fetchActiveCycle: (workspaceSlug: string, projectId: string) => Promise; + fetchArchivedCycles: (workspaceSlug: string, projectId: string) => Promise; fetchCycleDetails: (workspaceSlug: string, projectId: string, cycleId: string) => Promise; // crud createCycle: (workspaceSlug: string, projectId: string, data: Partial) => Promise; @@ -55,6 +59,9 @@ export interface ICycleStore { // favorites addCycleToFavorites: (workspaceSlug: string, projectId: string, cycleId: string) => Promise; removeCycleFromFavorites: (workspaceSlug: string, projectId: string, cycleId: string) => Promise; + // archive + archiveCycle: (workspaceSlug: string, projectId: string, cycleId: string) => Promise; + restoreCycle: (workspaceSlug: string, projectId: string, cycleId: string) => Promise; } export class CycleStore implements ICycleStore { @@ -70,6 +77,7 @@ export class CycleStore implements ICycleStore { projectService; issueService; cycleService; + cycleArchiveService; constructor(_rootStore: RootStore) { makeObservable(this, { @@ -85,22 +93,29 @@ export class CycleStore implements ICycleStore { currentProjectIncompleteCycleIds: computed, currentProjectDraftCycleIds: computed, currentProjectActiveCycleId: computed, + currentProjectArchivedCycleIds: computed, // actions fetchWorkspaceCycles: action, fetchAllCycles: action, fetchActiveCycle: action, + fetchArchivedCycles: action, fetchCycleDetails: action, createCycle: action, updateCycleDetails: action, deleteCycle: action, addCycleToFavorites: action, removeCycleFromFavorites: action, + archiveCycle: action, + restoreCycle: action, }); this.rootStore = _rootStore; + + // services this.projectService = new ProjectService(); this.issueService = new IssueService(); this.cycleService = new CycleService(); + this.cycleArchiveService = new CycleArchiveService(); } // computed @@ -110,7 +125,7 @@ export class CycleStore implements ICycleStore { get currentProjectCycleIds() { const projectId = this.rootStore.app.router.projectId; if (!projectId || !this.fetchedMap[projectId]) return null; - let allCycles = Object.values(this.cycleMap ?? {}).filter((c) => c?.project_id === projectId); + let allCycles = Object.values(this.cycleMap ?? {}).filter((c) => c?.project_id === projectId && !c?.archived_at); allCycles = sortBy(allCycles, [(c) => c.sort_order]); const allCycleIds = allCycles.map((c) => c.id); return allCycleIds; @@ -126,7 +141,7 @@ export class CycleStore implements ICycleStore { const endDate = getDate(c.end_date); const hasEndDatePassed = endDate && isPast(endDate); const isEndDateToday = endDate && isToday(endDate); - return c.project_id === projectId && hasEndDatePassed && !isEndDateToday; + return c.project_id === projectId && hasEndDatePassed && !isEndDateToday && !c?.archived_at; }); completedCycles = sortBy(completedCycles, [(c) => c.sort_order]); const completedCycleIds = completedCycles.map((c) => c.id); @@ -142,7 +157,7 @@ export class CycleStore implements ICycleStore { let upcomingCycles = Object.values(this.cycleMap ?? {}).filter((c) => { const startDate = getDate(c.start_date); const isStartDateUpcoming = startDate && isFuture(startDate); - return c.project_id === projectId && isStartDateUpcoming; + return c.project_id === projectId && isStartDateUpcoming && !c?.archived_at; }); upcomingCycles = sortBy(upcomingCycles, [(c) => c.sort_order]); const upcomingCycleIds = upcomingCycles.map((c) => c.id); @@ -158,7 +173,7 @@ export class CycleStore implements ICycleStore { let incompleteCycles = Object.values(this.cycleMap ?? {}).filter((c) => { const endDate = getDate(c.end_date); const hasEndDatePassed = endDate && isPast(endDate); - return c.project_id === projectId && !hasEndDatePassed; + return c.project_id === projectId && !hasEndDatePassed && !c?.archived_at; }); incompleteCycles = sortBy(incompleteCycles, [(c) => c.sort_order]); const incompleteCycleIds = incompleteCycles.map((c) => c.id); @@ -172,7 +187,7 @@ export class CycleStore implements ICycleStore { const projectId = this.rootStore.app.router.projectId; if (!projectId || !this.fetchedMap[projectId]) return null; let draftCycles = Object.values(this.cycleMap ?? {}).filter( - (c) => c.project_id === projectId && !c.start_date && !c.end_date + (c) => c.project_id === projectId && !c.start_date && !c.end_date && !c?.archived_at ); draftCycles = sortBy(draftCycles, [(c) => c.sort_order]); const draftCycleIds = draftCycles.map((c) => c.id); @@ -191,6 +206,20 @@ export class CycleStore implements ICycleStore { return activeCycle || null; } + /** + * returns all archived cycle ids for a project + */ + get currentProjectArchivedCycleIds() { + const projectId = this.rootStore.app.router.projectId; + if (!projectId || !this.fetchedMap[projectId]) return null; + let archivedCycles = Object.values(this.cycleMap ?? {}).filter( + (c) => c.project_id === projectId && !!c.archived_at + ); + archivedCycles = sortBy(archivedCycles, [(c) => c.sort_order]); + const archivedCycleIds = archivedCycles.map((c) => c.id); + return archivedCycleIds; + } + /** * @description returns filtered cycle ids based on display filters and filters * @param {TCycleDisplayFilters} displayFilters @@ -204,6 +233,7 @@ export class CycleStore implements ICycleStore { let cycles = Object.values(this.cycleMap ?? {}).filter( (c) => c.project_id === projectId && + !c.archived_at && c.name.toLowerCase().includes(searchQuery.toLowerCase()) && shouldFilterCycle(c, filters ?? {}) ); @@ -225,6 +255,7 @@ export class CycleStore implements ICycleStore { let cycles = Object.values(this.cycleMap ?? {}).filter( (c) => c.project_id === projectId && + !c.archived_at && c.status.toLowerCase() === "completed" && c.name.toLowerCase().includes(searchQuery.toLowerCase()) && shouldFilterCycle(c, filters ?? {}) @@ -234,6 +265,27 @@ export class CycleStore implements ICycleStore { return cycleIds; }); + /** + * @description returns filtered archived cycle ids based on display filters and filters + * @param {string} projectId + * @returns {string[] | null} + */ + getFilteredArchivedCycleIds = computedFn((projectId: string) => { + const filters = this.rootStore.cycleFilter.getArchivedFiltersByProjectId(projectId); + const searchQuery = this.rootStore.cycleFilter.archivedCyclesSearchQuery; + if (!this.fetchedMap[projectId]) return null; + let cycles = Object.values(this.cycleMap ?? {}).filter( + (c) => + c.project_id === projectId && + !!c.archived_at && + 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 @@ -264,7 +316,7 @@ export class CycleStore implements ICycleStore { getProjectCycleIds = computedFn((projectId: string): string[] | null => { if (!this.fetchedMap[projectId]) return null; - let cycles = Object.values(this.cycleMap ?? {}).filter((c) => c.project_id === projectId); + let cycles = Object.values(this.cycleMap ?? {}).filter((c) => c.project_id === projectId && !c?.archived_at); cycles = sortBy(cycles, [(c) => c.sort_order]); const cycleIds = cycles.map((c) => c.id); return cycleIds || null; @@ -321,6 +373,31 @@ export class CycleStore implements ICycleStore { } }; + /** + * @description fetches archived cycles for a project + * @param workspaceSlug + * @param projectId + * @returns + */ + fetchArchivedCycles = async (workspaceSlug: string, projectId: string) => { + this.loader = true; + return await this.cycleArchiveService + .getArchivedCycles(workspaceSlug, projectId) + .then((response) => { + runInAction(() => { + response.forEach((cycle) => { + set(this.cycleMap, [cycle.id], cycle); + }); + this.loader = false; + }); + return response; + }) + .catch(() => { + this.loader = false; + return undefined; + }); + }; + /** * @description fetches active cycle for a project * @param workspaceSlug @@ -452,4 +529,48 @@ export class CycleStore implements ICycleStore { throw error; } }; + + /** + * @description archives a cycle + * @param workspaceSlug + * @param projectId + * @param cycleId + * @returns + */ + archiveCycle = async (workspaceSlug: string, projectId: string, cycleId: string) => { + const cycleDetails = this.getCycleById(cycleId); + if (cycleDetails?.archived_at) return; + await this.cycleArchiveService + .archiveCycle(workspaceSlug, projectId, cycleId) + .then((response) => { + runInAction(() => { + set(this.cycleMap, [cycleId, "archived_at"], response.archived_at); + }); + }) + .catch((error) => { + console.error("Failed to archive cycle in cycle store", error); + }); + }; + + /** + * @description restores a cycle + * @param workspaceSlug + * @param projectId + * @param cycleId + * @returns + */ + restoreCycle = async (workspaceSlug: string, projectId: string, cycleId: string) => { + const cycleDetails = this.getCycleById(cycleId); + if (!cycleDetails?.archived_at) return; + await this.cycleArchiveService + .restoreCycle(workspaceSlug, projectId, cycleId) + .then(() => { + runInAction(() => { + set(this.cycleMap, [cycleId, "archived_at"], null); + }); + }) + .catch((error) => { + console.error("Failed to restore cycle in cycle store", error); + }); + }; } diff --git a/web/store/cycle_filter.store.ts b/web/store/cycle_filter.store.ts index 2c57b5e7827..182ab0251dd 100644 --- a/web/store/cycle_filter.store.ts +++ b/web/store/cycle_filter.store.ts @@ -1,33 +1,39 @@ +import set from "lodash/set"; import { action, computed, observable, makeObservable, runInAction, reaction } from "mobx"; import { computedFn } from "mobx-utils"; -import set from "lodash/set"; // types +import { TCycleDisplayFilters, TCycleFilters, TCycleFiltersByState } from "@plane/types"; +// store import { RootStore } from "@/store/root.store"; -import { TCycleDisplayFilters, TCycleFilters } from "@plane/types"; export interface ICycleFilterStore { // observables displayFilters: Record; - filters: Record; + filters: Record; searchQuery: string; + archivedCyclesSearchQuery: string; // computed currentProjectDisplayFilters: TCycleDisplayFilters | undefined; currentProjectFilters: TCycleFilters | undefined; + currentProjectArchivedFilters: TCycleFilters | undefined; // computed functions getDisplayFiltersByProjectId: (projectId: string) => TCycleDisplayFilters | undefined; getFiltersByProjectId: (projectId: string) => TCycleFilters | undefined; + getArchivedFiltersByProjectId: (projectId: string) => TCycleFilters | undefined; // actions updateDisplayFilters: (projectId: string, displayFilters: TCycleDisplayFilters) => void; - updateFilters: (projectId: string, filters: TCycleFilters) => void; + updateFilters: (projectId: string, filters: TCycleFilters, state?: keyof TCycleFiltersByState) => void; updateSearchQuery: (query: string) => void; - clearAllFilters: (projectId: string) => void; + updateArchivedCyclesSearchQuery: (query: string) => void; + clearAllFilters: (projectId: string, state?: keyof TCycleFiltersByState) => void; } export class CycleFilterStore implements ICycleFilterStore { // observables displayFilters: Record = {}; - filters: Record = {}; + filters: Record = {}; searchQuery: string = ""; + archivedCyclesSearchQuery: string = ""; // root store rootStore: RootStore; @@ -37,13 +43,16 @@ export class CycleFilterStore implements ICycleFilterStore { displayFilters: observable, filters: observable, searchQuery: observable.ref, + archivedCyclesSearchQuery: observable.ref, // computed currentProjectDisplayFilters: computed, currentProjectFilters: computed, + currentProjectArchivedFilters: computed, // actions updateDisplayFilters: action, updateFilters: action, updateSearchQuery: action, + updateArchivedCyclesSearchQuery: action, clearAllFilters: action, }); // root store @@ -73,7 +82,16 @@ export class CycleFilterStore implements ICycleFilterStore { get currentProjectFilters() { const projectId = this.rootStore.app.router.projectId; if (!projectId) return; - return this.filters[projectId]; + return this.filters[projectId]?.default ?? {}; + } + + /** + * @description get archived filters of the current project + */ + get currentProjectArchivedFilters() { + const projectId = this.rootStore.app.router.projectId; + if (!projectId) return; + return this.filters[projectId].archived; } /** @@ -86,7 +104,13 @@ export class CycleFilterStore implements ICycleFilterStore { * @description get filters of a project by projectId * @param {string} projectId */ - getFiltersByProjectId = computedFn((projectId: string) => this.filters[projectId]); + getFiltersByProjectId = computedFn((projectId: string) => this.filters[projectId]?.default ?? {}); + + /** + * @description get archived filters of a project by projectId + * @param {string} projectId + */ + getArchivedFiltersByProjectId = computedFn((projectId: string) => this.filters[projectId].archived); /** * @description initialize display filters and filters of a project @@ -99,7 +123,10 @@ export class CycleFilterStore implements ICycleFilterStore { active_tab: displayFilters?.active_tab || "active", layout: displayFilters?.layout || "list", }; - this.filters[projectId] = this.filters[projectId] ?? {}; + this.filters[projectId] = this.filters[projectId] ?? { + default: {}, + archived: {}, + }; }); }; @@ -121,10 +148,10 @@ export class CycleFilterStore implements ICycleFilterStore { * @param {string} projectId * @param {TCycleFilters} filters */ - updateFilters = (projectId: string, filters: TCycleFilters) => { + updateFilters = (projectId: string, filters: TCycleFilters, state: keyof TCycleFiltersByState = "default") => { runInAction(() => { Object.keys(filters).forEach((key) => { - set(this.filters, [projectId, key], filters[key as keyof TCycleFilters]); + set(this.filters, [projectId, state, key], filters[key as keyof TCycleFilters]); }); }); }; @@ -135,13 +162,19 @@ export class CycleFilterStore implements ICycleFilterStore { */ updateSearchQuery = (query: string) => (this.searchQuery = query); + /** + * @description update archived search query + * @param {string} query + */ + updateArchivedCyclesSearchQuery = (query: string) => (this.archivedCyclesSearchQuery = query); + /** * @description clear all filters of a project * @param {string} projectId */ - clearAllFilters = (projectId: string) => { + clearAllFilters = (projectId: string, state: keyof TCycleFiltersByState = "default") => { runInAction(() => { - this.filters[projectId] = {}; + this.filters[projectId][state] = {}; }); }; } diff --git a/web/store/module.store.ts b/web/store/module.store.ts index 7f24a9b5756..1a2896aa25a 100644 --- a/web/store/module.store.ts +++ b/web/store/module.store.ts @@ -2,14 +2,16 @@ import set from "lodash/set"; import sortBy from "lodash/sortBy"; import { action, computed, observable, makeObservable, runInAction } from "mobx"; import { computedFn } from "mobx-utils"; +// types +import { IModule, ILinkDetails } from "@plane/types"; +// helpers +import { orderModules, shouldFilterModule } from "@/helpers/module.helper"; // services import { ModuleService } from "@/services/module.service"; +import { ModuleArchiveService } from "@/services/module_archive.service"; import { ProjectService } from "@/services/project"; -// helpers -import { orderModules, shouldFilterModule } from "@/helpers/module.helper"; -// types +// store import { RootStore } from "@/store/root.store"; -import { IModule, ILinkDetails } from "@plane/types"; export interface IModuleStore { //Loaders @@ -19,8 +21,10 @@ export interface IModuleStore { moduleMap: Record; // computed projectModuleIds: string[] | null; + projectArchivedModuleIds: string[] | null; // computed actions getFilteredModuleIds: (projectId: string) => string[] | null; + getFilteredArchivedModuleIds: (projectId: string) => string[] | null; getModuleById: (moduleId: string) => IModule | null; getModuleNameById: (moduleId: string) => string; getProjectModuleIds: (projectId: string) => string[] | null; @@ -28,6 +32,7 @@ export interface IModuleStore { // fetch fetchWorkspaceModules: (workspaceSlug: string) => Promise; fetchModules: (workspaceSlug: string, projectId: string) => Promise; + fetchArchivedModules: (workspaceSlug: string, projectId: string) => Promise; fetchModuleDetails: (workspaceSlug: string, projectId: string, moduleId: string) => Promise; // crud createModule: (workspaceSlug: string, projectId: string, data: Partial) => Promise; @@ -55,6 +60,9 @@ export interface IModuleStore { // favorites addModuleToFavorites: (workspaceSlug: string, projectId: string, moduleId: string) => Promise; removeModuleFromFavorites: (workspaceSlug: string, projectId: string, moduleId: string) => Promise; + // archive + archiveModule: (workspaceSlug: string, projectId: string, moduleId: string) => Promise; + restoreModule: (workspaceSlug: string, projectId: string, moduleId: string) => Promise; } export class ModulesStore implements IModuleStore { @@ -68,6 +76,7 @@ export class ModulesStore implements IModuleStore { // services projectService; moduleService; + moduleArchiveService; constructor(_rootStore: RootStore) { makeObservable(this, { @@ -77,9 +86,11 @@ export class ModulesStore implements IModuleStore { fetchedMap: observable, // computed projectModuleIds: computed, + projectArchivedModuleIds: computed, // actions fetchWorkspaceModules: action, fetchModules: action, + fetchArchivedModules: action, fetchModuleDetails: action, createModule: action, updateModuleDetails: action, @@ -89,6 +100,8 @@ export class ModulesStore implements IModuleStore { deleteModuleLink: action, addModuleToFavorites: action, removeModuleFromFavorites: action, + archiveModule: action, + restoreModule: action, }); this.rootStore = _rootStore; @@ -96,6 +109,7 @@ export class ModulesStore implements IModuleStore { // services this.projectService = new ProjectService(); this.moduleService = new ModuleService(); + this.moduleArchiveService = new ModuleArchiveService(); } // computed @@ -105,12 +119,24 @@ export class ModulesStore implements IModuleStore { get projectModuleIds() { const projectId = this.rootStore.app.router.projectId; if (!projectId || !this.fetchedMap[projectId]) return null; - let projectModules = Object.values(this.moduleMap).filter((m) => m.project_id === projectId); + let projectModules = Object.values(this.moduleMap).filter((m) => m.project_id === projectId && !m?.archived_at); projectModules = sortBy(projectModules, [(m) => m.sort_order]); const projectModuleIds = projectModules.map((m) => m.id); return projectModuleIds || null; } + /** + * get all archived module ids for the current project + */ + get projectArchivedModuleIds() { + const projectId = this.rootStore.app.router.projectId; + if (!projectId || !this.fetchedMap[projectId]) return null; + let archivedModules = Object.values(this.moduleMap).filter((m) => m.project_id === projectId && !!m?.archived_at); + archivedModules = sortBy(archivedModules, [(m) => m.sort_order]); + const projectModuleIds = archivedModules.map((m) => m.id); + return projectModuleIds || null; + } + /** * @description returns filtered module ids based on display filters and filters * @param {TModuleDisplayFilters} displayFilters @@ -125,6 +151,29 @@ export class ModulesStore implements IModuleStore { let modules = Object.values(this.moduleMap ?? {}).filter( (m) => m.project_id === projectId && + !m.archived_at && + m.name.toLowerCase().includes(searchQuery.toLowerCase()) && + shouldFilterModule(m, displayFilters ?? {}, filters ?? {}) + ); + modules = orderModules(modules, displayFilters?.order_by); + const moduleIds = modules.map((m) => m.id); + return moduleIds; + }); + + /** + * @description returns filtered archived module ids based on display filters and filters + * @param {string} projectId + * @returns {string[] | null} + */ + getFilteredArchivedModuleIds = computedFn((projectId: string) => { + const displayFilters = this.rootStore.moduleFilter.getDisplayFiltersByProjectId(projectId); + const filters = this.rootStore.moduleFilter.getArchivedFiltersByProjectId(projectId); + const searchQuery = this.rootStore.moduleFilter.archivedModulesSearchQuery; + if (!this.fetchedMap[projectId]) return null; + let modules = Object.values(this.moduleMap ?? {}).filter( + (m) => + m.project_id === projectId && + !!m.archived_at && m.name.toLowerCase().includes(searchQuery.toLowerCase()) && shouldFilterModule(m, displayFilters ?? {}, filters ?? {}) ); @@ -154,7 +203,7 @@ export class ModulesStore implements IModuleStore { getProjectModuleIds = computedFn((projectId: string) => { if (!this.fetchedMap[projectId]) return null; - let projectModules = Object.values(this.moduleMap).filter((m) => m.project_id === projectId); + let projectModules = Object.values(this.moduleMap).filter((m) => m.project_id === projectId && !m.archived_at); projectModules = sortBy(projectModules, [(m) => m.sort_order]); const projectModuleIds = projectModules.map((m) => m.id); return projectModuleIds; @@ -200,6 +249,31 @@ export class ModulesStore implements IModuleStore { } }; + /** + * @description fetch all archived modules + * @param workspaceSlug + * @param projectId + * @returns IModule[] + */ + fetchArchivedModules = async (workspaceSlug: string, projectId: string) => { + this.loader = true; + return await this.moduleArchiveService + .getArchivedModules(workspaceSlug, projectId) + .then((response) => { + runInAction(() => { + response.forEach((module) => { + set(this.moduleMap, [module.id], { ...this.moduleMap[module.id], ...module }); + }); + this.loader = false; + }); + return response; + }) + .catch(() => { + this.loader = false; + return undefined; + }); + }; + /** * @description fetch module details * @param workspaceSlug @@ -386,4 +460,48 @@ export class ModulesStore implements IModuleStore { }); } }; + + /** + * @description archives a module + * @param workspaceSlug + * @param projectId + * @param moduleId + * @returns + */ + archiveModule = async (workspaceSlug: string, projectId: string, moduleId: string) => { + const moduleDetails = this.getModuleById(moduleId); + if (moduleDetails?.archived_at) return; + await this.moduleArchiveService + .archiveModule(workspaceSlug, projectId, moduleId) + .then((response) => { + runInAction(() => { + set(this.moduleMap, [moduleId, "archived_at"], response.archived_at); + }); + }) + .catch((error) => { + console.error("Failed to archive module in module store", error); + }); + }; + + /** + * @description restores a module + * @param workspaceSlug + * @param projectId + * @param moduleId + * @returns + */ + restoreModule = async (workspaceSlug: string, projectId: string, moduleId: string) => { + const moduleDetails = this.getModuleById(moduleId); + if (!moduleDetails?.archived_at) return; + await this.moduleArchiveService + .restoreModule(workspaceSlug, projectId, moduleId) + .then(() => { + runInAction(() => { + set(this.moduleMap, [moduleId, "archived_at"], null); + }); + }) + .catch((error) => { + console.error("Failed to restore module in module store", error); + }); + }; } diff --git a/web/store/module_filter.store.ts b/web/store/module_filter.store.ts index ae5d0713511..5b46e17321c 100644 --- a/web/store/module_filter.store.ts +++ b/web/store/module_filter.store.ts @@ -1,33 +1,39 @@ +import set from "lodash/set"; import { action, computed, observable, makeObservable, runInAction, reaction } from "mobx"; import { computedFn } from "mobx-utils"; -import set from "lodash/set"; // types +import { TModuleDisplayFilters, TModuleFilters, TModuleFiltersByState } from "@plane/types"; +// store import { RootStore } from "@/store/root.store"; -import { TModuleDisplayFilters, TModuleFilters } from "@plane/types"; export interface IModuleFilterStore { // observables displayFilters: Record; - filters: Record; + filters: Record; searchQuery: string; + archivedModulesSearchQuery: string; // computed currentProjectDisplayFilters: TModuleDisplayFilters | undefined; currentProjectFilters: TModuleFilters | undefined; + currentProjectArchivedFilters: TModuleFilters | undefined; // computed functions getDisplayFiltersByProjectId: (projectId: string) => TModuleDisplayFilters | undefined; getFiltersByProjectId: (projectId: string) => TModuleFilters | undefined; + getArchivedFiltersByProjectId: (projectId: string) => TModuleFilters | undefined; // actions updateDisplayFilters: (projectId: string, displayFilters: TModuleDisplayFilters) => void; - updateFilters: (projectId: string, filters: TModuleFilters) => void; + updateFilters: (projectId: string, filters: TModuleFilters, state?: keyof TModuleFiltersByState) => void; updateSearchQuery: (query: string) => void; - clearAllFilters: (projectId: string) => void; + updateArchivedModulesSearchQuery: (query: string) => void; + clearAllFilters: (projectId: string, state?: keyof TModuleFiltersByState) => void; } export class ModuleFilterStore implements IModuleFilterStore { // observables displayFilters: Record = {}; - filters: Record = {}; + filters: Record = {}; searchQuery: string = ""; + archivedModulesSearchQuery: string = ""; // root store rootStore: RootStore; @@ -37,13 +43,16 @@ export class ModuleFilterStore implements IModuleFilterStore { displayFilters: observable, filters: observable, searchQuery: observable.ref, + archivedModulesSearchQuery: observable.ref, // computed currentProjectDisplayFilters: computed, currentProjectFilters: computed, + currentProjectArchivedFilters: computed, // actions updateDisplayFilters: action, updateFilters: action, updateSearchQuery: action, + updateArchivedModulesSearchQuery: action, clearAllFilters: action, }); // root store @@ -73,7 +82,16 @@ export class ModuleFilterStore implements IModuleFilterStore { get currentProjectFilters() { const projectId = this.rootStore.app.router.projectId; if (!projectId) return; - return this.filters[projectId]; + return this.filters[projectId]?.default ?? {}; + } + + /** + * @description get archived filters of the current project + */ + get currentProjectArchivedFilters() { + const projectId = this.rootStore.app.router.projectId; + if (!projectId) return; + return this.filters[projectId].archived; } /** @@ -86,7 +104,13 @@ export class ModuleFilterStore implements IModuleFilterStore { * @description get filters of a project by projectId * @param {string} projectId */ - getFiltersByProjectId = computedFn((projectId: string) => this.filters[projectId]); + getFiltersByProjectId = computedFn((projectId: string) => this.filters[projectId]?.default ?? {}); + + /** + * @description get archived filters of a project by projectId + * @param {string} projectId + */ + getArchivedFiltersByProjectId = computedFn((projectId: string) => this.filters[projectId].archived); /** * @description initialize display filters and filters of a project @@ -100,7 +124,10 @@ export class ModuleFilterStore implements IModuleFilterStore { layout: displayFilters?.layout || "list", order_by: displayFilters?.order_by || "name", }; - this.filters[projectId] = this.filters[projectId] ?? {}; + this.filters[projectId] = this.filters[projectId] ?? { + default: {}, + archived: {}, + }; }); }; @@ -122,10 +149,10 @@ export class ModuleFilterStore implements IModuleFilterStore { * @param {string} projectId * @param {TModuleFilters} filters */ - updateFilters = (projectId: string, filters: TModuleFilters) => { + updateFilters = (projectId: string, filters: TModuleFilters, state: keyof TModuleFiltersByState = "default") => { runInAction(() => { Object.keys(filters).forEach((key) => { - set(this.filters, [projectId, key], filters[key as keyof TModuleFilters]); + set(this.filters, [projectId, state, key], filters[key as keyof TModuleFilters]); }); }); }; @@ -136,13 +163,19 @@ export class ModuleFilterStore implements IModuleFilterStore { */ updateSearchQuery = (query: string) => (this.searchQuery = query); + /** + * @description update archived search query + * @param {string} query + */ + updateArchivedModulesSearchQuery = (query: string) => (this.archivedModulesSearchQuery = query); + /** * @description clear all filters of a project * @param {string} projectId */ - clearAllFilters = (projectId: string) => { + clearAllFilters = (projectId: string, state: keyof TModuleFiltersByState = "default") => { runInAction(() => { - this.filters[projectId] = {}; + this.filters[projectId][state] = {}; }); }; }