feat: cycles and modules archive. (#4005)

* fix: GET request changes

* fix: filtering changes

* feat: cycles and modules archive.

* chore: disable fetching of cycle/ module details when clicked on the card in archives page.

* chore: remove copy link button from archived modules/ cycles.

* fix: archived cycle and module loading fliker issue.

* chore: add validation to only archive completed cycles.

* chore: add validation to only archive completed or cancelled module.

* chore: archived issues/ cycles/ modules empty state update.

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
This commit is contained in:
Prateek Shourya 2024-03-20 21:02:58 +05:30 committed by GitHub
parent 4d1b5adfc4
commit 061be85a5d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
72 changed files with 2429 additions and 682 deletions

View File

@ -481,7 +481,7 @@ class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView):
.distinct() .distinct()
) )
def list(self, request, slug, project_id): def get(self, request, slug, project_id):
return self.paginate( return self.paginate(
request=request, request=request,
queryset=(self.get_queryset()), queryset=(self.get_queryset()),

View File

@ -553,7 +553,7 @@ class ModuleArchiveUnarchiveAPIEndpoint(BaseAPIView):
.order_by(self.kwargs.get("order_by", "-created_at")) .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( return self.paginate(
request=request, request=request,
queryset=(self.get_queryset()), queryset=(self.get_queryset()),

View File

@ -714,10 +714,8 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView):
project_id=self.kwargs.get("project_id"), project_id=self.kwargs.get("project_id"),
workspace__slug=self.kwargs.get("slug"), workspace__slug=self.kwargs.get("slug"),
) )
return self.filter_queryset( return (
super() Cycle.objects.filter(workspace__slug=self.kwargs.get("slug"))
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id")) .filter(project_id=self.kwargs.get("project_id"))
.filter(archived_at__isnull=False) .filter(archived_at__isnull=False)
.filter( .filter(
@ -831,7 +829,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView):
.distinct() .distinct()
) )
def list(self, request, slug, project_id): def get(self, request, slug, project_id):
queryset = ( queryset = (
self.get_queryset() self.get_queryset()
.annotate( .annotate(
@ -869,6 +867,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView):
"backlog_issues", "backlog_issues",
"assignee_ids", "assignee_ids",
"status", "status",
"archived_at",
) )
).order_by("-is_favorite", "-created_at") ).order_by("-is_favorite", "-created_at")
return Response(queryset, status=status.HTTP_200_OK) return Response(queryset, status=status.HTTP_200_OK)

View File

@ -498,10 +498,7 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
workspace__slug=self.kwargs.get("slug"), workspace__slug=self.kwargs.get("slug"),
) )
return ( return (
super() Module.objects.filter(workspace__slug=self.kwargs.get("slug"))
.get_queryset()
.filter(project_id=self.kwargs.get("project_id"))
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(archived_at__isnull=False) .filter(archived_at__isnull=False)
.annotate(is_favorite=Exists(favorite_subquery)) .annotate(is_favorite=Exists(favorite_subquery))
.select_related("project") .select_related("project")
@ -594,7 +591,7 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
.order_by("-is_favorite", "-created_at") .order_by("-is_favorite", "-created_at")
) )
def list(self, request, slug, project_id): def get(self, request, slug, project_id):
queryset = self.get_queryset() queryset = self.get_queryset()
modules = queryset.values( # Required fields modules = queryset.values( # Required fields
"id", "id",
@ -624,6 +621,7 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
"backlog_issues", "backlog_issues",
"created_at", "created_at",
"updated_at", "updated_at",
"archived_at"
) )
return Response(modules, status=status.HTTP_200_OK) return Response(modules, status=status.HTTP_200_OK)

View File

@ -31,6 +31,7 @@ export interface ICycle {
unstarted_issues: number; unstarted_issues: number;
updated_at: Date; updated_at: Date;
updated_by: string; updated_by: string;
archived_at: string | null;
assignee_ids: string[]; assignee_ids: string[];
view_props: { view_props: {
filters: IIssueFilterOptions; filters: IIssueFilterOptions;

View File

@ -13,6 +13,11 @@ export type TCycleFilters = {
status?: string[] | null; status?: string[] | null;
}; };
export type TCycleFiltersByState = {
default: TCycleFilters;
archived: TCycleFilters;
};
export type TCycleStoredFilters = { export type TCycleStoredFilters = {
display_filters?: TCycleDisplayFilters; display_filters?: TCycleDisplayFilters;
filters?: TCycleFilters; filters?: TCycleFilters;

View File

@ -26,6 +26,11 @@ export type TModuleFilters = {
target_date?: string[] | null; target_date?: string[] | null;
}; };
export type TModuleFiltersByState = {
default: TModuleFilters;
archived: TModuleFilters;
};
export type TModuleStoredFilters = { export type TModuleStoredFilters = {
display_filters?: TModuleDisplayFilters; display_filters?: TModuleDisplayFilters;
filters?: TModuleFilters; filters?: TModuleFilters;

View File

@ -39,6 +39,7 @@ export interface IModule {
unstarted_issues: number; unstarted_issues: number;
updated_at: Date; updated_at: Date;
updated_by: string; updated_by: string;
archived_at: string | null;
view_props: { view_props: {
filters: IIssueFilterOptions; filters: IIssueFilterOptions;
}; };

View File

@ -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) && (
<Link key={tab.key} href={`/${workspaceSlug}/projects/${projectId}/archives/${tab.key}`}>
<span
className={`flex min-w-min flex-shrink-0 whitespace-nowrap border-b-2 py-3 px-4 text-sm font-medium outline-none ${
tab.key === activeTab
? "border-custom-primary-100 text-custom-primary-100"
: "border-transparent hover:border-custom-border-200 text-custom-text-300 hover:text-custom-text-400"
}`}
>
{tab.label}
</span>
</Link>
)
)}
</>
);
});

View File

@ -0,0 +1 @@
export * from "./archive-tabs-list";

View File

@ -16,12 +16,13 @@ type Props = {
handleDeleteLink: (linkId: string) => void; handleDeleteLink: (linkId: string) => void;
handleEditLink: (link: ILinkDetails) => void; handleEditLink: (link: ILinkDetails) => void;
userAuth: UserAuth; userAuth: UserAuth;
disabled?: boolean;
}; };
export const LinksList: React.FC<Props> = observer(({ links, handleDeleteLink, handleEditLink, userAuth }) => { export const LinksList: React.FC<Props> = observer(({ links, handleDeleteLink, handleEditLink, userAuth, disabled }) => {
const { getUserDetails } = useMember(); const { getUserDetails } = useMember();
const { isMobile } = usePlatformOS(); const { isMobile } = usePlatformOS();
const isNotAllowed = userAuth.isGuest || userAuth.isViewer; const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disabled;
const copyToClipboard = (text: string) => { const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text); navigator.clipboard.writeText(text);

View File

@ -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<HTMLInputElement>(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<HTMLInputElement>) => {
if (e.key === "Escape") {
if (archivedCyclesSearchQuery && archivedCyclesSearchQuery.trim() !== "") updateArchivedCyclesSearchQuery("");
else {
setIsSearchOpen(false);
inputRef.current?.blur();
}
}
};
return (
<div className="group relative flex border-b border-custom-border-200">
<div className="flex w-full items-center overflow-x-auto px-4 gap-2 horizontal-scrollbar scrollbar-sm">
<ArchiveTabsList />
</div>
{/* filter options */}
<div className="h-full flex items-center gap-3 self-end px-8">
{!isSearchOpen && (
<button
type="button"
className="-mr-5 p-2 hover:bg-custom-background-80 rounded text-custom-text-400 grid place-items-center"
onClick={() => {
setIsSearchOpen(true);
inputRef.current?.focus();
}}
>
<Search className="h-3.5 w-3.5" />
</button>
)}
<div
className={cn(
"ml-auto flex items-center justify-start gap-1 rounded-md border border-transparent bg-custom-background-100 text-custom-text-400 w-0 transition-[width] ease-linear overflow-hidden opacity-0",
{
"w-64 px-2.5 py-1.5 border-custom-border-200 opacity-100": isSearchOpen,
}
)}
>
<Search className="h-3.5 w-3.5" />
<input
ref={inputRef}
className="w-full max-w-[234px] border-none bg-transparent text-sm text-custom-text-100 placeholder:text-custom-text-400 focus:outline-none"
placeholder="Search"
value={archivedCyclesSearchQuery}
onChange={(e) => updateArchivedCyclesSearchQuery(e.target.value)}
onKeyDown={handleInputKeyDown}
/>
{isSearchOpen && (
<button
type="button"
className="grid place-items-center"
onClick={() => {
updateArchivedCyclesSearchQuery("");
setIsSearchOpen(false);
}}
>
<X className="h-3 w-3" />
</button>
)}
</div>
<FiltersDropdown icon={<ListFilter className="h-3 w-3" />} title="Filters" placement="bottom-end">
<CycleFiltersSelection
filters={currentProjectArchivedFilters ?? {}}
handleFiltersUpdate={handleFilters}
isArchived
/>
</FiltersDropdown>
</div>
</div>
);
});

View File

@ -0,0 +1,4 @@
export * from "./root";
export * from "./view";
export * from "./header";
export * from "./modal";

View File

@ -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<void>;
};
export const ArchiveCycleModal: React.FC<Props> = (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 (
<Transition.Root show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-20" onClose={onClose}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-custom-backdrop transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-custom-background-100 text-left shadow-custom-shadow-md transition-all sm:my-8 sm:w-full sm:max-w-lg">
<div className="px-5 py-4">
<h3 className="text-xl font-medium 2xl:text-2xl">Archive cycle {cycleName}</h3>
<p className="mt-3 text-sm text-custom-text-200">
Are you sure you want to archive the cycle? All your archives can be restored later.
</p>
<div className="mt-3 flex justify-end gap-2">
<Button variant="neutral-primary" size="sm" onClick={onClose}>
Cancel
</Button>
<Button size="sm" tabIndex={1} onClick={handleArchiveIssue} loading={isArchiving}>
{isArchiving ? "Archiving" : "Archive"}
</Button>
</div>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
};

View File

@ -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 <CycleModuleListLayout />;
}
return (
<>
{calculateTotalFilters(currentProjectArchivedFilters ?? {}) !== 0 && (
<div className="border-b border-custom-border-200 px-5 py-3">
<CycleAppliedFiltersList
appliedFilters={currentProjectArchivedFilters ?? {}}
handleClearAllFilters={() => clearAllFilters(projectId.toString(), "archived")}
handleRemoveFilter={handleRemoveFilter}
/>
</div>
)}
{totalArchivedCycles === 0 ? (
<div className="h-full place-items-center">
<EmptyState type={EmptyStateType.PROJECT_ARCHIVED_NO_CYCLES} />
</div>
) : (
<div className="relative h-full w-full overflow-auto">
<ArchivedCyclesView workspaceSlug={workspaceSlug.toString()} projectId={projectId.toString()} />
</div>
)}
</>
);
});

View File

@ -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<IArchivedCyclesView> = 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 <CycleModuleListLayout />;
if (filteredArchivedCycleIds.length === 0)
return (
<div className="h-full w-full grid place-items-center">
<div className="text-center">
<Image
src={archivedCyclesSearchQuery.trim() === "" ? AllFiltersImage : NameFilterImage}
className="h-36 sm:h-48 w-36 sm:w-48 mx-auto"
alt="No matching cycles"
/>
<h5 className="text-xl font-medium mt-7 mb-1">No matching cycles</h5>
<p className="text-custom-text-400 text-base">
{archivedCyclesSearchQuery.trim() === ""
? "Remove the filters to see all cycles"
: "Remove the search criteria to see all cycles"}
</p>
</div>
</div>
);
return (
<CyclesList
completedCycleIds={[]}
cycleIds={filteredArchivedCycleIds}
workspaceSlug={workspaceSlug}
projectId={projectId}
isArchived
/>
);
});

View File

@ -9,9 +9,10 @@ import { CycleDetailsSidebar } from "./sidebar";
type Props = { type Props = {
projectId: string; projectId: string;
workspaceSlug: string; workspaceSlug: string;
isArchived?: boolean;
}; };
export const CyclePeekOverview: React.FC<Props> = observer(({ projectId, workspaceSlug }) => { export const CyclePeekOverview: React.FC<Props> = observer(({ projectId, workspaceSlug, isArchived = false }) => {
// router // router
const router = useRouter(); const router = useRouter();
const { peekCycle } = router.query; const { peekCycle } = router.query;
@ -29,9 +30,9 @@ export const CyclePeekOverview: React.FC<Props> = observer(({ projectId, workspa
}; };
useEffect(() => { useEffect(() => {
if (!peekCycle) return; if (!peekCycle || isArchived) return;
fetchCycleDetails(workspaceSlug, projectId, peekCycle.toString()); fetchCycleDetails(workspaceSlug, projectId, peekCycle.toString());
}, [fetchCycleDetails, peekCycle, projectId, workspaceSlug]); }, [fetchCycleDetails, isArchived, peekCycle, projectId, workspaceSlug]);
return ( return (
<> <>
@ -44,7 +45,11 @@ export const CyclePeekOverview: React.FC<Props> = 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)", "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)",
}} }}
> >
<CycleDetailsSidebar cycleId={peekCycle?.toString() ?? ""} handleClose={handleClose} /> <CycleDetailsSidebar
cycleId={peekCycle?.toString() ?? ""}
handleClose={handleClose}
isArchived={isArchived}
/>
</div> </div>
)} )}
</> </>

View File

@ -2,21 +2,21 @@ import { useCallback, useRef, useState } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { ListFilter, Search, X } from "lucide-react"; import { ListFilter, Search, X } from "lucide-react";
import { Tab } from "@headlessui/react"; import { Tab } from "@headlessui/react";
// types
import { TCycleFilters } from "@plane/types"; import { TCycleFilters } from "@plane/types";
// hooks // ui
import { Tooltip } from "@plane/ui"; import { Tooltip } from "@plane/ui";
// components
import { CycleFiltersSelection } from "@/components/cycles"; import { CycleFiltersSelection } from "@/components/cycles";
import { FiltersDropdown } from "@/components/issues"; import { FiltersDropdown } from "@/components/issues";
// constants
import { CYCLE_TABS_LIST, CYCLE_VIEW_LAYOUTS } from "@/constants/cycle"; import { CYCLE_TABS_LIST, CYCLE_VIEW_LAYOUTS } from "@/constants/cycle";
// helpers
import { cn } from "@/helpers/common.helper"; import { cn } from "@/helpers/common.helper";
// hooks
import { useCycleFilter } from "@/hooks/store"; import { useCycleFilter } from "@/hooks/store";
import useOutsideClickDetector from "@/hooks/use-outside-click-detector"; import useOutsideClickDetector from "@/hooks/use-outside-click-detector";
import { usePlatformOS } from "@/hooks/use-platform-os"; import { usePlatformOS } from "@/hooks/use-platform-os";
// components
// ui
// helpers
// types
// constants
type Props = { type Props = {
projectId: string; projectId: string;
@ -24,8 +24,6 @@ type Props = {
export const CyclesViewHeader: React.FC<Props> = observer((props) => { export const CyclesViewHeader: React.FC<Props> = observer((props) => {
const { projectId } = props; const { projectId } = props;
// states
const [isSearchOpen, setIsSearchOpen] = useState(false);
// refs // refs
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
// hooks // hooks
@ -38,6 +36,8 @@ export const CyclesViewHeader: React.FC<Props> = observer((props) => {
updateSearchQuery, updateSearchQuery,
} = useCycleFilter(); } = useCycleFilter();
const { isMobile } = usePlatformOS(); const { isMobile } = usePlatformOS();
// states
const [isSearchOpen, setIsSearchOpen] = useState(searchQuery !== "" ? true : false);
// outside click detector hook // outside click detector hook
useOutsideClickDetector(inputRef, () => { useOutsideClickDetector(inputRef, () => {
if (isSearchOpen && searchQuery.trim() === "") setIsSearchOpen(false); if (isSearchOpen && searchQuery.trim() === "") setIsSearchOpen(false);

View File

@ -9,10 +9,11 @@ import { FilterEndDate, FilterStartDate, FilterStatus } from "@/components/cycle
type Props = { type Props = {
filters: TCycleFilters; filters: TCycleFilters;
handleFiltersUpdate: (key: keyof TCycleFilters, value: string | string[]) => void; handleFiltersUpdate: (key: keyof TCycleFilters, value: string | string[]) => void;
isArchived?: boolean;
}; };
export const CycleFiltersSelection: React.FC<Props> = observer((props) => { export const CycleFiltersSelection: React.FC<Props> = observer((props) => {
const { filters, handleFiltersUpdate } = props; const { filters, handleFiltersUpdate, isArchived = false } = props;
// states // states
const [filtersSearchQuery, setFiltersSearchQuery] = useState(""); const [filtersSearchQuery, setFiltersSearchQuery] = useState("");
@ -38,6 +39,7 @@ export const CycleFiltersSelection: React.FC<Props> = observer((props) => {
</div> </div>
<div className="h-full w-full divide-y divide-custom-border-200 overflow-y-auto px-2.5 vertical-scrollbar scrollbar-sm"> <div className="h-full w-full divide-y divide-custom-border-200 overflow-y-auto px-2.5 vertical-scrollbar scrollbar-sm">
{/* cycle status */} {/* cycle status */}
{!isArchived && (
<div className="py-2"> <div className="py-2">
<FilterStatus <FilterStatus
appliedFilters={(filters.status as TCycleGroups[]) ?? null} appliedFilters={(filters.status as TCycleGroups[]) ?? null}
@ -45,6 +47,7 @@ export const CycleFiltersSelection: React.FC<Props> = observer((props) => {
searchQuery={filtersSearchQuery} searchQuery={filtersSearchQuery}
/> />
</div> </div>
)}
{/* start date */} {/* start date */}
<div className="py-2"> <div className="py-2">

View File

@ -14,3 +14,6 @@ export * from "./quick-actions";
export * from "./sidebar"; export * from "./sidebar";
export * from "./transfer-issues-modal"; export * from "./transfer-issues-modal";
export * from "./transfer-issues"; export * from "./transfer-issues";
// archived cycles
export * from "./archived-cycles";

View File

@ -2,27 +2,21 @@ import { FC, MouseEvent } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
// hooks
import { Check, Info, Star, User2 } from "lucide-react";
import type { TCycleGroups } from "@plane/types";
import { Tooltip, CircularProgressIndicator, CycleGroupIcon, AvatarGroup, Avatar, setPromiseToast } from "@plane/ui";
import { CycleQuickActions } from "@/components/cycles";
// components
// import { CycleCreateUpdateModal, CycleDeleteModal } from "@/components/cycles";
// ui
// icons // icons
// helpers import { Check, Info, Star, User2 } from "lucide-react";
// types
import type { TCycleGroups } from "@plane/types";
// ui
import { Tooltip, CircularProgressIndicator, CycleGroupIcon, AvatarGroup, Avatar, setPromiseToast } from "@plane/ui";
// components
import { CycleQuickActions } from "@/components/cycles";
// constants // constants
import { CYCLE_STATUS } from "@/constants/cycle"; import { CYCLE_STATUS } from "@/constants/cycle";
import { CYCLE_FAVORITED, CYCLE_UNFAVORITED } from "@/constants/event-tracker"; import { CYCLE_FAVORITED, CYCLE_UNFAVORITED } from "@/constants/event-tracker";
// components
// ui
// icons
// helpers
// constants
// types
import { EUserProjectRoles } from "@/constants/project"; import { EUserProjectRoles } from "@/constants/project";
// helpers
import { findHowManyDaysLeft, getDate, renderFormattedDate } from "@/helpers/date-time.helper"; import { findHowManyDaysLeft, getDate, renderFormattedDate } from "@/helpers/date-time.helper";
// hooks
import { useEventTracker, useCycle, useUser, useMember } from "@/hooks/store"; import { useEventTracker, useCycle, useUser, useMember } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os"; import { usePlatformOS } from "@/hooks/use-platform-os";
@ -34,10 +28,11 @@ type TCyclesListItem = {
handleRemoveFromFavorites?: () => void; handleRemoveFromFavorites?: () => void;
workspaceSlug: string; workspaceSlug: string;
projectId: string; projectId: string;
isArchived?: boolean;
}; };
export const CyclesListItem: FC<TCyclesListItem> = observer((props) => { export const CyclesListItem: FC<TCyclesListItem> = observer((props) => {
const { cycleId, workspaceSlug, projectId } = props; const { cycleId, workspaceSlug, projectId, isArchived } = props;
// router // router
const router = useRouter(); const router = useRouter();
// hooks // hooks
@ -106,7 +101,7 @@ export const CyclesListItem: FC<TCyclesListItem> = observer((props) => {
}); });
}; };
const openCycleOverview = (e: MouseEvent<HTMLButtonElement>) => { const openCycleOverview = (e: MouseEvent<HTMLButtonElement | HTMLAnchorElement>) => {
const { query } = router; const { query } = router;
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
@ -151,7 +146,14 @@ export const CyclesListItem: FC<TCyclesListItem> = observer((props) => {
return ( return (
<> <>
<Link href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycleDetails.id}`}> <Link
href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycleDetails.id}`}
onClick={(e) => {
if (isArchived) {
openCycleOverview(e);
}
}}
>
<div className="group flex w-full flex-col items-center justify-between gap-5 border-b border-custom-border-100 bg-custom-background-100 px-5 py-6 text-sm hover:bg-custom-background-90 md:flex-row"> <div className="group flex w-full flex-col items-center justify-between gap-5 border-b border-custom-border-100 bg-custom-background-100 px-5 py-6 text-sm hover:bg-custom-background-90 md:flex-row">
<div className="relative flex w-full items-center justify-between gap-3 overflow-hidden"> <div className="relative flex w-full items-center justify-between gap-3 overflow-hidden">
<div className="relative flex w-full items-center gap-3 overflow-hidden"> <div className="relative flex w-full items-center gap-3 overflow-hidden">
@ -221,9 +223,9 @@ export const CyclesListItem: FC<TCyclesListItem> = observer((props) => {
</div> </div>
</Tooltip> </Tooltip>
{isEditingAllowed && ( {isEditingAllowed &&
<> !isArchived &&
{cycleDetails.is_favorite ? ( (cycleDetails.is_favorite ? (
<button type="button" onClick={handleRemoveFromFavorites}> <button type="button" onClick={handleRemoveFromFavorites}>
<Star className="h-3.5 w-3.5 fill-current text-amber-500" /> <Star className="h-3.5 w-3.5 fill-current text-amber-500" />
</button> </button>
@ -231,11 +233,13 @@ export const CyclesListItem: FC<TCyclesListItem> = observer((props) => {
<button type="button" onClick={handleAddToFavorites}> <button type="button" onClick={handleAddToFavorites}>
<Star className="h-3.5 w-3.5 text-custom-text-200" /> <Star className="h-3.5 w-3.5 text-custom-text-200" />
</button> </button>
)} ))}
<CycleQuickActions
<CycleQuickActions cycleId={cycleId} projectId={projectId} workspaceSlug={workspaceSlug} /> cycleId={cycleId}
</> projectId={projectId}
)} workspaceSlug={workspaceSlug}
isArchived={isArchived}
/>
</div> </div>
</div> </div>
</div> </div>

View File

@ -5,15 +5,22 @@ type Props = {
cycleIds: string[]; cycleIds: string[];
projectId: string; projectId: string;
workspaceSlug: string; workspaceSlug: string;
isArchived?: boolean;
}; };
export const CyclesListMap: React.FC<Props> = (props) => { export const CyclesListMap: React.FC<Props> = (props) => {
const { cycleIds, projectId, workspaceSlug } = props; const { cycleIds, projectId, workspaceSlug, isArchived } = props;
return ( return (
<> <>
{cycleIds.map((cycleId) => ( {cycleIds.map((cycleId) => (
<CyclesListItem key={cycleId} cycleId={cycleId} workspaceSlug={workspaceSlug} projectId={projectId} /> <CyclesListItem
key={cycleId}
cycleId={cycleId}
workspaceSlug={workspaceSlug}
projectId={projectId}
isArchived={isArchived}
/>
))} ))}
</> </>
); );

View File

@ -12,16 +12,22 @@ export interface ICyclesList {
cycleIds: string[]; cycleIds: string[];
workspaceSlug: string; workspaceSlug: string;
projectId: string; projectId: string;
isArchived?: boolean;
} }
export const CyclesList: FC<ICyclesList> = observer((props) => { export const CyclesList: FC<ICyclesList> = observer((props) => {
const { completedCycleIds, cycleIds, workspaceSlug, projectId } = props; const { completedCycleIds, cycleIds, workspaceSlug, projectId, isArchived = false } = props;
return ( return (
<div className="h-full overflow-y-auto"> <div className="h-full overflow-y-auto">
<div className="flex h-full w-full justify-between"> <div className="flex h-full w-full justify-between">
<div className="flex h-full w-full flex-col overflow-y-auto vertical-scrollbar scrollbar-lg"> <div className="flex h-full w-full flex-col overflow-y-auto vertical-scrollbar scrollbar-lg">
<CyclesListMap cycleIds={cycleIds} projectId={projectId} workspaceSlug={workspaceSlug} /> <CyclesListMap
cycleIds={cycleIds}
projectId={projectId}
workspaceSlug={workspaceSlug}
isArchived={isArchived}
/>
{completedCycleIds.length !== 0 && ( {completedCycleIds.length !== 0 && (
<Disclosure as="div" className="mt-4 space-y-4"> <Disclosure as="div" className="mt-4 space-y-4">
<Disclosure.Button className="bg-custom-background-80 font-semibold text-sm py-1 px-2 rounded ml-5 flex items-center gap-1"> <Disclosure.Button className="bg-custom-background-80 font-semibold text-sm py-1 px-2 rounded ml-5 flex items-center gap-1">
@ -37,12 +43,17 @@ export const CyclesList: FC<ICyclesList> = observer((props) => {
)} )}
</Disclosure.Button> </Disclosure.Button>
<Disclosure.Panel> <Disclosure.Panel>
<CyclesListMap cycleIds={completedCycleIds} projectId={projectId} workspaceSlug={workspaceSlug} /> <CyclesListMap
cycleIds={completedCycleIds}
projectId={projectId}
workspaceSlug={workspaceSlug}
isArchived={isArchived}
/>
</Disclosure.Panel> </Disclosure.Panel>
</Disclosure> </Disclosure>
)} )}
</div> </div>
<CyclePeekOverview projectId={projectId} workspaceSlug={workspaceSlug} /> <CyclePeekOverview projectId={projectId} workspaceSlug={workspaceSlug} isArchived={isArchived} />
</div> </div>
</div> </div>
); );

View File

@ -1,34 +1,40 @@
import { useState } from "react"; import { useState } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { LinkIcon, Pencil, Trash2 } from "lucide-react"; import { useRouter } from "next/router";
// hooks // icons
// components import { ArchiveRestoreIcon, LinkIcon, Pencil, Trash2 } from "lucide-react";
import { CustomMenu, TOAST_TYPE, setToast } from "@plane/ui";
import { CycleCreateUpdateModal, CycleDeleteModal } from "@/components/cycles";
// ui // ui
// helpers import { ArchiveIcon, CustomMenu, TOAST_TYPE, setToast } from "@plane/ui";
import { EUserProjectRoles } from "@/constants/project"; // components
import { copyUrlToClipboard } from "@/helpers/string.helper"; import { ArchiveCycleModal, CycleCreateUpdateModal, CycleDeleteModal } from "@/components/cycles";
// constants // constants
import { EUserProjectRoles } from "@/constants/project";
// helpers
import { copyUrlToClipboard } from "@/helpers/string.helper";
// hooks
import { useCycle, useEventTracker, useUser } from "@/hooks/store"; import { useCycle, useEventTracker, useUser } from "@/hooks/store";
type Props = { type Props = {
cycleId: string; cycleId: string;
projectId: string; projectId: string;
workspaceSlug: string; workspaceSlug: string;
isArchived?: boolean;
}; };
export const CycleQuickActions: React.FC<Props> = observer((props) => { export const CycleQuickActions: React.FC<Props> = observer((props) => {
const { cycleId, projectId, workspaceSlug } = props; const { cycleId, projectId, workspaceSlug, isArchived } = props;
// router
const router = useRouter();
// states // states
const [updateModal, setUpdateModal] = useState(false); const [updateModal, setUpdateModal] = useState(false);
const [archiveCycleModal, setArchiveCycleModal] = useState(false);
const [deleteModal, setDeleteModal] = useState(false); const [deleteModal, setDeleteModal] = useState(false);
// store hooks // store hooks
const { setTrackElement } = useEventTracker(); const { setTrackElement } = useEventTracker();
const { const {
membership: { currentWorkspaceAllProjectsRole }, membership: { currentWorkspaceAllProjectsRole },
} = useUser(); } = useUser();
const { getCycleById } = useCycle(); const { getCycleById, restoreCycle } = useCycle();
// derived values // derived values
const cycleDetails = getCycleById(cycleId); const cycleDetails = getCycleById(cycleId);
const isCompleted = cycleDetails?.status.toLowerCase() === "completed"; const isCompleted = cycleDetails?.status.toLowerCase() === "completed";
@ -56,6 +62,33 @@ export const CycleQuickActions: React.FC<Props> = observer((props) => {
setUpdateModal(true); setUpdateModal(true);
}; };
const handleArchiveCycle = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setArchiveCycleModal(true);
};
const handleRestoreCycle = async (e: React.MouseEvent<HTMLButtonElement>) => {
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<HTMLButtonElement>) => { const handleDeleteCycle = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
@ -74,6 +107,13 @@ export const CycleQuickActions: React.FC<Props> = observer((props) => {
workspaceSlug={workspaceSlug} workspaceSlug={workspaceSlug}
projectId={projectId} projectId={projectId}
/> />
<ArchiveCycleModal
workspaceSlug={workspaceSlug}
projectId={projectId}
cycleId={cycleId}
isOpen={archiveCycleModal}
handleClose={() => setArchiveCycleModal(false)}
/>
<CycleDeleteModal <CycleDeleteModal
cycle={cycleDetails} cycle={cycleDetails}
isOpen={deleteModal} isOpen={deleteModal}
@ -84,28 +124,60 @@ export const CycleQuickActions: React.FC<Props> = observer((props) => {
</div> </div>
)} )}
<CustomMenu ellipsis placement="bottom-end"> <CustomMenu ellipsis placement="bottom-end">
{!isCompleted && isEditingAllowed && ( {!isCompleted && isEditingAllowed && !isArchived && (
<>
<CustomMenu.MenuItem onClick={handleEditCycle}> <CustomMenu.MenuItem onClick={handleEditCycle}>
<span className="flex items-center justify-start gap-2"> <span className="flex items-center justify-start gap-2">
<Pencil className="h-3 w-3" /> <Pencil className="h-3 w-3" />
<span>Edit cycle</span> <span>Edit cycle</span>
</span> </span>
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={handleDeleteCycle}> )}
{isEditingAllowed && !isArchived && (
<CustomMenu.MenuItem onClick={handleArchiveCycle} disabled={!isCompleted}>
{isCompleted ? (
<div className="flex items-center gap-2">
<ArchiveIcon className="h-3 w-3" />
Archive cycle
</div>
) : (
<div className="flex items-start gap-2">
<ArchiveIcon className="h-3 w-3" />
<div className="-mt-1">
<p>Archive cycle</p>
<p className="text-xs text-custom-text-400">
Only completed cycle <br /> can be archived.
</p>
</div>
</div>
)}
</CustomMenu.MenuItem>
)}
{isEditingAllowed && isArchived && (
<CustomMenu.MenuItem onClick={handleRestoreCycle}>
<span className="flex items-center justify-start gap-2"> <span className="flex items-center justify-start gap-2">
<Trash2 className="h-3 w-3" /> <ArchiveRestoreIcon className="h-3 w-3" />
<span>Delete cycle</span> <span>Restore cycle</span>
</span> </span>
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
</>
)} )}
{!isArchived && (
<CustomMenu.MenuItem onClick={handleCopyText}> <CustomMenu.MenuItem onClick={handleCopyText}>
<span className="flex items-center justify-start gap-2"> <span className="flex items-center justify-start gap-2">
<LinkIcon className="h-3 w-3" /> <LinkIcon className="h-3 w-3" />
<span>Copy cycle link</span> <span>Copy cycle link</span>
</span> </span>
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
)}
{!isCompleted && isEditingAllowed && (
<div className="border-t pt-1 mt-1">
<CustomMenu.MenuItem onClick={handleDeleteCycle}>
<span className="flex items-center justify-start gap-2">
<Trash2 className="h-3 w-3" />
<span>Delete cycle</span>
</span>
</CustomMenu.MenuItem>
</div>
)}
</CustomMenu> </CustomMenu>
</> </>
); );

View File

@ -3,33 +3,43 @@ import isEmpty from "lodash/isEmpty";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { Controller, useForm } from "react-hook-form"; 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 // 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"; import { ICycle } from "@plane/types";
// ui // 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 // components
import { SidebarProgressStats } from "@/components/core"; import { SidebarProgressStats } from "@/components/core";
import ProgressChart from "@/components/core/sidebar/progress-chart"; 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"; import { DateRangeDropdown } from "@/components/dropdowns";
// constants // constants
import { CYCLE_STATUS } from "@/constants/cycle"; import { CYCLE_STATUS } from "@/constants/cycle";
import { CYCLE_UPDATED } from "@/constants/event-tracker"; import { CYCLE_UPDATED } from "@/constants/event-tracker";
import { EUserWorkspaceRoles } from "@/constants/workspace"; import { EUserWorkspaceRoles } from "@/constants/workspace";
// helpers // helpers
// hooks
import { findHowManyDaysLeft, getDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper"; import { findHowManyDaysLeft, getDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper";
import { copyUrlToClipboard } from "@/helpers/string.helper"; import { copyUrlToClipboard } from "@/helpers/string.helper";
// hooks
import { useEventTracker, useCycle, useUser, useMember } from "@/hooks/store"; import { useEventTracker, useCycle, useUser, useMember } from "@/hooks/store";
// services // services
import { CycleService } from "@/services/cycle.service"; import { CycleService } from "@/services/cycle.service";
// types
type Props = { type Props = {
cycleId: string; cycleId: string;
handleClose: () => void; handleClose: () => void;
isArchived?: boolean;
}; };
const defaultValues: Partial<ICycle> = { const defaultValues: Partial<ICycle> = {
@ -42,8 +52,9 @@ const cycleService = new CycleService();
// TODO: refactor the whole component // TODO: refactor the whole component
export const CycleDetailsSidebar: React.FC<Props> = observer((props) => { export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
const { cycleId, handleClose } = props; const { cycleId, handleClose, isArchived } = props;
// states // states
const [archiveCycleModal, setArchiveCycleModal] = useState(false);
const [cycleDeleteModal, setCycleDeleteModal] = useState(false); const [cycleDeleteModal, setCycleDeleteModal] = useState(false);
// router // router
const router = useRouter(); const router = useRouter();
@ -53,7 +64,7 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
const { const {
membership: { currentProjectRole }, membership: { currentProjectRole },
} = useUser(); } = useUser();
const { getCycleById, updateCycleDetails } = useCycle(); const { getCycleById, updateCycleDetails, restoreCycle } = useCycle();
const { getUserDetails } = useMember(); const { getUserDetails } = useMember();
// derived values // derived values
const cycleDetails = getCycleById(cycleId); const cycleDetails = getCycleById(cycleId);
@ -108,6 +119,27 @@ export const CycleDetailsSidebar: React.FC<Props> = 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(() => { useEffect(() => {
if (cycleDetails) if (cycleDetails)
reset({ reset({
@ -229,6 +261,14 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
return ( return (
<div className="relative"> <div className="relative">
{cycleDetails && workspaceSlug && projectId && ( {cycleDetails && workspaceSlug && projectId && (
<>
<ArchiveCycleModal
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
cycleId={cycleId}
isOpen={archiveCycleModal}
handleClose={() => setArchiveCycleModal(false)}
/>
<CycleDeleteModal <CycleDeleteModal
cycle={cycleDetails} cycle={cycleDetails}
isOpen={cycleDeleteModal} isOpen={cycleDeleteModal}
@ -236,6 +276,7 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
workspaceSlug={workspaceSlug.toString()} workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()} projectId={projectId.toString()}
/> />
</>
)} )}
<> <>
@ -249,11 +290,42 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
</button> </button>
</div> </div>
<div className="flex items-center gap-3.5"> <div className="flex items-center gap-3.5">
{!isArchived && (
<button onClick={handleCopyText}> <button onClick={handleCopyText}>
<LinkIcon className="h-3 w-3 text-custom-text-300" /> <LinkIcon className="h-3 w-3 text-custom-text-300" />
</button> </button>
{!isCompleted && isEditingAllowed && ( )}
{isEditingAllowed && (
<CustomMenu placement="bottom-end" ellipsis> <CustomMenu placement="bottom-end" ellipsis>
{!isArchived && (
<CustomMenu.MenuItem onClick={() => setArchiveCycleModal(true)} disabled={!isCompleted}>
{isCompleted ? (
<div className="flex items-center gap-2">
<ArchiveIcon className="h-3 w-3" />
Archive cycle
</div>
) : (
<div className="flex items-start gap-2">
<ArchiveIcon className="h-3 w-3" />
<div className="-mt-1">
<p>Archive cycle</p>
<p className="text-xs text-custom-text-400">
Only completed cycle <br /> can be archived.
</p>
</div>
</div>
)}
</CustomMenu.MenuItem>
)}
{isArchived && (
<CustomMenu.MenuItem onClick={handleRestoreCycle}>
<span className="flex items-center justify-start gap-2">
<ArchiveRestoreIcon className="h-3 w-3" />
<span>Restore cycle</span>
</span>
</CustomMenu.MenuItem>
)}
{!isCompleted && (
<CustomMenu.MenuItem <CustomMenu.MenuItem
onClick={() => { onClick={() => {
setTrackElement("CYCLE_PAGE_SIDEBAR"); setTrackElement("CYCLE_PAGE_SIDEBAR");
@ -265,6 +337,7 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
<span>Delete cycle</span> <span>Delete cycle</span>
</span> </span>
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
)}
</CustomMenu> </CustomMenu>
)} )}
</div> </div>
@ -331,6 +404,7 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
to: "End date", to: "End date",
}} }}
required={cycleDetails.status !== "draft"} required={cycleDetails.status !== "draft"}
disabled={isArchived}
/> />
)} )}
/> />

View File

@ -149,6 +149,7 @@ export const DateRangeDropdown: React.FC<Props> = (props) => {
if (!isOpen) handleKeyDown(e); if (!isOpen) handleKeyDown(e);
} else handleKeyDown(e); } else handleKeyDown(e);
}} }}
disabled={disabled}
> >
<Combobox.Button as={React.Fragment}> <Combobox.Button as={React.Fragment}>
<button <button

View File

@ -17,7 +17,7 @@ export * from "./workspace-settings";
export * from "./pages"; export * from "./pages";
export * from "./project-draft-issues"; export * from "./project-draft-issues";
export * from "./project-archived-issue-details"; export * from "./project-archived-issue-details";
export * from "./project-archived-issues"; export * from "./project-archives";
export * from "./project-issue-details"; export * from "./project-issue-details";
export * from "./user-profile"; export * from "./user-profile";
export * from "./workspace-active-cycles"; export * from "./workspace-active-cycles";

View File

@ -23,8 +23,6 @@ import { usePlatformOS } from "@/hooks/use-platform-os";
// types // types
export const ModulesListHeader: React.FC = observer(() => { export const ModulesListHeader: React.FC = observer(() => {
// states
const [isSearchOpen, setIsSearchOpen] = useState(false);
// refs // refs
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
// router // router
@ -49,6 +47,8 @@ export const ModulesListHeader: React.FC = observer(() => {
updateFilters, updateFilters,
updateSearchQuery, updateSearchQuery,
} = useModuleFilter(); } = useModuleFilter();
// states
const [isSearchOpen, setIsSearchOpen] = useState(searchQuery !== "" ? true : false);
// outside click detector hook // outside click detector hook
useOutsideClickDetector(inputRef, () => { useOutsideClickDetector(inputRef, () => {
if (isSearchOpen && searchQuery.trim() === "") setIsSearchOpen(false); if (isSearchOpen && searchQuery.trim() === "") setIsSearchOpen(false);

View File

@ -3,7 +3,7 @@ import { observer } from "mobx-react-lite";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import useSWR from "swr"; import useSWR from "swr";
// hooks // hooks
import { Breadcrumbs, LayersIcon } from "@plane/ui"; import { ArchiveIcon, Breadcrumbs, LayersIcon } from "@plane/ui";
import { BreadcrumbLink } from "@/components/common"; import { BreadcrumbLink } from "@/components/common";
import { ProjectLogo } from "@/components/project"; import { ProjectLogo } from "@/components/project";
import { ISSUE_DETAILS } from "@/constants/fetch-keys"; import { ISSUE_DETAILS } from "@/constants/fetch-keys";
@ -39,7 +39,7 @@ export const ProjectArchivedIssueDetailsHeader: FC = observer(() => {
); );
return ( return (
<div className="relative z-10 flex h-[3.75rem] w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 bg-custom-sidebar-background-100 p-4"> <div className="relative z-10 flex h-14 w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 bg-custom-sidebar-background-100 p-4">
<div className="flex w-full flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap"> <div className="flex w-full flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap">
<div> <div>
<Breadcrumbs> <Breadcrumbs>
@ -59,18 +59,26 @@ export const ProjectArchivedIssueDetailsHeader: FC = observer(() => {
/> />
} }
/> />
<Breadcrumbs.BreadcrumbItem <Breadcrumbs.BreadcrumbItem
type="text" type="text"
link={ link={
<BreadcrumbLink <BreadcrumbLink
href={`/${workspaceSlug}/projects/${projectId}/archived-issues`} href={`/${workspaceSlug}/projects/${projectId}/archives/issues`}
label="Archived issues" label="Archives"
icon={<ArchiveIcon className="h-4 w-4 text-custom-text-300" />}
/>
}
/>
<Breadcrumbs.BreadcrumbItem
type="text"
link={
<BreadcrumbLink
href={`/${workspaceSlug}/projects/${projectId}/archives/issues`}
label="Issues"
icon={<LayersIcon className="h-4 w-4 text-custom-text-300" />} icon={<LayersIcon className="h-4 w-4 text-custom-text-300" />}
/> />
} }
/> />
<Breadcrumbs.BreadcrumbItem <Breadcrumbs.BreadcrumbItem
type="text" type="text"
link={ link={

View File

@ -0,0 +1,96 @@
import { FC } from "react";
import { observer } from "mobx-react-lite";
import { useRouter } from "next/router";
// ui
import { ArchiveIcon, Breadcrumbs, Tooltip } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common";
import { ProjectLogo } from "@/components/project";
// constants
import { PROJECT_ARCHIVES_BREADCRUMB_LIST } from "@/constants/archives";
import { EIssuesStoreType } from "@/constants/issue";
// hooks
import { useIssues, useProject } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os";
export const ProjectArchivesHeader: FC = observer(() => {
// router
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const activeTab = router.pathname.split("/").pop();
// store hooks
const {
issuesFilter: { issueFilters },
} = useIssues(EIssuesStoreType.ARCHIVED);
const { currentProjectDetails } = useProject();
// hooks
const { isMobile } = usePlatformOS();
const issueCount = currentProjectDetails
? issueFilters?.displayFilters?.sub_issue
? currentProjectDetails.archived_issues + currentProjectDetails.archived_sub_issues
: currentProjectDetails.archived_issues
: undefined;
const activeTabBreadcrumbDetail =
PROJECT_ARCHIVES_BREADCRUMB_LIST[activeTab as keyof typeof PROJECT_ARCHIVES_BREADCRUMB_LIST];
return (
<div className="relative z-10 flex h-14 w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 bg-custom-sidebar-background-100 p-4">
<div className="flex w-full flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap">
<div className="flex items-center gap-2.5">
<Breadcrumbs onBack={router.back}>
<Breadcrumbs.BreadcrumbItem
type="text"
link={
<BreadcrumbLink
href={`/${workspaceSlug}/projects`}
label={currentProjectDetails?.name ?? "Project"}
icon={
currentProjectDetails && (
<span className="grid place-items-center flex-shrink-0 h-4 w-4">
<ProjectLogo logo={currentProjectDetails?.logo_props} className="text-sm" />
</span>
)
}
/>
}
/>
<Breadcrumbs.BreadcrumbItem
type="text"
link={
<BreadcrumbLink
href={`/${workspaceSlug}/projects/${projectId}/archives/issues`}
label="Archives"
icon={<ArchiveIcon className="h-4 w-4 text-custom-text-300" />}
/>
}
/>
{activeTabBreadcrumbDetail && (
<Breadcrumbs.BreadcrumbItem
type="text"
link={
<BreadcrumbLink
label={activeTabBreadcrumbDetail.label}
icon={<activeTabBreadcrumbDetail.icon className="h-4 w-4 text-custom-text-300" />}
/>
}
/>
)}
</Breadcrumbs>
{activeTab === "issues" && issueCount && issueCount > 0 ? (
<Tooltip
isMobile={isMobile}
tooltipContent={`There are ${issueCount} ${issueCount > 1 ? "issues" : "issue"} in project's archived`}
position="bottom"
>
<span className="cursor-default flex items-center text-center justify-center px-2.5 py-0.5 flex-shrink-0 bg-custom-primary-100/20 text-custom-primary-100 text-xs font-semibold rounded-xl">
{issueCount}
</span>
</Tooltip>
) : null}
</div>
</div>
</div>
);
});

View File

@ -1,22 +1,17 @@
import { FC } from "react"; import { FC } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import type { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "@plane/types";
// hooks
// constants
// ui
import { Breadcrumbs, LayersIcon, Tooltip } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common";
import { DisplayFiltersSelection, FilterSelection, FiltersDropdown } from "@/components/issues";
import { ProjectLogo } from "@/components/project";
import { EIssueFilterType, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@/constants/issue";
// helpers
import { useIssues, useLabel, useMember, useProject, useProjectState } from "@/hooks/store";
// types // types
import { usePlatformOS } from "@/hooks/use-platform-os"; import type { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "@plane/types";
// components
import { ArchiveTabsList } from "@/components/archives";
import { DisplayFiltersSelection, FilterSelection, FiltersDropdown } from "@/components/issues";
// constants
import { EIssueFilterType, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@/constants/issue";
// hooks
import { useIssues, useLabel, useMember, useProjectState } from "@/hooks/store";
export const ProjectArchivedIssuesHeader: FC = observer(() => { export const ArchivedIssuesHeader: FC = observer(() => {
// router // router
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId } = router.query; const { workspaceSlug, projectId } = router.query;
@ -24,7 +19,6 @@ export const ProjectArchivedIssuesHeader: FC = observer(() => {
const { const {
issuesFilter: { issueFilters, updateFilters }, issuesFilter: { issueFilters, updateFilters },
} = useIssues(EIssuesStoreType.ARCHIVED); } = useIssues(EIssuesStoreType.ARCHIVED);
const { currentProjectDetails } = useProject();
const { projectStates } = useProjectState(); const { projectStates } = useProjectState();
const { projectLabels } = useLabel(); const { projectLabels } = useLabel();
const { const {
@ -33,7 +27,6 @@ export const ProjectArchivedIssuesHeader: FC = observer(() => {
// for archived issues list layout is the only option // for archived issues list layout is the only option
const activeLayout = "list"; const activeLayout = "list";
// hooks // hooks
const { isMobile } = usePlatformOS();
const handleFiltersUpdate = (key: keyof IIssueFilterOptions, value: string | string[]) => { const handleFiltersUpdate = (key: keyof IIssueFilterOptions, value: string | string[]) => {
if (!workspaceSlug || !projectId) return; if (!workspaceSlug || !projectId) return;
@ -68,60 +61,13 @@ export const ProjectArchivedIssuesHeader: FC = observer(() => {
updateFilters(workspaceSlug.toString(), projectId.toString(), EIssueFilterType.DISPLAY_PROPERTIES, property); updateFilters(workspaceSlug.toString(), projectId.toString(), EIssueFilterType.DISPLAY_PROPERTIES, property);
}; };
const issueCount = currentProjectDetails
? issueFilters?.displayFilters?.sub_issue
? currentProjectDetails.archived_issues + currentProjectDetails.archived_sub_issues
: currentProjectDetails.archived_issues
: undefined;
return ( return (
<div className="relative z-10 flex h-14 w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 bg-custom-sidebar-background-100 p-4"> <div className="group relative flex border-b border-custom-border-200">
<div className="flex w-full flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap"> <div className="flex w-full items-center overflow-x-auto px-4 gap-2 horizontal-scrollbar scrollbar-sm">
<div className="flex items-center gap-2.5"> <ArchiveTabsList />
<Breadcrumbs onBack={router.back}>
<Breadcrumbs.BreadcrumbItem
type="text"
link={
<BreadcrumbLink
href={`/${workspaceSlug}/projects`}
label={currentProjectDetails?.name ?? "Project"}
icon={
currentProjectDetails && (
<span className="grid place-items-center flex-shrink-0 h-4 w-4">
<ProjectLogo logo={currentProjectDetails?.logo_props} className="text-sm" />
</span>
)
}
/>
}
/>
<Breadcrumbs.BreadcrumbItem
type="text"
link={
<BreadcrumbLink
label="Archived issues"
icon={<LayersIcon className="h-4 w-4 text-custom-text-300" />}
/>
}
/>
</Breadcrumbs>
{issueCount && issueCount > 0 ? (
<Tooltip
isMobile={isMobile}
tooltipContent={`There are ${issueCount} ${issueCount > 1 ? "issues" : "issue"} in project's archived`}
position="bottom"
>
<span className="cursor-default flex items-center text-center justify-center px-2.5 py-0.5 flex-shrink-0 bg-custom-primary-100/20 text-custom-primary-100 text-xs font-semibold rounded-xl">
{issueCount}
</span>
</Tooltip>
) : null}
</div> </div>
</div>
{/* filter options */} {/* filter options */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 px-8">
<FiltersDropdown title="Filters" placement="bottom-end"> <FiltersDropdown title="Filters" placement="bottom-end">
<FilterSelection <FilterSelection
filters={issueFilters?.filters || {}} filters={issueFilters?.filters || {}}

View File

@ -16,3 +16,4 @@ export * from "./peek-overview";
// archived issue // archived issue
export * from "./archive-issue-modal"; export * from "./archive-issue-modal";
export * from "./archived-issues-header";

View File

@ -110,7 +110,7 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
const handleArchiveIssue = async () => { const handleArchiveIssue = async () => {
if (!issueOperations.archive) return; if (!issueOperations.archive) return;
await issueOperations.archive(workspaceSlug, projectId, issueId); await issueOperations.archive(workspaceSlug, projectId, issueId);
router.push(`/${workspaceSlug}/projects/${projectId}/archived-issues/${issue.id}`); router.push(`/${workspaceSlug}/projects/${projectId}/archives/issues/${issue.id}`);
}; };
// derived values // derived values
const projectDetails = getProjectById(issue.project_id); const projectDetails = getProjectById(issue.project_id);

View File

@ -73,7 +73,7 @@ const KanbanIssueDetailsBlock: React.FC<IssueDetailsBlockProps> = observer((prop
</Tooltip> </Tooltip>
) : ( ) : (
<ControlLink <ControlLink
href={`/${workspaceSlug}/projects/${issue.project_id}/${issue.archived_at ? "archived-issues" : "issues"}/${ href={`/${workspaceSlug}/projects/${issue.project_id}/${issue.archived_at ? "archives/" : ""}issues/${
issue.id issue.id
}`} }`}
target="_blank" target="_blank"

View File

@ -71,9 +71,9 @@ export const IssueBlock: React.FC<IssueBlockProps> = observer((props: IssueBlock
</Tooltip> </Tooltip>
) : ( ) : (
<ControlLink <ControlLink
href={`/${workspaceSlug}/projects/${issue.project_id}/${ href={`/${workspaceSlug}/projects/${issue.project_id}/${issue.archived_at ? "archives/" : ""}issues/${
issue.archived_at ? "archived-issues" : "issues" issue.id
}/${issue.id}`} }`}
target="_blank" target="_blank"
onClick={() => handleIssuePeekOverview(issue)} onClick={() => handleIssuePeekOverview(issue)}
className="w-full line-clamp-1 cursor-pointer text-sm text-custom-text-100" className="w-full line-clamp-1 cursor-pointer text-sm text-custom-text-100"

View File

@ -235,7 +235,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
const redirectToIssueDetail = () => { const redirectToIssueDetail = () => {
router.push({ router.push({
pathname: `/${workspaceSlug}/projects/${issue.project_id}/${issue.archived_at ? "archived-issues" : "issues"}/${ pathname: `/${workspaceSlug}/projects/${issue.project_id}/${issue.archived_at ? "archives/" : ""}issues/${
issue.id issue.id
}`, }`,
hash: "sub-issues", hash: "sub-issues",

View File

@ -1,6 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { ExternalLink, Link, RotateCcw, Trash2 } from "lucide-react"; import { ArchiveRestoreIcon, ExternalLink, Link, Trash2 } from "lucide-react";
// hooks // hooks
import { CustomMenu, TOAST_TYPE, setToast } from "@plane/ui"; import { CustomMenu, TOAST_TYPE, setToast } from "@plane/ui";
@ -35,7 +35,7 @@ export const ArchivedIssueQuickActions: React.FC<IQuickActionProps> = (props) =>
const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER && !readOnly; const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER && !readOnly;
const isRestoringAllowed = handleRestore && isEditingAllowed; const isRestoringAllowed = handleRestore && isEditingAllowed;
const issueLink = `${workspaceSlug}/projects/${issue.project_id}/archived-issues/${issue.id}`; const issueLink = `${workspaceSlug}/projects/${issue.project_id}/archives/issues/${issue.id}`;
const handleOpenInNewTab = () => window.open(`/${issueLink}`, "_blank"); const handleOpenInNewTab = () => window.open(`/${issueLink}`, "_blank");
const handleCopyIssueLink = () => const handleCopyIssueLink = () =>
@ -67,7 +67,7 @@ export const ArchivedIssueQuickActions: React.FC<IQuickActionProps> = (props) =>
{isRestoringAllowed && ( {isRestoringAllowed && (
<CustomMenu.MenuItem onClick={handleRestore}> <CustomMenu.MenuItem onClick={handleRestore}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<RotateCcw className="h-3 w-3" /> <ArchiveRestoreIcon className="h-3 w-3" />
Restore Restore
</div> </div>
</CustomMenu.MenuItem> </CustomMenu.MenuItem>

View File

@ -43,9 +43,8 @@ export const ArchivedIssueLayoutRoot: React.FC = observer(() => {
if (!workspaceSlug || !projectId) return <></>; if (!workspaceSlug || !projectId) return <></>;
return ( return (
<div className="relative flex h-full w-full flex-col overflow-hidden"> <>
<ArchivedIssueAppliedFiltersRoot /> <ArchivedIssueAppliedFiltersRoot />
{issues?.groupedIssueIds?.length === 0 ? ( {issues?.groupedIssueIds?.length === 0 ? (
<div className="relative h-full w-full overflow-y-auto"> <div className="relative h-full w-full overflow-y-auto">
<ProjectArchivedEmptyState /> <ProjectArchivedEmptyState />
@ -58,6 +57,6 @@ export const ArchivedIssueLayoutRoot: React.FC = observer(() => {
<IssuePeekOverview is_archived /> <IssuePeekOverview is_archived />
</Fragment> </Fragment>
)} )}
</div> </>
); );
}); });

View File

@ -23,7 +23,7 @@ export const SpreadsheetSubIssueColumn: React.FC<Props> = observer((props: Props
const redirectToIssueDetail = () => { const redirectToIssueDetail = () => {
router.push({ router.push({
pathname: `/${workspaceSlug}/projects/${issue.project_id}/${issue.archived_at ? "archived-issues" : "issues"}/${ pathname: `/${workspaceSlug}/projects/${issue.project_id}/${issue.archived_at ? "archives/" : ""}issues/${
issue.id issue.id
}`, }`,
hash: "sub-issues", hash: "sub-issues",

View File

@ -1,7 +1,7 @@
import { FC } from "react"; import { FC } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import Link from "next/link"; import Link from "next/link";
import { MoveRight, MoveDiagonal, Link2, Trash2, RotateCcw } from "lucide-react"; import { MoveRight, MoveDiagonal, Link2, Trash2, ArchiveRestoreIcon } from "lucide-react";
// ui // ui
import { import {
ArchiveIcon, ArchiveIcon,
@ -86,7 +86,7 @@ export const IssuePeekOverviewHeader: FC<PeekOverviewHeaderProps> = observer((pr
const stateDetails = issueDetails ? getStateById(issueDetails?.state_id) : undefined; const stateDetails = issueDetails ? getStateById(issueDetails?.state_id) : undefined;
const currentMode = PEEK_OPTIONS.find((m) => m.key === peekMode); const currentMode = PEEK_OPTIONS.find((m) => m.key === peekMode);
const issueLink = `${workspaceSlug}/projects/${projectId}/${isArchived ? "archived-issues" : "issues"}/${issueId}`; const issueLink = `${workspaceSlug}/projects/${projectId}/${isArchived ? "archives/" : ""}issues/${issueId}`;
const handleCopyText = (e: React.MouseEvent<HTMLButtonElement>) => { const handleCopyText = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation(); e.stopPropagation();
@ -182,7 +182,7 @@ export const IssuePeekOverviewHeader: FC<PeekOverviewHeaderProps> = observer((pr
{isRestoringAllowed && ( {isRestoringAllowed && (
<Tooltip tooltipContent="Restore" isMobile={isMobile}> <Tooltip tooltipContent="Restore" isMobile={isMobile}>
<button type="button" onClick={handleRestoreIssue}> <button type="button" onClick={handleRestoreIssue}>
<RotateCcw className="h-4 w-4 text-custom-text-300 hover:text-custom-text-200" /> <ArchiveRestoreIcon className="h-4 w-4 text-custom-text-300 hover:text-custom-text-200" />
</button> </button>
</Tooltip> </Tooltip>
)} )}

View File

@ -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<HTMLInputElement>(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<HTMLInputElement>) => {
if (e.key === "Escape") {
if (archivedModulesSearchQuery && archivedModulesSearchQuery.trim() !== "") updateArchivedModulesSearchQuery("");
else {
setIsSearchOpen(false);
inputRef.current?.blur();
}
}
};
return (
<div className="group relative flex border-b border-custom-border-200">
<div className="flex w-full items-center overflow-x-auto px-4 gap-2 horizontal-scrollbar scrollbar-sm">
<ArchiveTabsList />
</div>
{/* filter options */}
<div className="h-full flex items-center gap-3 self-end px-8">
{!isSearchOpen && (
<button
type="button"
className="-mr-5 p-2 hover:bg-custom-background-80 rounded text-custom-text-400 grid place-items-center"
onClick={() => {
setIsSearchOpen(true);
inputRef.current?.focus();
}}
>
<Search className="h-3.5 w-3.5" />
</button>
)}
<div
className={cn(
"ml-auto flex items-center justify-start gap-1 rounded-md border border-transparent bg-custom-background-100 text-custom-text-400 w-0 transition-[width] ease-linear overflow-hidden opacity-0",
{
"w-64 px-2.5 py-1.5 border-custom-border-200 opacity-100": isSearchOpen,
}
)}
>
<Search className="h-3.5 w-3.5" />
<input
ref={inputRef}
className="w-full max-w-[234px] border-none bg-transparent text-sm text-custom-text-100 placeholder:text-custom-text-400 focus:outline-none"
placeholder="Search"
value={archivedModulesSearchQuery}
onChange={(e) => updateArchivedModulesSearchQuery(e.target.value)}
onKeyDown={handleInputKeyDown}
/>
{isSearchOpen && (
<button
type="button"
className="grid place-items-center"
onClick={() => {
updateArchivedModulesSearchQuery("");
setIsSearchOpen(false);
}}
>
<X className="h-3 w-3" />
</button>
)}
</div>
<ModuleOrderByDropdown
value={currentProjectDisplayFilters?.order_by}
onChange={(val) => {
if (!projectId || val === currentProjectDisplayFilters?.order_by) return;
updateDisplayFilters(projectId.toString(), {
order_by: val,
});
}}
/>
<FiltersDropdown icon={<ListFilter className="h-3 w-3" />} title="Filters" placement="bottom-end">
<ModuleFiltersSelection
displayFilters={currentProjectDisplayFilters ?? {}}
filters={currentProjectArchivedFilters ?? {}}
handleDisplayFiltersUpdate={(val) => {
if (!projectId) return;
updateDisplayFilters(projectId.toString(), val);
}}
handleFiltersUpdate={handleFilters}
memberIds={workspaceMemberIds ?? undefined}
isArchived
/>
</FiltersDropdown>
</div>
</div>
);
});

View File

@ -0,0 +1,4 @@
export * from "./root";
export * from "./view";
export * from "./header";
export * from "./modal";

View File

@ -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<void>;
};
export const ArchiveModuleModal: React.FC<Props> = (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 (
<Transition.Root show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-20" onClose={onClose}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-custom-backdrop transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-custom-background-100 text-left shadow-custom-shadow-md transition-all sm:my-8 sm:w-full sm:max-w-lg">
<div className="px-5 py-4">
<h3 className="text-xl font-medium 2xl:text-2xl">Archive module {moduleName}</h3>
<p className="mt-3 text-sm text-custom-text-200">
Are you sure you want to archive the module? All your archives can be restored later.
</p>
<div className="mt-3 flex justify-end gap-2">
<Button variant="neutral-primary" size="sm" onClick={onClose}>
Cancel
</Button>
<Button size="sm" tabIndex={1} onClick={handleArchiveIssue} loading={isArchiving}>
{isArchiving ? "Archiving" : "Archive"}
</Button>
</div>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
};

View File

@ -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 <CycleModuleListLayout />;
}
return (
<>
{calculateTotalFilters(currentProjectArchivedFilters ?? {}) !== 0 && (
<div className="border-b border-custom-border-200 px-5 py-3">
<ModuleAppliedFiltersList
appliedFilters={currentProjectArchivedFilters ?? {}}
handleClearAllFilters={() => clearAllFilters(projectId.toString(), "archived")}
handleRemoveFilter={handleRemoveFilter}
alwaysAllowEditing
/>
</div>
)}
{totalArchivedModules === 0 ? (
<div className="h-full place-items-center">
<EmptyState type={EmptyStateType.PROJECT_ARCHIVED_NO_MODULES} />
</div>
) : (
<div className="relative h-full w-full overflow-auto">
<ArchivedModulesView workspaceSlug={workspaceSlug.toString()} projectId={projectId.toString()} />
</div>
)}
</>
);
});

View File

@ -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<IArchivedModulesView> = 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 <CycleModuleListLayout />;
if (filteredArchivedModuleIds.length === 0)
return (
<div className="h-full w-full grid place-items-center">
<div className="text-center">
<Image
src={archivedModulesSearchQuery.trim() === "" ? AllFiltersImage : NameFilterImage}
className="h-36 sm:h-48 w-36 sm:w-48 mx-auto"
alt="No matching modules"
/>
<h5 className="text-xl font-medium mt-7 mb-1">No matching modules</h5>
<p className="text-custom-text-400 text-base">
{archivedModulesSearchQuery.trim() === ""
? "Remove the filters to see all modules"
: "Remove the search criteria to see all modules"}
</p>
</div>
</div>
);
return (
<div className="h-full overflow-y-auto">
<div className="flex h-full w-full justify-between">
<div className="flex h-full w-full flex-col overflow-y-auto vertical-scrollbar scrollbar-lg">
{filteredArchivedModuleIds.map((moduleId) => (
<ModuleListItem key={moduleId} moduleId={moduleId} isArchived />
))}
</div>
<ModulePeekOverview
projectId={projectId?.toString() ?? ""}
workspaceSlug={workspaceSlug?.toString() ?? ""}
isArchived
/>
</div>
</div>
);
});

View File

@ -14,10 +14,18 @@ type Props = {
handleDisplayFiltersUpdate: (updatedDisplayProperties: Partial<TModuleDisplayFilters>) => void; handleDisplayFiltersUpdate: (updatedDisplayProperties: Partial<TModuleDisplayFilters>) => void;
handleFiltersUpdate: (key: keyof TModuleFilters, value: string | string[]) => void; handleFiltersUpdate: (key: keyof TModuleFilters, value: string | string[]) => void;
memberIds?: string[] | undefined; memberIds?: string[] | undefined;
isArchived?: boolean;
}; };
export const ModuleFiltersSelection: React.FC<Props> = observer((props) => { export const ModuleFiltersSelection: React.FC<Props> = observer((props) => {
const { displayFilters, filters, handleDisplayFiltersUpdate, handleFiltersUpdate, memberIds } = props; const {
displayFilters,
filters,
handleDisplayFiltersUpdate,
handleFiltersUpdate,
memberIds,
isArchived = false,
} = props;
// states // states
const [filtersSearchQuery, setFiltersSearchQuery] = useState(""); const [filtersSearchQuery, setFiltersSearchQuery] = useState("");
@ -42,6 +50,7 @@ export const ModuleFiltersSelection: React.FC<Props> = observer((props) => {
</div> </div>
</div> </div>
<div className="h-full w-full divide-y divide-custom-border-200 overflow-y-auto px-2.5 vertical-scrollbar scrollbar-sm"> <div className="h-full w-full divide-y divide-custom-border-200 overflow-y-auto px-2.5 vertical-scrollbar scrollbar-sm">
{!isArchived && (
<div className="py-2"> <div className="py-2">
<FilterOption <FilterOption
isChecked={!!displayFilters.favorites} isChecked={!!displayFilters.favorites}
@ -53,8 +62,10 @@ export const ModuleFiltersSelection: React.FC<Props> = observer((props) => {
title="Favorites" title="Favorites"
/> />
</div> </div>
)}
{/* status */} {/* status */}
{!isArchived && (
<div className="py-2"> <div className="py-2">
<FilterStatus <FilterStatus
appliedFilters={(filters.status as TModuleStatus[]) ?? null} appliedFilters={(filters.status as TModuleStatus[]) ?? null}
@ -62,6 +73,7 @@ export const ModuleFiltersSelection: React.FC<Props> = observer((props) => {
searchQuery={filtersSearchQuery} searchQuery={filtersSearchQuery}
/> />
</div> </div>
)}
{/* lead */} {/* lead */}
<div className="py-2"> <div className="py-2">

View File

@ -11,3 +11,7 @@ export * from "./sidebar";
export * from "./module-card-item"; export * from "./module-card-item";
export * from "./module-list-item"; export * from "./module-list-item";
export * from "./module-peek-overview"; export * from "./module-peek-overview";
export * from "./quick-actions";
// archived modules
export * from "./archived-modules";

View File

@ -1,19 +1,19 @@
import React, { useState } from "react"; import React from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { Info, LinkIcon, Pencil, Star, Trash2 } from "lucide-react"; // icons
import { Info, Star } from "lucide-react";
// ui // ui
import { Avatar, AvatarGroup, CustomMenu, LayersIcon, Tooltip, TOAST_TYPE, setToast, setPromiseToast } from "@plane/ui"; import { Avatar, AvatarGroup, LayersIcon, Tooltip, setPromiseToast } from "@plane/ui";
// components // components
import { CreateUpdateModuleModal, DeleteModuleModal } from "@/components/modules"; import { ModuleQuickActions } from "@/components/modules";
// constants // constants
import { MODULE_FAVORITED, MODULE_UNFAVORITED } from "@/constants/event-tracker"; import { MODULE_FAVORITED, MODULE_UNFAVORITED } from "@/constants/event-tracker";
import { MODULE_STATUS } from "@/constants/module"; import { MODULE_STATUS } from "@/constants/module";
import { EUserProjectRoles } from "@/constants/project"; import { EUserProjectRoles } from "@/constants/project";
// helpers // helpers
import { getDate, renderFormattedDate } from "@/helpers/date-time.helper"; import { getDate, renderFormattedDate } from "@/helpers/date-time.helper";
import { copyUrlToClipboard } from "@/helpers/string.helper";
// hooks // hooks
import { useEventTracker, useMember, useModule, useUser } from "@/hooks/store"; import { useEventTracker, useMember, useModule, useUser } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os"; import { usePlatformOS } from "@/hooks/use-platform-os";
@ -24,9 +24,6 @@ type Props = {
export const ModuleCardItem: React.FC<Props> = observer((props) => { export const ModuleCardItem: React.FC<Props> = observer((props) => {
const { moduleId } = props; const { moduleId } = props;
// states
const [editModal, setEditModal] = useState(false);
const [deleteModal, setDeleteModal] = useState(false);
// router // router
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId } = router.query; const { workspaceSlug, projectId } = router.query;
@ -36,7 +33,7 @@ export const ModuleCardItem: React.FC<Props> = observer((props) => {
} = useUser(); } = useUser();
const { getModuleById, addModuleToFavorites, removeModuleFromFavorites } = useModule(); const { getModuleById, addModuleToFavorites, removeModuleFromFavorites } = useModule();
const { getUserDetails } = useMember(); const { getUserDetails } = useMember();
const { setTrackElement, captureEvent } = useEventTracker(); const { captureEvent } = useEventTracker();
// derived values // derived values
const moduleDetails = getModuleById(moduleId); const moduleDetails = getModuleById(moduleId);
const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER;
@ -99,32 +96,6 @@ export const ModuleCardItem: React.FC<Props> = observer((props) => {
}); });
}; };
const handleCopyText = (e: React.MouseEvent<HTMLButtonElement>) => {
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<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setTrackElement("Modules page grid layout");
setEditModal(true);
};
const handleDeleteModule = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setTrackElement("Modules page grid layout");
setDeleteModal(true);
};
const openModuleOverview = (e: React.MouseEvent<HTMLButtonElement>) => { const openModuleOverview = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
@ -165,17 +136,6 @@ export const ModuleCardItem: React.FC<Props> = observer((props) => {
: "0 Issue"; : "0 Issue";
return ( return (
<>
{workspaceSlug && projectId && (
<CreateUpdateModuleModal
isOpen={editModal}
onClose={() => setEditModal(false)}
data={moduleDetails}
projectId={projectId.toString()}
workspaceSlug={workspaceSlug.toString()}
/>
)}
<DeleteModuleModal data={moduleDetails} isOpen={deleteModal} onClose={() => setDeleteModal(false)} />
<Link href={`/${workspaceSlug}/projects/${moduleDetails.project_id}/modules/${moduleDetails.id}`}> <Link href={`/${workspaceSlug}/projects/${moduleDetails.project_id}/modules/${moduleDetails.id}`}>
<div className="flex h-44 w-full flex-col justify-between rounded border border-custom-border-100 bg-custom-background-100 p-4 text-sm hover:shadow-md"> <div className="flex h-44 w-full flex-col justify-between rounded border border-custom-border-100 bg-custom-background-100 p-4 text-sm hover:shadow-md">
<div> <div>
@ -266,36 +226,17 @@ export const ModuleCardItem: React.FC<Props> = observer((props) => {
<Star className="h-3.5 w-3.5 text-custom-text-200" /> <Star className="h-3.5 w-3.5 text-custom-text-200" />
</button> </button>
))} ))}
{workspaceSlug && projectId && (
<CustomMenu ellipsis className="z-10" placement="left-start"> <ModuleQuickActions
{isEditingAllowed && ( moduleId={moduleId}
<> projectId={projectId.toString()}
<CustomMenu.MenuItem onClick={handleEditModule}> workspaceSlug={workspaceSlug.toString()}
<span className="flex items-center justify-start gap-2"> />
<Pencil className="h-3 w-3" />
<span>Edit module</span>
</span>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={handleDeleteModule}>
<span className="flex items-center justify-start gap-2">
<Trash2 className="h-3 w-3" />
<span>Delete module</span>
</span>
</CustomMenu.MenuItem>
</>
)} )}
<CustomMenu.MenuItem onClick={handleCopyText}>
<span className="flex items-center justify-start gap-2">
<LinkIcon className="h-3 w-3" />
<span>Copy module link</span>
</span>
</CustomMenu.MenuItem>
</CustomMenu>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</Link> </Link>
</>
); );
}); });

View File

@ -1,44 +1,30 @@
import React, { useState } from "react"; import React from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; 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 // ui
import { import { Avatar, AvatarGroup, CircularProgressIndicator, Tooltip, setPromiseToast } from "@plane/ui";
Avatar, // components
AvatarGroup, import { ModuleQuickActions } from "@/components/modules";
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
// constants // constants
import { MODULE_FAVORITED, MODULE_UNFAVORITED } from "@/constants/event-tracker";
import { MODULE_STATUS } from "@/constants/module"; import { MODULE_STATUS } from "@/constants/module";
import { EUserProjectRoles } from "@/constants/project"; import { EUserProjectRoles } from "@/constants/project";
// helpers
import { getDate, renderFormattedDate } from "@/helpers/date-time.helper"; import { getDate, renderFormattedDate } from "@/helpers/date-time.helper";
import { copyUrlToClipboard } from "@/helpers/string.helper";
// hooks // hooks
import { useModule, useUser, useEventTracker, useMember } from "@/hooks/store"; import { useModule, useUser, useEventTracker, useMember } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os"; import { usePlatformOS } from "@/hooks/use-platform-os";
// components
// ui
// helpers
// constants
type Props = { type Props = {
moduleId: string; moduleId: string;
isArchived?: boolean;
}; };
export const ModuleListItem: React.FC<Props> = observer((props) => { export const ModuleListItem: React.FC<Props> = observer((props) => {
const { moduleId } = props; const { moduleId, isArchived = false } = props;
// states
const [editModal, setEditModal] = useState(false);
const [deleteModal, setDeleteModal] = useState(false);
// router // router
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId } = router.query; const { workspaceSlug, projectId } = router.query;
@ -48,7 +34,7 @@ export const ModuleListItem: React.FC<Props> = observer((props) => {
} = useUser(); } = useUser();
const { getModuleById, addModuleToFavorites, removeModuleFromFavorites } = useModule(); const { getModuleById, addModuleToFavorites, removeModuleFromFavorites } = useModule();
const { getUserDetails } = useMember(); const { getUserDetails } = useMember();
const { setTrackElement, captureEvent } = useEventTracker(); const { captureEvent } = useEventTracker();
// derived values // derived values
const moduleDetails = getModuleById(moduleId); const moduleDetails = getModuleById(moduleId);
const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER;
@ -111,33 +97,7 @@ export const ModuleListItem: React.FC<Props> = observer((props) => {
}); });
}; };
const handleCopyText = (e: React.MouseEvent<HTMLButtonElement>) => { const openModuleOverview = (e: React.MouseEvent<HTMLButtonElement | HTMLAnchorElement>) => {
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<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setTrackElement("Modules page list layout");
setEditModal(true);
};
const handleDeleteModule = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setTrackElement("Modules page list layout");
setDeleteModal(true);
};
const openModuleOverview = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
const { query } = router; const { query } = router;
@ -167,18 +127,14 @@ export const ModuleListItem: React.FC<Props> = observer((props) => {
const completedModuleCheck = moduleDetails.status === "completed"; const completedModuleCheck = moduleDetails.status === "completed";
return ( return (
<> <Link
{workspaceSlug && projectId && ( href={`/${workspaceSlug}/projects/${moduleDetails.project_id}/modules/${moduleDetails.id}`}
<CreateUpdateModuleModal onClick={(e) => {
isOpen={editModal} if (isArchived) {
onClose={() => setEditModal(false)} openModuleOverview(e);
data={moduleDetails} }
projectId={projectId.toString()} }}
workspaceSlug={workspaceSlug.toString()} >
/>
)}
<DeleteModuleModal data={moduleDetails} isOpen={deleteModal} onClose={() => setDeleteModal(false)} />
<Link href={`/${workspaceSlug}/projects/${moduleDetails.project_id}/modules/${moduleDetails.id}`}>
<div className="group flex w-full flex-col items-center justify-between gap-5 border-b border-custom-border-100 bg-custom-background-100 px-5 py-6 text-sm hover:bg-custom-background-90 sm:flex-row"> <div className="group flex w-full flex-col items-center justify-between gap-5 border-b border-custom-border-100 bg-custom-background-100 px-5 py-6 text-sm hover:bg-custom-background-90 sm:flex-row">
<div className="relative flex w-full items-center justify-between gap-3 overflow-hidden"> <div className="relative flex w-full items-center justify-between gap-3 overflow-hidden">
<div className="relative flex w-full items-center gap-3 overflow-hidden"> <div className="relative flex w-full items-center gap-3 overflow-hidden">
@ -249,6 +205,7 @@ export const ModuleListItem: React.FC<Props> = observer((props) => {
</Tooltip> </Tooltip>
{isEditingAllowed && {isEditingAllowed &&
!isArchived &&
(moduleDetails.is_favorite ? ( (moduleDetails.is_favorite ? (
<button type="button" onClick={handleRemoveFromFavorites} className="z-[1]"> <button type="button" onClick={handleRemoveFromFavorites} className="z-[1]">
<Star className="h-3.5 w-3.5 fill-current text-amber-500" /> <Star className="h-3.5 w-3.5 fill-current text-amber-500" />
@ -258,35 +215,17 @@ export const ModuleListItem: React.FC<Props> = observer((props) => {
<Star className="h-3.5 w-3.5 text-custom-text-300" /> <Star className="h-3.5 w-3.5 text-custom-text-300" />
</button> </button>
))} ))}
{workspaceSlug && projectId && (
<CustomMenu verticalEllipsis buttonClassName="z-[1]" placement="left-start"> <ModuleQuickActions
{isEditingAllowed && ( moduleId={moduleId}
<> projectId={projectId.toString()}
<CustomMenu.MenuItem onClick={handleEditModule}> workspaceSlug={workspaceSlug.toString()}
<span className="flex items-center justify-start gap-2"> isArchived={isArchived}
<Pencil className="h-3 w-3" /> />
<span>Edit module</span>
</span>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={handleDeleteModule}>
<span className="flex items-center justify-start gap-2">
<Trash2 className="h-3 w-3" />
<span>Delete module</span>
</span>
</CustomMenu.MenuItem>
</>
)} )}
<CustomMenu.MenuItem onClick={handleCopyText}>
<span className="flex items-center justify-start gap-2">
<LinkIcon className="h-3 w-3" />
<span>Copy module link</span>
</span>
</CustomMenu.MenuItem>
</CustomMenu>
</div> </div>
</div> </div>
</div> </div>
</Link> </Link>
</>
); );
}); });

View File

@ -9,9 +9,10 @@ import { ModuleDetailsSidebar } from "./sidebar";
type Props = { type Props = {
projectId: string; projectId: string;
workspaceSlug: string; workspaceSlug: string;
isArchived?: boolean;
}; };
export const ModulePeekOverview: React.FC<Props> = observer(({ projectId, workspaceSlug }) => { export const ModulePeekOverview: React.FC<Props> = observer(({ projectId, workspaceSlug, isArchived = false }) => {
// router // router
const router = useRouter(); const router = useRouter();
const { peekModule } = router.query; const { peekModule } = router.query;
@ -29,10 +30,10 @@ export const ModulePeekOverview: React.FC<Props> = observer(({ projectId, worksp
}; };
useEffect(() => { useEffect(() => {
if (!peekModule) return; if (!peekModule || isArchived) return;
fetchModuleDetails(workspaceSlug, projectId, peekModule.toString()); fetchModuleDetails(workspaceSlug, projectId, peekModule.toString());
}, [fetchModuleDetails, peekModule, projectId, workspaceSlug]); }, [fetchModuleDetails, isArchived, peekModule, projectId, workspaceSlug]);
return ( return (
<> <>
@ -45,7 +46,11 @@ export const ModulePeekOverview: React.FC<Props> = 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)", "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)",
}} }}
> >
<ModuleDetailsSidebar moduleId={peekModule?.toString() ?? ""} handleClose={handleClose} /> <ModuleDetailsSidebar
moduleId={peekModule?.toString() ?? ""}
handleClose={handleClose}
isArchived={isArchived}
/>
</div> </div>
)} )}
</> </>

View File

@ -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<Props> = 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<HTMLButtonElement>) => {
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<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setTrackElement("Modules page list layout");
setEditModal(true);
};
const handleArchiveModule = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setArchiveModuleModal(true);
};
const handleRestoreModule = async (e: React.MouseEvent<HTMLButtonElement>) => {
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<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setTrackElement("Modules page list layout");
setDeleteModal(true);
};
return (
<>
{moduleDetails && (
<div className="fixed">
<CreateUpdateModuleModal
isOpen={editModal}
onClose={() => setEditModal(false)}
data={moduleDetails}
projectId={projectId}
workspaceSlug={workspaceSlug}
/>
<ArchiveModuleModal
workspaceSlug={workspaceSlug}
projectId={projectId}
moduleId={moduleId}
isOpen={archiveModuleModal}
handleClose={() => setArchiveModuleModal(false)}
/>
<DeleteModuleModal data={moduleDetails} isOpen={deleteModal} onClose={() => setDeleteModal(false)} />
</div>
)}
<CustomMenu ellipsis placement="left-start">
{isEditingAllowed && !isArchived && (
<CustomMenu.MenuItem onClick={handleEditModule}>
<span className="flex items-center justify-start gap-2">
<Pencil className="h-3 w-3" />
<span>Edit module</span>
</span>
</CustomMenu.MenuItem>
)}
{isEditingAllowed && !isArchived && (
<CustomMenu.MenuItem onClick={handleArchiveModule} disabled={!isInArchivableGroup}>
{isInArchivableGroup ? (
<div className="flex items-center gap-2">
<ArchiveIcon className="h-3 w-3" />
Archive module
</div>
) : (
<div className="flex items-start gap-2">
<ArchiveIcon className="h-3 w-3" />
<div className="-mt-1">
<p>Archive module</p>
<p className="text-xs text-custom-text-400">
Only completed or cancelled <br /> module can be archived.
</p>
</div>
</div>
)}
</CustomMenu.MenuItem>
)}
{isEditingAllowed && isArchived && (
<CustomMenu.MenuItem onClick={handleRestoreModule}>
<span className="flex items-center justify-start gap-2">
<ArchiveRestoreIcon className="h-3 w-3" />
<span>Restore module</span>
</span>
</CustomMenu.MenuItem>
)}
{!isArchived && (
<CustomMenu.MenuItem onClick={handleCopyText}>
<span className="flex items-center justify-start gap-2">
<LinkIcon className="h-3 w-3" />
<span>Copy module link</span>
</span>
</CustomMenu.MenuItem>
)}
{isEditingAllowed && (
<div className="border-t pt-1 mt-1">
<CustomMenu.MenuItem onClick={handleDeleteModule}>
<span className="flex items-center justify-start gap-2">
<Trash2 className="h-3 w-3" />
<span>Delete module</span>
</span>
</CustomMenu.MenuItem>
</div>
)}
</CustomMenu>
</>
);
});

View File

@ -4,6 +4,7 @@ import { useRouter } from "next/router";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { import {
AlertCircle, AlertCircle,
ArchiveRestoreIcon,
CalendarClock, CalendarClock,
ChevronDown, ChevronDown,
ChevronRight, ChevronRight,
@ -25,13 +26,14 @@ import {
UserGroupIcon, UserGroupIcon,
TOAST_TYPE, TOAST_TYPE,
setToast, setToast,
ArchiveIcon,
TextArea, TextArea,
} from "@plane/ui"; } from "@plane/ui";
// components // components
import { LinkModal, LinksList, SidebarProgressStats } from "@/components/core"; import { LinkModal, LinksList, SidebarProgressStats } from "@/components/core";
import ProgressChart from "@/components/core/sidebar/progress-chart"; import ProgressChart from "@/components/core/sidebar/progress-chart";
import { DateRangeDropdown, MemberDropdown } from "@/components/dropdowns"; import { DateRangeDropdown, MemberDropdown } from "@/components/dropdowns";
import { DeleteModuleModal } from "@/components/modules"; import { ArchiveModuleModal, DeleteModuleModal } from "@/components/modules";
// constant // constant
import { import {
MODULE_LINK_CREATED, MODULE_LINK_CREATED,
@ -59,13 +61,15 @@ const defaultValues: Partial<IModule> = {
type Props = { type Props = {
moduleId: string; moduleId: string;
handleClose: () => void; handleClose: () => void;
isArchived?: boolean;
}; };
// TODO: refactor this component // TODO: refactor this component
export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => { export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
const { moduleId, handleClose } = props; const { moduleId, handleClose, isArchived } = props;
// states // states
const [moduleDeleteModal, setModuleDeleteModal] = useState(false); const [moduleDeleteModal, setModuleDeleteModal] = useState(false);
const [archiveModuleModal, setArchiveModuleModal] = useState(false);
const [moduleLinkModal, setModuleLinkModal] = useState(false); const [moduleLinkModal, setModuleLinkModal] = useState(false);
const [selectedLinkToUpdate, setSelectedLinkToUpdate] = useState<ILinkDetails | null>(null); const [selectedLinkToUpdate, setSelectedLinkToUpdate] = useState<ILinkDetails | null>(null);
// router // router
@ -75,10 +79,14 @@ export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
const { const {
membership: { currentProjectRole }, membership: { currentProjectRole },
} = useUser(); } = useUser();
const { getModuleById, updateModuleDetails, createModuleLink, updateModuleLink, deleteModuleLink } = useModule(); const { getModuleById, updateModuleDetails, createModuleLink, updateModuleLink, deleteModuleLink, restoreModule } =
useModule();
const { setTrackElement, captureModuleEvent, captureEvent } = useEventTracker(); const { setTrackElement, captureModuleEvent, captureEvent } = useEventTracker();
const moduleDetails = getModuleById(moduleId); const moduleDetails = getModuleById(moduleId);
const moduleState = moduleDetails?.status.toLocaleLowerCase();
const isInArchivableGroup = !!moduleState && ["completed", "cancelled"].includes(moduleState);
const { reset, control } = useForm({ const { reset, control } = useForm({
defaultValues, defaultValues,
}); });
@ -206,6 +214,30 @@ export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
}); });
}; };
const handleRestoreModule = async (e: React.MouseEvent<HTMLButtonElement>) => {
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(() => { useEffect(() => {
if (moduleDetails) if (moduleDetails)
reset({ reset({
@ -262,8 +294,16 @@ export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
createIssueLink={handleCreateLink} createIssueLink={handleCreateLink}
updateIssueLink={handleUpdateLink} updateIssueLink={handleUpdateLink}
/> />
{workspaceSlug && projectId && (
<ArchiveModuleModal
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
moduleId={moduleId}
isOpen={archiveModuleModal}
handleClose={() => setArchiveModuleModal(false)}
/>
)}
<DeleteModuleModal isOpen={moduleDeleteModal} onClose={() => setModuleDeleteModal(false)} data={moduleDetails} /> <DeleteModuleModal isOpen={moduleDeleteModal} onClose={() => setModuleDeleteModal(false)} data={moduleDetails} />
<> <>
<div className="sticky z-10 top-0 flex items-center justify-between bg-custom-sidebar-background-100 py-5"> <div className="sticky z-10 top-0 flex items-center justify-between bg-custom-sidebar-background-100 py-5">
<div> <div>
@ -275,11 +315,41 @@ export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
</button> </button>
</div> </div>
<div className="flex items-center gap-3.5"> <div className="flex items-center gap-3.5">
{!isArchived && (
<button onClick={handleCopyText}> <button onClick={handleCopyText}>
<LinkIcon className="h-3 w-3 text-custom-text-300" /> <LinkIcon className="h-3 w-3 text-custom-text-300" />
</button> </button>
)}
{isEditingAllowed && ( {isEditingAllowed && (
<CustomMenu placement="bottom-end" ellipsis> <CustomMenu placement="bottom-end" ellipsis>
{!isArchived && (
<CustomMenu.MenuItem onClick={() => setArchiveModuleModal(true)} disabled={!isInArchivableGroup}>
{isInArchivableGroup ? (
<div className="flex items-center gap-2">
<ArchiveIcon className="h-3 w-3" />
Archive module
</div>
) : (
<div className="flex items-start gap-2">
<ArchiveIcon className="h-3 w-3" />
<div className="-mt-1">
<p>Archive module</p>
<p className="text-xs text-custom-text-400">
Only completed or cancelled <br /> module can be archived.
</p>
</div>
</div>
)}
</CustomMenu.MenuItem>
)}
{isArchived && (
<CustomMenu.MenuItem onClick={handleRestoreModule}>
<span className="flex items-center justify-start gap-2">
<ArchiveRestoreIcon className="h-3 w-3" />
<span>Restore module</span>
</span>
</CustomMenu.MenuItem>
)}
<CustomMenu.MenuItem <CustomMenu.MenuItem
onClick={() => { onClick={() => {
setTrackElement("Module peek-overview"); setTrackElement("Module peek-overview");
@ -306,7 +376,7 @@ export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
customButton={ customButton={
<span <span
className={`flex h-6 w-20 items-center justify-center rounded-sm text-center text-xs ${ className={`flex h-6 w-20 items-center justify-center rounded-sm text-center text-xs ${
isEditingAllowed ? "cursor-pointer" : "cursor-not-allowed" isEditingAllowed && !isArchived ? "cursor-pointer" : "cursor-not-allowed"
}`} }`}
style={{ style={{
color: moduleStatus ? moduleStatus.color : "#a3a3a2", color: moduleStatus ? moduleStatus.color : "#a3a3a2",
@ -320,7 +390,7 @@ export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
onChange={(value: any) => { onChange={(value: any) => {
submitChanges({ status: value }); submitChanges({ status: value });
}} }}
disabled={!isEditingAllowed} disabled={!isEditingAllowed || isArchived}
> >
{MODULE_STATUS.map((status) => ( {MODULE_STATUS.map((status) => (
<CustomSelect.Option key={status.value} value={status.value}> <CustomSelect.Option key={status.value} value={status.value}>
@ -379,6 +449,7 @@ export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
from: "Start date", from: "Start date",
to: "Target date", to: "Target date",
}} }}
disabled={isArchived}
/> />
); );
}} }}
@ -408,6 +479,7 @@ export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
multiple={false} multiple={false}
buttonVariant="background-with-text" buttonVariant="background-with-text"
placeholder="Lead" placeholder="Lead"
disabled={isArchived}
/> />
</div> </div>
)} )}
@ -432,7 +504,7 @@ export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
projectId={projectId?.toString() ?? ""} projectId={projectId?.toString() ?? ""}
buttonVariant={value && value?.length > 0 ? "transparent-without-text" : "background-with-text"} buttonVariant={value && value?.length > 0 ? "transparent-without-text" : "background-with-text"}
buttonClassName={value && value.length > 0 ? "hover:bg-transparent px-0" : ""} buttonClassName={value && value.length > 0 ? "hover:bg-transparent px-0" : ""}
disabled={!isEditingAllowed} disabled={!isEditingAllowed || isArchived}
/> />
</div> </div>
)} )}
@ -556,7 +628,7 @@ export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
<div className="mt-2 flex h-72 w-full flex-col space-y-3 overflow-y-auto"> <div className="mt-2 flex h-72 w-full flex-col space-y-3 overflow-y-auto">
{currentProjectRole && moduleDetails.link_module && moduleDetails.link_module.length > 0 ? ( {currentProjectRole && moduleDetails.link_module && moduleDetails.link_module.length > 0 ? (
<> <>
{isEditingAllowed && ( {isEditingAllowed && !isArchived && (
<div className="flex w-full items-center justify-end"> <div className="flex w-full items-center justify-end">
<button <button
className="flex items-center gap-1.5 text-sm font-medium text-custom-primary-100" className="flex items-center gap-1.5 text-sm font-medium text-custom-primary-100"
@ -578,6 +650,7 @@ export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
isMember: currentProjectRole === EUserProjectRoles.MEMBER, isMember: currentProjectRole === EUserProjectRoles.MEMBER,
isOwner: currentProjectRole === EUserProjectRoles.ADMIN, isOwner: currentProjectRole === EUserProjectRoles.ADMIN,
}} }}
disabled={isArchived}
/> />
</> </>
) : ( ) : (
@ -586,6 +659,7 @@ export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
<Info className="h-3.5 w-3.5 stroke-[1.5] text-custom-text-300" /> <Info className="h-3.5 w-3.5 stroke-[1.5] text-custom-text-300" />
<span className="p-0.5 text-xs text-custom-text-300">No links added yet</span> <span className="p-0.5 text-xs text-custom-text-300">No links added yet</span>
</div> </div>
{isEditingAllowed && !isArchived && (
<button <button
className="flex items-center gap-1.5 text-sm font-medium text-custom-primary-100" className="flex items-center gap-1.5 text-sm font-medium text-custom-primary-100"
onClick={() => setModuleLinkModal(true)} onClick={() => setModuleLinkModal(true)}
@ -593,6 +667,7 @@ export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
<Plus className="h-3 w-3" /> <Plus className="h-3 w-3" />
Add link Add link
</button> </button>
)}
</div> </div>
)} )}
</div> </div>

View File

@ -16,12 +16,12 @@ import {
NOTIFICATION_SNOOZED, NOTIFICATION_SNOOZED,
} from "@/constants/event-tracker"; } from "@/constants/event-tracker";
import { snoozeOptions } from "@/constants/notification"; 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 // hooks
import { useEventTracker } from "@/hooks/store"; import { useEventTracker } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os"; 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 = { type NotificationCardProps = {
@ -137,8 +137,8 @@ export const NotificationCard: React.FC<NotificationCardProps> = (props) => {
closePopover(); closePopover();
}} }}
href={`/${workspaceSlug}/projects/${notification.project}/${ href={`/${workspaceSlug}/projects/${notification.project}/${
notificationField === "archived_at" ? "archived-issues" : "issues" notificationField === "archived_at" ? "archives/" : ""
}/${notification.data.issue.id}`} }issues/${notification.data.issue.id}`}
className={`group relative flex w-full cursor-pointer items-center gap-4 p-3 pl-6 ${ 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" notification.read_at === null ? "bg-custom-primary-70/5" : "hover:bg-custom-background-200"
}`} }`}

View File

@ -282,13 +282,6 @@ export const ProjectSidebarListItem: React.FC<Props> = observer((props) => {
</span> </span>
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
)} )}
<CustomMenu.MenuItem onClick={handleCopyText}>
<span className="flex items-center justify-start gap-2">
<LinkIcon className="h-3.5 w-3.5 stroke-[1.5]" />
<span>Copy project link</span>
</span>
</CustomMenu.MenuItem>
{/* publish project settings */} {/* publish project settings */}
{isAdmin && ( {isAdmin && (
<CustomMenu.MenuItem onClick={() => setPublishModal(true)}> <CustomMenu.MenuItem onClick={() => setPublishModal(true)}>
@ -300,16 +293,6 @@ export const ProjectSidebarListItem: React.FC<Props> = observer((props) => {
</div> </div>
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
)} )}
{!isViewerOrGuest && (
<CustomMenu.MenuItem>
<Link href={`/${workspaceSlug}/projects/${project?.id}/archived-issues/`}>
<div className="flex items-center justify-start gap-2">
<ArchiveIcon className="h-3.5 w-3.5 stroke-[1.5]" />
<span>Archived issues</span>
</div>
</Link>
</CustomMenu.MenuItem>
)}
<CustomMenu.MenuItem> <CustomMenu.MenuItem>
<Link href={`/${workspaceSlug}/projects/${project?.id}/draft-issues/`}> <Link href={`/${workspaceSlug}/projects/${project?.id}/draft-issues/`}>
<div className="flex items-center justify-start gap-2"> <div className="flex items-center justify-start gap-2">
@ -318,6 +301,23 @@ export const ProjectSidebarListItem: React.FC<Props> = observer((props) => {
</div> </div>
</Link> </Link>
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={handleCopyText}>
<span className="flex items-center justify-start gap-2">
<LinkIcon className="h-3.5 w-3.5 stroke-[1.5]" />
<span>Copy link</span>
</span>
</CustomMenu.MenuItem>
{!isViewerOrGuest && (
<CustomMenu.MenuItem>
<Link href={`/${workspaceSlug}/projects/${project?.id}/archives/issues`}>
<div className="flex items-center justify-start gap-2">
<ArchiveIcon className="h-3.5 w-3.5 stroke-[1.5]" />
<span>Archives</span>
</div>
</Link>
</CustomMenu.MenuItem>
)}
<CustomMenu.MenuItem> <CustomMenu.MenuItem>
<Link href={`/${workspaceSlug}/projects/${project?.id}/settings`}> <Link href={`/${workspaceSlug}/projects/${project?.id}/settings`}>
<div className="flex items-center justify-start gap-2"> <div className="flex items-center justify-start gap-2">

50
web/constants/archives.ts Normal file
View File

@ -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<React.SVGAttributes<SVGElement> & { className?: string }>;
};
} = {
issues: {
label: "Issues",
href: "/issues",
icon: LayersIcon,
},
cycles: {
label: "Cycles",
href: "/cycles",
icon: ContrastIcon,
},
modules: {
label: "Modules",
href: "/modules",
icon: DiceIcon,
},
};

View File

@ -51,6 +51,7 @@ export enum EmptyStateType {
PROJECT_CYCLE_ACTIVE = "project-cycle-active", PROJECT_CYCLE_ACTIVE = "project-cycle-active",
PROJECT_CYCLE_ALL = "project-cycle-all", PROJECT_CYCLE_ALL = "project-cycle-all",
PROJECT_CYCLE_COMPLETED_NO_ISSUES = "project-cycle-completed-no-issues", 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_EMPTY_FILTER = "project-empty-filter",
PROJECT_ARCHIVED_EMPTY_FILTER = "project-archived-empty-filter", PROJECT_ARCHIVED_EMPTY_FILTER = "project-archived-empty-filter",
PROJECT_DRAFT_EMPTY_FILTER = "project-draft-empty-filter", PROJECT_DRAFT_EMPTY_FILTER = "project-draft-empty-filter",
@ -62,6 +63,7 @@ export enum EmptyStateType {
MEMBERS_EMPTY_SEARCH = "members-empty-search", MEMBERS_EMPTY_SEARCH = "members-empty-search",
PROJECT_MODULE_ISSUES = "project-module-issues", PROJECT_MODULE_ISSUES = "project-module-issues",
PROJECT_MODULE = "project-module", PROJECT_MODULE = "project-module",
PROJECT_ARCHIVED_NO_MODULES = "project-archived-no-modules",
PROJECT_VIEW = "project-view", PROJECT_VIEW = "project-view",
PROJECT_PAGE = "project-page", PROJECT_PAGE = "project-page",
PROJECT_PAGE_ALL = "project-page-all", 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.", "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", 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]: { [EmptyStateType.PROJECT_CYCLE_ALL]: {
key: EmptyStateType.PROJECT_CYCLE_ALL, key: EmptyStateType.PROJECT_CYCLE_ALL,
title: "No cycles", title: "No cycles",
@ -368,7 +376,7 @@ const emptyStateDetails = {
key: EmptyStateType.PROJECT_ARCHIVED_NO_ISSUES, key: EmptyStateType.PROJECT_ARCHIVED_NO_ISSUES,
title: "No archived issues yet", title: "No archived issues yet",
description: 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", path: "/empty-state/archived/empty-issues",
primaryButton: { primaryButton: {
text: "Set automation", text: "Set automation",
@ -432,6 +440,12 @@ const emptyStateDetails = {
accessType: "project", accessType: "project",
access: EUserProjectRoles.MEMBER, 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 // project views
[EmptyStateType.PROJECT_VIEW]: { [EmptyStateType.PROJECT_VIEW]: {
key: EmptyStateType.PROJECT_VIEW, key: EmptyStateType.PROJECT_VIEW,

View File

@ -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 (
<>
<PageHead title={pageTitle} />
<div className="relative flex h-full w-full flex-col overflow-hidden">
<ArchivedCyclesHeader />
<ArchivedCycleLayoutRoot />
</div>
</>
);
});
ProjectArchivedCyclesPage.getLayout = function getLayout(page: ReactElement) {
return (
<AppLayout header={<ProjectArchivesHeader />} withProjectWrapper>
{page}
</AppLayout>
);
};
export default ProjectArchivedCyclesPage;

View File

@ -2,23 +2,23 @@ import { useState, ReactElement } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import useSWR from "swr"; import useSWR from "swr";
// hooks // icons
import { RotateCcw } from "lucide-react"; import { ArchiveRestoreIcon } from "lucide-react";
// ui
import { ArchiveIcon, Button, Loader, TOAST_TYPE, setToast } from "@plane/ui"; import { ArchiveIcon, Button, Loader, TOAST_TYPE, setToast } from "@plane/ui";
// components
import { PageHead } from "@/components/core"; import { PageHead } from "@/components/core";
import { ProjectArchivedIssueDetailsHeader } from "@/components/headers"; import { ProjectArchivedIssueDetailsHeader } from "@/components/headers";
import { IssueDetailRoot } from "@/components/issues"; import { IssueDetailRoot } from "@/components/issues";
// constants
import { EIssuesStoreType } from "@/constants/issue"; import { EIssuesStoreType } from "@/constants/issue";
import { EUserProjectRoles } from "@/constants/project"; import { EUserProjectRoles } from "@/constants/project";
// hooks
import { useIssueDetail, useIssues, useProject, useUser } from "@/hooks/store"; import { useIssueDetail, useIssues, useProject, useUser } from "@/hooks/store";
// layouts // layouts
import { AppLayout } from "@/layouts/app-layout"; import { AppLayout } from "@/layouts/app-layout";
// components
// ui
// icons
// types // types
import { NextPageWithLayout } from "@/lib/types"; import { NextPageWithLayout } from "@/lib/types";
// constants
const ArchivedIssueDetailsPage: NextPageWithLayout = observer(() => { const ArchivedIssueDetailsPage: NextPageWithLayout = observer(() => {
// router // router
@ -112,7 +112,7 @@ const ArchivedIssueDetailsPage: NextPageWithLayout = observer(() => {
{issue?.archived_at && canRestoreIssue && ( {issue?.archived_at && canRestoreIssue && (
<div className="flex items-center justify-between gap-2 rounded-md border border-custom-border-200 bg-custom-background-90 px-2.5 py-2 text-sm text-custom-text-200"> <div className="flex items-center justify-between gap-2 rounded-md border border-custom-border-200 bg-custom-background-90 px-2.5 py-2 text-sm text-custom-text-200">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ArchiveIcon className="h-3.5 w-3.5" /> <ArchiveIcon className="h-4 w-4" />
<p>This issue has been archived.</p> <p>This issue has been archived.</p>
</div> </div>
<Button <Button
@ -121,7 +121,7 @@ const ArchivedIssueDetailsPage: NextPageWithLayout = observer(() => {
disabled={isRestoring} disabled={isRestoring}
variant="neutral-primary" variant="neutral-primary"
> >
<RotateCcw className="h-3 w-3" /> <ArchiveRestoreIcon className="h-3.5 w-3.5" />
<span>{isRestoring ? "Restoring" : "Restore"}</span> <span>{isRestoring ? "Restoring" : "Restore"}</span>
</Button> </Button>
</div> </div>

View File

@ -1,17 +1,16 @@
import { ReactElement } from "react"; import { ReactElement } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
// layouts
import { PageHead } from "@/components/core";
import { ProjectArchivedIssuesHeader } from "@/components/headers";
import { ArchivedIssueLayoutRoot } from "@/components/issues";
import { useProject } from "@/hooks/store";
import { AppLayout } from "@/layouts/app-layout";
// contexts
// components // components
import { PageHead } from "@/components/core";
import { ProjectArchivesHeader } from "@/components/headers";
import { ArchivedIssueLayoutRoot, ArchivedIssuesHeader } from "@/components/issues";
// hooks
import { useProject } from "@/hooks/store";
// layouts
import { AppLayout } from "@/layouts/app-layout";
// types // types
import { NextPageWithLayout } from "@/lib/types"; import { NextPageWithLayout } from "@/lib/types";
// hooks
const ProjectArchivedIssuesPage: NextPageWithLayout = observer(() => { const ProjectArchivedIssuesPage: NextPageWithLayout = observer(() => {
// router // router
@ -26,14 +25,17 @@ const ProjectArchivedIssuesPage: NextPageWithLayout = observer(() => {
return ( return (
<> <>
<PageHead title={pageTitle} /> <PageHead title={pageTitle} />
<div className="relative flex h-full w-full flex-col overflow-hidden">
<ArchivedIssuesHeader />
<ArchivedIssueLayoutRoot /> <ArchivedIssueLayoutRoot />
</div>
</> </>
); );
}); });
ProjectArchivedIssuesPage.getLayout = function getLayout(page: ReactElement) { ProjectArchivedIssuesPage.getLayout = function getLayout(page: ReactElement) {
return ( return (
<AppLayout header={<ProjectArchivedIssuesHeader />} withProjectWrapper> <AppLayout header={<ProjectArchivesHeader />} withProjectWrapper>
{page} {page}
</AppLayout> </AppLayout>
); );

View File

@ -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 (
<>
<PageHead title={pageTitle} />
<div className="relative flex h-full w-full flex-col overflow-hidden">
<ArchivedModulesHeader />
<ArchivedModuleLayoutRoot />
</div>
</>
);
});
ProjectArchivedModulesPage.getLayout = function getLayout(page: ReactElement) {
return (
<AppLayout header={<ProjectArchivesHeader />} withProjectWrapper>
{page}
</AppLayout>
);
};
export default ProjectArchivedModulesPage;

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

View File

@ -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<ICycle[]> {
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<void> {
return this.delete(`/api/workspaces/${workspaceSlug}/projects/${projectId}/cycles/${cycleId}/archive/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
}

View File

@ -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<IModule[]> {
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<void> {
return this.delete(`/api/workspaces/${workspaceSlug}/projects/${projectId}/modules/${moduleId}/archive/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
}

View File

@ -3,17 +3,18 @@ import set from "lodash/set";
import sortBy from "lodash/sortBy"; import sortBy from "lodash/sortBy";
import { action, computed, observable, makeObservable, runInAction } from "mobx"; import { action, computed, observable, makeObservable, runInAction } from "mobx";
import { computedFn } from "mobx-utils"; import { computedFn } from "mobx-utils";
// helpers
import { getDate } from "@/helpers/date-time.helper";
import { orderCycles, shouldFilterCycle } from "@/helpers/cycle.helper";
// services
import { CycleService } from "@/services/cycle.service";
import { IssueService } from "@/services/issue";
import { ProjectService } from "@/services/project";
// mobx
import { RootStore } from "@/store/root.store";
// types // types
import { ICycle, CycleDateCheckData } from "@plane/types"; import { ICycle, CycleDateCheckData } from "@plane/types";
// helpers
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";
// store
import { RootStore } from "@/store/root.store";
export interface ICycleStore { export interface ICycleStore {
// loaders // loaders
@ -29,9 +30,11 @@ export interface ICycleStore {
currentProjectIncompleteCycleIds: string[] | null; currentProjectIncompleteCycleIds: string[] | null;
currentProjectDraftCycleIds: string[] | null; currentProjectDraftCycleIds: string[] | null;
currentProjectActiveCycleId: string | null; currentProjectActiveCycleId: string | null;
currentProjectArchivedCycleIds: string[] | null;
// computed actions // computed actions
getFilteredCycleIds: (projectId: string) => string[] | null; getFilteredCycleIds: (projectId: string) => string[] | null;
getFilteredCompletedCycleIds: (projectId: string) => string[] | null; getFilteredCompletedCycleIds: (projectId: string) => string[] | null;
getFilteredArchivedCycleIds: (projectId: string) => string[] | null;
getCycleById: (cycleId: string) => ICycle | null; getCycleById: (cycleId: string) => ICycle | null;
getCycleNameById: (cycleId: string) => string | undefined; getCycleNameById: (cycleId: string) => string | undefined;
getActiveCycleById: (cycleId: string) => ICycle | null; getActiveCycleById: (cycleId: string) => ICycle | null;
@ -42,6 +45,7 @@ export interface ICycleStore {
fetchWorkspaceCycles: (workspaceSlug: string) => Promise<ICycle[]>; fetchWorkspaceCycles: (workspaceSlug: string) => Promise<ICycle[]>;
fetchAllCycles: (workspaceSlug: string, projectId: string) => Promise<undefined | ICycle[]>; fetchAllCycles: (workspaceSlug: string, projectId: string) => Promise<undefined | ICycle[]>;
fetchActiveCycle: (workspaceSlug: string, projectId: string) => Promise<undefined | ICycle[]>; fetchActiveCycle: (workspaceSlug: string, projectId: string) => Promise<undefined | ICycle[]>;
fetchArchivedCycles: (workspaceSlug: string, projectId: string) => Promise<undefined | ICycle[]>;
fetchCycleDetails: (workspaceSlug: string, projectId: string, cycleId: string) => Promise<ICycle>; fetchCycleDetails: (workspaceSlug: string, projectId: string, cycleId: string) => Promise<ICycle>;
// crud // crud
createCycle: (workspaceSlug: string, projectId: string, data: Partial<ICycle>) => Promise<ICycle>; createCycle: (workspaceSlug: string, projectId: string, data: Partial<ICycle>) => Promise<ICycle>;
@ -55,6 +59,9 @@ export interface ICycleStore {
// favorites // favorites
addCycleToFavorites: (workspaceSlug: string, projectId: string, cycleId: string) => Promise<any>; addCycleToFavorites: (workspaceSlug: string, projectId: string, cycleId: string) => Promise<any>;
removeCycleFromFavorites: (workspaceSlug: string, projectId: string, cycleId: string) => Promise<void>; removeCycleFromFavorites: (workspaceSlug: string, projectId: string, cycleId: string) => Promise<void>;
// archive
archiveCycle: (workspaceSlug: string, projectId: string, cycleId: string) => Promise<void>;
restoreCycle: (workspaceSlug: string, projectId: string, cycleId: string) => Promise<void>;
} }
export class CycleStore implements ICycleStore { export class CycleStore implements ICycleStore {
@ -70,6 +77,7 @@ export class CycleStore implements ICycleStore {
projectService; projectService;
issueService; issueService;
cycleService; cycleService;
cycleArchiveService;
constructor(_rootStore: RootStore) { constructor(_rootStore: RootStore) {
makeObservable(this, { makeObservable(this, {
@ -85,22 +93,29 @@ export class CycleStore implements ICycleStore {
currentProjectIncompleteCycleIds: computed, currentProjectIncompleteCycleIds: computed,
currentProjectDraftCycleIds: computed, currentProjectDraftCycleIds: computed,
currentProjectActiveCycleId: computed, currentProjectActiveCycleId: computed,
currentProjectArchivedCycleIds: computed,
// actions // actions
fetchWorkspaceCycles: action, fetchWorkspaceCycles: action,
fetchAllCycles: action, fetchAllCycles: action,
fetchActiveCycle: action, fetchActiveCycle: action,
fetchArchivedCycles: action,
fetchCycleDetails: action, fetchCycleDetails: action,
createCycle: action, createCycle: action,
updateCycleDetails: action, updateCycleDetails: action,
deleteCycle: action, deleteCycle: action,
addCycleToFavorites: action, addCycleToFavorites: action,
removeCycleFromFavorites: action, removeCycleFromFavorites: action,
archiveCycle: action,
restoreCycle: action,
}); });
this.rootStore = _rootStore; this.rootStore = _rootStore;
// services
this.projectService = new ProjectService(); this.projectService = new ProjectService();
this.issueService = new IssueService(); this.issueService = new IssueService();
this.cycleService = new CycleService(); this.cycleService = new CycleService();
this.cycleArchiveService = new CycleArchiveService();
} }
// computed // computed
@ -110,7 +125,7 @@ export class CycleStore implements ICycleStore {
get currentProjectCycleIds() { get currentProjectCycleIds() {
const projectId = this.rootStore.app.router.projectId; const projectId = this.rootStore.app.router.projectId;
if (!projectId || !this.fetchedMap[projectId]) return null; 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]); allCycles = sortBy(allCycles, [(c) => c.sort_order]);
const allCycleIds = allCycles.map((c) => c.id); const allCycleIds = allCycles.map((c) => c.id);
return allCycleIds; return allCycleIds;
@ -126,7 +141,7 @@ export class CycleStore implements ICycleStore {
const endDate = getDate(c.end_date); const endDate = getDate(c.end_date);
const hasEndDatePassed = endDate && isPast(endDate); const hasEndDatePassed = endDate && isPast(endDate);
const isEndDateToday = endDate && isToday(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]); completedCycles = sortBy(completedCycles, [(c) => c.sort_order]);
const completedCycleIds = completedCycles.map((c) => c.id); const completedCycleIds = completedCycles.map((c) => c.id);
@ -142,7 +157,7 @@ export class CycleStore implements ICycleStore {
let upcomingCycles = Object.values(this.cycleMap ?? {}).filter((c) => { let upcomingCycles = Object.values(this.cycleMap ?? {}).filter((c) => {
const startDate = getDate(c.start_date); const startDate = getDate(c.start_date);
const isStartDateUpcoming = startDate && isFuture(startDate); 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]); upcomingCycles = sortBy(upcomingCycles, [(c) => c.sort_order]);
const upcomingCycleIds = upcomingCycles.map((c) => c.id); const upcomingCycleIds = upcomingCycles.map((c) => c.id);
@ -158,7 +173,7 @@ export class CycleStore implements ICycleStore {
let incompleteCycles = Object.values(this.cycleMap ?? {}).filter((c) => { let incompleteCycles = Object.values(this.cycleMap ?? {}).filter((c) => {
const endDate = getDate(c.end_date); const endDate = getDate(c.end_date);
const hasEndDatePassed = endDate && isPast(endDate); 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]); incompleteCycles = sortBy(incompleteCycles, [(c) => c.sort_order]);
const incompleteCycleIds = incompleteCycles.map((c) => c.id); const incompleteCycleIds = incompleteCycles.map((c) => c.id);
@ -172,7 +187,7 @@ export class CycleStore implements ICycleStore {
const projectId = this.rootStore.app.router.projectId; const projectId = this.rootStore.app.router.projectId;
if (!projectId || !this.fetchedMap[projectId]) return null; if (!projectId || !this.fetchedMap[projectId]) return null;
let draftCycles = Object.values(this.cycleMap ?? {}).filter( 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]); draftCycles = sortBy(draftCycles, [(c) => c.sort_order]);
const draftCycleIds = draftCycles.map((c) => c.id); const draftCycleIds = draftCycles.map((c) => c.id);
@ -191,6 +206,20 @@ export class CycleStore implements ICycleStore {
return activeCycle || null; 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 * @description returns filtered cycle ids based on display filters and filters
* @param {TCycleDisplayFilters} displayFilters * @param {TCycleDisplayFilters} displayFilters
@ -204,6 +233,7 @@ export class CycleStore implements ICycleStore {
let cycles = Object.values(this.cycleMap ?? {}).filter( let cycles = Object.values(this.cycleMap ?? {}).filter(
(c) => (c) =>
c.project_id === projectId && c.project_id === projectId &&
!c.archived_at &&
c.name.toLowerCase().includes(searchQuery.toLowerCase()) && c.name.toLowerCase().includes(searchQuery.toLowerCase()) &&
shouldFilterCycle(c, filters ?? {}) shouldFilterCycle(c, filters ?? {})
); );
@ -225,6 +255,7 @@ export class CycleStore implements ICycleStore {
let cycles = Object.values(this.cycleMap ?? {}).filter( let cycles = Object.values(this.cycleMap ?? {}).filter(
(c) => (c) =>
c.project_id === projectId && c.project_id === projectId &&
!c.archived_at &&
c.status.toLowerCase() === "completed" && c.status.toLowerCase() === "completed" &&
c.name.toLowerCase().includes(searchQuery.toLowerCase()) && c.name.toLowerCase().includes(searchQuery.toLowerCase()) &&
shouldFilterCycle(c, filters ?? {}) shouldFilterCycle(c, filters ?? {})
@ -234,6 +265,27 @@ export class CycleStore implements ICycleStore {
return cycleIds; 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 * @description returns cycle details by cycle id
* @param cycleId * @param cycleId
@ -264,7 +316,7 @@ export class CycleStore implements ICycleStore {
getProjectCycleIds = computedFn((projectId: string): string[] | null => { getProjectCycleIds = computedFn((projectId: string): string[] | null => {
if (!this.fetchedMap[projectId]) return 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]); cycles = sortBy(cycles, [(c) => c.sort_order]);
const cycleIds = cycles.map((c) => c.id); const cycleIds = cycles.map((c) => c.id);
return cycleIds || null; 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 * @description fetches active cycle for a project
* @param workspaceSlug * @param workspaceSlug
@ -452,4 +529,48 @@ export class CycleStore implements ICycleStore {
throw error; 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);
});
};
} }

View File

@ -1,33 +1,39 @@
import set from "lodash/set";
import { action, computed, observable, makeObservable, runInAction, reaction } from "mobx"; import { action, computed, observable, makeObservable, runInAction, reaction } from "mobx";
import { computedFn } from "mobx-utils"; import { computedFn } from "mobx-utils";
import set from "lodash/set";
// types // types
import { TCycleDisplayFilters, TCycleFilters, TCycleFiltersByState } from "@plane/types";
// store
import { RootStore } from "@/store/root.store"; import { RootStore } from "@/store/root.store";
import { TCycleDisplayFilters, TCycleFilters } from "@plane/types";
export interface ICycleFilterStore { export interface ICycleFilterStore {
// observables // observables
displayFilters: Record<string, TCycleDisplayFilters>; displayFilters: Record<string, TCycleDisplayFilters>;
filters: Record<string, TCycleFilters>; filters: Record<string, TCycleFiltersByState>;
searchQuery: string; searchQuery: string;
archivedCyclesSearchQuery: string;
// computed // computed
currentProjectDisplayFilters: TCycleDisplayFilters | undefined; currentProjectDisplayFilters: TCycleDisplayFilters | undefined;
currentProjectFilters: TCycleFilters | undefined; currentProjectFilters: TCycleFilters | undefined;
currentProjectArchivedFilters: TCycleFilters | undefined;
// computed functions // computed functions
getDisplayFiltersByProjectId: (projectId: string) => TCycleDisplayFilters | undefined; getDisplayFiltersByProjectId: (projectId: string) => TCycleDisplayFilters | undefined;
getFiltersByProjectId: (projectId: string) => TCycleFilters | undefined; getFiltersByProjectId: (projectId: string) => TCycleFilters | undefined;
getArchivedFiltersByProjectId: (projectId: string) => TCycleFilters | undefined;
// actions // actions
updateDisplayFilters: (projectId: string, displayFilters: TCycleDisplayFilters) => void; 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; updateSearchQuery: (query: string) => void;
clearAllFilters: (projectId: string) => void; updateArchivedCyclesSearchQuery: (query: string) => void;
clearAllFilters: (projectId: string, state?: keyof TCycleFiltersByState) => void;
} }
export class CycleFilterStore implements ICycleFilterStore { export class CycleFilterStore implements ICycleFilterStore {
// observables // observables
displayFilters: Record<string, TCycleDisplayFilters> = {}; displayFilters: Record<string, TCycleDisplayFilters> = {};
filters: Record<string, TCycleFilters> = {}; filters: Record<string, TCycleFiltersByState> = {};
searchQuery: string = ""; searchQuery: string = "";
archivedCyclesSearchQuery: string = "";
// root store // root store
rootStore: RootStore; rootStore: RootStore;
@ -37,13 +43,16 @@ export class CycleFilterStore implements ICycleFilterStore {
displayFilters: observable, displayFilters: observable,
filters: observable, filters: observable,
searchQuery: observable.ref, searchQuery: observable.ref,
archivedCyclesSearchQuery: observable.ref,
// computed // computed
currentProjectDisplayFilters: computed, currentProjectDisplayFilters: computed,
currentProjectFilters: computed, currentProjectFilters: computed,
currentProjectArchivedFilters: computed,
// actions // actions
updateDisplayFilters: action, updateDisplayFilters: action,
updateFilters: action, updateFilters: action,
updateSearchQuery: action, updateSearchQuery: action,
updateArchivedCyclesSearchQuery: action,
clearAllFilters: action, clearAllFilters: action,
}); });
// root store // root store
@ -73,7 +82,16 @@ export class CycleFilterStore implements ICycleFilterStore {
get currentProjectFilters() { get currentProjectFilters() {
const projectId = this.rootStore.app.router.projectId; const projectId = this.rootStore.app.router.projectId;
if (!projectId) return; 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 * @description get filters of a project by projectId
* @param {string} 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 * @description initialize display filters and filters of a project
@ -99,7 +123,10 @@ export class CycleFilterStore implements ICycleFilterStore {
active_tab: displayFilters?.active_tab || "active", active_tab: displayFilters?.active_tab || "active",
layout: displayFilters?.layout || "list", 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 {string} projectId
* @param {TCycleFilters} filters * @param {TCycleFilters} filters
*/ */
updateFilters = (projectId: string, filters: TCycleFilters) => { updateFilters = (projectId: string, filters: TCycleFilters, state: keyof TCycleFiltersByState = "default") => {
runInAction(() => { runInAction(() => {
Object.keys(filters).forEach((key) => { 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); 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 * @description clear all filters of a project
* @param {string} projectId * @param {string} projectId
*/ */
clearAllFilters = (projectId: string) => { clearAllFilters = (projectId: string, state: keyof TCycleFiltersByState = "default") => {
runInAction(() => { runInAction(() => {
this.filters[projectId] = {}; this.filters[projectId][state] = {};
}); });
}; };
} }

View File

@ -2,14 +2,16 @@ import set from "lodash/set";
import sortBy from "lodash/sortBy"; import sortBy from "lodash/sortBy";
import { action, computed, observable, makeObservable, runInAction } from "mobx"; import { action, computed, observable, makeObservable, runInAction } from "mobx";
import { computedFn } from "mobx-utils"; import { computedFn } from "mobx-utils";
// services // types
import { ModuleService } from "@/services/module.service"; import { IModule, ILinkDetails } from "@plane/types";
import { ProjectService } from "@/services/project";
// helpers // helpers
import { orderModules, shouldFilterModule } from "@/helpers/module.helper"; import { orderModules, shouldFilterModule } from "@/helpers/module.helper";
// types // services
import { ModuleService } from "@/services/module.service";
import { ModuleArchiveService } from "@/services/module_archive.service";
import { ProjectService } from "@/services/project";
// store
import { RootStore } from "@/store/root.store"; import { RootStore } from "@/store/root.store";
import { IModule, ILinkDetails } from "@plane/types";
export interface IModuleStore { export interface IModuleStore {
//Loaders //Loaders
@ -19,8 +21,10 @@ export interface IModuleStore {
moduleMap: Record<string, IModule>; moduleMap: Record<string, IModule>;
// computed // computed
projectModuleIds: string[] | null; projectModuleIds: string[] | null;
projectArchivedModuleIds: string[] | null;
// computed actions // computed actions
getFilteredModuleIds: (projectId: string) => string[] | null; getFilteredModuleIds: (projectId: string) => string[] | null;
getFilteredArchivedModuleIds: (projectId: string) => string[] | null;
getModuleById: (moduleId: string) => IModule | null; getModuleById: (moduleId: string) => IModule | null;
getModuleNameById: (moduleId: string) => string; getModuleNameById: (moduleId: string) => string;
getProjectModuleIds: (projectId: string) => string[] | null; getProjectModuleIds: (projectId: string) => string[] | null;
@ -28,6 +32,7 @@ export interface IModuleStore {
// fetch // fetch
fetchWorkspaceModules: (workspaceSlug: string) => Promise<IModule[]>; fetchWorkspaceModules: (workspaceSlug: string) => Promise<IModule[]>;
fetchModules: (workspaceSlug: string, projectId: string) => Promise<undefined | IModule[]>; fetchModules: (workspaceSlug: string, projectId: string) => Promise<undefined | IModule[]>;
fetchArchivedModules: (workspaceSlug: string, projectId: string) => Promise<undefined | IModule[]>;
fetchModuleDetails: (workspaceSlug: string, projectId: string, moduleId: string) => Promise<IModule>; fetchModuleDetails: (workspaceSlug: string, projectId: string, moduleId: string) => Promise<IModule>;
// crud // crud
createModule: (workspaceSlug: string, projectId: string, data: Partial<IModule>) => Promise<IModule>; createModule: (workspaceSlug: string, projectId: string, data: Partial<IModule>) => Promise<IModule>;
@ -55,6 +60,9 @@ export interface IModuleStore {
// favorites // favorites
addModuleToFavorites: (workspaceSlug: string, projectId: string, moduleId: string) => Promise<void>; addModuleToFavorites: (workspaceSlug: string, projectId: string, moduleId: string) => Promise<void>;
removeModuleFromFavorites: (workspaceSlug: string, projectId: string, moduleId: string) => Promise<void>; removeModuleFromFavorites: (workspaceSlug: string, projectId: string, moduleId: string) => Promise<void>;
// archive
archiveModule: (workspaceSlug: string, projectId: string, moduleId: string) => Promise<void>;
restoreModule: (workspaceSlug: string, projectId: string, moduleId: string) => Promise<void>;
} }
export class ModulesStore implements IModuleStore { export class ModulesStore implements IModuleStore {
@ -68,6 +76,7 @@ export class ModulesStore implements IModuleStore {
// services // services
projectService; projectService;
moduleService; moduleService;
moduleArchiveService;
constructor(_rootStore: RootStore) { constructor(_rootStore: RootStore) {
makeObservable(this, { makeObservable(this, {
@ -77,9 +86,11 @@ export class ModulesStore implements IModuleStore {
fetchedMap: observable, fetchedMap: observable,
// computed // computed
projectModuleIds: computed, projectModuleIds: computed,
projectArchivedModuleIds: computed,
// actions // actions
fetchWorkspaceModules: action, fetchWorkspaceModules: action,
fetchModules: action, fetchModules: action,
fetchArchivedModules: action,
fetchModuleDetails: action, fetchModuleDetails: action,
createModule: action, createModule: action,
updateModuleDetails: action, updateModuleDetails: action,
@ -89,6 +100,8 @@ export class ModulesStore implements IModuleStore {
deleteModuleLink: action, deleteModuleLink: action,
addModuleToFavorites: action, addModuleToFavorites: action,
removeModuleFromFavorites: action, removeModuleFromFavorites: action,
archiveModule: action,
restoreModule: action,
}); });
this.rootStore = _rootStore; this.rootStore = _rootStore;
@ -96,6 +109,7 @@ export class ModulesStore implements IModuleStore {
// services // services
this.projectService = new ProjectService(); this.projectService = new ProjectService();
this.moduleService = new ModuleService(); this.moduleService = new ModuleService();
this.moduleArchiveService = new ModuleArchiveService();
} }
// computed // computed
@ -105,12 +119,24 @@ export class ModulesStore implements IModuleStore {
get projectModuleIds() { get projectModuleIds() {
const projectId = this.rootStore.app.router.projectId; const projectId = this.rootStore.app.router.projectId;
if (!projectId || !this.fetchedMap[projectId]) return null; 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]); projectModules = sortBy(projectModules, [(m) => m.sort_order]);
const projectModuleIds = projectModules.map((m) => m.id); const projectModuleIds = projectModules.map((m) => m.id);
return projectModuleIds || null; 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 * @description returns filtered module ids based on display filters and filters
* @param {TModuleDisplayFilters} displayFilters * @param {TModuleDisplayFilters} displayFilters
@ -125,6 +151,29 @@ export class ModulesStore implements IModuleStore {
let modules = Object.values(this.moduleMap ?? {}).filter( let modules = Object.values(this.moduleMap ?? {}).filter(
(m) => (m) =>
m.project_id === projectId && 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()) && m.name.toLowerCase().includes(searchQuery.toLowerCase()) &&
shouldFilterModule(m, displayFilters ?? {}, filters ?? {}) shouldFilterModule(m, displayFilters ?? {}, filters ?? {})
); );
@ -154,7 +203,7 @@ export class ModulesStore implements IModuleStore {
getProjectModuleIds = computedFn((projectId: string) => { getProjectModuleIds = computedFn((projectId: string) => {
if (!this.fetchedMap[projectId]) return null; 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]); projectModules = sortBy(projectModules, [(m) => m.sort_order]);
const projectModuleIds = projectModules.map((m) => m.id); const projectModuleIds = projectModules.map((m) => m.id);
return projectModuleIds; 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 * @description fetch module details
* @param workspaceSlug * @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);
});
};
} }

View File

@ -1,33 +1,39 @@
import set from "lodash/set";
import { action, computed, observable, makeObservable, runInAction, reaction } from "mobx"; import { action, computed, observable, makeObservable, runInAction, reaction } from "mobx";
import { computedFn } from "mobx-utils"; import { computedFn } from "mobx-utils";
import set from "lodash/set";
// types // types
import { TModuleDisplayFilters, TModuleFilters, TModuleFiltersByState } from "@plane/types";
// store
import { RootStore } from "@/store/root.store"; import { RootStore } from "@/store/root.store";
import { TModuleDisplayFilters, TModuleFilters } from "@plane/types";
export interface IModuleFilterStore { export interface IModuleFilterStore {
// observables // observables
displayFilters: Record<string, TModuleDisplayFilters>; displayFilters: Record<string, TModuleDisplayFilters>;
filters: Record<string, TModuleFilters>; filters: Record<string, TModuleFiltersByState>;
searchQuery: string; searchQuery: string;
archivedModulesSearchQuery: string;
// computed // computed
currentProjectDisplayFilters: TModuleDisplayFilters | undefined; currentProjectDisplayFilters: TModuleDisplayFilters | undefined;
currentProjectFilters: TModuleFilters | undefined; currentProjectFilters: TModuleFilters | undefined;
currentProjectArchivedFilters: TModuleFilters | undefined;
// computed functions // computed functions
getDisplayFiltersByProjectId: (projectId: string) => TModuleDisplayFilters | undefined; getDisplayFiltersByProjectId: (projectId: string) => TModuleDisplayFilters | undefined;
getFiltersByProjectId: (projectId: string) => TModuleFilters | undefined; getFiltersByProjectId: (projectId: string) => TModuleFilters | undefined;
getArchivedFiltersByProjectId: (projectId: string) => TModuleFilters | undefined;
// actions // actions
updateDisplayFilters: (projectId: string, displayFilters: TModuleDisplayFilters) => void; 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; updateSearchQuery: (query: string) => void;
clearAllFilters: (projectId: string) => void; updateArchivedModulesSearchQuery: (query: string) => void;
clearAllFilters: (projectId: string, state?: keyof TModuleFiltersByState) => void;
} }
export class ModuleFilterStore implements IModuleFilterStore { export class ModuleFilterStore implements IModuleFilterStore {
// observables // observables
displayFilters: Record<string, TModuleDisplayFilters> = {}; displayFilters: Record<string, TModuleDisplayFilters> = {};
filters: Record<string, TModuleFilters> = {}; filters: Record<string, TModuleFiltersByState> = {};
searchQuery: string = ""; searchQuery: string = "";
archivedModulesSearchQuery: string = "";
// root store // root store
rootStore: RootStore; rootStore: RootStore;
@ -37,13 +43,16 @@ export class ModuleFilterStore implements IModuleFilterStore {
displayFilters: observable, displayFilters: observable,
filters: observable, filters: observable,
searchQuery: observable.ref, searchQuery: observable.ref,
archivedModulesSearchQuery: observable.ref,
// computed // computed
currentProjectDisplayFilters: computed, currentProjectDisplayFilters: computed,
currentProjectFilters: computed, currentProjectFilters: computed,
currentProjectArchivedFilters: computed,
// actions // actions
updateDisplayFilters: action, updateDisplayFilters: action,
updateFilters: action, updateFilters: action,
updateSearchQuery: action, updateSearchQuery: action,
updateArchivedModulesSearchQuery: action,
clearAllFilters: action, clearAllFilters: action,
}); });
// root store // root store
@ -73,7 +82,16 @@ export class ModuleFilterStore implements IModuleFilterStore {
get currentProjectFilters() { get currentProjectFilters() {
const projectId = this.rootStore.app.router.projectId; const projectId = this.rootStore.app.router.projectId;
if (!projectId) return; 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 * @description get filters of a project by projectId
* @param {string} 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 * @description initialize display filters and filters of a project
@ -100,7 +124,10 @@ export class ModuleFilterStore implements IModuleFilterStore {
layout: displayFilters?.layout || "list", layout: displayFilters?.layout || "list",
order_by: displayFilters?.order_by || "name", 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 {string} projectId
* @param {TModuleFilters} filters * @param {TModuleFilters} filters
*/ */
updateFilters = (projectId: string, filters: TModuleFilters) => { updateFilters = (projectId: string, filters: TModuleFilters, state: keyof TModuleFiltersByState = "default") => {
runInAction(() => { runInAction(() => {
Object.keys(filters).forEach((key) => { 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); 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 * @description clear all filters of a project
* @param {string} projectId * @param {string} projectId
*/ */
clearAllFilters = (projectId: string) => { clearAllFilters = (projectId: string, state: keyof TModuleFiltersByState = "default") => {
runInAction(() => { runInAction(() => {
this.filters[projectId] = {}; this.filters[projectId][state] = {};
}); });
}; };
} }