[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.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):
cycle = Cycle.objects.get(

View File

@ -621,7 +621,7 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
"backlog_issues",
"created_at",
"updated_at",
"archived_at"
"archived_at",
)
return Response(modules, status=status.HTTP_200_OK)
@ -631,7 +631,10 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
)
module.archived_at = timezone.now()
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):
module = Module.objects.get(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,5 @@
export * from "./access";
export * from "./date";
export * from "./members";
export * from "./project-display-filters";
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 { TProjectFilters } from "@plane/types";
// components
import { Tooltip } from "@plane/ui";
import { AppliedAccessFilters, AppliedDateFilters, AppliedMembersFilters } from "@/components/project";
// types
import { TProjectAppliedDisplayFilterKeys, TProjectFilters } from "@plane/types";
// ui
import { Tooltip } from "@plane/ui";
// components
import {
AppliedAccessFilters,
AppliedDateFilters,
AppliedMembersFilters,
AppliedProjectDisplayFilters,
} from "@/components/project";
// helpers
import { replaceUnderscoreIfSnakeCase } from "@/helpers/string.helper";
// types
type Props = {
appliedFilters: TProjectFilters;
appliedDisplayFilters: TProjectAppliedDisplayFilterKeys[];
handleClearAllFilters: () => void;
handleRemoveFilter: (key: keyof TProjectFilters, value: string | null) => void;
handleRemoveDisplayFilter: (key: TProjectAppliedDisplayFilterKeys) => void;
alwaysAllowEditing?: boolean;
filteredProjects: number;
totalProjects: number;
@ -23,21 +30,24 @@ const DATE_FILTERS = ["created_at"];
export const ProjectAppliedFiltersList: React.FC<Props> = (props) => {
const {
appliedFilters,
appliedDisplayFilters,
handleClearAllFilters,
handleRemoveFilter,
handleRemoveDisplayFilter,
alwaysAllowEditing,
filteredProjects,
totalProjects,
} = props;
if (!appliedFilters) return null;
if (Object.keys(appliedFilters).length === 0) return null;
if (!appliedFilters && !appliedDisplayFilters) return null;
if (Object.keys(appliedFilters).length === 0 && appliedDisplayFilters.length === 0) return null;
const isEditingAllowed = alwaysAllowEditing;
return (
<div className="flex items-start justify-between gap-1.5">
<div className="flex flex-wrap items-stretch gap-2 bg-custom-background-100">
{/* Applied filters */}
{Object.entries(appliedFilters).map(([key, value]) => {
const filterKey = key as keyof TProjectFilters;
@ -85,6 +95,22 @@ export const ProjectAppliedFiltersList: React.FC<Props> = (props) => {
</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 && (
<button
type="button"

View File

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

View File

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

View File

@ -51,6 +51,15 @@ export const ProjectFiltersSelection: React.FC<Props> = observer((props) => {
}
title="My projects"
/>
<FilterOption
isChecked={!!displayFilters.archived_projects}
onClick={() =>
handleDisplayFiltersUpdate({
archived_projects: !displayFilters.archived_projects,
})
}
title="Archived"
/>
</div>
{/* 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";
// ui
import { ChevronDown, ChevronUp } from "lucide-react";
import { ChevronRight, ChevronUp } from "lucide-react";
import { Disclosure, Transition } from "@headlessui/react";
import { IProject } from "@plane/types";
import { Button, Loader } from "@plane/ui";
// icons
// types
import { IProject } from "@plane/types";
// ui
import { Button, Loader } from "@plane/ui";
export interface IDeleteProjectSection {
projectDetails: IProject;
@ -17,12 +15,12 @@ export const DeleteProjectSection: React.FC<IDeleteProjectSection> = (props) =>
const { projectDetails, handleDelete } = props;
return (
<Disclosure as="div" className="border-t border-custom-border-100">
<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 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>
{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>
<Transition
@ -35,7 +33,7 @@ export const DeleteProjectSection: React.FC<IDeleteProjectSection> = (props) =>
leaveTo="transform opacity-0"
>
<Disclosure.Panel>
<div className="flex flex-col gap-8">
<div className="flex flex-col gap-8 pt-4">
<span className="text-sm tracking-tight">
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

View File

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

View File

@ -125,9 +125,9 @@ const emptyStateDetails = {
},
[EmptyStateType.WORKSPACE_PROJECTS]: {
key: EmptyStateType.WORKSPACE_PROJECTS,
title: "Start a Project",
title: "No active projects",
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",
primaryButton: {
text: "Start your first project",

View File

@ -1,9 +1,9 @@
// icons
import { Globe2, Lock, LucideIcon } from "lucide-react";
import { TProjectAppliedDisplayFilterKeys, TProjectOrderByOptions } from "@plane/types";
import { SettingIcon } from "@/components/icons";
// types
import { Props } from "@/components/icons/types";
import { TProjectOrderByOptions } from "@plane/types";
export enum EUserProjectRoles {
GUEST = 5,
@ -162,3 +162,17 @@ export const PROJECT_ORDER_BY_OPTIONS: {
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";
// helpers
import { satisfiesDateFilter } from "@/helpers/filter.helper";
import { getDate } from "@/helpers/date-time.helper";
// types
import { IProject, TProjectDisplayFilters, TProjectFilters, TProjectOrderByOptions } from "@plane/types";
// constants
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.
@ -93,6 +93,8 @@ export const shouldFilterProject = (
}
});
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;
};

View File

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

View File

@ -1,6 +1,6 @@
import { ReactElement, useCallback } from "react";
import { observer } from "mobx-react";
import { TProjectFilters } from "@plane/types";
import { TProjectAppliedDisplayFilterKeys, TProjectFilters } from "@plane/types";
// components
import { PageHead } from "@/components/core";
import { ProjectsHeader } from "@/components/headers";
@ -19,8 +19,15 @@ const ProjectsPage: NextPageWithLayout = observer(() => {
router: { workspaceSlug },
} = useApplication();
const { currentWorkspace } = useWorkspace();
const { workspaceProjectIds, filteredProjectIds } = useProject();
const { currentWorkspaceFilters, clearAllFilters, updateFilters } = useProjectFilter();
const { totalProjectIds, filteredProjectIds } = useProject();
const {
currentWorkspaceFilters,
currentWorkspaceAppliedDisplayFilters,
clearAllFilters,
clearAllAppliedDisplayFilters,
updateFilters,
updateDisplayFilters,
} = useProjectFilter();
// derived values
const pageTitle = currentWorkspace?.name ? `${currentWorkspace?.name} - Projects` : undefined;
@ -37,18 +44,35 @@ const ProjectsPage: NextPageWithLayout = observer(() => {
[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 (
<>
<PageHead title={pageTitle} />
<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">
<ProjectAppliedFiltersList
appliedFilters={currentWorkspaceFilters ?? {}}
handleClearAllFilters={() => clearAllFilters(`${workspaceSlug}`)}
appliedDisplayFilters={currentWorkspaceAppliedDisplayFilters ?? []}
handleClearAllFilters={handleClearAllFilters}
handleRemoveFilter={handleRemoveFilter}
handleRemoveDisplayFilter={handleRemoveDisplayFilter}
filteredProjects={filteredProjectIds?.length ?? 0}
totalProjects={workspaceProjectIds?.length ?? 0}
totalProjects={totalProjectIds?.length ?? 0}
alwaysAllowEditing
/>
</div>

View File

@ -4,3 +4,4 @@ export * from "./project-export.service";
export * from "./project-member.service";
export * from "./project-state.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 { computedFn } from "mobx-utils";
// types
import { IssueLabelService, IssueService } from "@/services/issue";
import { ProjectService, ProjectStateService } from "@/services/project";
import { IProject } from "@plane/types";
import { RootStore } from "../root.store";
// helpers
import { orderProjects, shouldFilterProject } from "@/helpers/project.helper";
// services
import { IssueLabelService, IssueService } from "@/services/issue";
import { ProjectService, ProjectStateService, ProjectArchiveService } from "@/services/project";
// store
import { RootStore } from "../root.store";
export interface IProjectStore {
// observables
projectMap: {
@ -17,6 +20,8 @@ export interface IProjectStore {
// computed
filteredProjectIds: string[] | undefined;
workspaceProjectIds: string[] | undefined;
archivedProjectIds: string[] | undefined;
totalProjectIds: string[] | undefined;
joinedProjectIds: string[];
favoriteProjectIds: string[];
currentProjectDetails: IProject | undefined;
@ -35,6 +40,9 @@ export interface IProjectStore {
createProject: (workspaceSlug: string, data: Partial<IProject>) => Promise<IProject>;
updateProject: (workspaceSlug: string, projectId: string, data: Partial<IProject>) => Promise<IProject>;
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 {
@ -46,6 +54,7 @@ export class ProjectStore implements IProjectStore {
rootStore: RootStore;
// service
projectService;
projectArchiveService;
issueLabelService;
issueService;
stateService;
@ -57,6 +66,8 @@ export class ProjectStore implements IProjectStore {
// computed
filteredProjectIds: computed,
workspaceProjectIds: computed,
archivedProjectIds: computed,
totalProjectIds: computed,
currentProjectDetails: computed,
joinedProjectIds: computed,
favoriteProjectIds: computed,
@ -76,6 +87,7 @@ export class ProjectStore implements IProjectStore {
this.rootStore = _rootStore;
// services
this.projectService = new ProjectService();
this.projectArchiveService = new ProjectArchiveService();
this.issueService = new IssueService();
this.issueLabelService = new IssueLabelService();
this.stateService = new ProjectStateService();
@ -109,11 +121,42 @@ export class ProjectStore implements IProjectStore {
get workspaceProjectIds() {
const workspaceDetails = this.rootStore.workspaceRoot.currentWorkspace;
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);
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
*/
@ -133,7 +176,7 @@ export class ProjectStore implements IProjectStore {
projects = sortBy(projects, "sort_order");
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);
return projectIds;
}
@ -149,7 +192,10 @@ export class ProjectStore implements IProjectStore {
projects = sortBy(projects, "created_at");
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);
return projectIds;
}
@ -348,4 +394,48 @@ export class ProjectStore implements IProjectStore {
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 { computedFn } from "mobx-utils";
import set from "lodash/set";
// types
import { TProjectDisplayFilters, TProjectFilters, TProjectAppliedDisplayFilterKeys } from "@plane/types";
// store
import { RootStore } from "@/store/root.store";
import { TProjectDisplayFilters, TProjectFilters } from "@plane/types";
export interface IProjectFilterStore {
// observables
@ -12,6 +13,7 @@ export interface IProjectFilterStore {
searchQuery: string;
// computed
currentWorkspaceDisplayFilters: TProjectDisplayFilters | undefined;
currentWorkspaceAppliedDisplayFilters: TProjectAppliedDisplayFilterKeys[] | undefined;
currentWorkspaceFilters: TProjectFilters | undefined;
// computed functions
getDisplayFiltersByWorkspaceSlug: (workspaceSlug: string) => TProjectDisplayFilters | undefined;
@ -21,6 +23,7 @@ export interface IProjectFilterStore {
updateFilters: (workspaceSlug: string, filters: TProjectFilters) => void;
updateSearchQuery: (query: string) => void;
clearAllFilters: (workspaceSlug: string) => void;
clearAllAppliedDisplayFilters: (workspaceSlug: string) => void;
}
export class ProjectFilterStore implements IProjectFilterStore {
@ -39,12 +42,14 @@ export class ProjectFilterStore implements IProjectFilterStore {
searchQuery: observable.ref,
// computed
currentWorkspaceDisplayFilters: computed,
currentWorkspaceAppliedDisplayFilters: computed,
currentWorkspaceFilters: computed,
// actions
updateDisplayFilters: action,
updateFilters: action,
updateSearchQuery: action,
clearAllFilters: action,
clearAllAppliedDisplayFilters: action,
});
// root store
this.rootStore = _rootStore;
@ -67,6 +72,21 @@ export class ProjectFilterStore implements IProjectFilterStore {
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
*/
@ -143,4 +163,17 @@ export class ProjectFilterStore implements IProjectFilterStore {
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);
});
});
};
}