feat: cycles and modules archive. (#4005)

* fix: GET request changes

* fix: filtering changes

* feat: cycles and modules archive.

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

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

* fix: archived cycle and module loading fliker issue.

* chore: add validation to only archive completed cycles.

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

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

---------

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

View File

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

View File

@ -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()),

View File

@ -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)

View File

@ -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)

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

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

View File

@ -0,0 +1,43 @@
import { FC } from "react";
import { observer } from "mobx-react";
import Link from "next/link";
import { useRouter } from "next/router";
// constants
import { ARCHIVES_TAB_LIST } from "@/constants/archives";
// hooks
import { useProject } from "@/hooks/store";
export const ArchiveTabsList: FC = observer(() => {
// router
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const activeTab = router.pathname.split("/").pop();
// store hooks
const { getProjectById } = useProject();
// derived values
if (!projectId) return null;
const projectDetails = getProjectById(projectId?.toString());
if (!projectDetails) return null;
return (
<>
{ARCHIVES_TAB_LIST.map(
(tab) =>
tab.shouldRender(projectDetails) && (
<Link key={tab.key} href={`/${workspaceSlug}/projects/${projectId}/archives/${tab.key}`}>
<span
className={`flex min-w-min flex-shrink-0 whitespace-nowrap border-b-2 py-3 px-4 text-sm font-medium outline-none ${
tab.key === activeTab
? "border-custom-primary-100 text-custom-primary-100"
: "border-transparent hover:border-custom-border-200 text-custom-text-300 hover:text-custom-text-400"
}`}
>
{tab.label}
</span>
</Link>
)
)}
</>
);
});

View File

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

View File

@ -16,12 +16,13 @@ type Props = {
handleDeleteLink: (linkId: string) => void;
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);

View File

@ -0,0 +1,123 @@
import { FC, useCallback, useRef, useState } from "react";
import { observer } from "mobx-react-lite";
import { useRouter } from "next/router";
// icons
import { ListFilter, Search, X } from "lucide-react";
// types
import type { TCycleFilters } from "@plane/types";
// components
import { ArchiveTabsList } from "@/components/archives";
import { CycleFiltersSelection } from "@/components/cycles";
import { FiltersDropdown } from "@/components/issues";
// helpers
import { cn } from "@/helpers/common.helper";
// hooks
import { useCycleFilter } from "@/hooks/store";
import useOutsideClickDetector from "@/hooks/use-outside-click-detector";
export const ArchivedCyclesHeader: FC = observer(() => {
// router
const router = useRouter();
const { projectId } = router.query;
// refs
const inputRef = useRef<HTMLInputElement>(null);
// hooks
const { currentProjectArchivedFilters, archivedCyclesSearchQuery, updateFilters, updateArchivedCyclesSearchQuery } =
useCycleFilter();
// states
const [isSearchOpen, setIsSearchOpen] = useState(archivedCyclesSearchQuery !== "" ? true : false);
// outside click detector hook
useOutsideClickDetector(inputRef, () => {
if (isSearchOpen && archivedCyclesSearchQuery.trim() === "") setIsSearchOpen(false);
});
const handleFilters = useCallback(
(key: keyof TCycleFilters, value: string | string[]) => {
if (!projectId) return;
const newValues = currentProjectArchivedFilters?.[key] ?? [];
if (Array.isArray(value))
value.forEach((val) => {
if (!newValues.includes(val)) newValues.push(val);
});
else {
if (currentProjectArchivedFilters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
else newValues.push(value);
}
updateFilters(projectId.toString(), { [key]: newValues }, "archived");
},
[currentProjectArchivedFilters, projectId, updateFilters]
);
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Escape") {
if (archivedCyclesSearchQuery && archivedCyclesSearchQuery.trim() !== "") updateArchivedCyclesSearchQuery("");
else {
setIsSearchOpen(false);
inputRef.current?.blur();
}
}
};
return (
<div className="group relative flex border-b border-custom-border-200">
<div className="flex w-full items-center overflow-x-auto px-4 gap-2 horizontal-scrollbar scrollbar-sm">
<ArchiveTabsList />
</div>
{/* filter options */}
<div className="h-full flex items-center gap-3 self-end px-8">
{!isSearchOpen && (
<button
type="button"
className="-mr-5 p-2 hover:bg-custom-background-80 rounded text-custom-text-400 grid place-items-center"
onClick={() => {
setIsSearchOpen(true);
inputRef.current?.focus();
}}
>
<Search className="h-3.5 w-3.5" />
</button>
)}
<div
className={cn(
"ml-auto flex items-center justify-start gap-1 rounded-md border border-transparent bg-custom-background-100 text-custom-text-400 w-0 transition-[width] ease-linear overflow-hidden opacity-0",
{
"w-64 px-2.5 py-1.5 border-custom-border-200 opacity-100": isSearchOpen,
}
)}
>
<Search className="h-3.5 w-3.5" />
<input
ref={inputRef}
className="w-full max-w-[234px] border-none bg-transparent text-sm text-custom-text-100 placeholder:text-custom-text-400 focus:outline-none"
placeholder="Search"
value={archivedCyclesSearchQuery}
onChange={(e) => updateArchivedCyclesSearchQuery(e.target.value)}
onKeyDown={handleInputKeyDown}
/>
{isSearchOpen && (
<button
type="button"
className="grid place-items-center"
onClick={() => {
updateArchivedCyclesSearchQuery("");
setIsSearchOpen(false);
}}
>
<X className="h-3 w-3" />
</button>
)}
</div>
<FiltersDropdown icon={<ListFilter className="h-3 w-3" />} title="Filters" placement="bottom-end">
<CycleFiltersSelection
filters={currentProjectArchivedFilters ?? {}}
handleFiltersUpdate={handleFilters}
isArchived
/>
</FiltersDropdown>
</div>
</div>
);
});

View File

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

View File

@ -0,0 +1,104 @@
import { useState, Fragment } from "react";
import { useRouter } from "next/router";
import { Dialog, Transition } from "@headlessui/react";
// ui
import { Button, TOAST_TYPE, setToast } from "@plane/ui";
// hooks
import { useCycle } from "@/hooks/store";
type Props = {
workspaceSlug: string;
projectId: string;
cycleId: string;
handleClose: () => void;
isOpen: boolean;
onSubmit?: () => Promise<void>;
};
export const ArchiveCycleModal: React.FC<Props> = (props) => {
const { workspaceSlug, projectId, cycleId, isOpen, handleClose } = props;
// router
const router = useRouter();
// states
const [isArchiving, setIsArchiving] = useState(false);
// store hooks
const { getCycleNameById, archiveCycle } = useCycle();
const cycleName = getCycleNameById(cycleId);
const onClose = () => {
setIsArchiving(false);
handleClose();
};
const handleArchiveIssue = async () => {
setIsArchiving(true);
await archiveCycle(workspaceSlug, projectId, cycleId)
.then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Archive success",
message: "Your archives can be found in project archives.",
});
onClose();
router.push(`/${workspaceSlug}/projects/${projectId}/archives/cycles?peekCycle=${cycleId}`);
})
.catch(() =>
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Cycle could not be archived. Please try again.",
})
)
.finally(() => setIsArchiving(false));
};
return (
<Transition.Root show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-20" onClose={onClose}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-custom-backdrop transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-custom-background-100 text-left shadow-custom-shadow-md transition-all sm:my-8 sm:w-full sm:max-w-lg">
<div className="px-5 py-4">
<h3 className="text-xl font-medium 2xl:text-2xl">Archive cycle {cycleName}</h3>
<p className="mt-3 text-sm text-custom-text-200">
Are you sure you want to archive the cycle? All your archives can be restored later.
</p>
<div className="mt-3 flex justify-end gap-2">
<Button variant="neutral-primary" size="sm" onClick={onClose}>
Cancel
</Button>
<Button size="sm" tabIndex={1} onClick={handleArchiveIssue} loading={isArchiving}>
{isArchiving ? "Archiving" : "Archive"}
</Button>
</div>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
};

View File

@ -0,0 +1,77 @@
import React from "react";
import { observer } from "mobx-react-lite";
import { useRouter } from "next/router";
import useSWR from "swr";
// types
import { TCycleFilters } from "@plane/types";
// components
import { ArchivedCyclesView, CycleAppliedFiltersList } from "@/components/cycles";
import { EmptyState } from "@/components/empty-state";
import { CycleModuleListLayout } from "@/components/ui";
// constants
import { EmptyStateType } from "@/constants/empty-state";
// helpers
import { calculateTotalFilters } from "@/helpers/filter.helper";
// hooks
import { useCycle, useCycleFilter } from "@/hooks/store";
export const ArchivedCycleLayoutRoot: React.FC = observer(() => {
// router
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
// hooks
const { fetchArchivedCycles, currentProjectArchivedCycleIds, loader } = useCycle();
// cycle filters hook
const { clearAllFilters, currentProjectArchivedFilters, updateFilters } = useCycleFilter();
// derived values
const totalArchivedCycles = currentProjectArchivedCycleIds?.length ?? 0;
useSWR(
workspaceSlug && projectId ? `ARCHIVED_CYCLES_${workspaceSlug.toString()}_${projectId.toString()}` : null,
async () => {
if (workspaceSlug && projectId) {
await fetchArchivedCycles(workspaceSlug.toString(), projectId.toString());
}
},
{ revalidateIfStale: false, revalidateOnFocus: false }
);
const handleRemoveFilter = (key: keyof TCycleFilters, value: string | null) => {
if (!projectId) return;
let newValues = currentProjectArchivedFilters?.[key] ?? [];
if (!value) newValues = [];
else newValues = newValues.filter((val) => val !== value);
updateFilters(projectId.toString(), { [key]: newValues }, "archived");
};
if (!workspaceSlug || !projectId) return <></>;
if (loader || !currentProjectArchivedCycleIds) {
return <CycleModuleListLayout />;
}
return (
<>
{calculateTotalFilters(currentProjectArchivedFilters ?? {}) !== 0 && (
<div className="border-b border-custom-border-200 px-5 py-3">
<CycleAppliedFiltersList
appliedFilters={currentProjectArchivedFilters ?? {}}
handleClearAllFilters={() => clearAllFilters(projectId.toString(), "archived")}
handleRemoveFilter={handleRemoveFilter}
/>
</div>
)}
{totalArchivedCycles === 0 ? (
<div className="h-full place-items-center">
<EmptyState type={EmptyStateType.PROJECT_ARCHIVED_NO_CYCLES} />
</div>
) : (
<div className="relative h-full w-full overflow-auto">
<ArchivedCyclesView workspaceSlug={workspaceSlug.toString()} projectId={projectId.toString()} />
</div>
)}
</>
);
});

View File

@ -0,0 +1,57 @@
import { FC } from "react";
import { observer } from "mobx-react-lite";
import Image from "next/image";
// components
import { CyclesList } from "@/components/cycles";
// ui
import { CycleModuleListLayout } from "@/components/ui";
// hooks
import { useCycle, useCycleFilter } from "@/hooks/store";
// assets
import AllFiltersImage from "@/public/empty-state/cycle/all-filters.svg";
import NameFilterImage from "@/public/empty-state/cycle/name-filter.svg";
export interface IArchivedCyclesView {
workspaceSlug: string;
projectId: string;
}
export const ArchivedCyclesView: FC<IArchivedCyclesView> = observer((props) => {
const { workspaceSlug, projectId } = props;
// store hooks
const { getFilteredArchivedCycleIds, loader } = useCycle();
const { archivedCyclesSearchQuery } = useCycleFilter();
// derived values
const filteredArchivedCycleIds = getFilteredArchivedCycleIds(projectId);
if (loader || !filteredArchivedCycleIds) return <CycleModuleListLayout />;
if (filteredArchivedCycleIds.length === 0)
return (
<div className="h-full w-full grid place-items-center">
<div className="text-center">
<Image
src={archivedCyclesSearchQuery.trim() === "" ? AllFiltersImage : NameFilterImage}
className="h-36 sm:h-48 w-36 sm:w-48 mx-auto"
alt="No matching cycles"
/>
<h5 className="text-xl font-medium mt-7 mb-1">No matching cycles</h5>
<p className="text-custom-text-400 text-base">
{archivedCyclesSearchQuery.trim() === ""
? "Remove the filters to see all cycles"
: "Remove the search criteria to see all cycles"}
</p>
</div>
</div>
);
return (
<CyclesList
completedCycleIds={[]}
cycleIds={filteredArchivedCycleIds}
workspaceSlug={workspaceSlug}
projectId={projectId}
isArchived
/>
);
});

View File

@ -9,9 +9,10 @@ import { CycleDetailsSidebar } from "./sidebar";
type Props = {
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>
)}
</>

View File

@ -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);

View File

@ -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">

View File

@ -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";

View File

@ -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>

View File

@ -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}
/>
))}
</>
);

View File

@ -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>
);

View File

@ -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>
</>
);

View File

@ -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}
/>
)}
/>

View File

@ -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

View File

@ -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";

View File

@ -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);

View File

@ -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={

View File

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

View File

@ -1,22 +1,17 @@
import { FC } from "react";
import { 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 || {}}

View File

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

View File

@ -110,7 +110,7 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
const handleArchiveIssue = async () => {
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);

View File

@ -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"

View File

@ -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"

View File

@ -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",

View File

@ -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>

View File

@ -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>
</>
);
});

View File

@ -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",

View File

@ -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>
)}

View File

@ -0,0 +1,147 @@
import { FC, useCallback, useRef, useState } from "react";
import { observer } from "mobx-react-lite";
import { useRouter } from "next/router";
// icons
import { ListFilter, Search, X } from "lucide-react";
// types
import type { TModuleFilters } from "@plane/types";
// components
import { ArchiveTabsList } from "@/components/archives";
import { FiltersDropdown } from "@/components/issues";
import { ModuleFiltersSelection, ModuleOrderByDropdown } from "@/components/modules";
// helpers
import { cn } from "@/helpers/common.helper";
// hooks
import { useMember, useModuleFilter } from "@/hooks/store";
import useOutsideClickDetector from "@/hooks/use-outside-click-detector";
export const ArchivedModulesHeader: FC = observer(() => {
// router
const router = useRouter();
const { projectId } = router.query;
// refs
const inputRef = useRef<HTMLInputElement>(null);
// hooks
const {
currentProjectArchivedFilters,
currentProjectDisplayFilters,
archivedModulesSearchQuery,
updateFilters,
updateDisplayFilters,
updateArchivedModulesSearchQuery,
} = useModuleFilter();
const {
workspace: { workspaceMemberIds },
} = useMember();
// states
const [isSearchOpen, setIsSearchOpen] = useState(archivedModulesSearchQuery !== "" ? true : false);
// outside click detector hook
useOutsideClickDetector(inputRef, () => {
if (isSearchOpen && archivedModulesSearchQuery.trim() === "") setIsSearchOpen(false);
});
const handleFilters = useCallback(
(key: keyof TModuleFilters, value: string | string[]) => {
if (!projectId) return;
const newValues = currentProjectArchivedFilters?.[key] ?? [];
if (Array.isArray(value))
value.forEach((val) => {
if (!newValues.includes(val)) newValues.push(val);
});
else {
if (currentProjectArchivedFilters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
else newValues.push(value);
}
updateFilters(projectId.toString(), { [key]: newValues }, "archived");
},
[currentProjectArchivedFilters, projectId, updateFilters]
);
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Escape") {
if (archivedModulesSearchQuery && archivedModulesSearchQuery.trim() !== "") updateArchivedModulesSearchQuery("");
else {
setIsSearchOpen(false);
inputRef.current?.blur();
}
}
};
return (
<div className="group relative flex border-b border-custom-border-200">
<div className="flex w-full items-center overflow-x-auto px-4 gap-2 horizontal-scrollbar scrollbar-sm">
<ArchiveTabsList />
</div>
{/* filter options */}
<div className="h-full flex items-center gap-3 self-end px-8">
{!isSearchOpen && (
<button
type="button"
className="-mr-5 p-2 hover:bg-custom-background-80 rounded text-custom-text-400 grid place-items-center"
onClick={() => {
setIsSearchOpen(true);
inputRef.current?.focus();
}}
>
<Search className="h-3.5 w-3.5" />
</button>
)}
<div
className={cn(
"ml-auto flex items-center justify-start gap-1 rounded-md border border-transparent bg-custom-background-100 text-custom-text-400 w-0 transition-[width] ease-linear overflow-hidden opacity-0",
{
"w-64 px-2.5 py-1.5 border-custom-border-200 opacity-100": isSearchOpen,
}
)}
>
<Search className="h-3.5 w-3.5" />
<input
ref={inputRef}
className="w-full max-w-[234px] border-none bg-transparent text-sm text-custom-text-100 placeholder:text-custom-text-400 focus:outline-none"
placeholder="Search"
value={archivedModulesSearchQuery}
onChange={(e) => updateArchivedModulesSearchQuery(e.target.value)}
onKeyDown={handleInputKeyDown}
/>
{isSearchOpen && (
<button
type="button"
className="grid place-items-center"
onClick={() => {
updateArchivedModulesSearchQuery("");
setIsSearchOpen(false);
}}
>
<X className="h-3 w-3" />
</button>
)}
</div>
<ModuleOrderByDropdown
value={currentProjectDisplayFilters?.order_by}
onChange={(val) => {
if (!projectId || val === currentProjectDisplayFilters?.order_by) return;
updateDisplayFilters(projectId.toString(), {
order_by: val,
});
}}
/>
<FiltersDropdown icon={<ListFilter className="h-3 w-3" />} title="Filters" placement="bottom-end">
<ModuleFiltersSelection
displayFilters={currentProjectDisplayFilters ?? {}}
filters={currentProjectArchivedFilters ?? {}}
handleDisplayFiltersUpdate={(val) => {
if (!projectId) return;
updateDisplayFilters(projectId.toString(), val);
}}
handleFiltersUpdate={handleFilters}
memberIds={workspaceMemberIds ?? undefined}
isArchived
/>
</FiltersDropdown>
</div>
</div>
);
});

View File

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

View File

@ -0,0 +1,104 @@
import { useState, Fragment } from "react";
import { useRouter } from "next/router";
import { Dialog, Transition } from "@headlessui/react";
// ui
import { Button, TOAST_TYPE, setToast } from "@plane/ui";
// hooks
import { useModule } from "@/hooks/store";
type Props = {
workspaceSlug: string;
projectId: string;
moduleId: string;
handleClose: () => void;
isOpen: boolean;
onSubmit?: () => Promise<void>;
};
export const ArchiveModuleModal: React.FC<Props> = (props) => {
const { workspaceSlug, projectId, moduleId, isOpen, handleClose } = props;
// router
const router = useRouter();
// states
const [isArchiving, setIsArchiving] = useState(false);
// store hooks
const { getModuleNameById, archiveModule } = useModule();
const moduleName = getModuleNameById(moduleId);
const onClose = () => {
setIsArchiving(false);
handleClose();
};
const handleArchiveIssue = async () => {
setIsArchiving(true);
await archiveModule(workspaceSlug, projectId, moduleId)
.then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Archive success",
message: "Your archives can be found in project archives.",
});
onClose();
router.push(`/${workspaceSlug}/projects/${projectId}/archives/modules?peekModule=${moduleId}`);
})
.catch(() =>
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Module could not be archived. Please try again.",
})
)
.finally(() => setIsArchiving(false));
};
return (
<Transition.Root show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-20" onClose={onClose}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-custom-backdrop transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-custom-background-100 text-left shadow-custom-shadow-md transition-all sm:my-8 sm:w-full sm:max-w-lg">
<div className="px-5 py-4">
<h3 className="text-xl font-medium 2xl:text-2xl">Archive module {moduleName}</h3>
<p className="mt-3 text-sm text-custom-text-200">
Are you sure you want to archive the module? All your archives can be restored later.
</p>
<div className="mt-3 flex justify-end gap-2">
<Button variant="neutral-primary" size="sm" onClick={onClose}>
Cancel
</Button>
<Button size="sm" tabIndex={1} onClick={handleArchiveIssue} loading={isArchiving}>
{isArchiving ? "Archiving" : "Archive"}
</Button>
</div>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
};

View File

@ -0,0 +1,81 @@
import React, { useCallback } from "react";
import { observer } from "mobx-react-lite";
import { useRouter } from "next/router";
import useSWR from "swr";
// types
import { TModuleFilters } from "@plane/types";
// components
import { EmptyState } from "@/components/empty-state";
import { ArchivedModulesView, ModuleAppliedFiltersList } from "@/components/modules";
import { CycleModuleListLayout } from "@/components/ui";
// constants
import { EmptyStateType } from "@/constants/empty-state";
// helpers
import { calculateTotalFilters } from "@/helpers/filter.helper";
// hooks
import { useModule, useModuleFilter } from "@/hooks/store";
export const ArchivedModuleLayoutRoot: React.FC = observer(() => {
// router
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
// hooks
const { fetchArchivedModules, projectArchivedModuleIds, loader } = useModule();
// module filters hook
const { clearAllFilters, currentProjectArchivedFilters, updateFilters } = useModuleFilter();
// derived values
const totalArchivedModules = projectArchivedModuleIds?.length ?? 0;
useSWR(
workspaceSlug && projectId ? `ARCHIVED_MODULES_${workspaceSlug.toString()}_${projectId.toString()}` : null,
async () => {
if (workspaceSlug && projectId) {
await fetchArchivedModules(workspaceSlug.toString(), projectId.toString());
}
},
{ revalidateIfStale: false, revalidateOnFocus: false }
);
const handleRemoveFilter = useCallback(
(key: keyof TModuleFilters, value: string | null) => {
if (!projectId) return;
let newValues = currentProjectArchivedFilters?.[key] ?? [];
if (!value) newValues = [];
else newValues = newValues.filter((val) => val !== value);
updateFilters(projectId.toString(), { [key]: newValues }, "archived");
},
[currentProjectArchivedFilters, projectId, updateFilters]
);
if (!workspaceSlug || !projectId) return <></>;
if (loader || !projectArchivedModuleIds) {
return <CycleModuleListLayout />;
}
return (
<>
{calculateTotalFilters(currentProjectArchivedFilters ?? {}) !== 0 && (
<div className="border-b border-custom-border-200 px-5 py-3">
<ModuleAppliedFiltersList
appliedFilters={currentProjectArchivedFilters ?? {}}
handleClearAllFilters={() => clearAllFilters(projectId.toString(), "archived")}
handleRemoveFilter={handleRemoveFilter}
alwaysAllowEditing
/>
</div>
)}
{totalArchivedModules === 0 ? (
<div className="h-full place-items-center">
<EmptyState type={EmptyStateType.PROJECT_ARCHIVED_NO_MODULES} />
</div>
) : (
<div className="relative h-full w-full overflow-auto">
<ArchivedModulesView workspaceSlug={workspaceSlug.toString()} projectId={projectId.toString()} />
</div>
)}
</>
);
});

View File

@ -0,0 +1,64 @@
import { FC } from "react";
import { observer } from "mobx-react-lite";
import Image from "next/image";
// components
import { ModuleListItem, ModulePeekOverview } from "@/components/modules";
// ui
import { CycleModuleListLayout } from "@/components/ui";
// hooks
import { useModule, useModuleFilter } from "@/hooks/store";
// assets
import AllFiltersImage from "@/public/empty-state/module/all-filters.svg";
import NameFilterImage from "@/public/empty-state/module/name-filter.svg";
export interface IArchivedModulesView {
workspaceSlug: string;
projectId: string;
}
export const ArchivedModulesView: FC<IArchivedModulesView> = observer((props) => {
const { workspaceSlug, projectId } = props;
// store hooks
const { getFilteredArchivedModuleIds, loader } = useModule();
const { archivedModulesSearchQuery } = useModuleFilter();
// derived values
const filteredArchivedModuleIds = getFilteredArchivedModuleIds(projectId);
if (loader || !filteredArchivedModuleIds) return <CycleModuleListLayout />;
if (filteredArchivedModuleIds.length === 0)
return (
<div className="h-full w-full grid place-items-center">
<div className="text-center">
<Image
src={archivedModulesSearchQuery.trim() === "" ? AllFiltersImage : NameFilterImage}
className="h-36 sm:h-48 w-36 sm:w-48 mx-auto"
alt="No matching modules"
/>
<h5 className="text-xl font-medium mt-7 mb-1">No matching modules</h5>
<p className="text-custom-text-400 text-base">
{archivedModulesSearchQuery.trim() === ""
? "Remove the filters to see all modules"
: "Remove the search criteria to see all modules"}
</p>
</div>
</div>
);
return (
<div className="h-full overflow-y-auto">
<div className="flex h-full w-full justify-between">
<div className="flex h-full w-full flex-col overflow-y-auto vertical-scrollbar scrollbar-lg">
{filteredArchivedModuleIds.map((moduleId) => (
<ModuleListItem key={moduleId} moduleId={moduleId} isArchived />
))}
</div>
<ModulePeekOverview
projectId={projectId?.toString() ?? ""}
workspaceSlug={workspaceSlug?.toString() ?? ""}
isArchived
/>
</div>
</div>
);
});

View File

@ -14,10 +14,18 @@ type Props = {
handleDisplayFiltersUpdate: (updatedDisplayProperties: Partial<TModuleDisplayFilters>) => void;
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">

View File

@ -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";

View File

@ -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>
);
});

View File

@ -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>
);
});

View File

@ -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>
)}
</>

View File

@ -0,0 +1,179 @@
import { useState } from "react";
import { observer } from "mobx-react";
import { useRouter } from "next/router";
// icons
import { ArchiveRestoreIcon, LinkIcon, Pencil, Trash2 } from "lucide-react";
// ui
import { ArchiveIcon, CustomMenu, TOAST_TYPE, setToast } from "@plane/ui";
// components
import { ArchiveModuleModal, CreateUpdateModuleModal, DeleteModuleModal } from "@/components/modules";
// constants
import { EUserProjectRoles } from "@/constants/project";
// helpers
import { copyUrlToClipboard } from "@/helpers/string.helper";
// hooks
import { useModule, useEventTracker, useUser } from "@/hooks/store";
type Props = {
moduleId: string;
projectId: string;
workspaceSlug: string;
isArchived?: boolean;
};
export const ModuleQuickActions: React.FC<Props> = observer((props) => {
const { moduleId, projectId, workspaceSlug, isArchived } = props;
// router
const router = useRouter();
// states
const [editModal, setEditModal] = useState(false);
const [archiveModuleModal, setArchiveModuleModal] = useState(false);
const [deleteModal, setDeleteModal] = useState(false);
// store hooks
const { setTrackElement } = useEventTracker();
const {
membership: { currentWorkspaceAllProjectsRole },
} = useUser();
const { getModuleById, restoreModule } = useModule();
// derived values
const moduleDetails = getModuleById(moduleId);
// auth
const isEditingAllowed =
!!currentWorkspaceAllProjectsRole && currentWorkspaceAllProjectsRole[projectId] >= EUserProjectRoles.MEMBER;
const moduleState = moduleDetails?.status.toLocaleLowerCase();
const isInArchivableGroup = !!moduleState && ["completed", "cancelled"].includes(moduleState);
const handleCopyText = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
e.preventDefault();
copyUrlToClipboard(`${workspaceSlug}/projects/${projectId}/modules/${moduleId}`).then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Link Copied!",
message: "Module link copied to clipboard.",
});
});
};
const handleEditModule = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setTrackElement("Modules page list layout");
setEditModal(true);
};
const handleArchiveModule = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setArchiveModuleModal(true);
};
const handleRestoreModule = async (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
await restoreModule(workspaceSlug, projectId, moduleId)
.then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Restore success",
message: "Your module can be found in project modules.",
});
router.push(`/${workspaceSlug}/projects/${projectId}/modules/${moduleId}`);
})
.catch(() =>
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Module could not be restored. Please try again.",
})
);
};
const handleDeleteModule = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setTrackElement("Modules page list layout");
setDeleteModal(true);
};
return (
<>
{moduleDetails && (
<div className="fixed">
<CreateUpdateModuleModal
isOpen={editModal}
onClose={() => setEditModal(false)}
data={moduleDetails}
projectId={projectId}
workspaceSlug={workspaceSlug}
/>
<ArchiveModuleModal
workspaceSlug={workspaceSlug}
projectId={projectId}
moduleId={moduleId}
isOpen={archiveModuleModal}
handleClose={() => setArchiveModuleModal(false)}
/>
<DeleteModuleModal data={moduleDetails} isOpen={deleteModal} onClose={() => setDeleteModal(false)} />
</div>
)}
<CustomMenu ellipsis placement="left-start">
{isEditingAllowed && !isArchived && (
<CustomMenu.MenuItem onClick={handleEditModule}>
<span className="flex items-center justify-start gap-2">
<Pencil className="h-3 w-3" />
<span>Edit module</span>
</span>
</CustomMenu.MenuItem>
)}
{isEditingAllowed && !isArchived && (
<CustomMenu.MenuItem onClick={handleArchiveModule} disabled={!isInArchivableGroup}>
{isInArchivableGroup ? (
<div className="flex items-center gap-2">
<ArchiveIcon className="h-3 w-3" />
Archive module
</div>
) : (
<div className="flex items-start gap-2">
<ArchiveIcon className="h-3 w-3" />
<div className="-mt-1">
<p>Archive module</p>
<p className="text-xs text-custom-text-400">
Only completed or cancelled <br /> module can be archived.
</p>
</div>
</div>
)}
</CustomMenu.MenuItem>
)}
{isEditingAllowed && isArchived && (
<CustomMenu.MenuItem onClick={handleRestoreModule}>
<span className="flex items-center justify-start gap-2">
<ArchiveRestoreIcon className="h-3 w-3" />
<span>Restore module</span>
</span>
</CustomMenu.MenuItem>
)}
{!isArchived && (
<CustomMenu.MenuItem onClick={handleCopyText}>
<span className="flex items-center justify-start gap-2">
<LinkIcon className="h-3 w-3" />
<span>Copy module link</span>
</span>
</CustomMenu.MenuItem>
)}
{isEditingAllowed && (
<div className="border-t pt-1 mt-1">
<CustomMenu.MenuItem onClick={handleDeleteModule}>
<span className="flex items-center justify-start gap-2">
<Trash2 className="h-3 w-3" />
<span>Delete module</span>
</span>
</CustomMenu.MenuItem>
</div>
)}
</CustomMenu>
</>
);
});

View File

@ -4,6 +4,7 @@ import { useRouter } from "next/router";
import { Controller, useForm } from "react-hook-form";
import {
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>

View File

@ -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">
{" "}

View File

@ -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
View File

@ -0,0 +1,50 @@
// types
import { IProject } from "@plane/types";
// icons
import { ContrastIcon, DiceIcon, LayersIcon } from "@plane/ui";
export const ARCHIVES_TAB_LIST: {
key: string;
label: string;
shouldRender: (projectDetails: IProject) => boolean;
}[] = [
{
key: "issues",
label: "Issues",
shouldRender: () => true,
},
{
key: "cycles",
label: "Cycles",
shouldRender: (projectDetails) => projectDetails.cycle_view,
},
{
key: "modules",
label: "Modules",
shouldRender: (projectDetails) => projectDetails.module_view,
},
];
export const PROJECT_ARCHIVES_BREADCRUMB_LIST: {
[key: string]: {
label: string;
href: string;
icon: React.FC<React.SVGAttributes<SVGElement> & { className?: string }>;
};
} = {
issues: {
label: "Issues",
href: "/issues",
icon: LayersIcon,
},
cycles: {
label: "Cycles",
href: "/cycles",
icon: ContrastIcon,
},
modules: {
label: "Modules",
href: "/modules",
icon: DiceIcon,
},
};

View File

@ -51,6 +51,7 @@ export enum EmptyStateType {
PROJECT_CYCLE_ACTIVE = "project-cycle-active",
PROJECT_CYCLE_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,

View File

@ -0,0 +1,44 @@
import { ReactElement } from "react";
import { observer } from "mobx-react";
import { useRouter } from "next/router";
// components
import { PageHead } from "@/components/core";
import { ArchivedCycleLayoutRoot, ArchivedCyclesHeader } from "@/components/cycles";
import { ProjectArchivesHeader } from "@/components/headers";
// hooks
import { useProject } from "@/hooks/store";
// layouts
import { AppLayout } from "@/layouts/app-layout";
// types
import { NextPageWithLayout } from "@/lib/types";
const ProjectArchivedCyclesPage: NextPageWithLayout = observer(() => {
// router
const router = useRouter();
const { projectId } = router.query;
// store hooks
const { getProjectById } = useProject();
// derived values
const project = projectId ? getProjectById(projectId.toString()) : undefined;
const pageTitle = project?.name && `${project?.name} - Archived cycles`;
return (
<>
<PageHead title={pageTitle} />
<div className="relative flex h-full w-full flex-col overflow-hidden">
<ArchivedCyclesHeader />
<ArchivedCycleLayoutRoot />
</div>
</>
);
});
ProjectArchivedCyclesPage.getLayout = function getLayout(page: ReactElement) {
return (
<AppLayout header={<ProjectArchivesHeader />} withProjectWrapper>
{page}
</AppLayout>
);
};
export default ProjectArchivedCyclesPage;

View File

@ -2,23 +2,23 @@ import { useState, ReactElement } from "react";
import { observer } from "mobx-react";
import { 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>

View File

@ -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>
);

View File

@ -0,0 +1,44 @@
import { ReactElement } from "react";
import { observer } from "mobx-react";
import { useRouter } from "next/router";
// components
import { PageHead } from "@/components/core";
import { ProjectArchivesHeader } from "@/components/headers";
import { ArchivedModuleLayoutRoot, ArchivedModulesHeader } from "@/components/modules";
// hooks
import { useProject } from "@/hooks/store";
// layouts
import { AppLayout } from "@/layouts/app-layout";
// types
import { NextPageWithLayout } from "@/lib/types";
const ProjectArchivedModulesPage: NextPageWithLayout = observer(() => {
// router
const router = useRouter();
const { projectId } = router.query;
// store hooks
const { getProjectById } = useProject();
// derived values
const project = projectId ? getProjectById(projectId.toString()) : undefined;
const pageTitle = project?.name && `${project?.name} - Archived modules`;
return (
<>
<PageHead title={pageTitle} />
<div className="relative flex h-full w-full flex-col overflow-hidden">
<ArchivedModulesHeader />
<ArchivedModuleLayoutRoot />
</div>
</>
);
});
ProjectArchivedModulesPage.getLayout = function getLayout(page: ReactElement) {
return (
<AppLayout header={<ProjectArchivesHeader />} withProjectWrapper>
{page}
</AppLayout>
);
};
export default ProjectArchivedModulesPage;

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

View File

@ -0,0 +1,42 @@
// type
import { ICycle } from "@plane/types";
// helpers
import { API_BASE_URL } from "@/helpers/common.helper";
// services
import { APIService } from "@/services/api.service";
export class CycleArchiveService extends APIService {
constructor() {
super(API_BASE_URL);
}
async getArchivedCycles(workspaceSlug: string, projectId: string): Promise<ICycle[]> {
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/archived-cycles/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async archiveCycle(
workspaceSlug: string,
projectId: string,
cycleId: string
): Promise<{
archived_at: string;
}> {
return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/cycles/${cycleId}/archive/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async restoreCycle(workspaceSlug: string, projectId: string, cycleId: string): Promise<void> {
return this.delete(`/api/workspaces/${workspaceSlug}/projects/${projectId}/cycles/${cycleId}/archive/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
}

View File

@ -0,0 +1,42 @@
// type
import { IModule } from "@plane/types";
// helpers
import { API_BASE_URL } from "@/helpers/common.helper";
// services
import { APIService } from "@/services/api.service";
export class ModuleArchiveService extends APIService {
constructor() {
super(API_BASE_URL);
}
async getArchivedModules(workspaceSlug: string, projectId: string): Promise<IModule[]> {
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/archived-modules/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async archiveModule(
workspaceSlug: string,
projectId: string,
moduleId: string
): Promise<{
archived_at: string;
}> {
return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/modules/${moduleId}/archive/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async restoreModule(workspaceSlug: string, projectId: string, moduleId: string): Promise<void> {
return this.delete(`/api/workspaces/${workspaceSlug}/projects/${projectId}/modules/${moduleId}/archive/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
}

View File

@ -3,17 +3,18 @@ import set from "lodash/set";
import sortBy from "lodash/sortBy";
import { 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);
});
};
}

View File

@ -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] = {};
});
};
}

View File

@ -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);
});
};
}

View File

@ -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] = {};
});
};
}