[WEB-447] feat: projects archive. (#4014)

* dev: project archive response

* feat: projects archive.

* dev: response changes for cycle and module

* chore: status message changed

* chore: update clear all applied display filters logic.

* style: archived project card UI update.

* chore: archive/ restore taost message update.

* fix: clear all applied display filter logic.

* chore: project empty state update to handle archived projects.

* chore: minor typo fix in cycles and modules archive.

* chore: close cycle/ module overview sidebar if it's already open when clicked on overview button.

* chore: optimize current workspace applied display filter logic.

* chore: update all `archived_at` type from `Date` to `string`.

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
This commit is contained in:
Prateek Shourya 2024-03-21 20:59:34 +05:30 committed by GitHub
parent 9642b761b7
commit 231fd52992
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 749 additions and 162 deletions

View File

@ -878,7 +878,10 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView):
) )
cycle.archived_at = timezone.now() cycle.archived_at = timezone.now()
cycle.save() cycle.save()
return Response(status=status.HTTP_204_NO_CONTENT) return Response(
{"archived_at": str(cycle.archived_at)},
status=status.HTTP_200_OK,
)
def delete(self, request, slug, project_id, cycle_id): def delete(self, request, slug, project_id, cycle_id):
cycle = Cycle.objects.get( cycle = Cycle.objects.get(

View File

@ -621,7 +621,7 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
"backlog_issues", "backlog_issues",
"created_at", "created_at",
"updated_at", "updated_at",
"archived_at" "archived_at",
) )
return Response(modules, status=status.HTTP_200_OK) return Response(modules, status=status.HTTP_200_OK)
@ -631,7 +631,10 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
) )
module.archived_at = timezone.now() module.archived_at = timezone.now()
module.save() module.save()
return Response(status=status.HTTP_204_NO_CONTENT) return Response(
{"archived_at": str(module.archived_at)},
status=status.HTTP_200_OK,
)
def delete(self, request, slug, project_id, module_id): def delete(self, request, slug, project_id, module_id):
module = Module.objects.get( module = Module.objects.get(

View File

@ -372,7 +372,7 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
return Response( return Response(
{"error": "Archived projects cannot be updated"}, {"error": "Archived projects cannot be updated"},
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
serializer = ProjectSerializer( serializer = ProjectSerializer(
project, project,
@ -433,11 +433,15 @@ class ProjectArchiveUnarchiveEndpoint(BaseAPIView):
permission_classes = [ permission_classes = [
ProjectBasePermission, ProjectBasePermission,
] ]
def post(self, request, slug, project_id): def post(self, request, slug, project_id):
project = Project.objects.get(pk=project_id, workspace__slug=slug) project = Project.objects.get(pk=project_id, workspace__slug=slug)
project.archived_at = timezone.now() project.archived_at = timezone.now()
project.save() project.save()
return Response(status=status.HTTP_204_NO_CONTENT) return Response(
{"archived_at": str(project.archived_at)},
status=status.HTTP_200_OK,
)
def delete(self, request, slug, project_id): def delete(self, request, slug, project_id):
project = Project.objects.get(pk=project_id, workspace__slug=slug) project = Project.objects.get(pk=project_id, workspace__slug=slug)

View File

@ -9,9 +9,14 @@ export type TProjectOrderByOptions =
export type TProjectDisplayFilters = { export type TProjectDisplayFilters = {
my_projects?: boolean; my_projects?: boolean;
archived_projects?: boolean;
order_by?: TProjectOrderByOptions; order_by?: TProjectOrderByOptions;
}; };
export type TProjectAppliedDisplayFilterKeys =
| "my_projects"
| "archived_projects";
export type TProjectFilters = { export type TProjectFilters = {
access?: string[] | null; access?: string[] | null;
lead?: string[] | null; lead?: string[] | null;

View File

@ -23,6 +23,7 @@ export type TProjectLogoProps = {
export interface IProject { export interface IProject {
archive_in: number; archive_in: number;
archived_at: string | null;
archived_issues: number; archived_issues: number;
archived_sub_issues: number; archived_sub_issues: number;
close_in: number; close_in: number;

View File

@ -31,7 +31,7 @@ export const ArchiveCycleModal: React.FC<Props> = (props) => {
handleClose(); handleClose();
}; };
const handleArchiveIssue = async () => { const handleArchiveCycle = async () => {
setIsArchiving(true); setIsArchiving(true);
await archiveCycle(workspaceSlug, projectId, cycleId) await archiveCycle(workspaceSlug, projectId, cycleId)
.then(() => { .then(() => {
@ -89,7 +89,7 @@ export const ArchiveCycleModal: React.FC<Props> = (props) => {
<Button variant="neutral-primary" size="sm" onClick={onClose}> <Button variant="neutral-primary" size="sm" onClick={onClose}>
Cancel Cancel
</Button> </Button>
<Button size="sm" tabIndex={1} onClick={handleArchiveIssue} loading={isArchiving}> <Button size="sm" tabIndex={1} onClick={handleArchiveCycle} loading={isArchiving}>
{isArchiving ? "Archiving" : "Archive"} {isArchiving ? "Archiving" : "Archive"}
</Button> </Button>
</div> </div>

View File

@ -69,8 +69,8 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = observer((props) => {
? cycleTotalIssues === 0 ? cycleTotalIssues === 0
? "0 Issue" ? "0 Issue"
: cycleTotalIssues === cycleDetails.completed_issues : cycleTotalIssues === cycleDetails.completed_issues
? `${cycleTotalIssues} Issue${cycleTotalIssues > 1 ? "s" : ""}` ? `${cycleTotalIssues} Issue${cycleTotalIssues > 1 ? "s" : ""}`
: `${cycleDetails.completed_issues}/${cycleTotalIssues} Issues` : `${cycleDetails.completed_issues}/${cycleTotalIssues} Issues`
: "0 Issue"; : "0 Issue";
const handleAddToFavorites = (e: MouseEvent<HTMLButtonElement>) => { const handleAddToFavorites = (e: MouseEvent<HTMLButtonElement>) => {
@ -134,10 +134,18 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = observer((props) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
router.push({ if (query.peekCycle) {
pathname: router.pathname, delete query.peekCycle;
query: { ...query, peekCycle: cycleId }, router.push({
}); pathname: router.pathname,
query: { ...query },
});
} else {
router.push({
pathname: router.pathname,
query: { ...query, peekCycle: cycleId },
});
}
}; };
const daysLeft = findHowManyDaysLeft(cycleDetails.end_date) ?? 0; const daysLeft = findHowManyDaysLeft(cycleDetails.end_date) ?? 0;

View File

@ -106,10 +106,18 @@ export const CyclesListItem: FC<TCyclesListItem> = observer((props) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
router.push({ if (query.peekCycle) {
pathname: router.pathname, delete query.peekCycle;
query: { ...query, peekCycle: cycleId }, router.push({
}); pathname: router.pathname,
query: { ...query },
});
} else {
router.push({
pathname: router.pathname,
query: { ...query, peekCycle: cycleId },
});
}
}; };
const cycleDetails = getCycleById(cycleId); const cycleDetails = getCycleById(cycleId);

View File

@ -31,7 +31,7 @@ export const ArchiveModuleModal: React.FC<Props> = (props) => {
handleClose(); handleClose();
}; };
const handleArchiveIssue = async () => { const handleArchiveModule = async () => {
setIsArchiving(true); setIsArchiving(true);
await archiveModule(workspaceSlug, projectId, moduleId) await archiveModule(workspaceSlug, projectId, moduleId)
.then(() => { .then(() => {
@ -89,7 +89,7 @@ export const ArchiveModuleModal: React.FC<Props> = (props) => {
<Button variant="neutral-primary" size="sm" onClick={onClose}> <Button variant="neutral-primary" size="sm" onClick={onClose}>
Cancel Cancel
</Button> </Button>
<Button size="sm" tabIndex={1} onClick={handleArchiveIssue} loading={isArchiving}> <Button size="sm" tabIndex={1} onClick={handleArchiveModule} loading={isArchiving}>
{isArchiving ? "Archiving" : "Archive"} {isArchiving ? "Archiving" : "Archive"}
</Button> </Button>
</div> </div>

View File

@ -101,10 +101,18 @@ export const ModuleCardItem: React.FC<Props> = observer((props) => {
e.preventDefault(); e.preventDefault();
const { query } = router; const { query } = router;
router.push({ if (query.peekModule) {
pathname: router.pathname, delete query.peekModule;
query: { ...query, peekModule: moduleId }, router.push({
}); pathname: router.pathname,
query: { ...query },
});
} else {
router.push({
pathname: router.pathname,
query: { ...query, peekModule: moduleId },
});
}
}; };
if (!moduleDetails) return null; if (!moduleDetails) return null;

View File

@ -102,10 +102,18 @@ export const ModuleListItem: React.FC<Props> = observer((props) => {
e.preventDefault(); e.preventDefault();
const { query } = router; const { query } = router;
router.push({ if (query.peekModule) {
pathname: router.pathname, delete query.peekModule;
query: { ...query, peekModule: moduleId }, router.push({
}); pathname: router.pathname,
query: { ...query },
});
} else {
router.push({
pathname: router.pathname,
query: { ...query, peekModule: moduleId },
});
}
}; };
if (!moduleDetails) return null; if (!moduleDetails) return null;

View File

@ -1,4 +1,5 @@
export * from "./access"; export * from "./access";
export * from "./date"; export * from "./date";
export * from "./members"; export * from "./members";
export * from "./project-display-filters";
export * from "./root"; export * from "./root";

View File

@ -0,0 +1,39 @@
import { observer } from "mobx-react-lite";
// icons
import { X } from "lucide-react";
// types
import { TProjectAppliedDisplayFilterKeys } from "@plane/types";
// constants
import { PROJECT_DISPLAY_FILTER_OPTIONS } from "@/constants/project";
type Props = {
handleRemove: (key: TProjectAppliedDisplayFilterKeys) => void;
values: TProjectAppliedDisplayFilterKeys[];
editable: boolean | undefined;
};
export const AppliedProjectDisplayFilters: React.FC<Props> = observer((props) => {
const { handleRemove, values, editable } = props;
return (
<>
{values.map((key) => {
const filterLabel = PROJECT_DISPLAY_FILTER_OPTIONS.find((s) => s.key === key)?.label;
return (
<div key={key} className="flex items-center gap-1 rounded p-1 text-xs bg-custom-background-80">
{filterLabel}
{editable && (
<button
type="button"
className="grid place-items-center text-custom-text-300 hover:text-custom-text-200"
onClick={() => handleRemove(key)}
>
<X size={10} strokeWidth={2} />
</button>
)}
</div>
);
})}
</>
);
});

View File

@ -1,17 +1,24 @@
import { X } from "lucide-react"; import { X } from "lucide-react";
import { TProjectFilters } from "@plane/types"; // types
// components import { TProjectAppliedDisplayFilterKeys, TProjectFilters } from "@plane/types";
import { Tooltip } from "@plane/ui";
import { AppliedAccessFilters, AppliedDateFilters, AppliedMembersFilters } from "@/components/project";
// ui // ui
import { Tooltip } from "@plane/ui";
// components
import {
AppliedAccessFilters,
AppliedDateFilters,
AppliedMembersFilters,
AppliedProjectDisplayFilters,
} from "@/components/project";
// helpers // helpers
import { replaceUnderscoreIfSnakeCase } from "@/helpers/string.helper"; import { replaceUnderscoreIfSnakeCase } from "@/helpers/string.helper";
// types
type Props = { type Props = {
appliedFilters: TProjectFilters; appliedFilters: TProjectFilters;
appliedDisplayFilters: TProjectAppliedDisplayFilterKeys[];
handleClearAllFilters: () => void; handleClearAllFilters: () => void;
handleRemoveFilter: (key: keyof TProjectFilters, value: string | null) => void; handleRemoveFilter: (key: keyof TProjectFilters, value: string | null) => void;
handleRemoveDisplayFilter: (key: TProjectAppliedDisplayFilterKeys) => void;
alwaysAllowEditing?: boolean; alwaysAllowEditing?: boolean;
filteredProjects: number; filteredProjects: number;
totalProjects: number; totalProjects: number;
@ -23,21 +30,24 @@ const DATE_FILTERS = ["created_at"];
export const ProjectAppliedFiltersList: React.FC<Props> = (props) => { export const ProjectAppliedFiltersList: React.FC<Props> = (props) => {
const { const {
appliedFilters, appliedFilters,
appliedDisplayFilters,
handleClearAllFilters, handleClearAllFilters,
handleRemoveFilter, handleRemoveFilter,
handleRemoveDisplayFilter,
alwaysAllowEditing, alwaysAllowEditing,
filteredProjects, filteredProjects,
totalProjects, totalProjects,
} = props; } = props;
if (!appliedFilters) return null; if (!appliedFilters && !appliedDisplayFilters) return null;
if (Object.keys(appliedFilters).length === 0) return null; if (Object.keys(appliedFilters).length === 0 && appliedDisplayFilters.length === 0) return null;
const isEditingAllowed = alwaysAllowEditing; const isEditingAllowed = alwaysAllowEditing;
return ( return (
<div className="flex items-start justify-between gap-1.5"> <div className="flex items-start justify-between gap-1.5">
<div className="flex flex-wrap items-stretch gap-2 bg-custom-background-100"> <div className="flex flex-wrap items-stretch gap-2 bg-custom-background-100">
{/* Applied filters */}
{Object.entries(appliedFilters).map(([key, value]) => { {Object.entries(appliedFilters).map(([key, value]) => {
const filterKey = key as keyof TProjectFilters; const filterKey = key as keyof TProjectFilters;
@ -85,6 +95,22 @@ export const ProjectAppliedFiltersList: React.FC<Props> = (props) => {
</div> </div>
); );
})} })}
{/* Applied display filters */}
{appliedDisplayFilters.length > 0 && (
<div
key="project_display_filters"
className="flex flex-wrap items-center gap-2 rounded-md border border-custom-border-200 px-2 py-1 capitalize"
>
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-xs text-custom-text-300">Projects</span>
<AppliedProjectDisplayFilters
editable={isEditingAllowed}
values={appliedDisplayFilters}
handleRemove={(key) => handleRemoveDisplayFilter(key)}
/>
</div>
</div>
)}
{isEditingAllowed && ( {isEditingAllowed && (
<button <button
type="button" type="button"

View File

@ -17,9 +17,9 @@ export const ProjectCardList = observer(() => {
const { commandPalette: commandPaletteStore } = useApplication(); const { commandPalette: commandPaletteStore } = useApplication();
const { setTrackElement } = useEventTracker(); const { setTrackElement } = useEventTracker();
const { workspaceProjectIds, filteredProjectIds, getProjectById } = useProject(); const { workspaceProjectIds, filteredProjectIds, getProjectById } = useProject();
const { searchQuery } = useProjectFilter(); const { searchQuery, currentWorkspaceDisplayFilters } = useProjectFilter();
if (workspaceProjectIds?.length === 0) if (workspaceProjectIds?.length === 0 && !currentWorkspaceDisplayFilters?.archived_projects)
return ( return (
<EmptyState <EmptyState
type={EmptyStateType.WORKSPACE_PROJECTS} type={EmptyStateType.WORKSPACE_PROJECTS}

View File

@ -2,12 +2,13 @@ import React, { useState } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { Check, LinkIcon, Lock, Pencil, Star } from "lucide-react"; import { ArchiveRestoreIcon, Check, LinkIcon, Lock, Pencil, Star, Trash2 } from "lucide-react";
import { cn } from "@plane/editor-core";
import type { IProject } from "@plane/types"; import type { IProject } from "@plane/types";
// ui // ui
import { Avatar, AvatarGroup, Button, Tooltip, TOAST_TYPE, setToast, setPromiseToast } from "@plane/ui"; import { Avatar, AvatarGroup, Button, Tooltip, TOAST_TYPE, setToast, setPromiseToast } from "@plane/ui";
// components // components
import { DeleteProjectModal, JoinProjectModal, ProjectLogo } from "@/components/project"; import { ArchiveRestoreProjectModal, DeleteProjectModal, JoinProjectModal, ProjectLogo } from "@/components/project";
// helpers // helpers
import { EUserProjectRoles } from "@/constants/project"; import { EUserProjectRoles } from "@/constants/project";
import { renderFormattedDate } from "@/helpers/date-time.helper"; import { renderFormattedDate } from "@/helpers/date-time.helper";
@ -28,6 +29,7 @@ export const ProjectCard: React.FC<Props> = observer((props) => {
// states // states
const [deleteProjectModalOpen, setDeleteProjectModal] = useState(false); const [deleteProjectModalOpen, setDeleteProjectModal] = useState(false);
const [joinProjectModalOpen, setJoinProjectModal] = useState(false); const [joinProjectModalOpen, setJoinProjectModal] = useState(false);
const [restoreProject, setRestoreProject] = useState(false);
// router // router
const router = useRouter(); const router = useRouter();
const { workspaceSlug } = router.query; const { workspaceSlug } = router.query;
@ -41,6 +43,8 @@ export const ProjectCard: React.FC<Props> = observer((props) => {
// auth // auth
const isOwner = project.member_role === EUserProjectRoles.ADMIN; const isOwner = project.member_role === EUserProjectRoles.ADMIN;
const isMember = project.member_role === EUserProjectRoles.MEMBER; const isMember = project.member_role === EUserProjectRoles.MEMBER;
// archive
const isArchived = !!project.archived_at;
const handleAddToFavorites = () => { const handleAddToFavorites = () => {
if (!workspaceSlug) return; if (!workspaceSlug) return;
@ -102,13 +106,23 @@ export const ProjectCard: React.FC<Props> = observer((props) => {
handleClose={() => setJoinProjectModal(false)} handleClose={() => setJoinProjectModal(false)}
/> />
)} )}
{/* Restore project modal */}
{workspaceSlug && project && (
<ArchiveRestoreProjectModal
workspaceSlug={workspaceSlug.toString()}
projectId={project.id}
isOpen={restoreProject}
onClose={() => setRestoreProject(false)}
archive={false}
/>
)}
<Link <Link
href={`/${workspaceSlug}/projects/${project.id}/issues`} href={`/${workspaceSlug}/projects/${project.id}/issues`}
onClick={(e) => { onClick={(e) => {
if (!project.is_member) { if (!project.is_member || isArchived) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
setJoinProjectModal(true); if (!isArchived) setJoinProjectModal(true);
} }
}} }}
className="flex flex-col rounded border border-custom-border-200 bg-custom-background-100" className="flex flex-col rounded border border-custom-border-200 bg-custom-background-100"
@ -140,96 +154,137 @@ export const ProjectCard: React.FC<Props> = observer((props) => {
</div> </div>
</div> </div>
<div className="flex h-full flex-shrink-0 items-center gap-2"> {!isArchived && (
<button <div className="flex h-full flex-shrink-0 items-center gap-2">
className="flex h-6 w-6 items-center justify-center rounded bg-white/10" <button
onClick={(e) => { className="flex h-6 w-6 items-center justify-center rounded bg-white/10"
e.stopPropagation(); onClick={(e) => {
e.preventDefault(); e.stopPropagation();
handleCopyText(); e.preventDefault();
}} handleCopyText();
> }}
<LinkIcon className="h-3 w-3 text-white" /> >
</button> <LinkIcon className="h-3 w-3 text-white" />
<button </button>
className="flex h-6 w-6 items-center justify-center rounded bg-white/10" <button
onClick={(e) => { className="flex h-6 w-6 items-center justify-center rounded bg-white/10"
e.preventDefault(); onClick={(e) => {
e.stopPropagation(); e.preventDefault();
if (project.is_favorite) handleRemoveFromFavorites(); e.stopPropagation();
else handleAddToFavorites(); if (project.is_favorite) handleRemoveFromFavorites();
}} else handleAddToFavorites();
> }}
<Star >
className={`h-3 w-3 ${project.is_favorite ? "fill-amber-400 text-transparent" : "text-white"} `} <Star
/> className={`h-3 w-3 ${project.is_favorite ? "fill-amber-400 text-transparent" : "text-white"} `}
</button> />
</div> </button>
</div>
)}
</div> </div>
</div> </div>
<div className="flex h-[104px] w-full flex-col justify-between rounded-b p-4"> <div
className={cn("flex h-[104px] w-full flex-col justify-between rounded-b p-4", {
"opacity-90": isArchived,
})}
>
<p className="line-clamp-2 break-words text-sm text-custom-text-300"> <p className="line-clamp-2 break-words text-sm text-custom-text-300">
{project.description && project.description.trim() !== "" {project.description && project.description.trim() !== ""
? project.description ? project.description
: `Created on ${renderFormattedDate(project.created_at)}`} : `Created on ${renderFormattedDate(project.created_at)}`}
</p> </p>
<div className="item-center flex justify-between"> <div className="item-center flex justify-between">
<Tooltip <div className="flex items-center justify-center gap-2">
isMobile={isMobile} <Tooltip
tooltipHeading="Members" isMobile={isMobile}
tooltipContent={ tooltipHeading="Members"
project.members && project.members.length > 0 ? `${project.members.length} Members` : "No Member" tooltipContent={
} project.members && project.members.length > 0 ? `${project.members.length} Members` : "No Member"
position="top" }
> position="top"
{projectMembersIds && projectMembersIds.length > 0 ? ( >
<div className="flex cursor-pointer items-center gap-2 text-custom-text-200"> {projectMembersIds && projectMembersIds.length > 0 ? (
<AvatarGroup showTooltip={false}> <div className="flex cursor-pointer items-center gap-2 text-custom-text-200">
{projectMembersIds.map((memberId) => { <AvatarGroup showTooltip={false}>
const member = project.members?.find((m) => m.member_id === memberId); {projectMembersIds.map((memberId) => {
const member = project.members?.find((m) => m.member_id === memberId);
if (!member) return null; if (!member) return null;
return (
return <Avatar key={member.id} name={member.member__display_name} src={member.member__avatar} />; <Avatar key={member.id} name={member.member__display_name} src={member.member__avatar} />
})} );
</AvatarGroup> })}
</AvatarGroup>
</div>
) : (
<span className="text-sm italic text-custom-text-400">No Member Yet</span>
)}
</Tooltip>
{isArchived && <div className="text-xs text-custom-text-400 font-medium">Archived</div>}
</div>
{isArchived ? (
isOwner && (
<div className="flex items-center justify-center gap-2">
<div
className="flex items-center justify-center text-xs text-custom-text-400 font-medium hover:text-custom-text-200"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setRestoreProject(true);
}}
>
<div className="flex items-center gap-1.5">
<ArchiveRestoreIcon className="h-3.5 w-3.5" />
Restore
</div>
</div>
<div
className="flex items-center justify-center text-xs text-custom-text-400 font-medium hover:text-custom-text-200"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setDeleteProjectModal(true);
}}
>
<Trash2 className="h-3.5 w-3.5" />
</div>
</div> </div>
) : ( )
<span className="text-sm italic text-custom-text-400">No Member Yet</span> ) : (
)} <>
</Tooltip> {project.is_member &&
{project.is_member && (isOwner || isMember ? (
(isOwner || isMember ? ( <Link
<Link className="flex items-center justify-center rounded p-1 text-custom-text-400 hover:bg-custom-background-80 hover:text-custom-text-200"
className="flex items-center justify-center rounded p-1 text-custom-text-400 hover:bg-custom-background-80 hover:text-custom-text-200" onClick={(e) => {
onClick={(e) => { e.stopPropagation();
e.stopPropagation(); }}
}} href={`/${workspaceSlug}/projects/${project.id}/settings`}
href={`/${workspaceSlug}/projects/${project.id}/settings`} >
> <Pencil className="h-3.5 w-3.5" />
<Pencil className="h-3.5 w-3.5" /> </Link>
</Link> ) : (
) : ( <span className="flex items-center gap-1 text-custom-text-400 text-sm">
<span className="flex items-center gap-1 text-custom-text-400 text-sm"> <Check className="h-3.5 w-3.5" />
<Check className="h-3.5 w-3.5" /> Joined
Joined </span>
</span> ))}
))} {!project.is_member && (
{!project.is_member && ( <div className="flex items-center">
<div className="flex items-center"> <Button
<Button variant="link-primary"
variant="link-primary" className="!p-0 font-semibold"
className="!p-0 font-semibold" onClick={(e) => {
onClick={(e) => { e.preventDefault();
e.preventDefault(); e.stopPropagation();
e.stopPropagation(); setJoinProjectModal(true);
setJoinProjectModal(true); }}
}} >
> Join
Join </Button>
</Button> </div>
</div> )}
</>
)} )}
</div> </div>
</div> </div>

View File

@ -51,6 +51,15 @@ export const ProjectFiltersSelection: React.FC<Props> = observer((props) => {
} }
title="My projects" title="My projects"
/> />
<FilterOption
isChecked={!!displayFilters.archived_projects}
onClick={() =>
handleDisplayFiltersUpdate({
archived_projects: !displayFilters.archived_projects,
})
}
title="Archived"
/>
</div> </div>
{/* access */} {/* access */}

View File

@ -0,0 +1,135 @@
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 { useProject } from "@/hooks/store";
type Props = {
workspaceSlug: string;
projectId: string;
isOpen: boolean;
onClose: () => void;
archive: boolean;
};
export const ArchiveRestoreProjectModal: React.FC<Props> = (props) => {
const { workspaceSlug, projectId, isOpen, onClose, archive } = props;
// router
const router = useRouter();
// states
const [isLoading, setIsLoading] = useState(false);
// store hooks
const { getProjectById, archiveProject, restoreProject } = useProject();
const projectDetails = getProjectById(projectId);
if (!projectDetails) return null;
const handleClose = () => {
setIsLoading(false);
onClose();
};
const handleArchiveProject = async () => {
setIsLoading(true);
await archiveProject(workspaceSlug, projectId)
.then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Archive success",
message: `${projectDetails.name} has been archived successfully`,
});
onClose();
router.push(`/${workspaceSlug}/projects/`);
})
.catch(() =>
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Project could not be archived. Please try again.",
})
)
.finally(() => setIsLoading(false));
};
const handleRestoreProject = async () => {
setIsLoading(true);
await restoreProject(workspaceSlug, projectId)
.then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Restore success",
message: `You can find ${projectDetails.name} in your projects.`,
});
onClose();
router.push(`/${workspaceSlug}/projects/`);
})
.catch(() =>
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Project could not be restored. Please try again.",
})
)
.finally(() => setIsLoading(false));
};
return (
<Transition.Root show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-20" onClose={handleClose}>
<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 ? "Archive" : "Restore"} {projectDetails.name}
</h3>
<p className="mt-3 text-sm text-custom-text-200">
{archive
? "This project and its issues, cycles, modules, and pages will be archived. Its issues wont appear in search. Only project admins can restore the project."
: "Restoring a project will activate it and make it visible to all members of the project. Are you sure you want to continue?"}
</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={archive ? handleArchiveProject : handleRestoreProject}
loading={isLoading}
>
{archive ? (isLoading ? "Archiving" : "Archive") : isLoading ? "Restoring" : "Restore"}
</Button>
</div>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
};

View File

@ -0,0 +1,2 @@
export * from "./selection";
export * from "./archive-restore-modal";

View File

@ -0,0 +1,60 @@
import React from "react";
import { ChevronRight, ChevronUp } from "lucide-react";
import { Disclosure, Transition } from "@headlessui/react";
// types
import { IProject } from "@plane/types";
// ui
import { Button, Loader } from "@plane/ui";
export interface IArchiveProject {
projectDetails: IProject;
handleArchive: () => void;
}
export const ArchiveProjectSelection: React.FC<IArchiveProject> = (props) => {
const { projectDetails, handleArchive } = props;
return (
<Disclosure as="div" className="border-t border-custom-border-100 py-4">
{({ open }) => (
<div className="w-full">
<Disclosure.Button as="button" type="button" className="flex w-full items-center justify-between">
<span className="text-xl tracking-tight">Archive project</span>
{open ? <ChevronUp className="h-5 w-5" /> : <ChevronRight className="h-5 w-5" />}
</Disclosure.Button>
<Transition
show={open}
enter="transition duration-100 ease-out"
enterFrom="transform opacity-0"
enterTo="transform opacity-100"
leave="transition duration-75 ease-out"
leaveFrom="transform opacity-100"
leaveTo="transform opacity-0"
>
<Disclosure.Panel>
<div className="flex flex-col gap-8 pt-4">
<span className="text-sm tracking-tight">
Archiving a project will unlist your project from your side navigation although you will still be able
to access it from your projects page. You can restore the project or delete it whenever you want.
</span>
<div>
{projectDetails ? (
<div>
<Button variant="outline-danger" onClick={handleArchive}>
Archive project
</Button>
</div>
) : (
<Loader className="mt-2 w-full">
<Loader.Item height="38px" width="144px" />
</Loader>
)}
</div>
</div>
</Disclosure.Panel>
</Transition>
</div>
)}
</Disclosure>
);
};

View File

@ -1,12 +1,10 @@
import React from "react"; import React from "react";
import { ChevronRight, ChevronUp } from "lucide-react";
// ui
import { ChevronDown, ChevronUp } from "lucide-react";
import { Disclosure, Transition } from "@headlessui/react"; import { Disclosure, Transition } from "@headlessui/react";
import { IProject } from "@plane/types";
import { Button, Loader } from "@plane/ui";
// icons
// types // types
import { IProject } from "@plane/types";
// ui
import { Button, Loader } from "@plane/ui";
export interface IDeleteProjectSection { export interface IDeleteProjectSection {
projectDetails: IProject; projectDetails: IProject;
@ -17,12 +15,12 @@ export const DeleteProjectSection: React.FC<IDeleteProjectSection> = (props) =>
const { projectDetails, handleDelete } = props; const { projectDetails, handleDelete } = props;
return ( return (
<Disclosure as="div" className="border-t border-custom-border-100"> <Disclosure as="div" className="border-t border-custom-border-100 py-4">
{({ open }) => ( {({ open }) => (
<div className="w-full"> <div className="w-full">
<Disclosure.Button as="button" type="button" className="flex w-full items-center justify-between py-4"> <Disclosure.Button as="button" type="button" className="flex w-full items-center justify-between">
<span className="text-xl tracking-tight">Delete Project</span> <span className="text-xl tracking-tight">Delete Project</span>
{open ? <ChevronUp className="h-5 w-5" /> : <ChevronDown className="h-5 w-5" />} {open ? <ChevronUp className="h-5 w-5" /> : <ChevronRight className="h-5 w-5" />}
</Disclosure.Button> </Disclosure.Button>
<Transition <Transition
@ -35,7 +33,7 @@ export const DeleteProjectSection: React.FC<IDeleteProjectSection> = (props) =>
leaveTo="transform opacity-0" leaveTo="transform opacity-0"
> >
<Disclosure.Panel> <Disclosure.Panel>
<div className="flex flex-col gap-8"> <div className="flex flex-col gap-8 pt-4">
<span className="text-sm tracking-tight"> <span className="text-sm tracking-tight">
The danger zone of the project delete page is a critical area that requires careful consideration and The danger zone of the project delete page is a critical area that requires careful consideration and
attention. When deleting a project, all of the data and resources within that project will be attention. When deleting a project, all of the data and resources within that project will be

View File

@ -1,2 +1,3 @@
export * from "./delete-project-section"; export * from "./delete-project-section";
export * from "./features-list"; export * from "./features-list";
export * from "./archive-project";

View File

@ -125,9 +125,9 @@ const emptyStateDetails = {
}, },
[EmptyStateType.WORKSPACE_PROJECTS]: { [EmptyStateType.WORKSPACE_PROJECTS]: {
key: EmptyStateType.WORKSPACE_PROJECTS, key: EmptyStateType.WORKSPACE_PROJECTS,
title: "Start a Project", title: "No active projects",
description: description:
"Think of each project as the parent for goal-oriented work. Projects are where Jobs, Cycles, and Modules live and, along with your colleagues, help you achieve that goal.", "Think of each project as the parent for goal-oriented work. Projects are where Jobs, Cycles, and Modules live and, along with your colleagues, help you achieve that goal. Create a new project or filter for archived projects.",
path: "/empty-state/onboarding/projects", path: "/empty-state/onboarding/projects",
primaryButton: { primaryButton: {
text: "Start your first project", text: "Start your first project",

View File

@ -1,9 +1,9 @@
// icons // icons
import { Globe2, Lock, LucideIcon } from "lucide-react"; import { Globe2, Lock, LucideIcon } from "lucide-react";
import { TProjectAppliedDisplayFilterKeys, TProjectOrderByOptions } from "@plane/types";
import { SettingIcon } from "@/components/icons"; import { SettingIcon } from "@/components/icons";
// types // types
import { Props } from "@/components/icons/types"; import { Props } from "@/components/icons/types";
import { TProjectOrderByOptions } from "@plane/types";
export enum EUserProjectRoles { export enum EUserProjectRoles {
GUEST = 5, GUEST = 5,
@ -162,3 +162,17 @@ export const PROJECT_ORDER_BY_OPTIONS: {
label: "Number of members", label: "Number of members",
}, },
]; ];
export const PROJECT_DISPLAY_FILTER_OPTIONS: {
key: TProjectAppliedDisplayFilterKeys;
label: string;
}[] = [
{
key: "my_projects",
label: "My projects",
},
{
key: "archived_projects",
label: "Archived",
},
];

View File

@ -1,11 +1,11 @@
import sortBy from "lodash/sortBy"; import sortBy from "lodash/sortBy";
// helpers
import { satisfiesDateFilter } from "@/helpers/filter.helper";
import { getDate } from "@/helpers/date-time.helper";
// types // types
import { IProject, TProjectDisplayFilters, TProjectFilters, TProjectOrderByOptions } from "@plane/types"; import { IProject, TProjectDisplayFilters, TProjectFilters, TProjectOrderByOptions } from "@plane/types";
// constants // constants
import { EUserProjectRoles } from "@/constants/project"; import { EUserProjectRoles } from "@/constants/project";
// helpers
import { getDate } from "@/helpers/date-time.helper";
import { satisfiesDateFilter } from "@/helpers/filter.helper";
/** /**
* Updates the sort order of the project. * Updates the sort order of the project.
@ -93,6 +93,8 @@ export const shouldFilterProject = (
} }
}); });
if (displayFilters.my_projects && !project.is_member) fallsInFilters = false; if (displayFilters.my_projects && !project.is_member) fallsInFilters = false;
if (displayFilters.archived_projects && !project.archived_at) fallsInFilters = false;
if (project.archived_at) fallsInFilters = displayFilters.archived_projects ? fallsInFilters : false;
return fallsInFilters; return fallsInFilters;
}; };

View File

@ -2,26 +2,29 @@ import { useState, ReactElement } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import useSWR from "swr"; import useSWR from "swr";
// hooks // components
import { PageHead } from "@/components/core"; import { PageHead } from "@/components/core";
import { ProjectSettingHeader } from "@/components/headers"; import { ProjectSettingHeader } from "@/components/headers";
import { import {
ArchiveRestoreProjectModal,
ArchiveProjectSelection,
DeleteProjectModal, DeleteProjectModal,
DeleteProjectSection, DeleteProjectSection,
ProjectDetailsForm, ProjectDetailsForm,
ProjectDetailsFormLoader, ProjectDetailsFormLoader,
} from "@/components/project"; } from "@/components/project";
// hooks
import { useProject } from "@/hooks/store"; import { useProject } from "@/hooks/store";
// layouts // layouts
import { AppLayout } from "@/layouts/app-layout"; import { AppLayout } from "@/layouts/app-layout";
import { ProjectSettingLayout } from "@/layouts/settings-layout"; import { ProjectSettingLayout } from "@/layouts/settings-layout";
// components
// types // types
import { NextPageWithLayout } from "@/lib/types"; import { NextPageWithLayout } from "@/lib/types";
const GeneralSettingsPage: NextPageWithLayout = observer(() => { const GeneralSettingsPage: NextPageWithLayout = observer(() => {
// states // states
const [selectProject, setSelectedProject] = useState<string | null>(null); const [selectProject, setSelectedProject] = useState<string | null>(null);
const [archiveProject, setArchiveProject] = useState<boolean>(false);
// router // router
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId } = router.query; const { workspaceSlug, projectId } = router.query;
@ -42,12 +45,21 @@ const GeneralSettingsPage: NextPageWithLayout = observer(() => {
return ( return (
<> <>
<PageHead title={pageTitle} /> <PageHead title={pageTitle} />
{currentProjectDetails && ( {currentProjectDetails && workspaceSlug && projectId && (
<DeleteProjectModal <>
project={currentProjectDetails} <ArchiveRestoreProjectModal
isOpen={Boolean(selectProject)} workspaceSlug={workspaceSlug.toString()}
onClose={() => setSelectedProject(null)} projectId={projectId.toString()}
/> isOpen={archiveProject}
onClose={() => setArchiveProject(false)}
archive
/>
<DeleteProjectModal
project={currentProjectDetails}
isOpen={Boolean(selectProject)}
onClose={() => setSelectedProject(null)}
/>
</>
)} )}
<div className={`w-full overflow-y-auto py-8 pr-9 ${isAdmin ? "" : "opacity-60"}`}> <div className={`w-full overflow-y-auto py-8 pr-9 ${isAdmin ? "" : "opacity-60"}`}>
@ -63,10 +75,16 @@ const GeneralSettingsPage: NextPageWithLayout = observer(() => {
)} )}
{isAdmin && ( {isAdmin && (
<DeleteProjectSection <>
projectDetails={currentProjectDetails} <ArchiveProjectSelection
handleDelete={() => setSelectedProject(currentProjectDetails.id ?? null)} projectDetails={currentProjectDetails}
/> handleArchive={() => setArchiveProject(true)}
/>
<DeleteProjectSection
projectDetails={currentProjectDetails}
handleDelete={() => setSelectedProject(currentProjectDetails.id ?? null)}
/>
</>
)} )}
</div> </div>
</> </>

View File

@ -1,6 +1,6 @@
import { ReactElement, useCallback } from "react"; import { ReactElement, useCallback } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { TProjectFilters } from "@plane/types"; import { TProjectAppliedDisplayFilterKeys, TProjectFilters } from "@plane/types";
// components // components
import { PageHead } from "@/components/core"; import { PageHead } from "@/components/core";
import { ProjectsHeader } from "@/components/headers"; import { ProjectsHeader } from "@/components/headers";
@ -19,8 +19,15 @@ const ProjectsPage: NextPageWithLayout = observer(() => {
router: { workspaceSlug }, router: { workspaceSlug },
} = useApplication(); } = useApplication();
const { currentWorkspace } = useWorkspace(); const { currentWorkspace } = useWorkspace();
const { workspaceProjectIds, filteredProjectIds } = useProject(); const { totalProjectIds, filteredProjectIds } = useProject();
const { currentWorkspaceFilters, clearAllFilters, updateFilters } = useProjectFilter(); const {
currentWorkspaceFilters,
currentWorkspaceAppliedDisplayFilters,
clearAllFilters,
clearAllAppliedDisplayFilters,
updateFilters,
updateDisplayFilters,
} = useProjectFilter();
// derived values // derived values
const pageTitle = currentWorkspace?.name ? `${currentWorkspace?.name} - Projects` : undefined; const pageTitle = currentWorkspace?.name ? `${currentWorkspace?.name} - Projects` : undefined;
@ -37,18 +44,35 @@ const ProjectsPage: NextPageWithLayout = observer(() => {
[currentWorkspaceFilters, updateFilters, workspaceSlug] [currentWorkspaceFilters, updateFilters, workspaceSlug]
); );
const handleRemoveDisplayFilter = useCallback(
(key: TProjectAppliedDisplayFilterKeys) => {
if (!workspaceSlug) return;
updateDisplayFilters(workspaceSlug.toString(), { [key]: false });
},
[updateDisplayFilters, workspaceSlug]
);
const handleClearAllFilters = useCallback(() => {
if (!workspaceSlug) return;
clearAllFilters(workspaceSlug.toString());
clearAllAppliedDisplayFilters(workspaceSlug.toString());
}, [clearAllFilters, clearAllAppliedDisplayFilters, workspaceSlug]);
return ( return (
<> <>
<PageHead title={pageTitle} /> <PageHead title={pageTitle} />
<div className="h-full w-full flex flex-col"> <div className="h-full w-full flex flex-col">
{calculateTotalFilters(currentWorkspaceFilters ?? {}) !== 0 && ( {(calculateTotalFilters(currentWorkspaceFilters ?? {}) !== 0 ||
currentWorkspaceAppliedDisplayFilters?.length !== 0) && (
<div className="border-b border-custom-border-200 px-5 py-3"> <div className="border-b border-custom-border-200 px-5 py-3">
<ProjectAppliedFiltersList <ProjectAppliedFiltersList
appliedFilters={currentWorkspaceFilters ?? {}} appliedFilters={currentWorkspaceFilters ?? {}}
handleClearAllFilters={() => clearAllFilters(`${workspaceSlug}`)} appliedDisplayFilters={currentWorkspaceAppliedDisplayFilters ?? []}
handleClearAllFilters={handleClearAllFilters}
handleRemoveFilter={handleRemoveFilter} handleRemoveFilter={handleRemoveFilter}
handleRemoveDisplayFilter={handleRemoveDisplayFilter}
filteredProjects={filteredProjectIds?.length ?? 0} filteredProjects={filteredProjectIds?.length ?? 0}
totalProjects={workspaceProjectIds?.length ?? 0} totalProjects={totalProjectIds?.length ?? 0}
alwaysAllowEditing alwaysAllowEditing
/> />
</div> </div>

View File

@ -4,3 +4,4 @@ export * from "./project-export.service";
export * from "./project-member.service"; export * from "./project-member.service";
export * from "./project-state.service"; export * from "./project-state.service";
export * from "./project-publish.service"; export * from "./project-publish.service";
export * from "./project-archive.service";

View File

@ -0,0 +1,31 @@
// helpers
import { API_BASE_URL } from "@/helpers/common.helper";
// services
import { APIService } from "@/services/api.service";
export class ProjectArchiveService extends APIService {
constructor() {
super(API_BASE_URL);
}
async archiveProject(
workspaceSlug: string,
projectId: string
): Promise<{
archived_at: string;
}> {
return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/archive/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async restoreProject(workspaceSlug: string, projectId: string): Promise<void> {
return this.delete(`/api/workspaces/${workspaceSlug}/projects/${projectId}/archive/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
}

View File

@ -3,12 +3,15 @@ import sortBy from "lodash/sortBy";
import { observable, action, computed, makeObservable, runInAction } from "mobx"; import { observable, action, computed, makeObservable, runInAction } from "mobx";
import { computedFn } from "mobx-utils"; import { computedFn } from "mobx-utils";
// types // types
import { IssueLabelService, IssueService } from "@/services/issue";
import { ProjectService, ProjectStateService } from "@/services/project";
import { IProject } from "@plane/types"; import { IProject } from "@plane/types";
import { RootStore } from "../root.store"; // helpers
import { orderProjects, shouldFilterProject } from "@/helpers/project.helper"; import { orderProjects, shouldFilterProject } from "@/helpers/project.helper";
// services // services
import { IssueLabelService, IssueService } from "@/services/issue";
import { ProjectService, ProjectStateService, ProjectArchiveService } from "@/services/project";
// store
import { RootStore } from "../root.store";
export interface IProjectStore { export interface IProjectStore {
// observables // observables
projectMap: { projectMap: {
@ -17,6 +20,8 @@ export interface IProjectStore {
// computed // computed
filteredProjectIds: string[] | undefined; filteredProjectIds: string[] | undefined;
workspaceProjectIds: string[] | undefined; workspaceProjectIds: string[] | undefined;
archivedProjectIds: string[] | undefined;
totalProjectIds: string[] | undefined;
joinedProjectIds: string[]; joinedProjectIds: string[];
favoriteProjectIds: string[]; favoriteProjectIds: string[];
currentProjectDetails: IProject | undefined; currentProjectDetails: IProject | undefined;
@ -35,6 +40,9 @@ export interface IProjectStore {
createProject: (workspaceSlug: string, data: Partial<IProject>) => Promise<IProject>; createProject: (workspaceSlug: string, data: Partial<IProject>) => Promise<IProject>;
updateProject: (workspaceSlug: string, projectId: string, data: Partial<IProject>) => Promise<IProject>; updateProject: (workspaceSlug: string, projectId: string, data: Partial<IProject>) => Promise<IProject>;
deleteProject: (workspaceSlug: string, projectId: string) => Promise<void>; deleteProject: (workspaceSlug: string, projectId: string) => Promise<void>;
// archive actions
archiveProject: (workspaceSlug: string, projectId: string) => Promise<void>;
restoreProject: (workspaceSlug: string, projectId: string) => Promise<void>;
} }
export class ProjectStore implements IProjectStore { export class ProjectStore implements IProjectStore {
@ -46,6 +54,7 @@ export class ProjectStore implements IProjectStore {
rootStore: RootStore; rootStore: RootStore;
// service // service
projectService; projectService;
projectArchiveService;
issueLabelService; issueLabelService;
issueService; issueService;
stateService; stateService;
@ -57,6 +66,8 @@ export class ProjectStore implements IProjectStore {
// computed // computed
filteredProjectIds: computed, filteredProjectIds: computed,
workspaceProjectIds: computed, workspaceProjectIds: computed,
archivedProjectIds: computed,
totalProjectIds: computed,
currentProjectDetails: computed, currentProjectDetails: computed,
joinedProjectIds: computed, joinedProjectIds: computed,
favoriteProjectIds: computed, favoriteProjectIds: computed,
@ -76,6 +87,7 @@ export class ProjectStore implements IProjectStore {
this.rootStore = _rootStore; this.rootStore = _rootStore;
// services // services
this.projectService = new ProjectService(); this.projectService = new ProjectService();
this.projectArchiveService = new ProjectArchiveService();
this.issueService = new IssueService(); this.issueService = new IssueService();
this.issueLabelService = new IssueLabelService(); this.issueLabelService = new IssueLabelService();
this.stateService = new ProjectStateService(); this.stateService = new ProjectStateService();
@ -109,11 +121,42 @@ export class ProjectStore implements IProjectStore {
get workspaceProjectIds() { get workspaceProjectIds() {
const workspaceDetails = this.rootStore.workspaceRoot.currentWorkspace; const workspaceDetails = this.rootStore.workspaceRoot.currentWorkspace;
if (!workspaceDetails) return; if (!workspaceDetails) return;
const workspaceProjects = Object.values(this.projectMap).filter((p) => p.workspace === workspaceDetails.id); const workspaceProjects = Object.values(this.projectMap).filter(
(p) => p.workspace === workspaceDetails.id && !p.archived_at
);
const projectIds = workspaceProjects.map((p) => p.id); const projectIds = workspaceProjects.map((p) => p.id);
return projectIds ?? null; return projectIds ?? null;
} }
/**
* Returns archived project IDs belong to current workspace.
*/
get archivedProjectIds() {
const currentWorkspace = this.rootStore.workspaceRoot.currentWorkspace;
if (!currentWorkspace) return;
let projects = Object.values(this.projectMap ?? {});
projects = sortBy(projects, "archived_at");
const projectIds = projects
.filter((project) => project.workspace === currentWorkspace.id && !!project.archived_at)
.map((project) => project.id);
return projectIds;
}
/**
* Returns total project IDs belong to the current workspace
*/
// workspaceProjectIds + archivedProjectIds
get totalProjectIds() {
const currentWorkspace = this.rootStore.workspaceRoot.currentWorkspace;
if (!currentWorkspace) return;
const workspaceProjects = this.workspaceProjectIds ?? [];
const archivedProjects = this.archivedProjectIds ?? [];
return [...workspaceProjects, ...archivedProjects];
}
/** /**
* Returns current project details * Returns current project details
*/ */
@ -133,7 +176,7 @@ export class ProjectStore implements IProjectStore {
projects = sortBy(projects, "sort_order"); projects = sortBy(projects, "sort_order");
const projectIds = projects const projectIds = projects
.filter((project) => project.workspace === currentWorkspace.id && project.is_member) .filter((project) => project.workspace === currentWorkspace.id && project.is_member && !project.archived_at)
.map((project) => project.id); .map((project) => project.id);
return projectIds; return projectIds;
} }
@ -149,7 +192,10 @@ export class ProjectStore implements IProjectStore {
projects = sortBy(projects, "created_at"); projects = sortBy(projects, "created_at");
const projectIds = projects const projectIds = projects
.filter((project) => project.workspace === currentWorkspace.id && project.is_member && project.is_favorite) .filter(
(project) =>
project.workspace === currentWorkspace.id && project.is_member && project.is_favorite && !project.archived_at
)
.map((project) => project.id); .map((project) => project.id);
return projectIds; return projectIds;
} }
@ -348,4 +394,48 @@ export class ProjectStore implements IProjectStore {
this.fetchProjects(workspaceSlug); this.fetchProjects(workspaceSlug);
} }
}; };
/**
* Archives a project from specific workspace and updates it in the store
* @param workspaceSlug
* @param projectId
* @returns Promise<void>
*/
archiveProject = async (workspaceSlug: string, projectId: string) => {
await this.projectArchiveService
.archiveProject(workspaceSlug, projectId)
.then((response) => {
runInAction(() => {
set(this.projectMap, [projectId, "archived_at"], response.archived_at);
});
})
.catch((error) => {
console.log("Failed to archive project from project store");
this.fetchProjects(workspaceSlug);
this.fetchProjectDetails(workspaceSlug, projectId);
throw error;
});
};
/**
* Restores a project from specific workspace and updates it in the store
* @param workspaceSlug
* @param projectId
* @returns Promise<void>
*/
restoreProject = async (workspaceSlug: string, projectId: string) => {
await this.projectArchiveService
.restoreProject(workspaceSlug, projectId)
.then(() => {
runInAction(() => {
set(this.projectMap, [projectId, "archived_at"], null);
});
})
.catch((error) => {
console.log("Failed to restore project from project store");
this.fetchProjects(workspaceSlug);
this.fetchProjectDetails(workspaceSlug, projectId);
throw error;
});
};
} }

View File

@ -1,9 +1,10 @@
import set from "lodash/set";
import { action, computed, observable, makeObservable, runInAction, reaction } from "mobx"; import { action, computed, observable, makeObservable, runInAction, reaction } from "mobx";
import { computedFn } from "mobx-utils"; import { computedFn } from "mobx-utils";
import set from "lodash/set";
// types // types
import { TProjectDisplayFilters, TProjectFilters, TProjectAppliedDisplayFilterKeys } from "@plane/types";
// store
import { RootStore } from "@/store/root.store"; import { RootStore } from "@/store/root.store";
import { TProjectDisplayFilters, TProjectFilters } from "@plane/types";
export interface IProjectFilterStore { export interface IProjectFilterStore {
// observables // observables
@ -12,6 +13,7 @@ export interface IProjectFilterStore {
searchQuery: string; searchQuery: string;
// computed // computed
currentWorkspaceDisplayFilters: TProjectDisplayFilters | undefined; currentWorkspaceDisplayFilters: TProjectDisplayFilters | undefined;
currentWorkspaceAppliedDisplayFilters: TProjectAppliedDisplayFilterKeys[] | undefined;
currentWorkspaceFilters: TProjectFilters | undefined; currentWorkspaceFilters: TProjectFilters | undefined;
// computed functions // computed functions
getDisplayFiltersByWorkspaceSlug: (workspaceSlug: string) => TProjectDisplayFilters | undefined; getDisplayFiltersByWorkspaceSlug: (workspaceSlug: string) => TProjectDisplayFilters | undefined;
@ -21,6 +23,7 @@ export interface IProjectFilterStore {
updateFilters: (workspaceSlug: string, filters: TProjectFilters) => void; updateFilters: (workspaceSlug: string, filters: TProjectFilters) => void;
updateSearchQuery: (query: string) => void; updateSearchQuery: (query: string) => void;
clearAllFilters: (workspaceSlug: string) => void; clearAllFilters: (workspaceSlug: string) => void;
clearAllAppliedDisplayFilters: (workspaceSlug: string) => void;
} }
export class ProjectFilterStore implements IProjectFilterStore { export class ProjectFilterStore implements IProjectFilterStore {
@ -39,12 +42,14 @@ export class ProjectFilterStore implements IProjectFilterStore {
searchQuery: observable.ref, searchQuery: observable.ref,
// computed // computed
currentWorkspaceDisplayFilters: computed, currentWorkspaceDisplayFilters: computed,
currentWorkspaceAppliedDisplayFilters: computed,
currentWorkspaceFilters: computed, currentWorkspaceFilters: computed,
// actions // actions
updateDisplayFilters: action, updateDisplayFilters: action,
updateFilters: action, updateFilters: action,
updateSearchQuery: action, updateSearchQuery: action,
clearAllFilters: action, clearAllFilters: action,
clearAllAppliedDisplayFilters: action,
}); });
// root store // root store
this.rootStore = _rootStore; this.rootStore = _rootStore;
@ -67,6 +72,21 @@ export class ProjectFilterStore implements IProjectFilterStore {
return this.displayFilters[workspaceSlug]; return this.displayFilters[workspaceSlug];
} }
/**
* @description get project state applied display filter of the current workspace
* @returns {TProjectAppliedDisplayFilterKeys[] | undefined} // An array of keys of applied display filters
*/
// TODO: Figure out a better approach for this
get currentWorkspaceAppliedDisplayFilters() {
const workspaceSlug = this.rootStore.app.router.workspaceSlug;
if (!workspaceSlug) return;
const displayFilters = this.displayFilters[workspaceSlug];
return Object.keys(displayFilters).filter(
(key): key is TProjectAppliedDisplayFilterKeys =>
["my_projects", "archived_projects"].includes(key) && !!displayFilters[key as keyof TProjectDisplayFilters]
);
}
/** /**
* @description get filters of the current workspace * @description get filters of the current workspace
*/ */
@ -143,4 +163,17 @@ export class ProjectFilterStore implements IProjectFilterStore {
this.filters[workspaceSlug] = {}; this.filters[workspaceSlug] = {};
}); });
}; };
/**
* @description clear project display filters of a workspace
* @param {string} workspaceSlug
*/
clearAllAppliedDisplayFilters = (workspaceSlug: string) => {
runInAction(() => {
if (!this.currentWorkspaceAppliedDisplayFilters) return;
this.currentWorkspaceAppliedDisplayFilters.forEach((key) => {
set(this.displayFilters, [workspaceSlug, key], false);
});
});
};
} }