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()
|
||||
)
|
||||
|
||||
def list(self, request, slug, project_id):
|
||||
def get(self, request, slug, project_id):
|
||||
return self.paginate(
|
||||
request=request,
|
||||
queryset=(self.get_queryset()),
|
||||
|
@ -553,7 +553,7 @@ class ModuleArchiveUnarchiveAPIEndpoint(BaseAPIView):
|
||||
.order_by(self.kwargs.get("order_by", "-created_at"))
|
||||
)
|
||||
|
||||
def list(self, request, slug, project_id):
|
||||
def get(self, request, slug, project_id):
|
||||
return self.paginate(
|
||||
request=request,
|
||||
queryset=(self.get_queryset()),
|
||||
|
@ -714,10 +714,8 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView):
|
||||
project_id=self.kwargs.get("project_id"),
|
||||
workspace__slug=self.kwargs.get("slug"),
|
||||
)
|
||||
return self.filter_queryset(
|
||||
super()
|
||||
.get_queryset()
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
return (
|
||||
Cycle.objects.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.filter(project_id=self.kwargs.get("project_id"))
|
||||
.filter(archived_at__isnull=False)
|
||||
.filter(
|
||||
@ -831,7 +829,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView):
|
||||
.distinct()
|
||||
)
|
||||
|
||||
def list(self, request, slug, project_id):
|
||||
def get(self, request, slug, project_id):
|
||||
queryset = (
|
||||
self.get_queryset()
|
||||
.annotate(
|
||||
@ -869,6 +867,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView):
|
||||
"backlog_issues",
|
||||
"assignee_ids",
|
||||
"status",
|
||||
"archived_at",
|
||||
)
|
||||
).order_by("-is_favorite", "-created_at")
|
||||
return Response(queryset, status=status.HTTP_200_OK)
|
||||
|
@ -498,10 +498,7 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
|
||||
workspace__slug=self.kwargs.get("slug"),
|
||||
)
|
||||
return (
|
||||
super()
|
||||
.get_queryset()
|
||||
.filter(project_id=self.kwargs.get("project_id"))
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
Module.objects.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.filter(archived_at__isnull=False)
|
||||
.annotate(is_favorite=Exists(favorite_subquery))
|
||||
.select_related("project")
|
||||
@ -594,7 +591,7 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
|
||||
.order_by("-is_favorite", "-created_at")
|
||||
)
|
||||
|
||||
def list(self, request, slug, project_id):
|
||||
def get(self, request, slug, project_id):
|
||||
queryset = self.get_queryset()
|
||||
modules = queryset.values( # Required fields
|
||||
"id",
|
||||
@ -624,6 +621,7 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
|
||||
"backlog_issues",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"archived_at"
|
||||
)
|
||||
return Response(modules, status=status.HTTP_200_OK)
|
||||
|
||||
|
1
packages/types/src/cycle/cycle.d.ts
vendored
@ -31,6 +31,7 @@ export interface ICycle {
|
||||
unstarted_issues: number;
|
||||
updated_at: Date;
|
||||
updated_by: string;
|
||||
archived_at: string | null;
|
||||
assignee_ids: string[];
|
||||
view_props: {
|
||||
filters: IIssueFilterOptions;
|
||||
|
5
packages/types/src/cycle/cycle_filters.d.ts
vendored
@ -13,6 +13,11 @@ export type TCycleFilters = {
|
||||
status?: string[] | null;
|
||||
};
|
||||
|
||||
export type TCycleFiltersByState = {
|
||||
default: TCycleFilters;
|
||||
archived: TCycleFilters;
|
||||
};
|
||||
|
||||
export type TCycleStoredFilters = {
|
||||
display_filters?: TCycleDisplayFilters;
|
||||
filters?: TCycleFilters;
|
||||
|
@ -26,6 +26,11 @@ export type TModuleFilters = {
|
||||
target_date?: string[] | null;
|
||||
};
|
||||
|
||||
export type TModuleFiltersByState = {
|
||||
default: TModuleFilters;
|
||||
archived: TModuleFilters;
|
||||
};
|
||||
|
||||
export type TModuleStoredFilters = {
|
||||
display_filters?: TModuleDisplayFilters;
|
||||
filters?: TModuleFilters;
|
||||
|
1
packages/types/src/module/modules.d.ts
vendored
@ -39,6 +39,7 @@ export interface IModule {
|
||||
unstarted_issues: number;
|
||||
updated_at: Date;
|
||||
updated_by: string;
|
||||
archived_at: string | null;
|
||||
view_props: {
|
||||
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;
|
||||
handleEditLink: (link: ILinkDetails) => void;
|
||||
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 { isMobile } = usePlatformOS();
|
||||
const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
|
||||
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disabled;
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
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 = {
|
||||
projectId: 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
|
||||
const router = useRouter();
|
||||
const { peekCycle } = router.query;
|
||||
@ -29,9 +30,9 @@ export const CyclePeekOverview: React.FC<Props> = observer(({ projectId, workspa
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!peekCycle) return;
|
||||
if (!peekCycle || isArchived) return;
|
||||
fetchCycleDetails(workspaceSlug, projectId, peekCycle.toString());
|
||||
}, [fetchCycleDetails, peekCycle, projectId, workspaceSlug]);
|
||||
}, [fetchCycleDetails, isArchived, peekCycle, projectId, workspaceSlug]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -44,7 +45,11 @@ export const CyclePeekOverview: React.FC<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)",
|
||||
}}
|
||||
>
|
||||
<CycleDetailsSidebar cycleId={peekCycle?.toString() ?? ""} handleClose={handleClose} />
|
||||
<CycleDetailsSidebar
|
||||
cycleId={peekCycle?.toString() ?? ""}
|
||||
handleClose={handleClose}
|
||||
isArchived={isArchived}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
@ -2,21 +2,21 @@ import { useCallback, useRef, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { ListFilter, Search, X } from "lucide-react";
|
||||
import { Tab } from "@headlessui/react";
|
||||
// types
|
||||
import { TCycleFilters } from "@plane/types";
|
||||
// hooks
|
||||
// ui
|
||||
import { Tooltip } from "@plane/ui";
|
||||
// components
|
||||
import { CycleFiltersSelection } from "@/components/cycles";
|
||||
import { FiltersDropdown } from "@/components/issues";
|
||||
// constants
|
||||
import { CYCLE_TABS_LIST, CYCLE_VIEW_LAYOUTS } from "@/constants/cycle";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
// hooks
|
||||
import { useCycleFilter } from "@/hooks/store";
|
||||
import useOutsideClickDetector from "@/hooks/use-outside-click-detector";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// components
|
||||
// ui
|
||||
// helpers
|
||||
// types
|
||||
// constants
|
||||
|
||||
type Props = {
|
||||
projectId: string;
|
||||
@ -24,8 +24,6 @@ type Props = {
|
||||
|
||||
export const CyclesViewHeader: React.FC<Props> = observer((props) => {
|
||||
const { projectId } = props;
|
||||
// states
|
||||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||
// refs
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
// hooks
|
||||
@ -38,6 +36,8 @@ export const CyclesViewHeader: React.FC<Props> = observer((props) => {
|
||||
updateSearchQuery,
|
||||
} = useCycleFilter();
|
||||
const { isMobile } = usePlatformOS();
|
||||
// states
|
||||
const [isSearchOpen, setIsSearchOpen] = useState(searchQuery !== "" ? true : false);
|
||||
// outside click detector hook
|
||||
useOutsideClickDetector(inputRef, () => {
|
||||
if (isSearchOpen && searchQuery.trim() === "") setIsSearchOpen(false);
|
||||
|
@ -9,10 +9,11 @@ import { FilterEndDate, FilterStartDate, FilterStatus } from "@/components/cycle
|
||||
type Props = {
|
||||
filters: TCycleFilters;
|
||||
handleFiltersUpdate: (key: keyof TCycleFilters, value: string | string[]) => void;
|
||||
isArchived?: boolean;
|
||||
};
|
||||
|
||||
export const CycleFiltersSelection: React.FC<Props> = observer((props) => {
|
||||
const { filters, handleFiltersUpdate } = props;
|
||||
const { filters, handleFiltersUpdate, isArchived = false } = props;
|
||||
// states
|
||||
const [filtersSearchQuery, setFiltersSearchQuery] = useState("");
|
||||
|
||||
@ -38,13 +39,15 @@ export const CycleFiltersSelection: React.FC<Props> = observer((props) => {
|
||||
</div>
|
||||
<div className="h-full w-full divide-y divide-custom-border-200 overflow-y-auto px-2.5 vertical-scrollbar scrollbar-sm">
|
||||
{/* cycle status */}
|
||||
<div className="py-2">
|
||||
<FilterStatus
|
||||
appliedFilters={(filters.status as TCycleGroups[]) ?? null}
|
||||
handleUpdate={(val) => handleFiltersUpdate("status", val)}
|
||||
searchQuery={filtersSearchQuery}
|
||||
/>
|
||||
</div>
|
||||
{!isArchived && (
|
||||
<div className="py-2">
|
||||
<FilterStatus
|
||||
appliedFilters={(filters.status as TCycleGroups[]) ?? null}
|
||||
handleUpdate={(val) => handleFiltersUpdate("status", val)}
|
||||
searchQuery={filtersSearchQuery}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* start date */}
|
||||
<div className="py-2">
|
||||
|
@ -14,3 +14,6 @@ export * from "./quick-actions";
|
||||
export * from "./sidebar";
|
||||
export * from "./transfer-issues-modal";
|
||||
export * from "./transfer-issues";
|
||||
|
||||
// archived cycles
|
||||
export * from "./archived-cycles";
|
||||
|
@ -2,27 +2,21 @@ import { FC, MouseEvent } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
// hooks
|
||||
import { Check, Info, Star, User2 } from "lucide-react";
|
||||
import type { TCycleGroups } from "@plane/types";
|
||||
import { Tooltip, CircularProgressIndicator, CycleGroupIcon, AvatarGroup, Avatar, setPromiseToast } from "@plane/ui";
|
||||
import { CycleQuickActions } from "@/components/cycles";
|
||||
// components
|
||||
// import { CycleCreateUpdateModal, CycleDeleteModal } from "@/components/cycles";
|
||||
// ui
|
||||
// icons
|
||||
// helpers
|
||||
import { Check, Info, Star, User2 } from "lucide-react";
|
||||
// types
|
||||
import type { TCycleGroups } from "@plane/types";
|
||||
// ui
|
||||
import { Tooltip, CircularProgressIndicator, CycleGroupIcon, AvatarGroup, Avatar, setPromiseToast } from "@plane/ui";
|
||||
// components
|
||||
import { CycleQuickActions } from "@/components/cycles";
|
||||
// constants
|
||||
import { CYCLE_STATUS } from "@/constants/cycle";
|
||||
import { CYCLE_FAVORITED, CYCLE_UNFAVORITED } from "@/constants/event-tracker";
|
||||
// components
|
||||
// ui
|
||||
// icons
|
||||
// helpers
|
||||
// constants
|
||||
// types
|
||||
import { EUserProjectRoles } from "@/constants/project";
|
||||
// helpers
|
||||
import { findHowManyDaysLeft, getDate, renderFormattedDate } from "@/helpers/date-time.helper";
|
||||
// hooks
|
||||
import { useEventTracker, useCycle, useUser, useMember } from "@/hooks/store";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
|
||||
@ -34,10 +28,11 @@ type TCyclesListItem = {
|
||||
handleRemoveFromFavorites?: () => void;
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
isArchived?: boolean;
|
||||
};
|
||||
|
||||
export const CyclesListItem: FC<TCyclesListItem> = observer((props) => {
|
||||
const { cycleId, workspaceSlug, projectId } = props;
|
||||
const { cycleId, workspaceSlug, projectId, isArchived } = props;
|
||||
// router
|
||||
const router = useRouter();
|
||||
// hooks
|
||||
@ -106,7 +101,7 @@ export const CyclesListItem: FC<TCyclesListItem> = observer((props) => {
|
||||
});
|
||||
};
|
||||
|
||||
const openCycleOverview = (e: MouseEvent<HTMLButtonElement>) => {
|
||||
const openCycleOverview = (e: MouseEvent<HTMLButtonElement | HTMLAnchorElement>) => {
|
||||
const { query } = router;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@ -151,7 +146,14 @@ export const CyclesListItem: FC<TCyclesListItem> = observer((props) => {
|
||||
|
||||
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="relative flex w-full items-center justify-between gap-3 overflow-hidden">
|
||||
<div className="relative flex w-full items-center gap-3 overflow-hidden">
|
||||
@ -221,21 +223,23 @@ export const CyclesListItem: FC<TCyclesListItem> = observer((props) => {
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
{isEditingAllowed && (
|
||||
<>
|
||||
{cycleDetails.is_favorite ? (
|
||||
<button type="button" onClick={handleRemoveFromFavorites}>
|
||||
<Star className="h-3.5 w-3.5 fill-current text-amber-500" />
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" onClick={handleAddToFavorites}>
|
||||
<Star className="h-3.5 w-3.5 text-custom-text-200" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<CycleQuickActions cycleId={cycleId} projectId={projectId} workspaceSlug={workspaceSlug} />
|
||||
</>
|
||||
)}
|
||||
{isEditingAllowed &&
|
||||
!isArchived &&
|
||||
(cycleDetails.is_favorite ? (
|
||||
<button type="button" onClick={handleRemoveFromFavorites}>
|
||||
<Star className="h-3.5 w-3.5 fill-current text-amber-500" />
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" onClick={handleAddToFavorites}>
|
||||
<Star className="h-3.5 w-3.5 text-custom-text-200" />
|
||||
</button>
|
||||
))}
|
||||
<CycleQuickActions
|
||||
cycleId={cycleId}
|
||||
projectId={projectId}
|
||||
workspaceSlug={workspaceSlug}
|
||||
isArchived={isArchived}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -5,15 +5,22 @@ type Props = {
|
||||
cycleIds: string[];
|
||||
projectId: string;
|
||||
workspaceSlug: string;
|
||||
isArchived?: boolean;
|
||||
};
|
||||
|
||||
export const CyclesListMap: React.FC<Props> = (props) => {
|
||||
const { cycleIds, projectId, workspaceSlug } = props;
|
||||
const { cycleIds, projectId, workspaceSlug, isArchived } = props;
|
||||
|
||||
return (
|
||||
<>
|
||||
{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[];
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
isArchived?: boolean;
|
||||
}
|
||||
|
||||
export const CyclesList: FC<ICyclesList> = observer((props) => {
|
||||
const { completedCycleIds, cycleIds, workspaceSlug, projectId } = props;
|
||||
const { completedCycleIds, cycleIds, workspaceSlug, projectId, isArchived = false } = props;
|
||||
|
||||
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">
|
||||
<CyclesListMap cycleIds={cycleIds} projectId={projectId} workspaceSlug={workspaceSlug} />
|
||||
<CyclesListMap
|
||||
cycleIds={cycleIds}
|
||||
projectId={projectId}
|
||||
workspaceSlug={workspaceSlug}
|
||||
isArchived={isArchived}
|
||||
/>
|
||||
{completedCycleIds.length !== 0 && (
|
||||
<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">
|
||||
@ -37,12 +43,17 @@ export const CyclesList: FC<ICyclesList> = observer((props) => {
|
||||
)}
|
||||
</Disclosure.Button>
|
||||
<Disclosure.Panel>
|
||||
<CyclesListMap cycleIds={completedCycleIds} projectId={projectId} workspaceSlug={workspaceSlug} />
|
||||
<CyclesListMap
|
||||
cycleIds={completedCycleIds}
|
||||
projectId={projectId}
|
||||
workspaceSlug={workspaceSlug}
|
||||
isArchived={isArchived}
|
||||
/>
|
||||
</Disclosure.Panel>
|
||||
</Disclosure>
|
||||
)}
|
||||
</div>
|
||||
<CyclePeekOverview projectId={projectId} workspaceSlug={workspaceSlug} />
|
||||
<CyclePeekOverview projectId={projectId} workspaceSlug={workspaceSlug} isArchived={isArchived} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -1,34 +1,40 @@
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { LinkIcon, Pencil, Trash2 } from "lucide-react";
|
||||
// hooks
|
||||
// components
|
||||
import { CustomMenu, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
import { CycleCreateUpdateModal, CycleDeleteModal } from "@/components/cycles";
|
||||
import { useRouter } from "next/router";
|
||||
// icons
|
||||
import { ArchiveRestoreIcon, LinkIcon, Pencil, Trash2 } from "lucide-react";
|
||||
// ui
|
||||
// helpers
|
||||
import { EUserProjectRoles } from "@/constants/project";
|
||||
import { copyUrlToClipboard } from "@/helpers/string.helper";
|
||||
import { ArchiveIcon, CustomMenu, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
// components
|
||||
import { ArchiveCycleModal, CycleCreateUpdateModal, CycleDeleteModal } from "@/components/cycles";
|
||||
// constants
|
||||
import { EUserProjectRoles } from "@/constants/project";
|
||||
// helpers
|
||||
import { copyUrlToClipboard } from "@/helpers/string.helper";
|
||||
// hooks
|
||||
import { useCycle, useEventTracker, useUser } from "@/hooks/store";
|
||||
|
||||
type Props = {
|
||||
cycleId: string;
|
||||
projectId: string;
|
||||
workspaceSlug: string;
|
||||
isArchived?: boolean;
|
||||
};
|
||||
|
||||
export const CycleQuickActions: React.FC<Props> = observer((props) => {
|
||||
const { cycleId, projectId, workspaceSlug } = props;
|
||||
const { cycleId, projectId, workspaceSlug, isArchived } = props;
|
||||
// router
|
||||
const router = useRouter();
|
||||
// states
|
||||
const [updateModal, setUpdateModal] = useState(false);
|
||||
const [archiveCycleModal, setArchiveCycleModal] = useState(false);
|
||||
const [deleteModal, setDeleteModal] = useState(false);
|
||||
// store hooks
|
||||
const { setTrackElement } = useEventTracker();
|
||||
const {
|
||||
membership: { currentWorkspaceAllProjectsRole },
|
||||
} = useUser();
|
||||
const { getCycleById } = useCycle();
|
||||
const { getCycleById, restoreCycle } = useCycle();
|
||||
// derived values
|
||||
const cycleDetails = getCycleById(cycleId);
|
||||
const isCompleted = cycleDetails?.status.toLowerCase() === "completed";
|
||||
@ -56,6 +62,33 @@ export const CycleQuickActions: React.FC<Props> = observer((props) => {
|
||||
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>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@ -74,6 +107,13 @@ export const CycleQuickActions: React.FC<Props> = observer((props) => {
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
/>
|
||||
<ArchiveCycleModal
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
cycleId={cycleId}
|
||||
isOpen={archiveCycleModal}
|
||||
handleClose={() => setArchiveCycleModal(false)}
|
||||
/>
|
||||
<CycleDeleteModal
|
||||
cycle={cycleDetails}
|
||||
isOpen={deleteModal}
|
||||
@ -84,28 +124,60 @@ export const CycleQuickActions: React.FC<Props> = observer((props) => {
|
||||
</div>
|
||||
)}
|
||||
<CustomMenu ellipsis placement="bottom-end">
|
||||
{!isCompleted && isEditingAllowed && !isArchived && (
|
||||
<CustomMenu.MenuItem onClick={handleEditCycle}>
|
||||
<span className="flex items-center justify-start gap-2">
|
||||
<Pencil className="h-3 w-3" />
|
||||
<span>Edit cycle</span>
|
||||
</span>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
{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">
|
||||
<ArchiveRestoreIcon className="h-3 w-3" />
|
||||
<span>Restore cycle</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 cycle link</span>
|
||||
</span>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
{!isCompleted && isEditingAllowed && (
|
||||
<>
|
||||
<CustomMenu.MenuItem onClick={handleEditCycle}>
|
||||
<span className="flex items-center justify-start gap-2">
|
||||
<Pencil className="h-3 w-3" />
|
||||
<span>Edit cycle</span>
|
||||
</span>
|
||||
</CustomMenu.MenuItem>
|
||||
<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.MenuItem onClick={handleCopyText}>
|
||||
<span className="flex items-center justify-start gap-2">
|
||||
<LinkIcon className="h-3 w-3" />
|
||||
<span>Copy cycle link</span>
|
||||
</span>
|
||||
</CustomMenu.MenuItem>
|
||||
</CustomMenu>
|
||||
</>
|
||||
);
|
||||
|
@ -3,33 +3,43 @@ import isEmpty from "lodash/isEmpty";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useRouter } from "next/router";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { ChevronDown, LinkIcon, Trash2, UserCircle2, AlertCircle, ChevronRight, CalendarClock } from "lucide-react";
|
||||
import { Disclosure, Transition } from "@headlessui/react";
|
||||
// icons
|
||||
import {
|
||||
ArchiveRestoreIcon,
|
||||
ChevronDown,
|
||||
LinkIcon,
|
||||
Trash2,
|
||||
UserCircle2,
|
||||
AlertCircle,
|
||||
ChevronRight,
|
||||
CalendarClock,
|
||||
} from "lucide-react";
|
||||
import { Disclosure, Transition } from "@headlessui/react";
|
||||
// types
|
||||
import { ICycle } from "@plane/types";
|
||||
// ui
|
||||
import { Avatar, CustomMenu, Loader, LayersIcon, TOAST_TYPE, setToast, TextArea } from "@plane/ui";
|
||||
import { Avatar, ArchiveIcon, CustomMenu, Loader, LayersIcon, TOAST_TYPE, setToast, TextArea } from "@plane/ui";
|
||||
// components
|
||||
import { SidebarProgressStats } from "@/components/core";
|
||||
import ProgressChart from "@/components/core/sidebar/progress-chart";
|
||||
import { CycleDeleteModal } from "@/components/cycles/delete-modal";
|
||||
import { ArchiveCycleModal, CycleDeleteModal } from "@/components/cycles";
|
||||
import { DateRangeDropdown } from "@/components/dropdowns";
|
||||
// constants
|
||||
import { CYCLE_STATUS } from "@/constants/cycle";
|
||||
import { CYCLE_UPDATED } from "@/constants/event-tracker";
|
||||
import { EUserWorkspaceRoles } from "@/constants/workspace";
|
||||
// helpers
|
||||
// hooks
|
||||
import { findHowManyDaysLeft, getDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper";
|
||||
import { copyUrlToClipboard } from "@/helpers/string.helper";
|
||||
// hooks
|
||||
import { useEventTracker, useCycle, useUser, useMember } from "@/hooks/store";
|
||||
// services
|
||||
import { CycleService } from "@/services/cycle.service";
|
||||
// types
|
||||
|
||||
type Props = {
|
||||
cycleId: string;
|
||||
handleClose: () => void;
|
||||
isArchived?: boolean;
|
||||
};
|
||||
|
||||
const defaultValues: Partial<ICycle> = {
|
||||
@ -42,8 +52,9 @@ const cycleService = new CycleService();
|
||||
|
||||
// TODO: refactor the whole component
|
||||
export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
const { cycleId, handleClose } = props;
|
||||
const { cycleId, handleClose, isArchived } = props;
|
||||
// states
|
||||
const [archiveCycleModal, setArchiveCycleModal] = useState(false);
|
||||
const [cycleDeleteModal, setCycleDeleteModal] = useState(false);
|
||||
// router
|
||||
const router = useRouter();
|
||||
@ -53,7 +64,7 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
const {
|
||||
membership: { currentProjectRole },
|
||||
} = useUser();
|
||||
const { getCycleById, updateCycleDetails } = useCycle();
|
||||
const { getCycleById, updateCycleDetails, restoreCycle } = useCycle();
|
||||
const { getUserDetails } = useMember();
|
||||
// derived values
|
||||
const cycleDetails = getCycleById(cycleId);
|
||||
@ -108,6 +119,27 @@ export const CycleDetailsSidebar: React.FC<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(() => {
|
||||
if (cycleDetails)
|
||||
reset({
|
||||
@ -229,13 +261,22 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
return (
|
||||
<div className="relative">
|
||||
{cycleDetails && workspaceSlug && projectId && (
|
||||
<CycleDeleteModal
|
||||
cycle={cycleDetails}
|
||||
isOpen={cycleDeleteModal}
|
||||
handleClose={() => setCycleDeleteModal(false)}
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
projectId={projectId.toString()}
|
||||
/>
|
||||
<>
|
||||
<ArchiveCycleModal
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
projectId={projectId.toString()}
|
||||
cycleId={cycleId}
|
||||
isOpen={archiveCycleModal}
|
||||
handleClose={() => setArchiveCycleModal(false)}
|
||||
/>
|
||||
<CycleDeleteModal
|
||||
cycle={cycleDetails}
|
||||
isOpen={cycleDeleteModal}
|
||||
handleClose={() => setCycleDeleteModal(false)}
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
projectId={projectId.toString()}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<>
|
||||
@ -249,22 +290,54 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-3.5">
|
||||
<button onClick={handleCopyText}>
|
||||
<LinkIcon className="h-3 w-3 text-custom-text-300" />
|
||||
</button>
|
||||
{!isCompleted && isEditingAllowed && (
|
||||
{!isArchived && (
|
||||
<button onClick={handleCopyText}>
|
||||
<LinkIcon className="h-3 w-3 text-custom-text-300" />
|
||||
</button>
|
||||
)}
|
||||
{isEditingAllowed && (
|
||||
<CustomMenu placement="bottom-end" ellipsis>
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => {
|
||||
setTrackElement("CYCLE_PAGE_SIDEBAR");
|
||||
setCycleDeleteModal(true);
|
||||
}}
|
||||
>
|
||||
<span className="flex items-center justify-start gap-2">
|
||||
<Trash2 className="h-3 w-3" />
|
||||
<span>Delete cycle</span>
|
||||
</span>
|
||||
</CustomMenu.MenuItem>
|
||||
{!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
|
||||
onClick={() => {
|
||||
setTrackElement("CYCLE_PAGE_SIDEBAR");
|
||||
setCycleDeleteModal(true);
|
||||
}}
|
||||
>
|
||||
<span className="flex items-center justify-start gap-2">
|
||||
<Trash2 className="h-3 w-3" />
|
||||
<span>Delete cycle</span>
|
||||
</span>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
</CustomMenu>
|
||||
)}
|
||||
</div>
|
||||
@ -331,6 +404,7 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
to: "End date",
|
||||
}}
|
||||
required={cycleDetails.status !== "draft"}
|
||||
disabled={isArchived}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
@ -149,6 +149,7 @@ export const DateRangeDropdown: React.FC<Props> = (props) => {
|
||||
if (!isOpen) handleKeyDown(e);
|
||||
} else handleKeyDown(e);
|
||||
}}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Combobox.Button as={React.Fragment}>
|
||||
<button
|
||||
|
@ -17,7 +17,7 @@ export * from "./workspace-settings";
|
||||
export * from "./pages";
|
||||
export * from "./project-draft-issues";
|
||||
export * from "./project-archived-issue-details";
|
||||
export * from "./project-archived-issues";
|
||||
export * from "./project-archives";
|
||||
export * from "./project-issue-details";
|
||||
export * from "./user-profile";
|
||||
export * from "./workspace-active-cycles";
|
||||
|
@ -23,8 +23,6 @@ import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// types
|
||||
|
||||
export const ModulesListHeader: React.FC = observer(() => {
|
||||
// states
|
||||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||
// refs
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
// router
|
||||
@ -49,6 +47,8 @@ export const ModulesListHeader: React.FC = observer(() => {
|
||||
updateFilters,
|
||||
updateSearchQuery,
|
||||
} = useModuleFilter();
|
||||
// states
|
||||
const [isSearchOpen, setIsSearchOpen] = useState(searchQuery !== "" ? true : false);
|
||||
// outside click detector hook
|
||||
useOutsideClickDetector(inputRef, () => {
|
||||
if (isSearchOpen && searchQuery.trim() === "") setIsSearchOpen(false);
|
||||
|
@ -3,7 +3,7 @@ import { observer } from "mobx-react-lite";
|
||||
import { useRouter } from "next/router";
|
||||
import useSWR from "swr";
|
||||
// hooks
|
||||
import { Breadcrumbs, LayersIcon } from "@plane/ui";
|
||||
import { ArchiveIcon, Breadcrumbs, LayersIcon } from "@plane/ui";
|
||||
import { BreadcrumbLink } from "@/components/common";
|
||||
import { ProjectLogo } from "@/components/project";
|
||||
import { ISSUE_DETAILS } from "@/constants/fetch-keys";
|
||||
@ -39,7 +39,7 @@ export const ProjectArchivedIssueDetailsHeader: FC = observer(() => {
|
||||
);
|
||||
|
||||
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>
|
||||
<Breadcrumbs>
|
||||
@ -59,18 +59,26 @@ export const ProjectArchivedIssueDetailsHeader: FC = observer(() => {
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={
|
||||
<BreadcrumbLink
|
||||
href={`/${workspaceSlug}/projects/${projectId}/archived-issues`}
|
||||
label="Archived issues"
|
||||
href={`/${workspaceSlug}/projects/${projectId}/archives/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" />}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
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 { observer } from "mobx-react-lite";
|
||||
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
|
||||
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
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
@ -24,7 +19,6 @@ export const ProjectArchivedIssuesHeader: FC = observer(() => {
|
||||
const {
|
||||
issuesFilter: { issueFilters, updateFilters },
|
||||
} = useIssues(EIssuesStoreType.ARCHIVED);
|
||||
const { currentProjectDetails } = useProject();
|
||||
const { projectStates } = useProjectState();
|
||||
const { projectLabels } = useLabel();
|
||||
const {
|
||||
@ -33,7 +27,6 @@ export const ProjectArchivedIssuesHeader: FC = observer(() => {
|
||||
// for archived issues list layout is the only option
|
||||
const activeLayout = "list";
|
||||
// hooks
|
||||
const { isMobile } = usePlatformOS();
|
||||
const handleFiltersUpdate = (key: keyof IIssueFilterOptions, value: string | string[]) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
@ -68,60 +61,13 @@ export const ProjectArchivedIssuesHeader: FC = observer(() => {
|
||||
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 (
|
||||
<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
|
||||
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 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="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 px-8">
|
||||
<FiltersDropdown title="Filters" placement="bottom-end">
|
||||
<FilterSelection
|
||||
filters={issueFilters?.filters || {}}
|
@ -16,3 +16,4 @@ export * from "./peek-overview";
|
||||
|
||||
// archived issue
|
||||
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 () => {
|
||||
if (!issueOperations.archive) return;
|
||||
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
|
||||
const projectDetails = getProjectById(issue.project_id);
|
||||
|
@ -73,7 +73,7 @@ const KanbanIssueDetailsBlock: React.FC<IssueDetailsBlockProps> = observer((prop
|
||||
</Tooltip>
|
||||
) : (
|
||||
<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
|
||||
}`}
|
||||
target="_blank"
|
||||
|
@ -71,9 +71,9 @@ export const IssueBlock: React.FC<IssueBlockProps> = observer((props: IssueBlock
|
||||
</Tooltip>
|
||||
) : (
|
||||
<ControlLink
|
||||
href={`/${workspaceSlug}/projects/${issue.project_id}/${
|
||||
issue.archived_at ? "archived-issues" : "issues"
|
||||
}/${issue.id}`}
|
||||
href={`/${workspaceSlug}/projects/${issue.project_id}/${issue.archived_at ? "archives/" : ""}issues/${
|
||||
issue.id
|
||||
}`}
|
||||
target="_blank"
|
||||
onClick={() => handleIssuePeekOverview(issue)}
|
||||
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 = () => {
|
||||
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
|
||||
}`,
|
||||
hash: "sub-issues",
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { ExternalLink, Link, RotateCcw, Trash2 } from "lucide-react";
|
||||
import { ArchiveRestoreIcon, ExternalLink, Link, Trash2 } from "lucide-react";
|
||||
// hooks
|
||||
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 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 handleCopyIssueLink = () =>
|
||||
@ -67,7 +67,7 @@ export const ArchivedIssueQuickActions: React.FC<IQuickActionProps> = (props) =>
|
||||
{isRestoringAllowed && (
|
||||
<CustomMenu.MenuItem onClick={handleRestore}>
|
||||
<div className="flex items-center gap-2">
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
<ArchiveRestoreIcon className="h-3 w-3" />
|
||||
Restore
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
|
@ -43,9 +43,8 @@ export const ArchivedIssueLayoutRoot: React.FC = observer(() => {
|
||||
|
||||
if (!workspaceSlug || !projectId) return <></>;
|
||||
return (
|
||||
<div className="relative flex h-full w-full flex-col overflow-hidden">
|
||||
<>
|
||||
<ArchivedIssueAppliedFiltersRoot />
|
||||
|
||||
{issues?.groupedIssueIds?.length === 0 ? (
|
||||
<div className="relative h-full w-full overflow-y-auto">
|
||||
<ProjectArchivedEmptyState />
|
||||
@ -58,6 +57,6 @@ export const ArchivedIssueLayoutRoot: React.FC = observer(() => {
|
||||
<IssuePeekOverview is_archived />
|
||||
</Fragment>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
@ -23,7 +23,7 @@ export const SpreadsheetSubIssueColumn: React.FC<Props> = observer((props: Props
|
||||
|
||||
const redirectToIssueDetail = () => {
|
||||
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
|
||||
}`,
|
||||
hash: "sub-issues",
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { MoveRight, MoveDiagonal, Link2, Trash2, RotateCcw } from "lucide-react";
|
||||
import { MoveRight, MoveDiagonal, Link2, Trash2, ArchiveRestoreIcon } from "lucide-react";
|
||||
// ui
|
||||
import {
|
||||
ArchiveIcon,
|
||||
@ -86,7 +86,7 @@ export const IssuePeekOverviewHeader: FC<PeekOverviewHeaderProps> = observer((pr
|
||||
const stateDetails = issueDetails ? getStateById(issueDetails?.state_id) : undefined;
|
||||
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>) => {
|
||||
e.stopPropagation();
|
||||
@ -182,7 +182,7 @@ export const IssuePeekOverviewHeader: FC<PeekOverviewHeaderProps> = observer((pr
|
||||
{isRestoringAllowed && (
|
||||
<Tooltip tooltipContent="Restore" isMobile={isMobile}>
|
||||
<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>
|
||||
</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;
|
||||
handleFiltersUpdate: (key: keyof TModuleFilters, value: string | string[]) => void;
|
||||
memberIds?: string[] | undefined;
|
||||
isArchived?: boolean;
|
||||
};
|
||||
|
||||
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
|
||||
const [filtersSearchQuery, setFiltersSearchQuery] = useState("");
|
||||
|
||||
@ -42,26 +50,30 @@ export const ModuleFiltersSelection: 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="py-2">
|
||||
<FilterOption
|
||||
isChecked={!!displayFilters.favorites}
|
||||
onClick={() =>
|
||||
handleDisplayFiltersUpdate({
|
||||
favorites: !displayFilters.favorites,
|
||||
})
|
||||
}
|
||||
title="Favorites"
|
||||
/>
|
||||
</div>
|
||||
{!isArchived && (
|
||||
<div className="py-2">
|
||||
<FilterOption
|
||||
isChecked={!!displayFilters.favorites}
|
||||
onClick={() =>
|
||||
handleDisplayFiltersUpdate({
|
||||
favorites: !displayFilters.favorites,
|
||||
})
|
||||
}
|
||||
title="Favorites"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* status */}
|
||||
<div className="py-2">
|
||||
<FilterStatus
|
||||
appliedFilters={(filters.status as TModuleStatus[]) ?? null}
|
||||
handleUpdate={(val) => handleFiltersUpdate("status", val)}
|
||||
searchQuery={filtersSearchQuery}
|
||||
/>
|
||||
</div>
|
||||
{!isArchived && (
|
||||
<div className="py-2">
|
||||
<FilterStatus
|
||||
appliedFilters={(filters.status as TModuleStatus[]) ?? null}
|
||||
handleUpdate={(val) => handleFiltersUpdate("status", val)}
|
||||
searchQuery={filtersSearchQuery}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* lead */}
|
||||
<div className="py-2">
|
||||
|
@ -11,3 +11,7 @@ export * from "./sidebar";
|
||||
export * from "./module-card-item";
|
||||
export * from "./module-list-item";
|
||||
export * from "./module-peek-overview";
|
||||
export * from "./quick-actions";
|
||||
|
||||
// archived modules
|
||||
export * from "./archived-modules";
|
||||
|
@ -1,19 +1,19 @@
|
||||
import React, { useState } from "react";
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { Info, LinkIcon, Pencil, Star, Trash2 } from "lucide-react";
|
||||
// icons
|
||||
import { Info, Star } from "lucide-react";
|
||||
// ui
|
||||
import { Avatar, AvatarGroup, CustomMenu, LayersIcon, Tooltip, TOAST_TYPE, setToast, setPromiseToast } from "@plane/ui";
|
||||
import { Avatar, AvatarGroup, LayersIcon, Tooltip, setPromiseToast } from "@plane/ui";
|
||||
// components
|
||||
import { CreateUpdateModuleModal, DeleteModuleModal } from "@/components/modules";
|
||||
import { ModuleQuickActions } from "@/components/modules";
|
||||
// constants
|
||||
import { MODULE_FAVORITED, MODULE_UNFAVORITED } from "@/constants/event-tracker";
|
||||
import { MODULE_STATUS } from "@/constants/module";
|
||||
import { EUserProjectRoles } from "@/constants/project";
|
||||
// helpers
|
||||
import { getDate, renderFormattedDate } from "@/helpers/date-time.helper";
|
||||
import { copyUrlToClipboard } from "@/helpers/string.helper";
|
||||
// hooks
|
||||
import { useEventTracker, useMember, useModule, useUser } from "@/hooks/store";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
@ -24,9 +24,6 @@ type Props = {
|
||||
|
||||
export const ModuleCardItem: React.FC<Props> = observer((props) => {
|
||||
const { moduleId } = props;
|
||||
// states
|
||||
const [editModal, setEditModal] = useState(false);
|
||||
const [deleteModal, setDeleteModal] = useState(false);
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
@ -36,7 +33,7 @@ export const ModuleCardItem: React.FC<Props> = observer((props) => {
|
||||
} = useUser();
|
||||
const { getModuleById, addModuleToFavorites, removeModuleFromFavorites } = useModule();
|
||||
const { getUserDetails } = useMember();
|
||||
const { setTrackElement, captureEvent } = useEventTracker();
|
||||
const { captureEvent } = useEventTracker();
|
||||
// derived values
|
||||
const moduleDetails = getModuleById(moduleId);
|
||||
const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER;
|
||||
@ -99,32 +96,6 @@ export const ModuleCardItem: React.FC<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>) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
@ -160,142 +131,112 @@ export const ModuleCardItem: React.FC<Props> = observer((props) => {
|
||||
? !moduleTotalIssues || moduleTotalIssues === 0
|
||||
? "0 Issue"
|
||||
: moduleTotalIssues === moduleDetails.completed_issues
|
||||
? `${moduleTotalIssues} Issue${moduleTotalIssues > 1 ? "s" : ""}`
|
||||
: `${moduleDetails.completed_issues}/${moduleTotalIssues} Issues`
|
||||
? `${moduleTotalIssues} Issue${moduleTotalIssues > 1 ? "s" : ""}`
|
||||
: `${moduleDetails.completed_issues}/${moduleTotalIssues} Issues`
|
||||
: "0 Issue";
|
||||
|
||||
return (
|
||||
<>
|
||||
{workspaceSlug && projectId && (
|
||||
<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}`}>
|
||||
<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 className="flex items-center justify-between gap-2">
|
||||
<Tooltip tooltipContent={moduleDetails.name} position="top" isMobile={isMobile}>
|
||||
<span className="truncate text-base font-medium">{moduleDetails.name}</span>
|
||||
</Tooltip>
|
||||
<div className="flex items-center gap-2">
|
||||
{moduleStatus && (
|
||||
<span
|
||||
className="flex h-6 w-20 items-center justify-center rounded-sm text-center text-xs"
|
||||
style={{
|
||||
color: moduleStatus.color,
|
||||
backgroundColor: `${moduleStatus.color}20`,
|
||||
}}
|
||||
>
|
||||
{moduleStatus.label}
|
||||
</span>
|
||||
)}
|
||||
<button onClick={openModuleOverview}>
|
||||
<Info className="h-4 w-4 text-custom-text-400" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5 text-custom-text-200">
|
||||
<LayersIcon className="h-4 w-4 text-custom-text-300" />
|
||||
<span className="text-xs text-custom-text-300">{issueCount ?? "0 Issue"}</span>
|
||||
</div>
|
||||
{moduleDetails.member_ids?.length > 0 && (
|
||||
<Tooltip tooltipContent={`${moduleDetails.member_ids.length} Members`} isMobile={isMobile}>
|
||||
<div className="flex cursor-default items-center gap-1">
|
||||
<AvatarGroup showTooltip={false}>
|
||||
{moduleDetails.member_ids.map((member_id) => {
|
||||
const member = getUserDetails(member_id);
|
||||
return <Avatar key={member?.id} name={member?.display_name} src={member?.avatar} />;
|
||||
})}
|
||||
</AvatarGroup>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Tooltip
|
||||
isMobile={isMobile}
|
||||
tooltipContent={isNaN(completionPercentage) ? "0" : `${completionPercentage.toFixed(0)}%`}
|
||||
position="top-left"
|
||||
>
|
||||
<div className="flex w-full items-center">
|
||||
<div
|
||||
className="bar relative h-1.5 w-full rounded bg-custom-background-90"
|
||||
<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>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Tooltip tooltipContent={moduleDetails.name} position="top" isMobile={isMobile}>
|
||||
<span className="truncate text-base font-medium">{moduleDetails.name}</span>
|
||||
</Tooltip>
|
||||
<div className="flex items-center gap-2">
|
||||
{moduleStatus && (
|
||||
<span
|
||||
className="flex h-6 w-20 items-center justify-center rounded-sm text-center text-xs"
|
||||
style={{
|
||||
boxShadow: "1px 1px 4px 0px rgba(161, 169, 191, 0.35) inset",
|
||||
color: moduleStatus.color,
|
||||
backgroundColor: `${moduleStatus.color}20`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute left-0 top-0 h-1.5 rounded bg-blue-600 duration-300"
|
||||
style={{
|
||||
width: `${isNaN(completionPercentage) ? 0 : completionPercentage.toFixed(0)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
{isDateValid ? (
|
||||
<>
|
||||
<span className="text-xs text-custom-text-300">
|
||||
{renderFormattedDate(startDate) ?? "_ _"} - {renderFormattedDate(endDate) ?? "_ _"}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-xs text-custom-text-400">No due date</span>
|
||||
{moduleStatus.label}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="z-[5] flex items-center gap-1.5">
|
||||
{isEditingAllowed &&
|
||||
(moduleDetails.is_favorite ? (
|
||||
<button type="button" onClick={handleRemoveFromFavorites}>
|
||||
<Star className="h-3.5 w-3.5 fill-current text-amber-500" />
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" onClick={handleAddToFavorites}>
|
||||
<Star className="h-3.5 w-3.5 text-custom-text-200" />
|
||||
</button>
|
||||
))}
|
||||
|
||||
<CustomMenu ellipsis className="z-10" placement="left-start">
|
||||
{isEditingAllowed && (
|
||||
<>
|
||||
<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>
|
||||
<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>
|
||||
<button onClick={openModuleOverview}>
|
||||
<Info className="h-4 w-4 text-custom-text-400" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5 text-custom-text-200">
|
||||
<LayersIcon className="h-4 w-4 text-custom-text-300" />
|
||||
<span className="text-xs text-custom-text-300">{issueCount ?? "0 Issue"}</span>
|
||||
</div>
|
||||
{moduleDetails.member_ids?.length > 0 && (
|
||||
<Tooltip tooltipContent={`${moduleDetails.member_ids.length} Members`} isMobile={isMobile}>
|
||||
<div className="flex cursor-default items-center gap-1">
|
||||
<AvatarGroup showTooltip={false}>
|
||||
{moduleDetails.member_ids.map((member_id) => {
|
||||
const member = getUserDetails(member_id);
|
||||
return <Avatar key={member?.id} name={member?.display_name} src={member?.avatar} />;
|
||||
})}
|
||||
</AvatarGroup>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Tooltip
|
||||
isMobile={isMobile}
|
||||
tooltipContent={isNaN(completionPercentage) ? "0" : `${completionPercentage.toFixed(0)}%`}
|
||||
position="top-left"
|
||||
>
|
||||
<div className="flex w-full items-center">
|
||||
<div
|
||||
className="bar relative h-1.5 w-full rounded bg-custom-background-90"
|
||||
style={{
|
||||
boxShadow: "1px 1px 4px 0px rgba(161, 169, 191, 0.35) inset",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute left-0 top-0 h-1.5 rounded bg-blue-600 duration-300"
|
||||
style={{
|
||||
width: `${isNaN(completionPercentage) ? 0 : completionPercentage.toFixed(0)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
{isDateValid ? (
|
||||
<>
|
||||
<span className="text-xs text-custom-text-300">
|
||||
{renderFormattedDate(startDate) ?? "_ _"} - {renderFormattedDate(endDate) ?? "_ _"}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-xs text-custom-text-400">No due date</span>
|
||||
)}
|
||||
|
||||
<div className="z-[5] flex items-center gap-1.5">
|
||||
{isEditingAllowed &&
|
||||
(moduleDetails.is_favorite ? (
|
||||
<button type="button" onClick={handleRemoveFromFavorites}>
|
||||
<Star className="h-3.5 w-3.5 fill-current text-amber-500" />
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" onClick={handleAddToFavorites}>
|
||||
<Star className="h-3.5 w-3.5 text-custom-text-200" />
|
||||
</button>
|
||||
))}
|
||||
{workspaceSlug && projectId && (
|
||||
<ModuleQuickActions
|
||||
moduleId={moduleId}
|
||||
projectId={projectId.toString()}
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
});
|
||||
|
@ -1,44 +1,30 @@
|
||||
import React, { useState } from "react";
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { Check, Info, LinkIcon, Pencil, Star, Trash2, User2 } from "lucide-react";
|
||||
// icons
|
||||
import { Check, Info, Star, User2 } from "lucide-react";
|
||||
// ui
|
||||
import {
|
||||
Avatar,
|
||||
AvatarGroup,
|
||||
CircularProgressIndicator,
|
||||
CustomMenu,
|
||||
Tooltip,
|
||||
TOAST_TYPE,
|
||||
setToast,
|
||||
setPromiseToast,
|
||||
} from "@plane/ui";
|
||||
import { CreateUpdateModuleModal, DeleteModuleModal } from "@/components/modules";
|
||||
import { MODULE_FAVORITED, MODULE_UNFAVORITED } from "@/constants/event-tracker";
|
||||
// helpers
|
||||
import { Avatar, AvatarGroup, CircularProgressIndicator, Tooltip, setPromiseToast } from "@plane/ui";
|
||||
// components
|
||||
import { ModuleQuickActions } from "@/components/modules";
|
||||
// constants
|
||||
import { MODULE_FAVORITED, MODULE_UNFAVORITED } from "@/constants/event-tracker";
|
||||
import { MODULE_STATUS } from "@/constants/module";
|
||||
import { EUserProjectRoles } from "@/constants/project";
|
||||
// helpers
|
||||
import { getDate, renderFormattedDate } from "@/helpers/date-time.helper";
|
||||
import { copyUrlToClipboard } from "@/helpers/string.helper";
|
||||
// hooks
|
||||
import { useModule, useUser, useEventTracker, useMember } from "@/hooks/store";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// components
|
||||
// ui
|
||||
// helpers
|
||||
// constants
|
||||
|
||||
type Props = {
|
||||
moduleId: string;
|
||||
isArchived?: boolean;
|
||||
};
|
||||
|
||||
export const ModuleListItem: React.FC<Props> = observer((props) => {
|
||||
const { moduleId } = props;
|
||||
// states
|
||||
const [editModal, setEditModal] = useState(false);
|
||||
const [deleteModal, setDeleteModal] = useState(false);
|
||||
const { moduleId, isArchived = false } = props;
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
@ -48,7 +34,7 @@ export const ModuleListItem: React.FC<Props> = observer((props) => {
|
||||
} = useUser();
|
||||
const { getModuleById, addModuleToFavorites, removeModuleFromFavorites } = useModule();
|
||||
const { getUserDetails } = useMember();
|
||||
const { setTrackElement, captureEvent } = useEventTracker();
|
||||
const { captureEvent } = useEventTracker();
|
||||
// derived values
|
||||
const moduleDetails = getModuleById(moduleId);
|
||||
const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER;
|
||||
@ -111,33 +97,7 @@ export const ModuleListItem: React.FC<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 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>) => {
|
||||
const openModuleOverview = (e: React.MouseEvent<HTMLButtonElement | HTMLAnchorElement>) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
const { query } = router;
|
||||
@ -167,126 +127,105 @@ export const ModuleListItem: React.FC<Props> = observer((props) => {
|
||||
const completedModuleCheck = moduleDetails.status === "completed";
|
||||
|
||||
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}`}>
|
||||
<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 gap-3 overflow-hidden">
|
||||
<div className="flex items-center gap-4 truncate">
|
||||
<span className="flex-shrink-0">
|
||||
<CircularProgressIndicator size={38} percentage={progress}>
|
||||
{completedModuleCheck ? (
|
||||
progress === 100 ? (
|
||||
<Check className="h-3 w-3 stroke-[2] text-custom-primary-100" />
|
||||
) : (
|
||||
<span className="text-sm text-custom-primary-100">{`!`}</span>
|
||||
)
|
||||
) : progress === 100 ? (
|
||||
<Link
|
||||
href={`/${workspaceSlug}/projects/${moduleDetails.project_id}/modules/${moduleDetails.id}`}
|
||||
onClick={(e) => {
|
||||
if (isArchived) {
|
||||
openModuleOverview(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 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 gap-3 overflow-hidden">
|
||||
<div className="flex items-center gap-4 truncate">
|
||||
<span className="flex-shrink-0">
|
||||
<CircularProgressIndicator size={38} percentage={progress}>
|
||||
{completedModuleCheck ? (
|
||||
progress === 100 ? (
|
||||
<Check className="h-3 w-3 stroke-[2] text-custom-primary-100" />
|
||||
) : (
|
||||
<span className="text-xs text-custom-text-300">{`${progress}%`}</span>
|
||||
)}
|
||||
</CircularProgressIndicator>
|
||||
</span>
|
||||
<Tooltip tooltipContent={moduleDetails.name} position="top" isMobile={isMobile}>
|
||||
<span className="truncate text-base font-medium">{moduleDetails.name}</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<button onClick={openModuleOverview} className="z-[5] hidden flex-shrink-0 group-hover:flex">
|
||||
<Info className="h-4 w-4 text-custom-text-400" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-shrink-0 items-center justify-center">
|
||||
{moduleStatus && (
|
||||
<span
|
||||
className="flex h-6 w-20 flex-shrink-0 items-center justify-center rounded-sm text-center text-xs"
|
||||
style={{
|
||||
color: moduleStatus.color,
|
||||
backgroundColor: `${moduleStatus.color}20`,
|
||||
}}
|
||||
>
|
||||
{moduleStatus.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative flex w-full items-center justify-between gap-2.5 overflow-hidden sm:w-auto sm:flex-shrink-0 sm:justify-end ">
|
||||
<div className="text-xs text-custom-text-300">
|
||||
{renderDate && (
|
||||
<span className=" text-xs text-custom-text-300">
|
||||
{renderFormattedDate(startDate) ?? "_ _"} - {renderFormattedDate(endDate) ?? "_ _"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative flex flex-shrink-0 items-center gap-3">
|
||||
<Tooltip tooltipContent={`${moduleDetails?.member_ids?.length || 0} Members`} isMobile={isMobile}>
|
||||
<div className="flex w-10 cursor-default items-center justify-center gap-1">
|
||||
{moduleDetails.member_ids.length > 0 ? (
|
||||
<AvatarGroup showTooltip={false}>
|
||||
{moduleDetails.member_ids.map((member_id) => {
|
||||
const member = getUserDetails(member_id);
|
||||
return <Avatar key={member?.id} name={member?.display_name} src={member?.avatar} />;
|
||||
})}
|
||||
</AvatarGroup>
|
||||
<span className="text-sm text-custom-primary-100">{`!`}</span>
|
||||
)
|
||||
) : progress === 100 ? (
|
||||
<Check className="h-3 w-3 stroke-[2] text-custom-primary-100" />
|
||||
) : (
|
||||
<span className="flex h-5 w-5 items-end justify-center rounded-full border border-dashed border-custom-text-400 bg-custom-background-80">
|
||||
<User2 className="h-4 w-4 text-custom-text-400" />
|
||||
</span>
|
||||
<span className="text-xs text-custom-text-300">{`${progress}%`}</span>
|
||||
)}
|
||||
</div>
|
||||
</CircularProgressIndicator>
|
||||
</span>
|
||||
<Tooltip tooltipContent={moduleDetails.name} position="top" isMobile={isMobile}>
|
||||
<span className="truncate text-base font-medium">{moduleDetails.name}</span>
|
||||
</Tooltip>
|
||||
|
||||
{isEditingAllowed &&
|
||||
(moduleDetails.is_favorite ? (
|
||||
<button type="button" onClick={handleRemoveFromFavorites} className="z-[1]">
|
||||
<Star className="h-3.5 w-3.5 fill-current text-amber-500" />
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" onClick={handleAddToFavorites} className="z-[1]">
|
||||
<Star className="h-3.5 w-3.5 text-custom-text-300" />
|
||||
</button>
|
||||
))}
|
||||
|
||||
<CustomMenu verticalEllipsis buttonClassName="z-[1]" placement="left-start">
|
||||
{isEditingAllowed && (
|
||||
<>
|
||||
<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>
|
||||
<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>
|
||||
<button onClick={openModuleOverview} className="z-[5] hidden flex-shrink-0 group-hover:flex">
|
||||
<Info className="h-4 w-4 text-custom-text-400" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-shrink-0 items-center justify-center">
|
||||
{moduleStatus && (
|
||||
<span
|
||||
className="flex h-6 w-20 flex-shrink-0 items-center justify-center rounded-sm text-center text-xs"
|
||||
style={{
|
||||
color: moduleStatus.color,
|
||||
backgroundColor: `${moduleStatus.color}20`,
|
||||
}}
|
||||
>
|
||||
{moduleStatus.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</>
|
||||
|
||||
<div className="relative flex w-full items-center justify-between gap-2.5 overflow-hidden sm:w-auto sm:flex-shrink-0 sm:justify-end ">
|
||||
<div className="text-xs text-custom-text-300">
|
||||
{renderDate && (
|
||||
<span className=" text-xs text-custom-text-300">
|
||||
{renderFormattedDate(startDate) ?? "_ _"} - {renderFormattedDate(endDate) ?? "_ _"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative flex flex-shrink-0 items-center gap-3">
|
||||
<Tooltip tooltipContent={`${moduleDetails?.member_ids?.length || 0} Members`} isMobile={isMobile}>
|
||||
<div className="flex w-10 cursor-default items-center justify-center gap-1">
|
||||
{moduleDetails.member_ids.length > 0 ? (
|
||||
<AvatarGroup showTooltip={false}>
|
||||
{moduleDetails.member_ids.map((member_id) => {
|
||||
const member = getUserDetails(member_id);
|
||||
return <Avatar key={member?.id} name={member?.display_name} src={member?.avatar} />;
|
||||
})}
|
||||
</AvatarGroup>
|
||||
) : (
|
||||
<span className="flex h-5 w-5 items-end justify-center rounded-full border border-dashed border-custom-text-400 bg-custom-background-80">
|
||||
<User2 className="h-4 w-4 text-custom-text-400" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
{isEditingAllowed &&
|
||||
!isArchived &&
|
||||
(moduleDetails.is_favorite ? (
|
||||
<button type="button" onClick={handleRemoveFromFavorites} className="z-[1]">
|
||||
<Star className="h-3.5 w-3.5 fill-current text-amber-500" />
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" onClick={handleAddToFavorites} className="z-[1]">
|
||||
<Star className="h-3.5 w-3.5 text-custom-text-300" />
|
||||
</button>
|
||||
))}
|
||||
{workspaceSlug && projectId && (
|
||||
<ModuleQuickActions
|
||||
moduleId={moduleId}
|
||||
projectId={projectId.toString()}
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
isArchived={isArchived}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
});
|
||||
|
@ -9,9 +9,10 @@ import { ModuleDetailsSidebar } from "./sidebar";
|
||||
type Props = {
|
||||
projectId: 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
|
||||
const router = useRouter();
|
||||
const { peekModule } = router.query;
|
||||
@ -29,10 +30,10 @@ export const ModulePeekOverview: React.FC<Props> = observer(({ projectId, worksp
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!peekModule) return;
|
||||
if (!peekModule || isArchived) return;
|
||||
|
||||
fetchModuleDetails(workspaceSlug, projectId, peekModule.toString());
|
||||
}, [fetchModuleDetails, peekModule, projectId, workspaceSlug]);
|
||||
}, [fetchModuleDetails, isArchived, peekModule, projectId, workspaceSlug]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -45,7 +46,11 @@ export const ModulePeekOverview: React.FC<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)",
|
||||
}}
|
||||
>
|
||||
<ModuleDetailsSidebar moduleId={peekModule?.toString() ?? ""} handleClose={handleClose} />
|
||||
<ModuleDetailsSidebar
|
||||
moduleId={peekModule?.toString() ?? ""}
|
||||
handleClose={handleClose}
|
||||
isArchived={isArchived}
|
||||
/>
|
||||
</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 {
|
||||
AlertCircle,
|
||||
ArchiveRestoreIcon,
|
||||
CalendarClock,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
@ -25,13 +26,14 @@ import {
|
||||
UserGroupIcon,
|
||||
TOAST_TYPE,
|
||||
setToast,
|
||||
ArchiveIcon,
|
||||
TextArea,
|
||||
} from "@plane/ui";
|
||||
// components
|
||||
import { LinkModal, LinksList, SidebarProgressStats } from "@/components/core";
|
||||
import ProgressChart from "@/components/core/sidebar/progress-chart";
|
||||
import { DateRangeDropdown, MemberDropdown } from "@/components/dropdowns";
|
||||
import { DeleteModuleModal } from "@/components/modules";
|
||||
import { ArchiveModuleModal, DeleteModuleModal } from "@/components/modules";
|
||||
// constant
|
||||
import {
|
||||
MODULE_LINK_CREATED,
|
||||
@ -59,13 +61,15 @@ const defaultValues: Partial<IModule> = {
|
||||
type Props = {
|
||||
moduleId: string;
|
||||
handleClose: () => void;
|
||||
isArchived?: boolean;
|
||||
};
|
||||
|
||||
// TODO: refactor this component
|
||||
export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
const { moduleId, handleClose } = props;
|
||||
const { moduleId, handleClose, isArchived } = props;
|
||||
// states
|
||||
const [moduleDeleteModal, setModuleDeleteModal] = useState(false);
|
||||
const [archiveModuleModal, setArchiveModuleModal] = useState(false);
|
||||
const [moduleLinkModal, setModuleLinkModal] = useState(false);
|
||||
const [selectedLinkToUpdate, setSelectedLinkToUpdate] = useState<ILinkDetails | null>(null);
|
||||
// router
|
||||
@ -75,10 +79,14 @@ export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
const {
|
||||
membership: { currentProjectRole },
|
||||
} = useUser();
|
||||
const { getModuleById, updateModuleDetails, createModuleLink, updateModuleLink, deleteModuleLink } = useModule();
|
||||
const { getModuleById, updateModuleDetails, createModuleLink, updateModuleLink, deleteModuleLink, restoreModule } =
|
||||
useModule();
|
||||
const { setTrackElement, captureModuleEvent, captureEvent } = useEventTracker();
|
||||
const moduleDetails = getModuleById(moduleId);
|
||||
|
||||
const moduleState = moduleDetails?.status.toLocaleLowerCase();
|
||||
const isInArchivableGroup = !!moduleState && ["completed", "cancelled"].includes(moduleState);
|
||||
|
||||
const { reset, control } = useForm({
|
||||
defaultValues,
|
||||
});
|
||||
@ -206,6 +214,30 @@ export const ModuleDetailsSidebar: React.FC<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(() => {
|
||||
if (moduleDetails)
|
||||
reset({
|
||||
@ -262,8 +294,16 @@ export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
createIssueLink={handleCreateLink}
|
||||
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} />
|
||||
|
||||
<>
|
||||
<div className="sticky z-10 top-0 flex items-center justify-between bg-custom-sidebar-background-100 py-5">
|
||||
<div>
|
||||
@ -275,11 +315,41 @@ export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-3.5">
|
||||
<button onClick={handleCopyText}>
|
||||
<LinkIcon className="h-3 w-3 text-custom-text-300" />
|
||||
</button>
|
||||
{!isArchived && (
|
||||
<button onClick={handleCopyText}>
|
||||
<LinkIcon className="h-3 w-3 text-custom-text-300" />
|
||||
</button>
|
||||
)}
|
||||
{isEditingAllowed && (
|
||||
<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
|
||||
onClick={() => {
|
||||
setTrackElement("Module peek-overview");
|
||||
@ -306,7 +376,7 @@ export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
customButton={
|
||||
<span
|
||||
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={{
|
||||
color: moduleStatus ? moduleStatus.color : "#a3a3a2",
|
||||
@ -320,7 +390,7 @@ export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
onChange={(value: any) => {
|
||||
submitChanges({ status: value });
|
||||
}}
|
||||
disabled={!isEditingAllowed}
|
||||
disabled={!isEditingAllowed || isArchived}
|
||||
>
|
||||
{MODULE_STATUS.map((status) => (
|
||||
<CustomSelect.Option key={status.value} value={status.value}>
|
||||
@ -379,6 +449,7 @@ export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
from: "Start date",
|
||||
to: "Target date",
|
||||
}}
|
||||
disabled={isArchived}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
@ -408,6 +479,7 @@ export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
multiple={false}
|
||||
buttonVariant="background-with-text"
|
||||
placeholder="Lead"
|
||||
disabled={isArchived}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@ -432,7 +504,7 @@ export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
projectId={projectId?.toString() ?? ""}
|
||||
buttonVariant={value && value?.length > 0 ? "transparent-without-text" : "background-with-text"}
|
||||
buttonClassName={value && value.length > 0 ? "hover:bg-transparent px-0" : ""}
|
||||
disabled={!isEditingAllowed}
|
||||
disabled={!isEditingAllowed || isArchived}
|
||||
/>
|
||||
</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">
|
||||
{currentProjectRole && moduleDetails.link_module && moduleDetails.link_module.length > 0 ? (
|
||||
<>
|
||||
{isEditingAllowed && (
|
||||
{isEditingAllowed && !isArchived && (
|
||||
<div className="flex w-full items-center justify-end">
|
||||
<button
|
||||
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,
|
||||
isOwner: currentProjectRole === EUserProjectRoles.ADMIN,
|
||||
}}
|
||||
disabled={isArchived}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
@ -586,13 +659,15 @@ export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
<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>
|
||||
</div>
|
||||
<button
|
||||
className="flex items-center gap-1.5 text-sm font-medium text-custom-primary-100"
|
||||
onClick={() => setModuleLinkModal(true)}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
Add link
|
||||
</button>
|
||||
{isEditingAllowed && !isArchived && (
|
||||
<button
|
||||
className="flex items-center gap-1.5 text-sm font-medium text-custom-primary-100"
|
||||
onClick={() => setModuleLinkModal(true)}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
Add link
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
@ -16,12 +16,12 @@ import {
|
||||
NOTIFICATION_SNOOZED,
|
||||
} from "@/constants/event-tracker";
|
||||
import { snoozeOptions } from "@/constants/notification";
|
||||
// helper
|
||||
import { calculateTimeAgo, renderFormattedTime, renderFormattedDate, getDate } from "@/helpers/date-time.helper";
|
||||
import { replaceUnderscoreIfSnakeCase, truncateText, stripAndTruncateHTML } from "@/helpers/string.helper";
|
||||
// hooks
|
||||
import { useEventTracker } from "@/hooks/store";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// helper
|
||||
import { calculateTimeAgo, renderFormattedTime, renderFormattedDate, getDate } from "helpers/date-time.helper";
|
||||
import { replaceUnderscoreIfSnakeCase, truncateText, stripAndTruncateHTML } from "helpers/string.helper";
|
||||
|
||||
|
||||
type NotificationCardProps = {
|
||||
@ -137,8 +137,8 @@ export const NotificationCard: React.FC<NotificationCardProps> = (props) => {
|
||||
closePopover();
|
||||
}}
|
||||
href={`/${workspaceSlug}/projects/${notification.project}/${
|
||||
notificationField === "archived_at" ? "archived-issues" : "issues"
|
||||
}/${notification.data.issue.id}`}
|
||||
notificationField === "archived_at" ? "archives/" : ""
|
||||
}issues/${notification.data.issue.id}`}
|
||||
className={`group relative flex w-full cursor-pointer items-center gap-4 p-3 pl-6 ${
|
||||
notification.read_at === null ? "bg-custom-primary-70/5" : "hover:bg-custom-background-200"
|
||||
}`}
|
||||
@ -184,12 +184,12 @@ export const NotificationCard: React.FC<NotificationCardProps> = (props) => {
|
||||
{notificationField === "comment"
|
||||
? "commented"
|
||||
: notificationField === "archived_at"
|
||||
? notification.data.issue_activity.new_value === "restore"
|
||||
? "restored the issue"
|
||||
: "archived the issue"
|
||||
: notificationField === "None"
|
||||
? null
|
||||
: replaceUnderscoreIfSnakeCase(notificationField)}{" "}
|
||||
? notification.data.issue_activity.new_value === "restore"
|
||||
? "restored the issue"
|
||||
: "archived the issue"
|
||||
: notificationField === "None"
|
||||
? null
|
||||
: replaceUnderscoreIfSnakeCase(notificationField)}{" "}
|
||||
{!["comment", "archived_at", "None"].includes(notificationField) ? "to" : ""}
|
||||
<span className="font-semibold">
|
||||
{" "}
|
||||
|
@ -282,13 +282,6 @@ export const ProjectSidebarListItem: React.FC<Props> = observer((props) => {
|
||||
</span>
|
||||
</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 */}
|
||||
{isAdmin && (
|
||||
<CustomMenu.MenuItem onClick={() => setPublishModal(true)}>
|
||||
@ -300,16 +293,6 @@ export const ProjectSidebarListItem: React.FC<Props> = observer((props) => {
|
||||
</div>
|
||||
</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>
|
||||
<Link href={`/${workspaceSlug}/projects/${project?.id}/draft-issues/`}>
|
||||
<div className="flex items-center justify-start gap-2">
|
||||
@ -318,6 +301,23 @@ export const ProjectSidebarListItem: React.FC<Props> = observer((props) => {
|
||||
</div>
|
||||
</Link>
|
||||
</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>
|
||||
<Link href={`/${workspaceSlug}/projects/${project?.id}/settings`}>
|
||||
<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_ALL = "project-cycle-all",
|
||||
PROJECT_CYCLE_COMPLETED_NO_ISSUES = "project-cycle-completed-no-issues",
|
||||
PROJECT_ARCHIVED_NO_CYCLES = "project-archived-no-cycles",
|
||||
PROJECT_EMPTY_FILTER = "project-empty-filter",
|
||||
PROJECT_ARCHIVED_EMPTY_FILTER = "project-archived-empty-filter",
|
||||
PROJECT_DRAFT_EMPTY_FILTER = "project-draft-empty-filter",
|
||||
@ -62,6 +63,7 @@ export enum EmptyStateType {
|
||||
MEMBERS_EMPTY_SEARCH = "members-empty-search",
|
||||
PROJECT_MODULE_ISSUES = "project-module-issues",
|
||||
PROJECT_MODULE = "project-module",
|
||||
PROJECT_ARCHIVED_NO_MODULES = "project-archived-no-modules",
|
||||
PROJECT_VIEW = "project-view",
|
||||
PROJECT_PAGE = "project-page",
|
||||
PROJECT_PAGE_ALL = "project-page-all",
|
||||
@ -308,6 +310,12 @@ const emptyStateDetails = {
|
||||
"No issues in the cycle. Issues are either transferred or hidden. To see hidden issues if any, update your display properties accordingly.",
|
||||
path: "/empty-state/cycle/completed-no-issues",
|
||||
},
|
||||
[EmptyStateType.PROJECT_ARCHIVED_NO_CYCLES]: {
|
||||
key: EmptyStateType.PROJECT_ARCHIVED_NO_CYCLES,
|
||||
title: "No archived cycles yet",
|
||||
description: "To tidy up your project, archive completed cycles. Find them here once archived.",
|
||||
path: "/empty-state/archived/empty-cycles",
|
||||
},
|
||||
[EmptyStateType.PROJECT_CYCLE_ALL]: {
|
||||
key: EmptyStateType.PROJECT_CYCLE_ALL,
|
||||
title: "No cycles",
|
||||
@ -368,7 +376,7 @@ const emptyStateDetails = {
|
||||
key: EmptyStateType.PROJECT_ARCHIVED_NO_ISSUES,
|
||||
title: "No archived issues yet",
|
||||
description:
|
||||
"Archived issues help you remove issues you completed or cancelled from focus. You can set automation to auto archive issues and find them here.",
|
||||
"Manually or through automation, you can archive issues that are completed or cancelled. Find them here once archived.",
|
||||
path: "/empty-state/archived/empty-issues",
|
||||
primaryButton: {
|
||||
text: "Set automation",
|
||||
@ -432,6 +440,12 @@ const emptyStateDetails = {
|
||||
accessType: "project",
|
||||
access: EUserProjectRoles.MEMBER,
|
||||
},
|
||||
[EmptyStateType.PROJECT_ARCHIVED_NO_MODULES]: {
|
||||
key: EmptyStateType.PROJECT_ARCHIVED_NO_MODULES,
|
||||
title: "No archived Modules yet",
|
||||
description: "To tidy up your project, archive completed or cancelled modules. Find them here once archived.",
|
||||
path: "/empty-state/archived/empty-modules",
|
||||
},
|
||||
// project views
|
||||
[EmptyStateType.PROJECT_VIEW]: {
|
||||
key: EmptyStateType.PROJECT_VIEW,
|
||||
|
@ -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 { useRouter } from "next/router";
|
||||
import useSWR from "swr";
|
||||
// hooks
|
||||
import { RotateCcw } from "lucide-react";
|
||||
// icons
|
||||
import { ArchiveRestoreIcon } from "lucide-react";
|
||||
// ui
|
||||
import { ArchiveIcon, Button, Loader, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
// components
|
||||
import { PageHead } from "@/components/core";
|
||||
import { ProjectArchivedIssueDetailsHeader } from "@/components/headers";
|
||||
import { IssueDetailRoot } from "@/components/issues";
|
||||
// constants
|
||||
import { EIssuesStoreType } from "@/constants/issue";
|
||||
import { EUserProjectRoles } from "@/constants/project";
|
||||
// hooks
|
||||
import { useIssueDetail, useIssues, useProject, useUser } from "@/hooks/store";
|
||||
// layouts
|
||||
import { AppLayout } from "@/layouts/app-layout";
|
||||
// components
|
||||
// ui
|
||||
// icons
|
||||
// types
|
||||
import { NextPageWithLayout } from "@/lib/types";
|
||||
// constants
|
||||
|
||||
const ArchivedIssueDetailsPage: NextPageWithLayout = observer(() => {
|
||||
// router
|
||||
@ -112,7 +112,7 @@ const ArchivedIssueDetailsPage: NextPageWithLayout = observer(() => {
|
||||
{issue?.archived_at && canRestoreIssue && (
|
||||
<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">
|
||||
<ArchiveIcon className="h-3.5 w-3.5" />
|
||||
<ArchiveIcon className="h-4 w-4" />
|
||||
<p>This issue has been archived.</p>
|
||||
</div>
|
||||
<Button
|
||||
@ -121,7 +121,7 @@ const ArchivedIssueDetailsPage: NextPageWithLayout = observer(() => {
|
||||
disabled={isRestoring}
|
||||
variant="neutral-primary"
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
<ArchiveRestoreIcon className="h-3.5 w-3.5" />
|
||||
<span>{isRestoring ? "Restoring" : "Restore"}</span>
|
||||
</Button>
|
||||
</div>
|
@ -1,17 +1,16 @@
|
||||
import { ReactElement } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
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
|
||||
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
|
||||
import { NextPageWithLayout } from "@/lib/types";
|
||||
// hooks
|
||||
|
||||
const ProjectArchivedIssuesPage: NextPageWithLayout = observer(() => {
|
||||
// router
|
||||
@ -26,14 +25,17 @@ const ProjectArchivedIssuesPage: NextPageWithLayout = observer(() => {
|
||||
return (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
<ArchivedIssueLayoutRoot />
|
||||
<div className="relative flex h-full w-full flex-col overflow-hidden">
|
||||
<ArchivedIssuesHeader />
|
||||
<ArchivedIssueLayoutRoot />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
ProjectArchivedIssuesPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return (
|
||||
<AppLayout header={<ProjectArchivedIssuesHeader />} withProjectWrapper>
|
||||
<AppLayout header={<ProjectArchivesHeader />} withProjectWrapper>
|
||||
{page}
|
||||
</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 { action, computed, observable, makeObservable, runInAction } from "mobx";
|
||||
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
|
||||
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 {
|
||||
// loaders
|
||||
@ -29,9 +30,11 @@ export interface ICycleStore {
|
||||
currentProjectIncompleteCycleIds: string[] | null;
|
||||
currentProjectDraftCycleIds: string[] | null;
|
||||
currentProjectActiveCycleId: string | null;
|
||||
currentProjectArchivedCycleIds: string[] | null;
|
||||
// computed actions
|
||||
getFilteredCycleIds: (projectId: string) => string[] | null;
|
||||
getFilteredCompletedCycleIds: (projectId: string) => string[] | null;
|
||||
getFilteredArchivedCycleIds: (projectId: string) => string[] | null;
|
||||
getCycleById: (cycleId: string) => ICycle | null;
|
||||
getCycleNameById: (cycleId: string) => string | undefined;
|
||||
getActiveCycleById: (cycleId: string) => ICycle | null;
|
||||
@ -42,6 +45,7 @@ export interface ICycleStore {
|
||||
fetchWorkspaceCycles: (workspaceSlug: string) => Promise<ICycle[]>;
|
||||
fetchAllCycles: (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>;
|
||||
// crud
|
||||
createCycle: (workspaceSlug: string, projectId: string, data: Partial<ICycle>) => Promise<ICycle>;
|
||||
@ -55,6 +59,9 @@ export interface ICycleStore {
|
||||
// favorites
|
||||
addCycleToFavorites: (workspaceSlug: string, projectId: string, cycleId: string) => Promise<any>;
|
||||
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 {
|
||||
@ -70,6 +77,7 @@ export class CycleStore implements ICycleStore {
|
||||
projectService;
|
||||
issueService;
|
||||
cycleService;
|
||||
cycleArchiveService;
|
||||
|
||||
constructor(_rootStore: RootStore) {
|
||||
makeObservable(this, {
|
||||
@ -85,22 +93,29 @@ export class CycleStore implements ICycleStore {
|
||||
currentProjectIncompleteCycleIds: computed,
|
||||
currentProjectDraftCycleIds: computed,
|
||||
currentProjectActiveCycleId: computed,
|
||||
currentProjectArchivedCycleIds: computed,
|
||||
// actions
|
||||
fetchWorkspaceCycles: action,
|
||||
fetchAllCycles: action,
|
||||
fetchActiveCycle: action,
|
||||
fetchArchivedCycles: action,
|
||||
fetchCycleDetails: action,
|
||||
createCycle: action,
|
||||
updateCycleDetails: action,
|
||||
deleteCycle: action,
|
||||
addCycleToFavorites: action,
|
||||
removeCycleFromFavorites: action,
|
||||
archiveCycle: action,
|
||||
restoreCycle: action,
|
||||
});
|
||||
|
||||
this.rootStore = _rootStore;
|
||||
|
||||
// services
|
||||
this.projectService = new ProjectService();
|
||||
this.issueService = new IssueService();
|
||||
this.cycleService = new CycleService();
|
||||
this.cycleArchiveService = new CycleArchiveService();
|
||||
}
|
||||
|
||||
// computed
|
||||
@ -110,7 +125,7 @@ export class CycleStore implements ICycleStore {
|
||||
get currentProjectCycleIds() {
|
||||
const projectId = this.rootStore.app.router.projectId;
|
||||
if (!projectId || !this.fetchedMap[projectId]) return null;
|
||||
let allCycles = Object.values(this.cycleMap ?? {}).filter((c) => c?.project_id === projectId);
|
||||
let allCycles = Object.values(this.cycleMap ?? {}).filter((c) => c?.project_id === projectId && !c?.archived_at);
|
||||
allCycles = sortBy(allCycles, [(c) => c.sort_order]);
|
||||
const allCycleIds = allCycles.map((c) => c.id);
|
||||
return allCycleIds;
|
||||
@ -126,7 +141,7 @@ export class CycleStore implements ICycleStore {
|
||||
const endDate = getDate(c.end_date);
|
||||
const hasEndDatePassed = endDate && isPast(endDate);
|
||||
const isEndDateToday = endDate && isToday(endDate);
|
||||
return c.project_id === projectId && hasEndDatePassed && !isEndDateToday;
|
||||
return c.project_id === projectId && hasEndDatePassed && !isEndDateToday && !c?.archived_at;
|
||||
});
|
||||
completedCycles = sortBy(completedCycles, [(c) => c.sort_order]);
|
||||
const completedCycleIds = completedCycles.map((c) => c.id);
|
||||
@ -142,7 +157,7 @@ export class CycleStore implements ICycleStore {
|
||||
let upcomingCycles = Object.values(this.cycleMap ?? {}).filter((c) => {
|
||||
const startDate = getDate(c.start_date);
|
||||
const isStartDateUpcoming = startDate && isFuture(startDate);
|
||||
return c.project_id === projectId && isStartDateUpcoming;
|
||||
return c.project_id === projectId && isStartDateUpcoming && !c?.archived_at;
|
||||
});
|
||||
upcomingCycles = sortBy(upcomingCycles, [(c) => c.sort_order]);
|
||||
const upcomingCycleIds = upcomingCycles.map((c) => c.id);
|
||||
@ -158,7 +173,7 @@ export class CycleStore implements ICycleStore {
|
||||
let incompleteCycles = Object.values(this.cycleMap ?? {}).filter((c) => {
|
||||
const endDate = getDate(c.end_date);
|
||||
const hasEndDatePassed = endDate && isPast(endDate);
|
||||
return c.project_id === projectId && !hasEndDatePassed;
|
||||
return c.project_id === projectId && !hasEndDatePassed && !c?.archived_at;
|
||||
});
|
||||
incompleteCycles = sortBy(incompleteCycles, [(c) => c.sort_order]);
|
||||
const incompleteCycleIds = incompleteCycles.map((c) => c.id);
|
||||
@ -172,7 +187,7 @@ export class CycleStore implements ICycleStore {
|
||||
const projectId = this.rootStore.app.router.projectId;
|
||||
if (!projectId || !this.fetchedMap[projectId]) return null;
|
||||
let draftCycles = Object.values(this.cycleMap ?? {}).filter(
|
||||
(c) => c.project_id === projectId && !c.start_date && !c.end_date
|
||||
(c) => c.project_id === projectId && !c.start_date && !c.end_date && !c?.archived_at
|
||||
);
|
||||
draftCycles = sortBy(draftCycles, [(c) => c.sort_order]);
|
||||
const draftCycleIds = draftCycles.map((c) => c.id);
|
||||
@ -191,6 +206,20 @@ export class CycleStore implements ICycleStore {
|
||||
return activeCycle || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* returns all archived cycle ids for a project
|
||||
*/
|
||||
get currentProjectArchivedCycleIds() {
|
||||
const projectId = this.rootStore.app.router.projectId;
|
||||
if (!projectId || !this.fetchedMap[projectId]) return null;
|
||||
let archivedCycles = Object.values(this.cycleMap ?? {}).filter(
|
||||
(c) => c.project_id === projectId && !!c.archived_at
|
||||
);
|
||||
archivedCycles = sortBy(archivedCycles, [(c) => c.sort_order]);
|
||||
const archivedCycleIds = archivedCycles.map((c) => c.id);
|
||||
return archivedCycleIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description returns filtered cycle ids based on display filters and filters
|
||||
* @param {TCycleDisplayFilters} displayFilters
|
||||
@ -204,6 +233,7 @@ export class CycleStore implements ICycleStore {
|
||||
let cycles = Object.values(this.cycleMap ?? {}).filter(
|
||||
(c) =>
|
||||
c.project_id === projectId &&
|
||||
!c.archived_at &&
|
||||
c.name.toLowerCase().includes(searchQuery.toLowerCase()) &&
|
||||
shouldFilterCycle(c, filters ?? {})
|
||||
);
|
||||
@ -225,6 +255,7 @@ export class CycleStore implements ICycleStore {
|
||||
let cycles = Object.values(this.cycleMap ?? {}).filter(
|
||||
(c) =>
|
||||
c.project_id === projectId &&
|
||||
!c.archived_at &&
|
||||
c.status.toLowerCase() === "completed" &&
|
||||
c.name.toLowerCase().includes(searchQuery.toLowerCase()) &&
|
||||
shouldFilterCycle(c, filters ?? {})
|
||||
@ -234,6 +265,27 @@ export class CycleStore implements ICycleStore {
|
||||
return cycleIds;
|
||||
});
|
||||
|
||||
/**
|
||||
* @description returns filtered archived cycle ids based on display filters and filters
|
||||
* @param {string} projectId
|
||||
* @returns {string[] | null}
|
||||
*/
|
||||
getFilteredArchivedCycleIds = computedFn((projectId: string) => {
|
||||
const filters = this.rootStore.cycleFilter.getArchivedFiltersByProjectId(projectId);
|
||||
const searchQuery = this.rootStore.cycleFilter.archivedCyclesSearchQuery;
|
||||
if (!this.fetchedMap[projectId]) return null;
|
||||
let cycles = Object.values(this.cycleMap ?? {}).filter(
|
||||
(c) =>
|
||||
c.project_id === projectId &&
|
||||
!!c.archived_at &&
|
||||
c.name.toLowerCase().includes(searchQuery.toLowerCase()) &&
|
||||
shouldFilterCycle(c, filters ?? {})
|
||||
);
|
||||
cycles = sortBy(cycles, [(c) => !c.start_date]);
|
||||
const cycleIds = cycles.map((c) => c.id);
|
||||
return cycleIds;
|
||||
});
|
||||
|
||||
/**
|
||||
* @description returns cycle details by cycle id
|
||||
* @param cycleId
|
||||
@ -264,7 +316,7 @@ export class CycleStore implements ICycleStore {
|
||||
getProjectCycleIds = computedFn((projectId: string): string[] | null => {
|
||||
if (!this.fetchedMap[projectId]) return null;
|
||||
|
||||
let cycles = Object.values(this.cycleMap ?? {}).filter((c) => c.project_id === projectId);
|
||||
let cycles = Object.values(this.cycleMap ?? {}).filter((c) => c.project_id === projectId && !c?.archived_at);
|
||||
cycles = sortBy(cycles, [(c) => c.sort_order]);
|
||||
const cycleIds = cycles.map((c) => c.id);
|
||||
return cycleIds || null;
|
||||
@ -321,6 +373,31 @@ export class CycleStore implements ICycleStore {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description fetches archived cycles for a project
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @returns
|
||||
*/
|
||||
fetchArchivedCycles = async (workspaceSlug: string, projectId: string) => {
|
||||
this.loader = true;
|
||||
return await this.cycleArchiveService
|
||||
.getArchivedCycles(workspaceSlug, projectId)
|
||||
.then((response) => {
|
||||
runInAction(() => {
|
||||
response.forEach((cycle) => {
|
||||
set(this.cycleMap, [cycle.id], cycle);
|
||||
});
|
||||
this.loader = false;
|
||||
});
|
||||
return response;
|
||||
})
|
||||
.catch(() => {
|
||||
this.loader = false;
|
||||
return undefined;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @description fetches active cycle for a project
|
||||
* @param workspaceSlug
|
||||
@ -452,4 +529,48 @@ export class CycleStore implements ICycleStore {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description archives a cycle
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param cycleId
|
||||
* @returns
|
||||
*/
|
||||
archiveCycle = async (workspaceSlug: string, projectId: string, cycleId: string) => {
|
||||
const cycleDetails = this.getCycleById(cycleId);
|
||||
if (cycleDetails?.archived_at) return;
|
||||
await this.cycleArchiveService
|
||||
.archiveCycle(workspaceSlug, projectId, cycleId)
|
||||
.then((response) => {
|
||||
runInAction(() => {
|
||||
set(this.cycleMap, [cycleId, "archived_at"], response.archived_at);
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to archive cycle in cycle store", error);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @description restores a cycle
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param cycleId
|
||||
* @returns
|
||||
*/
|
||||
restoreCycle = async (workspaceSlug: string, projectId: string, cycleId: string) => {
|
||||
const cycleDetails = this.getCycleById(cycleId);
|
||||
if (!cycleDetails?.archived_at) return;
|
||||
await this.cycleArchiveService
|
||||
.restoreCycle(workspaceSlug, projectId, cycleId)
|
||||
.then(() => {
|
||||
runInAction(() => {
|
||||
set(this.cycleMap, [cycleId, "archived_at"], null);
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to restore cycle in cycle store", error);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
@ -1,33 +1,39 @@
|
||||
import set from "lodash/set";
|
||||
import { action, computed, observable, makeObservable, runInAction, reaction } from "mobx";
|
||||
import { computedFn } from "mobx-utils";
|
||||
import set from "lodash/set";
|
||||
// types
|
||||
import { TCycleDisplayFilters, TCycleFilters, TCycleFiltersByState } from "@plane/types";
|
||||
// store
|
||||
import { RootStore } from "@/store/root.store";
|
||||
import { TCycleDisplayFilters, TCycleFilters } from "@plane/types";
|
||||
|
||||
export interface ICycleFilterStore {
|
||||
// observables
|
||||
displayFilters: Record<string, TCycleDisplayFilters>;
|
||||
filters: Record<string, TCycleFilters>;
|
||||
filters: Record<string, TCycleFiltersByState>;
|
||||
searchQuery: string;
|
||||
archivedCyclesSearchQuery: string;
|
||||
// computed
|
||||
currentProjectDisplayFilters: TCycleDisplayFilters | undefined;
|
||||
currentProjectFilters: TCycleFilters | undefined;
|
||||
currentProjectArchivedFilters: TCycleFilters | undefined;
|
||||
// computed functions
|
||||
getDisplayFiltersByProjectId: (projectId: string) => TCycleDisplayFilters | undefined;
|
||||
getFiltersByProjectId: (projectId: string) => TCycleFilters | undefined;
|
||||
getArchivedFiltersByProjectId: (projectId: string) => TCycleFilters | undefined;
|
||||
// actions
|
||||
updateDisplayFilters: (projectId: string, displayFilters: TCycleDisplayFilters) => void;
|
||||
updateFilters: (projectId: string, filters: TCycleFilters) => void;
|
||||
updateFilters: (projectId: string, filters: TCycleFilters, state?: keyof TCycleFiltersByState) => void;
|
||||
updateSearchQuery: (query: string) => void;
|
||||
clearAllFilters: (projectId: string) => void;
|
||||
updateArchivedCyclesSearchQuery: (query: string) => void;
|
||||
clearAllFilters: (projectId: string, state?: keyof TCycleFiltersByState) => void;
|
||||
}
|
||||
|
||||
export class CycleFilterStore implements ICycleFilterStore {
|
||||
// observables
|
||||
displayFilters: Record<string, TCycleDisplayFilters> = {};
|
||||
filters: Record<string, TCycleFilters> = {};
|
||||
filters: Record<string, TCycleFiltersByState> = {};
|
||||
searchQuery: string = "";
|
||||
archivedCyclesSearchQuery: string = "";
|
||||
// root store
|
||||
rootStore: RootStore;
|
||||
|
||||
@ -37,13 +43,16 @@ export class CycleFilterStore implements ICycleFilterStore {
|
||||
displayFilters: observable,
|
||||
filters: observable,
|
||||
searchQuery: observable.ref,
|
||||
archivedCyclesSearchQuery: observable.ref,
|
||||
// computed
|
||||
currentProjectDisplayFilters: computed,
|
||||
currentProjectFilters: computed,
|
||||
currentProjectArchivedFilters: computed,
|
||||
// actions
|
||||
updateDisplayFilters: action,
|
||||
updateFilters: action,
|
||||
updateSearchQuery: action,
|
||||
updateArchivedCyclesSearchQuery: action,
|
||||
clearAllFilters: action,
|
||||
});
|
||||
// root store
|
||||
@ -73,7 +82,16 @@ export class CycleFilterStore implements ICycleFilterStore {
|
||||
get currentProjectFilters() {
|
||||
const projectId = this.rootStore.app.router.projectId;
|
||||
if (!projectId) return;
|
||||
return this.filters[projectId];
|
||||
return this.filters[projectId]?.default ?? {};
|
||||
}
|
||||
|
||||
/**
|
||||
* @description get archived filters of the current project
|
||||
*/
|
||||
get currentProjectArchivedFilters() {
|
||||
const projectId = this.rootStore.app.router.projectId;
|
||||
if (!projectId) return;
|
||||
return this.filters[projectId].archived;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -86,7 +104,13 @@ export class CycleFilterStore implements ICycleFilterStore {
|
||||
* @description get filters of a project by projectId
|
||||
* @param {string} projectId
|
||||
*/
|
||||
getFiltersByProjectId = computedFn((projectId: string) => this.filters[projectId]);
|
||||
getFiltersByProjectId = computedFn((projectId: string) => this.filters[projectId]?.default ?? {});
|
||||
|
||||
/**
|
||||
* @description get archived filters of a project by projectId
|
||||
* @param {string} projectId
|
||||
*/
|
||||
getArchivedFiltersByProjectId = computedFn((projectId: string) => this.filters[projectId].archived);
|
||||
|
||||
/**
|
||||
* @description initialize display filters and filters of a project
|
||||
@ -99,7 +123,10 @@ export class CycleFilterStore implements ICycleFilterStore {
|
||||
active_tab: displayFilters?.active_tab || "active",
|
||||
layout: displayFilters?.layout || "list",
|
||||
};
|
||||
this.filters[projectId] = this.filters[projectId] ?? {};
|
||||
this.filters[projectId] = this.filters[projectId] ?? {
|
||||
default: {},
|
||||
archived: {},
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
@ -121,10 +148,10 @@ export class CycleFilterStore implements ICycleFilterStore {
|
||||
* @param {string} projectId
|
||||
* @param {TCycleFilters} filters
|
||||
*/
|
||||
updateFilters = (projectId: string, filters: TCycleFilters) => {
|
||||
updateFilters = (projectId: string, filters: TCycleFilters, state: keyof TCycleFiltersByState = "default") => {
|
||||
runInAction(() => {
|
||||
Object.keys(filters).forEach((key) => {
|
||||
set(this.filters, [projectId, key], filters[key as keyof TCycleFilters]);
|
||||
set(this.filters, [projectId, state, key], filters[key as keyof TCycleFilters]);
|
||||
});
|
||||
});
|
||||
};
|
||||
@ -135,13 +162,19 @@ export class CycleFilterStore implements ICycleFilterStore {
|
||||
*/
|
||||
updateSearchQuery = (query: string) => (this.searchQuery = query);
|
||||
|
||||
/**
|
||||
* @description update archived search query
|
||||
* @param {string} query
|
||||
*/
|
||||
updateArchivedCyclesSearchQuery = (query: string) => (this.archivedCyclesSearchQuery = query);
|
||||
|
||||
/**
|
||||
* @description clear all filters of a project
|
||||
* @param {string} projectId
|
||||
*/
|
||||
clearAllFilters = (projectId: string) => {
|
||||
clearAllFilters = (projectId: string, state: keyof TCycleFiltersByState = "default") => {
|
||||
runInAction(() => {
|
||||
this.filters[projectId] = {};
|
||||
this.filters[projectId][state] = {};
|
||||
});
|
||||
};
|
||||
}
|
||||
|
@ -2,14 +2,16 @@ import set from "lodash/set";
|
||||
import sortBy from "lodash/sortBy";
|
||||
import { action, computed, observable, makeObservable, runInAction } from "mobx";
|
||||
import { computedFn } from "mobx-utils";
|
||||
// services
|
||||
import { ModuleService } from "@/services/module.service";
|
||||
import { ProjectService } from "@/services/project";
|
||||
// types
|
||||
import { IModule, ILinkDetails } from "@plane/types";
|
||||
// helpers
|
||||
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 { IModule, ILinkDetails } from "@plane/types";
|
||||
|
||||
export interface IModuleStore {
|
||||
//Loaders
|
||||
@ -19,8 +21,10 @@ export interface IModuleStore {
|
||||
moduleMap: Record<string, IModule>;
|
||||
// computed
|
||||
projectModuleIds: string[] | null;
|
||||
projectArchivedModuleIds: string[] | null;
|
||||
// computed actions
|
||||
getFilteredModuleIds: (projectId: string) => string[] | null;
|
||||
getFilteredArchivedModuleIds: (projectId: string) => string[] | null;
|
||||
getModuleById: (moduleId: string) => IModule | null;
|
||||
getModuleNameById: (moduleId: string) => string;
|
||||
getProjectModuleIds: (projectId: string) => string[] | null;
|
||||
@ -28,6 +32,7 @@ export interface IModuleStore {
|
||||
// fetch
|
||||
fetchWorkspaceModules: (workspaceSlug: string) => Promise<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>;
|
||||
// crud
|
||||
createModule: (workspaceSlug: string, projectId: string, data: Partial<IModule>) => Promise<IModule>;
|
||||
@ -55,6 +60,9 @@ export interface IModuleStore {
|
||||
// favorites
|
||||
addModuleToFavorites: (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 {
|
||||
@ -68,6 +76,7 @@ export class ModulesStore implements IModuleStore {
|
||||
// services
|
||||
projectService;
|
||||
moduleService;
|
||||
moduleArchiveService;
|
||||
|
||||
constructor(_rootStore: RootStore) {
|
||||
makeObservable(this, {
|
||||
@ -77,9 +86,11 @@ export class ModulesStore implements IModuleStore {
|
||||
fetchedMap: observable,
|
||||
// computed
|
||||
projectModuleIds: computed,
|
||||
projectArchivedModuleIds: computed,
|
||||
// actions
|
||||
fetchWorkspaceModules: action,
|
||||
fetchModules: action,
|
||||
fetchArchivedModules: action,
|
||||
fetchModuleDetails: action,
|
||||
createModule: action,
|
||||
updateModuleDetails: action,
|
||||
@ -89,6 +100,8 @@ export class ModulesStore implements IModuleStore {
|
||||
deleteModuleLink: action,
|
||||
addModuleToFavorites: action,
|
||||
removeModuleFromFavorites: action,
|
||||
archiveModule: action,
|
||||
restoreModule: action,
|
||||
});
|
||||
|
||||
this.rootStore = _rootStore;
|
||||
@ -96,6 +109,7 @@ export class ModulesStore implements IModuleStore {
|
||||
// services
|
||||
this.projectService = new ProjectService();
|
||||
this.moduleService = new ModuleService();
|
||||
this.moduleArchiveService = new ModuleArchiveService();
|
||||
}
|
||||
|
||||
// computed
|
||||
@ -105,12 +119,24 @@ export class ModulesStore implements IModuleStore {
|
||||
get projectModuleIds() {
|
||||
const projectId = this.rootStore.app.router.projectId;
|
||||
if (!projectId || !this.fetchedMap[projectId]) return null;
|
||||
let projectModules = Object.values(this.moduleMap).filter((m) => m.project_id === projectId);
|
||||
let projectModules = Object.values(this.moduleMap).filter((m) => m.project_id === projectId && !m?.archived_at);
|
||||
projectModules = sortBy(projectModules, [(m) => m.sort_order]);
|
||||
const projectModuleIds = projectModules.map((m) => m.id);
|
||||
return projectModuleIds || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* get all archived module ids for the current project
|
||||
*/
|
||||
get projectArchivedModuleIds() {
|
||||
const projectId = this.rootStore.app.router.projectId;
|
||||
if (!projectId || !this.fetchedMap[projectId]) return null;
|
||||
let archivedModules = Object.values(this.moduleMap).filter((m) => m.project_id === projectId && !!m?.archived_at);
|
||||
archivedModules = sortBy(archivedModules, [(m) => m.sort_order]);
|
||||
const projectModuleIds = archivedModules.map((m) => m.id);
|
||||
return projectModuleIds || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description returns filtered module ids based on display filters and filters
|
||||
* @param {TModuleDisplayFilters} displayFilters
|
||||
@ -125,6 +151,29 @@ export class ModulesStore implements IModuleStore {
|
||||
let modules = Object.values(this.moduleMap ?? {}).filter(
|
||||
(m) =>
|
||||
m.project_id === projectId &&
|
||||
!m.archived_at &&
|
||||
m.name.toLowerCase().includes(searchQuery.toLowerCase()) &&
|
||||
shouldFilterModule(m, displayFilters ?? {}, filters ?? {})
|
||||
);
|
||||
modules = orderModules(modules, displayFilters?.order_by);
|
||||
const moduleIds = modules.map((m) => m.id);
|
||||
return moduleIds;
|
||||
});
|
||||
|
||||
/**
|
||||
* @description returns filtered archived module ids based on display filters and filters
|
||||
* @param {string} projectId
|
||||
* @returns {string[] | null}
|
||||
*/
|
||||
getFilteredArchivedModuleIds = computedFn((projectId: string) => {
|
||||
const displayFilters = this.rootStore.moduleFilter.getDisplayFiltersByProjectId(projectId);
|
||||
const filters = this.rootStore.moduleFilter.getArchivedFiltersByProjectId(projectId);
|
||||
const searchQuery = this.rootStore.moduleFilter.archivedModulesSearchQuery;
|
||||
if (!this.fetchedMap[projectId]) return null;
|
||||
let modules = Object.values(this.moduleMap ?? {}).filter(
|
||||
(m) =>
|
||||
m.project_id === projectId &&
|
||||
!!m.archived_at &&
|
||||
m.name.toLowerCase().includes(searchQuery.toLowerCase()) &&
|
||||
shouldFilterModule(m, displayFilters ?? {}, filters ?? {})
|
||||
);
|
||||
@ -154,7 +203,7 @@ export class ModulesStore implements IModuleStore {
|
||||
getProjectModuleIds = computedFn((projectId: string) => {
|
||||
if (!this.fetchedMap[projectId]) return null;
|
||||
|
||||
let projectModules = Object.values(this.moduleMap).filter((m) => m.project_id === projectId);
|
||||
let projectModules = Object.values(this.moduleMap).filter((m) => m.project_id === projectId && !m.archived_at);
|
||||
projectModules = sortBy(projectModules, [(m) => m.sort_order]);
|
||||
const projectModuleIds = projectModules.map((m) => m.id);
|
||||
return projectModuleIds;
|
||||
@ -200,6 +249,31 @@ export class ModulesStore implements IModuleStore {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description fetch all archived modules
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @returns IModule[]
|
||||
*/
|
||||
fetchArchivedModules = async (workspaceSlug: string, projectId: string) => {
|
||||
this.loader = true;
|
||||
return await this.moduleArchiveService
|
||||
.getArchivedModules(workspaceSlug, projectId)
|
||||
.then((response) => {
|
||||
runInAction(() => {
|
||||
response.forEach((module) => {
|
||||
set(this.moduleMap, [module.id], { ...this.moduleMap[module.id], ...module });
|
||||
});
|
||||
this.loader = false;
|
||||
});
|
||||
return response;
|
||||
})
|
||||
.catch(() => {
|
||||
this.loader = false;
|
||||
return undefined;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @description fetch module details
|
||||
* @param workspaceSlug
|
||||
@ -386,4 +460,48 @@ export class ModulesStore implements IModuleStore {
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description archives a module
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param moduleId
|
||||
* @returns
|
||||
*/
|
||||
archiveModule = async (workspaceSlug: string, projectId: string, moduleId: string) => {
|
||||
const moduleDetails = this.getModuleById(moduleId);
|
||||
if (moduleDetails?.archived_at) return;
|
||||
await this.moduleArchiveService
|
||||
.archiveModule(workspaceSlug, projectId, moduleId)
|
||||
.then((response) => {
|
||||
runInAction(() => {
|
||||
set(this.moduleMap, [moduleId, "archived_at"], response.archived_at);
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to archive module in module store", error);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @description restores a module
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param moduleId
|
||||
* @returns
|
||||
*/
|
||||
restoreModule = async (workspaceSlug: string, projectId: string, moduleId: string) => {
|
||||
const moduleDetails = this.getModuleById(moduleId);
|
||||
if (!moduleDetails?.archived_at) return;
|
||||
await this.moduleArchiveService
|
||||
.restoreModule(workspaceSlug, projectId, moduleId)
|
||||
.then(() => {
|
||||
runInAction(() => {
|
||||
set(this.moduleMap, [moduleId, "archived_at"], null);
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to restore module in module store", error);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
@ -1,33 +1,39 @@
|
||||
import set from "lodash/set";
|
||||
import { action, computed, observable, makeObservable, runInAction, reaction } from "mobx";
|
||||
import { computedFn } from "mobx-utils";
|
||||
import set from "lodash/set";
|
||||
// types
|
||||
import { TModuleDisplayFilters, TModuleFilters, TModuleFiltersByState } from "@plane/types";
|
||||
// store
|
||||
import { RootStore } from "@/store/root.store";
|
||||
import { TModuleDisplayFilters, TModuleFilters } from "@plane/types";
|
||||
|
||||
export interface IModuleFilterStore {
|
||||
// observables
|
||||
displayFilters: Record<string, TModuleDisplayFilters>;
|
||||
filters: Record<string, TModuleFilters>;
|
||||
filters: Record<string, TModuleFiltersByState>;
|
||||
searchQuery: string;
|
||||
archivedModulesSearchQuery: string;
|
||||
// computed
|
||||
currentProjectDisplayFilters: TModuleDisplayFilters | undefined;
|
||||
currentProjectFilters: TModuleFilters | undefined;
|
||||
currentProjectArchivedFilters: TModuleFilters | undefined;
|
||||
// computed functions
|
||||
getDisplayFiltersByProjectId: (projectId: string) => TModuleDisplayFilters | undefined;
|
||||
getFiltersByProjectId: (projectId: string) => TModuleFilters | undefined;
|
||||
getArchivedFiltersByProjectId: (projectId: string) => TModuleFilters | undefined;
|
||||
// actions
|
||||
updateDisplayFilters: (projectId: string, displayFilters: TModuleDisplayFilters) => void;
|
||||
updateFilters: (projectId: string, filters: TModuleFilters) => void;
|
||||
updateFilters: (projectId: string, filters: TModuleFilters, state?: keyof TModuleFiltersByState) => void;
|
||||
updateSearchQuery: (query: string) => void;
|
||||
clearAllFilters: (projectId: string) => void;
|
||||
updateArchivedModulesSearchQuery: (query: string) => void;
|
||||
clearAllFilters: (projectId: string, state?: keyof TModuleFiltersByState) => void;
|
||||
}
|
||||
|
||||
export class ModuleFilterStore implements IModuleFilterStore {
|
||||
// observables
|
||||
displayFilters: Record<string, TModuleDisplayFilters> = {};
|
||||
filters: Record<string, TModuleFilters> = {};
|
||||
filters: Record<string, TModuleFiltersByState> = {};
|
||||
searchQuery: string = "";
|
||||
archivedModulesSearchQuery: string = "";
|
||||
// root store
|
||||
rootStore: RootStore;
|
||||
|
||||
@ -37,13 +43,16 @@ export class ModuleFilterStore implements IModuleFilterStore {
|
||||
displayFilters: observable,
|
||||
filters: observable,
|
||||
searchQuery: observable.ref,
|
||||
archivedModulesSearchQuery: observable.ref,
|
||||
// computed
|
||||
currentProjectDisplayFilters: computed,
|
||||
currentProjectFilters: computed,
|
||||
currentProjectArchivedFilters: computed,
|
||||
// actions
|
||||
updateDisplayFilters: action,
|
||||
updateFilters: action,
|
||||
updateSearchQuery: action,
|
||||
updateArchivedModulesSearchQuery: action,
|
||||
clearAllFilters: action,
|
||||
});
|
||||
// root store
|
||||
@ -73,7 +82,16 @@ export class ModuleFilterStore implements IModuleFilterStore {
|
||||
get currentProjectFilters() {
|
||||
const projectId = this.rootStore.app.router.projectId;
|
||||
if (!projectId) return;
|
||||
return this.filters[projectId];
|
||||
return this.filters[projectId]?.default ?? {};
|
||||
}
|
||||
|
||||
/**
|
||||
* @description get archived filters of the current project
|
||||
*/
|
||||
get currentProjectArchivedFilters() {
|
||||
const projectId = this.rootStore.app.router.projectId;
|
||||
if (!projectId) return;
|
||||
return this.filters[projectId].archived;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -86,7 +104,13 @@ export class ModuleFilterStore implements IModuleFilterStore {
|
||||
* @description get filters of a project by projectId
|
||||
* @param {string} projectId
|
||||
*/
|
||||
getFiltersByProjectId = computedFn((projectId: string) => this.filters[projectId]);
|
||||
getFiltersByProjectId = computedFn((projectId: string) => this.filters[projectId]?.default ?? {});
|
||||
|
||||
/**
|
||||
* @description get archived filters of a project by projectId
|
||||
* @param {string} projectId
|
||||
*/
|
||||
getArchivedFiltersByProjectId = computedFn((projectId: string) => this.filters[projectId].archived);
|
||||
|
||||
/**
|
||||
* @description initialize display filters and filters of a project
|
||||
@ -100,7 +124,10 @@ export class ModuleFilterStore implements IModuleFilterStore {
|
||||
layout: displayFilters?.layout || "list",
|
||||
order_by: displayFilters?.order_by || "name",
|
||||
};
|
||||
this.filters[projectId] = this.filters[projectId] ?? {};
|
||||
this.filters[projectId] = this.filters[projectId] ?? {
|
||||
default: {},
|
||||
archived: {},
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
@ -122,10 +149,10 @@ export class ModuleFilterStore implements IModuleFilterStore {
|
||||
* @param {string} projectId
|
||||
* @param {TModuleFilters} filters
|
||||
*/
|
||||
updateFilters = (projectId: string, filters: TModuleFilters) => {
|
||||
updateFilters = (projectId: string, filters: TModuleFilters, state: keyof TModuleFiltersByState = "default") => {
|
||||
runInAction(() => {
|
||||
Object.keys(filters).forEach((key) => {
|
||||
set(this.filters, [projectId, key], filters[key as keyof TModuleFilters]);
|
||||
set(this.filters, [projectId, state, key], filters[key as keyof TModuleFilters]);
|
||||
});
|
||||
});
|
||||
};
|
||||
@ -136,13 +163,19 @@ export class ModuleFilterStore implements IModuleFilterStore {
|
||||
*/
|
||||
updateSearchQuery = (query: string) => (this.searchQuery = query);
|
||||
|
||||
/**
|
||||
* @description update archived search query
|
||||
* @param {string} query
|
||||
*/
|
||||
updateArchivedModulesSearchQuery = (query: string) => (this.archivedModulesSearchQuery = query);
|
||||
|
||||
/**
|
||||
* @description clear all filters of a project
|
||||
* @param {string} projectId
|
||||
*/
|
||||
clearAllFilters = (projectId: string) => {
|
||||
clearAllFilters = (projectId: string, state: keyof TModuleFiltersByState = "default") => {
|
||||
runInAction(() => {
|
||||
this.filters[projectId] = {};
|
||||
this.filters[projectId][state] = {};
|
||||
});
|
||||
};
|
||||
}
|
||||
|