mirror of
https://github.com/makeplane/plane
synced 2024-06-14 14:31:34 +00:00
[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:
parent
9642b761b7
commit
231fd52992
@ -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(
|
||||
|
@ -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(
|
||||
|
@ -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)
|
||||
|
@ -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;
|
||||
|
1
packages/types/src/project/projects.d.ts
vendored
1
packages/types/src/project/projects.d.ts
vendored
@ -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;
|
||||
|
@ -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>
|
||||
|
@ -134,10 +134,18 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = observer((props) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
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;
|
||||
|
@ -106,10 +106,18 @@ export const CyclesListItem: FC<TCyclesListItem> = observer((props) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
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);
|
||||
|
@ -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>
|
||||
|
@ -101,10 +101,18 @@ export const ModuleCardItem: React.FC<Props> = observer((props) => {
|
||||
e.preventDefault();
|
||||
const { query } = router;
|
||||
|
||||
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;
|
||||
|
@ -102,10 +102,18 @@ export const ModuleListItem: React.FC<Props> = observer((props) => {
|
||||
e.preventDefault();
|
||||
const { query } = router;
|
||||
|
||||
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;
|
||||
|
@ -1,4 +1,5 @@
|
||||
export * from "./access";
|
||||
export * from "./date";
|
||||
export * from "./members";
|
||||
export * from "./project-display-filters";
|
||||
export * from "./root";
|
||||
|
@ -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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
});
|
@ -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"
|
||||
|
@ -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}
|
||||
|
@ -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,6 +154,7 @@ export const ProjectCard: React.FC<Props> = observer((props) => {
|
||||
</div>
|
||||
</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"
|
||||
@ -165,16 +180,22 @@ export const ProjectCard: React.FC<Props> = observer((props) => {
|
||||
/>
|
||||
</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">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Tooltip
|
||||
isMobile={isMobile}
|
||||
tooltipHeading="Members"
|
||||
@ -188,10 +209,10 @@ export const ProjectCard: React.FC<Props> = observer((props) => {
|
||||
<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} />;
|
||||
return (
|
||||
<Avatar key={member.id} name={member.member__display_name} src={member.member__avatar} />
|
||||
);
|
||||
})}
|
||||
</AvatarGroup>
|
||||
</div>
|
||||
@ -199,6 +220,38 @@ export const ProjectCard: React.FC<Props> = observer((props) => {
|
||||
<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>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
{project.is_member &&
|
||||
(isOwner || isMember ? (
|
||||
<Link
|
||||
@ -231,6 +284,8 @@ export const ProjectCard: React.FC<Props> = observer((props) => {
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
@ -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 */}
|
||||
|
@ -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 won’t 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>
|
||||
);
|
||||
};
|
@ -0,0 +1,2 @@
|
||||
export * from "./selection";
|
||||
export * from "./archive-restore-modal";
|
@ -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>
|
||||
);
|
||||
};
|
@ -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
|
||||
|
@ -1,2 +1,3 @@
|
||||
export * from "./delete-project-section";
|
||||
export * from "./features-list";
|
||||
export * from "./archive-project";
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
},
|
||||
];
|
||||
|
@ -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;
|
||||
};
|
||||
|
@ -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 && (
|
||||
{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 && (
|
||||
<>
|
||||
<ArchiveProjectSelection
|
||||
projectDetails={currentProjectDetails}
|
||||
handleArchive={() => setArchiveProject(true)}
|
||||
/>
|
||||
<DeleteProjectSection
|
||||
projectDetails={currentProjectDetails}
|
||||
handleDelete={() => setSelectedProject(currentProjectDetails.id ?? null)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
|
@ -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>
|
||||
|
@ -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";
|
||||
|
31
web/services/project/project-archive.service.ts
Normal file
31
web/services/project/project-archive.service.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
}
|
@ -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;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
};
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user