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>
@ -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()),
|
||||||
|
@ -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()),
|
||||||
|
@ -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)
|
||||||
|
@ -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)
|
||||||
|
|
||||||
|
1
packages/types/src/cycle/cycle.d.ts
vendored
@ -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;
|
||||||
|
5
packages/types/src/cycle/cycle_filters.d.ts
vendored
@ -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;
|
||||||
|
@ -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;
|
||||||
|
1
packages/types/src/module/modules.d.ts
vendored
@ -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;
|
||||||
};
|
};
|
||||||
|
43
web/components/archives/archive-tabs-list.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
1
web/components/archives/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from "./archive-tabs-list";
|
@ -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);
|
||||||
|
123
web/components/cycles/archived-cycles/header.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
});
|
4
web/components/cycles/archived-cycles/index.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export * from "./root";
|
||||||
|
export * from "./view";
|
||||||
|
export * from "./header";
|
||||||
|
export * from "./modal";
|
104
web/components/cycles/archived-cycles/modal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
77
web/components/cycles/archived-cycles/root.tsx
Normal 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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
57
web/components/cycles/archived-cycles/view.tsx
Normal 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
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
@ -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>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
@ -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);
|
||||||
|
@ -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">
|
||||||
|
@ -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";
|
||||||
|
@ -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>
|
||||||
|
@ -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}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
|
@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
@ -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}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
@ -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
|
||||||
|
@ -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";
|
||||||
|
@ -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);
|
||||||
|
@ -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={
|
||||||
|
96
web/components/headers/project-archives.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
});
|
@ -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 || {}}
|
@ -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";
|
||||||
|
@ -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);
|
||||||
|
@ -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"
|
||||||
|
@ -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"
|
||||||
|
@ -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",
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
</>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -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",
|
||||||
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
147
web/components/modules/archived-modules/header.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
});
|
4
web/components/modules/archived-modules/index.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export * from "./root";
|
||||||
|
export * from "./view";
|
||||||
|
export * from "./header";
|
||||||
|
export * from "./modal";
|
104
web/components/modules/archived-modules/modal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
81
web/components/modules/archived-modules/root.tsx
Normal 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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
64
web/components/modules/archived-modules/view.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
});
|
@ -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">
|
||||||
|
@ -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";
|
||||||
|
@ -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>
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -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>
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -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>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
179
web/components/modules/quick-actions.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
@ -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>
|
||||||
|
@ -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"
|
||||||
}`}
|
}`}
|
||||||
|
@ -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
@ -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,
|
||||||
|
},
|
||||||
|
};
|
@ -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,
|
||||||
|
@ -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;
|
@ -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>
|
@ -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>
|
||||||
);
|
);
|
@ -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;
|
BIN
web/public/empty-state/archived/empty-cycles-dark.webp
Normal file
After Width: | Height: | Size: 47 KiB |
BIN
web/public/empty-state/archived/empty-cycles-light.webp
Normal file
After Width: | Height: | Size: 57 KiB |
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 54 KiB |
Before Width: | Height: | Size: 79 KiB After Width: | Height: | Size: 64 KiB |
BIN
web/public/empty-state/archived/empty-modules-dark.webp
Normal file
After Width: | Height: | Size: 42 KiB |
BIN
web/public/empty-state/archived/empty-modules-light.webp
Normal file
After Width: | Height: | Size: 44 KiB |
42
web/services/cycle_archive.service.ts
Normal 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
42
web/services/module_archive.service.ts
Normal 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
@ -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);
|
||||||
|
});
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
@ -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] = {};
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -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);
|
||||||
|
});
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
@ -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] = {};
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|