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.archived_at = timezone.now()
|
||||||
cycle.save()
|
cycle.save()
|
||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
return Response(
|
||||||
|
{"archived_at": str(cycle.archived_at)},
|
||||||
|
status=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
|
||||||
def delete(self, request, slug, project_id, cycle_id):
|
def delete(self, request, slug, project_id, cycle_id):
|
||||||
cycle = Cycle.objects.get(
|
cycle = Cycle.objects.get(
|
||||||
|
@ -621,7 +621,7 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
|
|||||||
"backlog_issues",
|
"backlog_issues",
|
||||||
"created_at",
|
"created_at",
|
||||||
"updated_at",
|
"updated_at",
|
||||||
"archived_at"
|
"archived_at",
|
||||||
)
|
)
|
||||||
return Response(modules, status=status.HTTP_200_OK)
|
return Response(modules, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
@ -631,7 +631,10 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
|
|||||||
)
|
)
|
||||||
module.archived_at = timezone.now()
|
module.archived_at = timezone.now()
|
||||||
module.save()
|
module.save()
|
||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
return Response(
|
||||||
|
{"archived_at": str(module.archived_at)},
|
||||||
|
status=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
|
||||||
def delete(self, request, slug, project_id, module_id):
|
def delete(self, request, slug, project_id, module_id):
|
||||||
module = Module.objects.get(
|
module = Module.objects.get(
|
||||||
|
@ -372,7 +372,7 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
|
|||||||
return Response(
|
return Response(
|
||||||
{"error": "Archived projects cannot be updated"},
|
{"error": "Archived projects cannot be updated"},
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
)
|
)
|
||||||
|
|
||||||
serializer = ProjectSerializer(
|
serializer = ProjectSerializer(
|
||||||
project,
|
project,
|
||||||
@ -433,11 +433,15 @@ class ProjectArchiveUnarchiveEndpoint(BaseAPIView):
|
|||||||
permission_classes = [
|
permission_classes = [
|
||||||
ProjectBasePermission,
|
ProjectBasePermission,
|
||||||
]
|
]
|
||||||
|
|
||||||
def post(self, request, slug, project_id):
|
def post(self, request, slug, project_id):
|
||||||
project = Project.objects.get(pk=project_id, workspace__slug=slug)
|
project = Project.objects.get(pk=project_id, workspace__slug=slug)
|
||||||
project.archived_at = timezone.now()
|
project.archived_at = timezone.now()
|
||||||
project.save()
|
project.save()
|
||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
return Response(
|
||||||
|
{"archived_at": str(project.archived_at)},
|
||||||
|
status=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
|
||||||
def delete(self, request, slug, project_id):
|
def delete(self, request, slug, project_id):
|
||||||
project = Project.objects.get(pk=project_id, workspace__slug=slug)
|
project = Project.objects.get(pk=project_id, workspace__slug=slug)
|
||||||
|
@ -9,9 +9,14 @@ export type TProjectOrderByOptions =
|
|||||||
|
|
||||||
export type TProjectDisplayFilters = {
|
export type TProjectDisplayFilters = {
|
||||||
my_projects?: boolean;
|
my_projects?: boolean;
|
||||||
|
archived_projects?: boolean;
|
||||||
order_by?: TProjectOrderByOptions;
|
order_by?: TProjectOrderByOptions;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TProjectAppliedDisplayFilterKeys =
|
||||||
|
| "my_projects"
|
||||||
|
| "archived_projects";
|
||||||
|
|
||||||
export type TProjectFilters = {
|
export type TProjectFilters = {
|
||||||
access?: string[] | null;
|
access?: string[] | null;
|
||||||
lead?: string[] | null;
|
lead?: string[] | null;
|
||||||
|
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 {
|
export interface IProject {
|
||||||
archive_in: number;
|
archive_in: number;
|
||||||
|
archived_at: string | null;
|
||||||
archived_issues: number;
|
archived_issues: number;
|
||||||
archived_sub_issues: number;
|
archived_sub_issues: number;
|
||||||
close_in: number;
|
close_in: number;
|
||||||
|
@ -31,7 +31,7 @@ export const ArchiveCycleModal: React.FC<Props> = (props) => {
|
|||||||
handleClose();
|
handleClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleArchiveIssue = async () => {
|
const handleArchiveCycle = async () => {
|
||||||
setIsArchiving(true);
|
setIsArchiving(true);
|
||||||
await archiveCycle(workspaceSlug, projectId, cycleId)
|
await archiveCycle(workspaceSlug, projectId, cycleId)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
@ -89,7 +89,7 @@ export const ArchiveCycleModal: React.FC<Props> = (props) => {
|
|||||||
<Button variant="neutral-primary" size="sm" onClick={onClose}>
|
<Button variant="neutral-primary" size="sm" onClick={onClose}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" tabIndex={1} onClick={handleArchiveIssue} loading={isArchiving}>
|
<Button size="sm" tabIndex={1} onClick={handleArchiveCycle} loading={isArchiving}>
|
||||||
{isArchiving ? "Archiving" : "Archive"}
|
{isArchiving ? "Archiving" : "Archive"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
@ -69,8 +69,8 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = observer((props) => {
|
|||||||
? cycleTotalIssues === 0
|
? cycleTotalIssues === 0
|
||||||
? "0 Issue"
|
? "0 Issue"
|
||||||
: cycleTotalIssues === cycleDetails.completed_issues
|
: cycleTotalIssues === cycleDetails.completed_issues
|
||||||
? `${cycleTotalIssues} Issue${cycleTotalIssues > 1 ? "s" : ""}`
|
? `${cycleTotalIssues} Issue${cycleTotalIssues > 1 ? "s" : ""}`
|
||||||
: `${cycleDetails.completed_issues}/${cycleTotalIssues} Issues`
|
: `${cycleDetails.completed_issues}/${cycleTotalIssues} Issues`
|
||||||
: "0 Issue";
|
: "0 Issue";
|
||||||
|
|
||||||
const handleAddToFavorites = (e: MouseEvent<HTMLButtonElement>) => {
|
const handleAddToFavorites = (e: MouseEvent<HTMLButtonElement>) => {
|
||||||
@ -134,10 +134,18 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = observer((props) => {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
router.push({
|
if (query.peekCycle) {
|
||||||
pathname: router.pathname,
|
delete query.peekCycle;
|
||||||
query: { ...query, peekCycle: cycleId },
|
router.push({
|
||||||
});
|
pathname: router.pathname,
|
||||||
|
query: { ...query },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
router.push({
|
||||||
|
pathname: router.pathname,
|
||||||
|
query: { ...query, peekCycle: cycleId },
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const daysLeft = findHowManyDaysLeft(cycleDetails.end_date) ?? 0;
|
const daysLeft = findHowManyDaysLeft(cycleDetails.end_date) ?? 0;
|
||||||
|
@ -106,10 +106,18 @@ export const CyclesListItem: FC<TCyclesListItem> = observer((props) => {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
router.push({
|
if (query.peekCycle) {
|
||||||
pathname: router.pathname,
|
delete query.peekCycle;
|
||||||
query: { ...query, peekCycle: cycleId },
|
router.push({
|
||||||
});
|
pathname: router.pathname,
|
||||||
|
query: { ...query },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
router.push({
|
||||||
|
pathname: router.pathname,
|
||||||
|
query: { ...query, peekCycle: cycleId },
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const cycleDetails = getCycleById(cycleId);
|
const cycleDetails = getCycleById(cycleId);
|
||||||
|
@ -31,7 +31,7 @@ export const ArchiveModuleModal: React.FC<Props> = (props) => {
|
|||||||
handleClose();
|
handleClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleArchiveIssue = async () => {
|
const handleArchiveModule = async () => {
|
||||||
setIsArchiving(true);
|
setIsArchiving(true);
|
||||||
await archiveModule(workspaceSlug, projectId, moduleId)
|
await archiveModule(workspaceSlug, projectId, moduleId)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
@ -89,7 +89,7 @@ export const ArchiveModuleModal: React.FC<Props> = (props) => {
|
|||||||
<Button variant="neutral-primary" size="sm" onClick={onClose}>
|
<Button variant="neutral-primary" size="sm" onClick={onClose}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" tabIndex={1} onClick={handleArchiveIssue} loading={isArchiving}>
|
<Button size="sm" tabIndex={1} onClick={handleArchiveModule} loading={isArchiving}>
|
||||||
{isArchiving ? "Archiving" : "Archive"}
|
{isArchiving ? "Archiving" : "Archive"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
@ -101,10 +101,18 @@ export const ModuleCardItem: React.FC<Props> = observer((props) => {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const { query } = router;
|
const { query } = router;
|
||||||
|
|
||||||
router.push({
|
if (query.peekModule) {
|
||||||
pathname: router.pathname,
|
delete query.peekModule;
|
||||||
query: { ...query, peekModule: moduleId },
|
router.push({
|
||||||
});
|
pathname: router.pathname,
|
||||||
|
query: { ...query },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
router.push({
|
||||||
|
pathname: router.pathname,
|
||||||
|
query: { ...query, peekModule: moduleId },
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!moduleDetails) return null;
|
if (!moduleDetails) return null;
|
||||||
|
@ -102,10 +102,18 @@ export const ModuleListItem: React.FC<Props> = observer((props) => {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const { query } = router;
|
const { query } = router;
|
||||||
|
|
||||||
router.push({
|
if (query.peekModule) {
|
||||||
pathname: router.pathname,
|
delete query.peekModule;
|
||||||
query: { ...query, peekModule: moduleId },
|
router.push({
|
||||||
});
|
pathname: router.pathname,
|
||||||
|
query: { ...query },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
router.push({
|
||||||
|
pathname: router.pathname,
|
||||||
|
query: { ...query, peekModule: moduleId },
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!moduleDetails) return null;
|
if (!moduleDetails) return null;
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
export * from "./access";
|
export * from "./access";
|
||||||
export * from "./date";
|
export * from "./date";
|
||||||
export * from "./members";
|
export * from "./members";
|
||||||
|
export * from "./project-display-filters";
|
||||||
export * from "./root";
|
export * from "./root";
|
||||||
|
@ -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 { X } from "lucide-react";
|
||||||
import { TProjectFilters } from "@plane/types";
|
// types
|
||||||
// components
|
import { TProjectAppliedDisplayFilterKeys, TProjectFilters } from "@plane/types";
|
||||||
import { Tooltip } from "@plane/ui";
|
|
||||||
import { AppliedAccessFilters, AppliedDateFilters, AppliedMembersFilters } from "@/components/project";
|
|
||||||
// ui
|
// ui
|
||||||
|
import { Tooltip } from "@plane/ui";
|
||||||
|
// components
|
||||||
|
import {
|
||||||
|
AppliedAccessFilters,
|
||||||
|
AppliedDateFilters,
|
||||||
|
AppliedMembersFilters,
|
||||||
|
AppliedProjectDisplayFilters,
|
||||||
|
} from "@/components/project";
|
||||||
// helpers
|
// helpers
|
||||||
import { replaceUnderscoreIfSnakeCase } from "@/helpers/string.helper";
|
import { replaceUnderscoreIfSnakeCase } from "@/helpers/string.helper";
|
||||||
// types
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
appliedFilters: TProjectFilters;
|
appliedFilters: TProjectFilters;
|
||||||
|
appliedDisplayFilters: TProjectAppliedDisplayFilterKeys[];
|
||||||
handleClearAllFilters: () => void;
|
handleClearAllFilters: () => void;
|
||||||
handleRemoveFilter: (key: keyof TProjectFilters, value: string | null) => void;
|
handleRemoveFilter: (key: keyof TProjectFilters, value: string | null) => void;
|
||||||
|
handleRemoveDisplayFilter: (key: TProjectAppliedDisplayFilterKeys) => void;
|
||||||
alwaysAllowEditing?: boolean;
|
alwaysAllowEditing?: boolean;
|
||||||
filteredProjects: number;
|
filteredProjects: number;
|
||||||
totalProjects: number;
|
totalProjects: number;
|
||||||
@ -23,21 +30,24 @@ const DATE_FILTERS = ["created_at"];
|
|||||||
export const ProjectAppliedFiltersList: React.FC<Props> = (props) => {
|
export const ProjectAppliedFiltersList: React.FC<Props> = (props) => {
|
||||||
const {
|
const {
|
||||||
appliedFilters,
|
appliedFilters,
|
||||||
|
appliedDisplayFilters,
|
||||||
handleClearAllFilters,
|
handleClearAllFilters,
|
||||||
handleRemoveFilter,
|
handleRemoveFilter,
|
||||||
|
handleRemoveDisplayFilter,
|
||||||
alwaysAllowEditing,
|
alwaysAllowEditing,
|
||||||
filteredProjects,
|
filteredProjects,
|
||||||
totalProjects,
|
totalProjects,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
if (!appliedFilters) return null;
|
if (!appliedFilters && !appliedDisplayFilters) return null;
|
||||||
if (Object.keys(appliedFilters).length === 0) return null;
|
if (Object.keys(appliedFilters).length === 0 && appliedDisplayFilters.length === 0) return null;
|
||||||
|
|
||||||
const isEditingAllowed = alwaysAllowEditing;
|
const isEditingAllowed = alwaysAllowEditing;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-start justify-between gap-1.5">
|
<div className="flex items-start justify-between gap-1.5">
|
||||||
<div className="flex flex-wrap items-stretch gap-2 bg-custom-background-100">
|
<div className="flex flex-wrap items-stretch gap-2 bg-custom-background-100">
|
||||||
|
{/* Applied filters */}
|
||||||
{Object.entries(appliedFilters).map(([key, value]) => {
|
{Object.entries(appliedFilters).map(([key, value]) => {
|
||||||
const filterKey = key as keyof TProjectFilters;
|
const filterKey = key as keyof TProjectFilters;
|
||||||
|
|
||||||
@ -85,6 +95,22 @@ export const ProjectAppliedFiltersList: React.FC<Props> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{/* Applied display filters */}
|
||||||
|
{appliedDisplayFilters.length > 0 && (
|
||||||
|
<div
|
||||||
|
key="project_display_filters"
|
||||||
|
className="flex flex-wrap items-center gap-2 rounded-md border border-custom-border-200 px-2 py-1 capitalize"
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
|
<span className="text-xs text-custom-text-300">Projects</span>
|
||||||
|
<AppliedProjectDisplayFilters
|
||||||
|
editable={isEditingAllowed}
|
||||||
|
values={appliedDisplayFilters}
|
||||||
|
handleRemove={(key) => handleRemoveDisplayFilter(key)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{isEditingAllowed && (
|
{isEditingAllowed && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
@ -17,9 +17,9 @@ export const ProjectCardList = observer(() => {
|
|||||||
const { commandPalette: commandPaletteStore } = useApplication();
|
const { commandPalette: commandPaletteStore } = useApplication();
|
||||||
const { setTrackElement } = useEventTracker();
|
const { setTrackElement } = useEventTracker();
|
||||||
const { workspaceProjectIds, filteredProjectIds, getProjectById } = useProject();
|
const { workspaceProjectIds, filteredProjectIds, getProjectById } = useProject();
|
||||||
const { searchQuery } = useProjectFilter();
|
const { searchQuery, currentWorkspaceDisplayFilters } = useProjectFilter();
|
||||||
|
|
||||||
if (workspaceProjectIds?.length === 0)
|
if (workspaceProjectIds?.length === 0 && !currentWorkspaceDisplayFilters?.archived_projects)
|
||||||
return (
|
return (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
type={EmptyStateType.WORKSPACE_PROJECTS}
|
type={EmptyStateType.WORKSPACE_PROJECTS}
|
||||||
|
@ -2,12 +2,13 @@ import React, { useState } from "react";
|
|||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { Check, LinkIcon, Lock, Pencil, Star } from "lucide-react";
|
import { ArchiveRestoreIcon, Check, LinkIcon, Lock, Pencil, Star, Trash2 } from "lucide-react";
|
||||||
|
import { cn } from "@plane/editor-core";
|
||||||
import type { IProject } from "@plane/types";
|
import type { IProject } from "@plane/types";
|
||||||
// ui
|
// ui
|
||||||
import { Avatar, AvatarGroup, Button, Tooltip, TOAST_TYPE, setToast, setPromiseToast } from "@plane/ui";
|
import { Avatar, AvatarGroup, Button, Tooltip, TOAST_TYPE, setToast, setPromiseToast } from "@plane/ui";
|
||||||
// components
|
// components
|
||||||
import { DeleteProjectModal, JoinProjectModal, ProjectLogo } from "@/components/project";
|
import { ArchiveRestoreProjectModal, DeleteProjectModal, JoinProjectModal, ProjectLogo } from "@/components/project";
|
||||||
// helpers
|
// helpers
|
||||||
import { EUserProjectRoles } from "@/constants/project";
|
import { EUserProjectRoles } from "@/constants/project";
|
||||||
import { renderFormattedDate } from "@/helpers/date-time.helper";
|
import { renderFormattedDate } from "@/helpers/date-time.helper";
|
||||||
@ -28,6 +29,7 @@ export const ProjectCard: React.FC<Props> = observer((props) => {
|
|||||||
// states
|
// states
|
||||||
const [deleteProjectModalOpen, setDeleteProjectModal] = useState(false);
|
const [deleteProjectModalOpen, setDeleteProjectModal] = useState(false);
|
||||||
const [joinProjectModalOpen, setJoinProjectModal] = useState(false);
|
const [joinProjectModalOpen, setJoinProjectModal] = useState(false);
|
||||||
|
const [restoreProject, setRestoreProject] = useState(false);
|
||||||
// router
|
// router
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { workspaceSlug } = router.query;
|
const { workspaceSlug } = router.query;
|
||||||
@ -41,6 +43,8 @@ export const ProjectCard: React.FC<Props> = observer((props) => {
|
|||||||
// auth
|
// auth
|
||||||
const isOwner = project.member_role === EUserProjectRoles.ADMIN;
|
const isOwner = project.member_role === EUserProjectRoles.ADMIN;
|
||||||
const isMember = project.member_role === EUserProjectRoles.MEMBER;
|
const isMember = project.member_role === EUserProjectRoles.MEMBER;
|
||||||
|
// archive
|
||||||
|
const isArchived = !!project.archived_at;
|
||||||
|
|
||||||
const handleAddToFavorites = () => {
|
const handleAddToFavorites = () => {
|
||||||
if (!workspaceSlug) return;
|
if (!workspaceSlug) return;
|
||||||
@ -102,13 +106,23 @@ export const ProjectCard: React.FC<Props> = observer((props) => {
|
|||||||
handleClose={() => setJoinProjectModal(false)}
|
handleClose={() => setJoinProjectModal(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{/* Restore project modal */}
|
||||||
|
{workspaceSlug && project && (
|
||||||
|
<ArchiveRestoreProjectModal
|
||||||
|
workspaceSlug={workspaceSlug.toString()}
|
||||||
|
projectId={project.id}
|
||||||
|
isOpen={restoreProject}
|
||||||
|
onClose={() => setRestoreProject(false)}
|
||||||
|
archive={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Link
|
<Link
|
||||||
href={`/${workspaceSlug}/projects/${project.id}/issues`}
|
href={`/${workspaceSlug}/projects/${project.id}/issues`}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
if (!project.is_member) {
|
if (!project.is_member || isArchived) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setJoinProjectModal(true);
|
if (!isArchived) setJoinProjectModal(true);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="flex flex-col rounded border border-custom-border-200 bg-custom-background-100"
|
className="flex flex-col rounded border border-custom-border-200 bg-custom-background-100"
|
||||||
@ -140,96 +154,137 @@ export const ProjectCard: React.FC<Props> = observer((props) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex h-full flex-shrink-0 items-center gap-2">
|
{!isArchived && (
|
||||||
<button
|
<div className="flex h-full flex-shrink-0 items-center gap-2">
|
||||||
className="flex h-6 w-6 items-center justify-center rounded bg-white/10"
|
<button
|
||||||
onClick={(e) => {
|
className="flex h-6 w-6 items-center justify-center rounded bg-white/10"
|
||||||
e.stopPropagation();
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.stopPropagation();
|
||||||
handleCopyText();
|
e.preventDefault();
|
||||||
}}
|
handleCopyText();
|
||||||
>
|
}}
|
||||||
<LinkIcon className="h-3 w-3 text-white" />
|
>
|
||||||
</button>
|
<LinkIcon className="h-3 w-3 text-white" />
|
||||||
<button
|
</button>
|
||||||
className="flex h-6 w-6 items-center justify-center rounded bg-white/10"
|
<button
|
||||||
onClick={(e) => {
|
className="flex h-6 w-6 items-center justify-center rounded bg-white/10"
|
||||||
e.preventDefault();
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.preventDefault();
|
||||||
if (project.is_favorite) handleRemoveFromFavorites();
|
e.stopPropagation();
|
||||||
else handleAddToFavorites();
|
if (project.is_favorite) handleRemoveFromFavorites();
|
||||||
}}
|
else handleAddToFavorites();
|
||||||
>
|
}}
|
||||||
<Star
|
>
|
||||||
className={`h-3 w-3 ${project.is_favorite ? "fill-amber-400 text-transparent" : "text-white"} `}
|
<Star
|
||||||
/>
|
className={`h-3 w-3 ${project.is_favorite ? "fill-amber-400 text-transparent" : "text-white"} `}
|
||||||
</button>
|
/>
|
||||||
</div>
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex h-[104px] w-full flex-col justify-between rounded-b p-4">
|
<div
|
||||||
|
className={cn("flex h-[104px] w-full flex-col justify-between rounded-b p-4", {
|
||||||
|
"opacity-90": isArchived,
|
||||||
|
})}
|
||||||
|
>
|
||||||
<p className="line-clamp-2 break-words text-sm text-custom-text-300">
|
<p className="line-clamp-2 break-words text-sm text-custom-text-300">
|
||||||
{project.description && project.description.trim() !== ""
|
{project.description && project.description.trim() !== ""
|
||||||
? project.description
|
? project.description
|
||||||
: `Created on ${renderFormattedDate(project.created_at)}`}
|
: `Created on ${renderFormattedDate(project.created_at)}`}
|
||||||
</p>
|
</p>
|
||||||
<div className="item-center flex justify-between">
|
<div className="item-center flex justify-between">
|
||||||
<Tooltip
|
<div className="flex items-center justify-center gap-2">
|
||||||
isMobile={isMobile}
|
<Tooltip
|
||||||
tooltipHeading="Members"
|
isMobile={isMobile}
|
||||||
tooltipContent={
|
tooltipHeading="Members"
|
||||||
project.members && project.members.length > 0 ? `${project.members.length} Members` : "No Member"
|
tooltipContent={
|
||||||
}
|
project.members && project.members.length > 0 ? `${project.members.length} Members` : "No Member"
|
||||||
position="top"
|
}
|
||||||
>
|
position="top"
|
||||||
{projectMembersIds && projectMembersIds.length > 0 ? (
|
>
|
||||||
<div className="flex cursor-pointer items-center gap-2 text-custom-text-200">
|
{projectMembersIds && projectMembersIds.length > 0 ? (
|
||||||
<AvatarGroup showTooltip={false}>
|
<div className="flex cursor-pointer items-center gap-2 text-custom-text-200">
|
||||||
{projectMembersIds.map((memberId) => {
|
<AvatarGroup showTooltip={false}>
|
||||||
const member = project.members?.find((m) => m.member_id === memberId);
|
{projectMembersIds.map((memberId) => {
|
||||||
|
const member = project.members?.find((m) => m.member_id === memberId);
|
||||||
if (!member) return null;
|
if (!member) return null;
|
||||||
|
return (
|
||||||
return <Avatar key={member.id} name={member.member__display_name} src={member.member__avatar} />;
|
<Avatar key={member.id} name={member.member__display_name} src={member.member__avatar} />
|
||||||
})}
|
);
|
||||||
</AvatarGroup>
|
})}
|
||||||
|
</AvatarGroup>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm italic text-custom-text-400">No Member Yet</span>
|
||||||
|
)}
|
||||||
|
</Tooltip>
|
||||||
|
{isArchived && <div className="text-xs text-custom-text-400 font-medium">Archived</div>}
|
||||||
|
</div>
|
||||||
|
{isArchived ? (
|
||||||
|
isOwner && (
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-center text-xs text-custom-text-400 font-medium hover:text-custom-text-200"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setRestoreProject(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<ArchiveRestoreIcon className="h-3.5 w-3.5" />
|
||||||
|
Restore
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-center text-xs text-custom-text-400 font-medium hover:text-custom-text-200"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setDeleteProjectModal(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
)
|
||||||
<span className="text-sm italic text-custom-text-400">No Member Yet</span>
|
) : (
|
||||||
)}
|
<>
|
||||||
</Tooltip>
|
{project.is_member &&
|
||||||
{project.is_member &&
|
(isOwner || isMember ? (
|
||||||
(isOwner || isMember ? (
|
<Link
|
||||||
<Link
|
className="flex items-center justify-center rounded p-1 text-custom-text-400 hover:bg-custom-background-80 hover:text-custom-text-200"
|
||||||
className="flex items-center justify-center rounded p-1 text-custom-text-400 hover:bg-custom-background-80 hover:text-custom-text-200"
|
onClick={(e) => {
|
||||||
onClick={(e) => {
|
e.stopPropagation();
|
||||||
e.stopPropagation();
|
}}
|
||||||
}}
|
href={`/${workspaceSlug}/projects/${project.id}/settings`}
|
||||||
href={`/${workspaceSlug}/projects/${project.id}/settings`}
|
>
|
||||||
>
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
<Pencil className="h-3.5 w-3.5" />
|
</Link>
|
||||||
</Link>
|
) : (
|
||||||
) : (
|
<span className="flex items-center gap-1 text-custom-text-400 text-sm">
|
||||||
<span className="flex items-center gap-1 text-custom-text-400 text-sm">
|
<Check className="h-3.5 w-3.5" />
|
||||||
<Check className="h-3.5 w-3.5" />
|
Joined
|
||||||
Joined
|
</span>
|
||||||
</span>
|
))}
|
||||||
))}
|
{!project.is_member && (
|
||||||
{!project.is_member && (
|
<div className="flex items-center">
|
||||||
<div className="flex items-center">
|
<Button
|
||||||
<Button
|
variant="link-primary"
|
||||||
variant="link-primary"
|
className="!p-0 font-semibold"
|
||||||
className="!p-0 font-semibold"
|
onClick={(e) => {
|
||||||
onClick={(e) => {
|
e.preventDefault();
|
||||||
e.preventDefault();
|
e.stopPropagation();
|
||||||
e.stopPropagation();
|
setJoinProjectModal(true);
|
||||||
setJoinProjectModal(true);
|
}}
|
||||||
}}
|
>
|
||||||
>
|
Join
|
||||||
Join
|
</Button>
|
||||||
</Button>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -51,6 +51,15 @@ export const ProjectFiltersSelection: React.FC<Props> = observer((props) => {
|
|||||||
}
|
}
|
||||||
title="My projects"
|
title="My projects"
|
||||||
/>
|
/>
|
||||||
|
<FilterOption
|
||||||
|
isChecked={!!displayFilters.archived_projects}
|
||||||
|
onClick={() =>
|
||||||
|
handleDisplayFiltersUpdate({
|
||||||
|
archived_projects: !displayFilters.archived_projects,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
title="Archived"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* access */}
|
{/* access */}
|
||||||
|
@ -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";
|
import React from "react";
|
||||||
|
import { ChevronRight, ChevronUp } from "lucide-react";
|
||||||
// ui
|
|
||||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
|
||||||
import { Disclosure, Transition } from "@headlessui/react";
|
import { Disclosure, Transition } from "@headlessui/react";
|
||||||
import { IProject } from "@plane/types";
|
|
||||||
import { Button, Loader } from "@plane/ui";
|
|
||||||
// icons
|
|
||||||
// types
|
// types
|
||||||
|
import { IProject } from "@plane/types";
|
||||||
|
// ui
|
||||||
|
import { Button, Loader } from "@plane/ui";
|
||||||
|
|
||||||
export interface IDeleteProjectSection {
|
export interface IDeleteProjectSection {
|
||||||
projectDetails: IProject;
|
projectDetails: IProject;
|
||||||
@ -17,12 +15,12 @@ export const DeleteProjectSection: React.FC<IDeleteProjectSection> = (props) =>
|
|||||||
const { projectDetails, handleDelete } = props;
|
const { projectDetails, handleDelete } = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Disclosure as="div" className="border-t border-custom-border-100">
|
<Disclosure as="div" className="border-t border-custom-border-100 py-4">
|
||||||
{({ open }) => (
|
{({ open }) => (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<Disclosure.Button as="button" type="button" className="flex w-full items-center justify-between py-4">
|
<Disclosure.Button as="button" type="button" className="flex w-full items-center justify-between">
|
||||||
<span className="text-xl tracking-tight">Delete Project</span>
|
<span className="text-xl tracking-tight">Delete Project</span>
|
||||||
{open ? <ChevronUp className="h-5 w-5" /> : <ChevronDown className="h-5 w-5" />}
|
{open ? <ChevronUp className="h-5 w-5" /> : <ChevronRight className="h-5 w-5" />}
|
||||||
</Disclosure.Button>
|
</Disclosure.Button>
|
||||||
|
|
||||||
<Transition
|
<Transition
|
||||||
@ -35,7 +33,7 @@ export const DeleteProjectSection: React.FC<IDeleteProjectSection> = (props) =>
|
|||||||
leaveTo="transform opacity-0"
|
leaveTo="transform opacity-0"
|
||||||
>
|
>
|
||||||
<Disclosure.Panel>
|
<Disclosure.Panel>
|
||||||
<div className="flex flex-col gap-8">
|
<div className="flex flex-col gap-8 pt-4">
|
||||||
<span className="text-sm tracking-tight">
|
<span className="text-sm tracking-tight">
|
||||||
The danger zone of the project delete page is a critical area that requires careful consideration and
|
The danger zone of the project delete page is a critical area that requires careful consideration and
|
||||||
attention. When deleting a project, all of the data and resources within that project will be
|
attention. When deleting a project, all of the data and resources within that project will be
|
||||||
|
@ -1,2 +1,3 @@
|
|||||||
export * from "./delete-project-section";
|
export * from "./delete-project-section";
|
||||||
export * from "./features-list";
|
export * from "./features-list";
|
||||||
|
export * from "./archive-project";
|
||||||
|
@ -125,9 +125,9 @@ const emptyStateDetails = {
|
|||||||
},
|
},
|
||||||
[EmptyStateType.WORKSPACE_PROJECTS]: {
|
[EmptyStateType.WORKSPACE_PROJECTS]: {
|
||||||
key: EmptyStateType.WORKSPACE_PROJECTS,
|
key: EmptyStateType.WORKSPACE_PROJECTS,
|
||||||
title: "Start a Project",
|
title: "No active projects",
|
||||||
description:
|
description:
|
||||||
"Think of each project as the parent for goal-oriented work. Projects are where Jobs, Cycles, and Modules live and, along with your colleagues, help you achieve that goal.",
|
"Think of each project as the parent for goal-oriented work. Projects are where Jobs, Cycles, and Modules live and, along with your colleagues, help you achieve that goal. Create a new project or filter for archived projects.",
|
||||||
path: "/empty-state/onboarding/projects",
|
path: "/empty-state/onboarding/projects",
|
||||||
primaryButton: {
|
primaryButton: {
|
||||||
text: "Start your first project",
|
text: "Start your first project",
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
// icons
|
// icons
|
||||||
import { Globe2, Lock, LucideIcon } from "lucide-react";
|
import { Globe2, Lock, LucideIcon } from "lucide-react";
|
||||||
|
import { TProjectAppliedDisplayFilterKeys, TProjectOrderByOptions } from "@plane/types";
|
||||||
import { SettingIcon } from "@/components/icons";
|
import { SettingIcon } from "@/components/icons";
|
||||||
// types
|
// types
|
||||||
import { Props } from "@/components/icons/types";
|
import { Props } from "@/components/icons/types";
|
||||||
import { TProjectOrderByOptions } from "@plane/types";
|
|
||||||
|
|
||||||
export enum EUserProjectRoles {
|
export enum EUserProjectRoles {
|
||||||
GUEST = 5,
|
GUEST = 5,
|
||||||
@ -162,3 +162,17 @@ export const PROJECT_ORDER_BY_OPTIONS: {
|
|||||||
label: "Number of members",
|
label: "Number of members",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const PROJECT_DISPLAY_FILTER_OPTIONS: {
|
||||||
|
key: TProjectAppliedDisplayFilterKeys;
|
||||||
|
label: string;
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
key: "my_projects",
|
||||||
|
label: "My projects",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "archived_projects",
|
||||||
|
label: "Archived",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
@ -1,11 +1,11 @@
|
|||||||
import sortBy from "lodash/sortBy";
|
import sortBy from "lodash/sortBy";
|
||||||
// helpers
|
|
||||||
import { satisfiesDateFilter } from "@/helpers/filter.helper";
|
|
||||||
import { getDate } from "@/helpers/date-time.helper";
|
|
||||||
// types
|
// types
|
||||||
import { IProject, TProjectDisplayFilters, TProjectFilters, TProjectOrderByOptions } from "@plane/types";
|
import { IProject, TProjectDisplayFilters, TProjectFilters, TProjectOrderByOptions } from "@plane/types";
|
||||||
// constants
|
// constants
|
||||||
import { EUserProjectRoles } from "@/constants/project";
|
import { EUserProjectRoles } from "@/constants/project";
|
||||||
|
// helpers
|
||||||
|
import { getDate } from "@/helpers/date-time.helper";
|
||||||
|
import { satisfiesDateFilter } from "@/helpers/filter.helper";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates the sort order of the project.
|
* Updates the sort order of the project.
|
||||||
@ -93,6 +93,8 @@ export const shouldFilterProject = (
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (displayFilters.my_projects && !project.is_member) fallsInFilters = false;
|
if (displayFilters.my_projects && !project.is_member) fallsInFilters = false;
|
||||||
|
if (displayFilters.archived_projects && !project.archived_at) fallsInFilters = false;
|
||||||
|
if (project.archived_at) fallsInFilters = displayFilters.archived_projects ? fallsInFilters : false;
|
||||||
|
|
||||||
return fallsInFilters;
|
return fallsInFilters;
|
||||||
};
|
};
|
||||||
|
@ -2,26 +2,29 @@ import { useState, ReactElement } from "react";
|
|||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
// hooks
|
// components
|
||||||
import { PageHead } from "@/components/core";
|
import { PageHead } from "@/components/core";
|
||||||
import { ProjectSettingHeader } from "@/components/headers";
|
import { ProjectSettingHeader } from "@/components/headers";
|
||||||
import {
|
import {
|
||||||
|
ArchiveRestoreProjectModal,
|
||||||
|
ArchiveProjectSelection,
|
||||||
DeleteProjectModal,
|
DeleteProjectModal,
|
||||||
DeleteProjectSection,
|
DeleteProjectSection,
|
||||||
ProjectDetailsForm,
|
ProjectDetailsForm,
|
||||||
ProjectDetailsFormLoader,
|
ProjectDetailsFormLoader,
|
||||||
} from "@/components/project";
|
} from "@/components/project";
|
||||||
|
// hooks
|
||||||
import { useProject } from "@/hooks/store";
|
import { useProject } from "@/hooks/store";
|
||||||
// layouts
|
// layouts
|
||||||
import { AppLayout } from "@/layouts/app-layout";
|
import { AppLayout } from "@/layouts/app-layout";
|
||||||
import { ProjectSettingLayout } from "@/layouts/settings-layout";
|
import { ProjectSettingLayout } from "@/layouts/settings-layout";
|
||||||
// components
|
|
||||||
// types
|
// types
|
||||||
import { NextPageWithLayout } from "@/lib/types";
|
import { NextPageWithLayout } from "@/lib/types";
|
||||||
|
|
||||||
const GeneralSettingsPage: NextPageWithLayout = observer(() => {
|
const GeneralSettingsPage: NextPageWithLayout = observer(() => {
|
||||||
// states
|
// states
|
||||||
const [selectProject, setSelectedProject] = useState<string | null>(null);
|
const [selectProject, setSelectedProject] = useState<string | null>(null);
|
||||||
|
const [archiveProject, setArchiveProject] = useState<boolean>(false);
|
||||||
// router
|
// router
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { workspaceSlug, projectId } = router.query;
|
const { workspaceSlug, projectId } = router.query;
|
||||||
@ -42,12 +45,21 @@ const GeneralSettingsPage: NextPageWithLayout = observer(() => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHead title={pageTitle} />
|
<PageHead title={pageTitle} />
|
||||||
{currentProjectDetails && (
|
{currentProjectDetails && workspaceSlug && projectId && (
|
||||||
<DeleteProjectModal
|
<>
|
||||||
project={currentProjectDetails}
|
<ArchiveRestoreProjectModal
|
||||||
isOpen={Boolean(selectProject)}
|
workspaceSlug={workspaceSlug.toString()}
|
||||||
onClose={() => setSelectedProject(null)}
|
projectId={projectId.toString()}
|
||||||
/>
|
isOpen={archiveProject}
|
||||||
|
onClose={() => setArchiveProject(false)}
|
||||||
|
archive
|
||||||
|
/>
|
||||||
|
<DeleteProjectModal
|
||||||
|
project={currentProjectDetails}
|
||||||
|
isOpen={Boolean(selectProject)}
|
||||||
|
onClose={() => setSelectedProject(null)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={`w-full overflow-y-auto py-8 pr-9 ${isAdmin ? "" : "opacity-60"}`}>
|
<div className={`w-full overflow-y-auto py-8 pr-9 ${isAdmin ? "" : "opacity-60"}`}>
|
||||||
@ -63,10 +75,16 @@ const GeneralSettingsPage: NextPageWithLayout = observer(() => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<DeleteProjectSection
|
<>
|
||||||
projectDetails={currentProjectDetails}
|
<ArchiveProjectSelection
|
||||||
handleDelete={() => setSelectedProject(currentProjectDetails.id ?? null)}
|
projectDetails={currentProjectDetails}
|
||||||
/>
|
handleArchive={() => setArchiveProject(true)}
|
||||||
|
/>
|
||||||
|
<DeleteProjectSection
|
||||||
|
projectDetails={currentProjectDetails}
|
||||||
|
handleDelete={() => setSelectedProject(currentProjectDetails.id ?? null)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { ReactElement, useCallback } from "react";
|
import { ReactElement, useCallback } from "react";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import { TProjectFilters } from "@plane/types";
|
import { TProjectAppliedDisplayFilterKeys, TProjectFilters } from "@plane/types";
|
||||||
// components
|
// components
|
||||||
import { PageHead } from "@/components/core";
|
import { PageHead } from "@/components/core";
|
||||||
import { ProjectsHeader } from "@/components/headers";
|
import { ProjectsHeader } from "@/components/headers";
|
||||||
@ -19,8 +19,15 @@ const ProjectsPage: NextPageWithLayout = observer(() => {
|
|||||||
router: { workspaceSlug },
|
router: { workspaceSlug },
|
||||||
} = useApplication();
|
} = useApplication();
|
||||||
const { currentWorkspace } = useWorkspace();
|
const { currentWorkspace } = useWorkspace();
|
||||||
const { workspaceProjectIds, filteredProjectIds } = useProject();
|
const { totalProjectIds, filteredProjectIds } = useProject();
|
||||||
const { currentWorkspaceFilters, clearAllFilters, updateFilters } = useProjectFilter();
|
const {
|
||||||
|
currentWorkspaceFilters,
|
||||||
|
currentWorkspaceAppliedDisplayFilters,
|
||||||
|
clearAllFilters,
|
||||||
|
clearAllAppliedDisplayFilters,
|
||||||
|
updateFilters,
|
||||||
|
updateDisplayFilters,
|
||||||
|
} = useProjectFilter();
|
||||||
// derived values
|
// derived values
|
||||||
const pageTitle = currentWorkspace?.name ? `${currentWorkspace?.name} - Projects` : undefined;
|
const pageTitle = currentWorkspace?.name ? `${currentWorkspace?.name} - Projects` : undefined;
|
||||||
|
|
||||||
@ -37,18 +44,35 @@ const ProjectsPage: NextPageWithLayout = observer(() => {
|
|||||||
[currentWorkspaceFilters, updateFilters, workspaceSlug]
|
[currentWorkspaceFilters, updateFilters, workspaceSlug]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleRemoveDisplayFilter = useCallback(
|
||||||
|
(key: TProjectAppliedDisplayFilterKeys) => {
|
||||||
|
if (!workspaceSlug) return;
|
||||||
|
updateDisplayFilters(workspaceSlug.toString(), { [key]: false });
|
||||||
|
},
|
||||||
|
[updateDisplayFilters, workspaceSlug]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleClearAllFilters = useCallback(() => {
|
||||||
|
if (!workspaceSlug) return;
|
||||||
|
clearAllFilters(workspaceSlug.toString());
|
||||||
|
clearAllAppliedDisplayFilters(workspaceSlug.toString());
|
||||||
|
}, [clearAllFilters, clearAllAppliedDisplayFilters, workspaceSlug]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHead title={pageTitle} />
|
<PageHead title={pageTitle} />
|
||||||
<div className="h-full w-full flex flex-col">
|
<div className="h-full w-full flex flex-col">
|
||||||
{calculateTotalFilters(currentWorkspaceFilters ?? {}) !== 0 && (
|
{(calculateTotalFilters(currentWorkspaceFilters ?? {}) !== 0 ||
|
||||||
|
currentWorkspaceAppliedDisplayFilters?.length !== 0) && (
|
||||||
<div className="border-b border-custom-border-200 px-5 py-3">
|
<div className="border-b border-custom-border-200 px-5 py-3">
|
||||||
<ProjectAppliedFiltersList
|
<ProjectAppliedFiltersList
|
||||||
appliedFilters={currentWorkspaceFilters ?? {}}
|
appliedFilters={currentWorkspaceFilters ?? {}}
|
||||||
handleClearAllFilters={() => clearAllFilters(`${workspaceSlug}`)}
|
appliedDisplayFilters={currentWorkspaceAppliedDisplayFilters ?? []}
|
||||||
|
handleClearAllFilters={handleClearAllFilters}
|
||||||
handleRemoveFilter={handleRemoveFilter}
|
handleRemoveFilter={handleRemoveFilter}
|
||||||
|
handleRemoveDisplayFilter={handleRemoveDisplayFilter}
|
||||||
filteredProjects={filteredProjectIds?.length ?? 0}
|
filteredProjects={filteredProjectIds?.length ?? 0}
|
||||||
totalProjects={workspaceProjectIds?.length ?? 0}
|
totalProjects={totalProjectIds?.length ?? 0}
|
||||||
alwaysAllowEditing
|
alwaysAllowEditing
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -4,3 +4,4 @@ export * from "./project-export.service";
|
|||||||
export * from "./project-member.service";
|
export * from "./project-member.service";
|
||||||
export * from "./project-state.service";
|
export * from "./project-state.service";
|
||||||
export * from "./project-publish.service";
|
export * from "./project-publish.service";
|
||||||
|
export * from "./project-archive.service";
|
||||||
|
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 { observable, action, computed, makeObservable, runInAction } from "mobx";
|
||||||
import { computedFn } from "mobx-utils";
|
import { computedFn } from "mobx-utils";
|
||||||
// types
|
// types
|
||||||
import { IssueLabelService, IssueService } from "@/services/issue";
|
|
||||||
import { ProjectService, ProjectStateService } from "@/services/project";
|
|
||||||
import { IProject } from "@plane/types";
|
import { IProject } from "@plane/types";
|
||||||
import { RootStore } from "../root.store";
|
// helpers
|
||||||
import { orderProjects, shouldFilterProject } from "@/helpers/project.helper";
|
import { orderProjects, shouldFilterProject } from "@/helpers/project.helper";
|
||||||
// services
|
// services
|
||||||
|
import { IssueLabelService, IssueService } from "@/services/issue";
|
||||||
|
import { ProjectService, ProjectStateService, ProjectArchiveService } from "@/services/project";
|
||||||
|
// store
|
||||||
|
import { RootStore } from "../root.store";
|
||||||
|
|
||||||
export interface IProjectStore {
|
export interface IProjectStore {
|
||||||
// observables
|
// observables
|
||||||
projectMap: {
|
projectMap: {
|
||||||
@ -17,6 +20,8 @@ export interface IProjectStore {
|
|||||||
// computed
|
// computed
|
||||||
filteredProjectIds: string[] | undefined;
|
filteredProjectIds: string[] | undefined;
|
||||||
workspaceProjectIds: string[] | undefined;
|
workspaceProjectIds: string[] | undefined;
|
||||||
|
archivedProjectIds: string[] | undefined;
|
||||||
|
totalProjectIds: string[] | undefined;
|
||||||
joinedProjectIds: string[];
|
joinedProjectIds: string[];
|
||||||
favoriteProjectIds: string[];
|
favoriteProjectIds: string[];
|
||||||
currentProjectDetails: IProject | undefined;
|
currentProjectDetails: IProject | undefined;
|
||||||
@ -35,6 +40,9 @@ export interface IProjectStore {
|
|||||||
createProject: (workspaceSlug: string, data: Partial<IProject>) => Promise<IProject>;
|
createProject: (workspaceSlug: string, data: Partial<IProject>) => Promise<IProject>;
|
||||||
updateProject: (workspaceSlug: string, projectId: string, data: Partial<IProject>) => Promise<IProject>;
|
updateProject: (workspaceSlug: string, projectId: string, data: Partial<IProject>) => Promise<IProject>;
|
||||||
deleteProject: (workspaceSlug: string, projectId: string) => Promise<void>;
|
deleteProject: (workspaceSlug: string, projectId: string) => Promise<void>;
|
||||||
|
// archive actions
|
||||||
|
archiveProject: (workspaceSlug: string, projectId: string) => Promise<void>;
|
||||||
|
restoreProject: (workspaceSlug: string, projectId: string) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ProjectStore implements IProjectStore {
|
export class ProjectStore implements IProjectStore {
|
||||||
@ -46,6 +54,7 @@ export class ProjectStore implements IProjectStore {
|
|||||||
rootStore: RootStore;
|
rootStore: RootStore;
|
||||||
// service
|
// service
|
||||||
projectService;
|
projectService;
|
||||||
|
projectArchiveService;
|
||||||
issueLabelService;
|
issueLabelService;
|
||||||
issueService;
|
issueService;
|
||||||
stateService;
|
stateService;
|
||||||
@ -57,6 +66,8 @@ export class ProjectStore implements IProjectStore {
|
|||||||
// computed
|
// computed
|
||||||
filteredProjectIds: computed,
|
filteredProjectIds: computed,
|
||||||
workspaceProjectIds: computed,
|
workspaceProjectIds: computed,
|
||||||
|
archivedProjectIds: computed,
|
||||||
|
totalProjectIds: computed,
|
||||||
currentProjectDetails: computed,
|
currentProjectDetails: computed,
|
||||||
joinedProjectIds: computed,
|
joinedProjectIds: computed,
|
||||||
favoriteProjectIds: computed,
|
favoriteProjectIds: computed,
|
||||||
@ -76,6 +87,7 @@ export class ProjectStore implements IProjectStore {
|
|||||||
this.rootStore = _rootStore;
|
this.rootStore = _rootStore;
|
||||||
// services
|
// services
|
||||||
this.projectService = new ProjectService();
|
this.projectService = new ProjectService();
|
||||||
|
this.projectArchiveService = new ProjectArchiveService();
|
||||||
this.issueService = new IssueService();
|
this.issueService = new IssueService();
|
||||||
this.issueLabelService = new IssueLabelService();
|
this.issueLabelService = new IssueLabelService();
|
||||||
this.stateService = new ProjectStateService();
|
this.stateService = new ProjectStateService();
|
||||||
@ -109,11 +121,42 @@ export class ProjectStore implements IProjectStore {
|
|||||||
get workspaceProjectIds() {
|
get workspaceProjectIds() {
|
||||||
const workspaceDetails = this.rootStore.workspaceRoot.currentWorkspace;
|
const workspaceDetails = this.rootStore.workspaceRoot.currentWorkspace;
|
||||||
if (!workspaceDetails) return;
|
if (!workspaceDetails) return;
|
||||||
const workspaceProjects = Object.values(this.projectMap).filter((p) => p.workspace === workspaceDetails.id);
|
const workspaceProjects = Object.values(this.projectMap).filter(
|
||||||
|
(p) => p.workspace === workspaceDetails.id && !p.archived_at
|
||||||
|
);
|
||||||
const projectIds = workspaceProjects.map((p) => p.id);
|
const projectIds = workspaceProjects.map((p) => p.id);
|
||||||
return projectIds ?? null;
|
return projectIds ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns archived project IDs belong to current workspace.
|
||||||
|
*/
|
||||||
|
get archivedProjectIds() {
|
||||||
|
const currentWorkspace = this.rootStore.workspaceRoot.currentWorkspace;
|
||||||
|
if (!currentWorkspace) return;
|
||||||
|
|
||||||
|
let projects = Object.values(this.projectMap ?? {});
|
||||||
|
projects = sortBy(projects, "archived_at");
|
||||||
|
|
||||||
|
const projectIds = projects
|
||||||
|
.filter((project) => project.workspace === currentWorkspace.id && !!project.archived_at)
|
||||||
|
.map((project) => project.id);
|
||||||
|
return projectIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns total project IDs belong to the current workspace
|
||||||
|
*/
|
||||||
|
// workspaceProjectIds + archivedProjectIds
|
||||||
|
get totalProjectIds() {
|
||||||
|
const currentWorkspace = this.rootStore.workspaceRoot.currentWorkspace;
|
||||||
|
if (!currentWorkspace) return;
|
||||||
|
|
||||||
|
const workspaceProjects = this.workspaceProjectIds ?? [];
|
||||||
|
const archivedProjects = this.archivedProjectIds ?? [];
|
||||||
|
return [...workspaceProjects, ...archivedProjects];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns current project details
|
* Returns current project details
|
||||||
*/
|
*/
|
||||||
@ -133,7 +176,7 @@ export class ProjectStore implements IProjectStore {
|
|||||||
projects = sortBy(projects, "sort_order");
|
projects = sortBy(projects, "sort_order");
|
||||||
|
|
||||||
const projectIds = projects
|
const projectIds = projects
|
||||||
.filter((project) => project.workspace === currentWorkspace.id && project.is_member)
|
.filter((project) => project.workspace === currentWorkspace.id && project.is_member && !project.archived_at)
|
||||||
.map((project) => project.id);
|
.map((project) => project.id);
|
||||||
return projectIds;
|
return projectIds;
|
||||||
}
|
}
|
||||||
@ -149,7 +192,10 @@ export class ProjectStore implements IProjectStore {
|
|||||||
projects = sortBy(projects, "created_at");
|
projects = sortBy(projects, "created_at");
|
||||||
|
|
||||||
const projectIds = projects
|
const projectIds = projects
|
||||||
.filter((project) => project.workspace === currentWorkspace.id && project.is_member && project.is_favorite)
|
.filter(
|
||||||
|
(project) =>
|
||||||
|
project.workspace === currentWorkspace.id && project.is_member && project.is_favorite && !project.archived_at
|
||||||
|
)
|
||||||
.map((project) => project.id);
|
.map((project) => project.id);
|
||||||
return projectIds;
|
return projectIds;
|
||||||
}
|
}
|
||||||
@ -348,4 +394,48 @@ export class ProjectStore implements IProjectStore {
|
|||||||
this.fetchProjects(workspaceSlug);
|
this.fetchProjects(workspaceSlug);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Archives a project from specific workspace and updates it in the store
|
||||||
|
* @param workspaceSlug
|
||||||
|
* @param projectId
|
||||||
|
* @returns Promise<void>
|
||||||
|
*/
|
||||||
|
archiveProject = async (workspaceSlug: string, projectId: string) => {
|
||||||
|
await this.projectArchiveService
|
||||||
|
.archiveProject(workspaceSlug, projectId)
|
||||||
|
.then((response) => {
|
||||||
|
runInAction(() => {
|
||||||
|
set(this.projectMap, [projectId, "archived_at"], response.archived_at);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.log("Failed to archive project from project store");
|
||||||
|
this.fetchProjects(workspaceSlug);
|
||||||
|
this.fetchProjectDetails(workspaceSlug, projectId);
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restores a project from specific workspace and updates it in the store
|
||||||
|
* @param workspaceSlug
|
||||||
|
* @param projectId
|
||||||
|
* @returns Promise<void>
|
||||||
|
*/
|
||||||
|
restoreProject = async (workspaceSlug: string, projectId: string) => {
|
||||||
|
await this.projectArchiveService
|
||||||
|
.restoreProject(workspaceSlug, projectId)
|
||||||
|
.then(() => {
|
||||||
|
runInAction(() => {
|
||||||
|
set(this.projectMap, [projectId, "archived_at"], null);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.log("Failed to restore project from project store");
|
||||||
|
this.fetchProjects(workspaceSlug);
|
||||||
|
this.fetchProjectDetails(workspaceSlug, projectId);
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
@ -1,9 +1,10 @@
|
|||||||
|
import set from "lodash/set";
|
||||||
import { action, computed, observable, makeObservable, runInAction, reaction } from "mobx";
|
import { action, computed, observable, makeObservable, runInAction, reaction } from "mobx";
|
||||||
import { computedFn } from "mobx-utils";
|
import { computedFn } from "mobx-utils";
|
||||||
import set from "lodash/set";
|
|
||||||
// types
|
// types
|
||||||
|
import { TProjectDisplayFilters, TProjectFilters, TProjectAppliedDisplayFilterKeys } from "@plane/types";
|
||||||
|
// store
|
||||||
import { RootStore } from "@/store/root.store";
|
import { RootStore } from "@/store/root.store";
|
||||||
import { TProjectDisplayFilters, TProjectFilters } from "@plane/types";
|
|
||||||
|
|
||||||
export interface IProjectFilterStore {
|
export interface IProjectFilterStore {
|
||||||
// observables
|
// observables
|
||||||
@ -12,6 +13,7 @@ export interface IProjectFilterStore {
|
|||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
// computed
|
// computed
|
||||||
currentWorkspaceDisplayFilters: TProjectDisplayFilters | undefined;
|
currentWorkspaceDisplayFilters: TProjectDisplayFilters | undefined;
|
||||||
|
currentWorkspaceAppliedDisplayFilters: TProjectAppliedDisplayFilterKeys[] | undefined;
|
||||||
currentWorkspaceFilters: TProjectFilters | undefined;
|
currentWorkspaceFilters: TProjectFilters | undefined;
|
||||||
// computed functions
|
// computed functions
|
||||||
getDisplayFiltersByWorkspaceSlug: (workspaceSlug: string) => TProjectDisplayFilters | undefined;
|
getDisplayFiltersByWorkspaceSlug: (workspaceSlug: string) => TProjectDisplayFilters | undefined;
|
||||||
@ -21,6 +23,7 @@ export interface IProjectFilterStore {
|
|||||||
updateFilters: (workspaceSlug: string, filters: TProjectFilters) => void;
|
updateFilters: (workspaceSlug: string, filters: TProjectFilters) => void;
|
||||||
updateSearchQuery: (query: string) => void;
|
updateSearchQuery: (query: string) => void;
|
||||||
clearAllFilters: (workspaceSlug: string) => void;
|
clearAllFilters: (workspaceSlug: string) => void;
|
||||||
|
clearAllAppliedDisplayFilters: (workspaceSlug: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ProjectFilterStore implements IProjectFilterStore {
|
export class ProjectFilterStore implements IProjectFilterStore {
|
||||||
@ -39,12 +42,14 @@ export class ProjectFilterStore implements IProjectFilterStore {
|
|||||||
searchQuery: observable.ref,
|
searchQuery: observable.ref,
|
||||||
// computed
|
// computed
|
||||||
currentWorkspaceDisplayFilters: computed,
|
currentWorkspaceDisplayFilters: computed,
|
||||||
|
currentWorkspaceAppliedDisplayFilters: computed,
|
||||||
currentWorkspaceFilters: computed,
|
currentWorkspaceFilters: computed,
|
||||||
// actions
|
// actions
|
||||||
updateDisplayFilters: action,
|
updateDisplayFilters: action,
|
||||||
updateFilters: action,
|
updateFilters: action,
|
||||||
updateSearchQuery: action,
|
updateSearchQuery: action,
|
||||||
clearAllFilters: action,
|
clearAllFilters: action,
|
||||||
|
clearAllAppliedDisplayFilters: action,
|
||||||
});
|
});
|
||||||
// root store
|
// root store
|
||||||
this.rootStore = _rootStore;
|
this.rootStore = _rootStore;
|
||||||
@ -67,6 +72,21 @@ export class ProjectFilterStore implements IProjectFilterStore {
|
|||||||
return this.displayFilters[workspaceSlug];
|
return this.displayFilters[workspaceSlug];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description get project state applied display filter of the current workspace
|
||||||
|
* @returns {TProjectAppliedDisplayFilterKeys[] | undefined} // An array of keys of applied display filters
|
||||||
|
*/
|
||||||
|
// TODO: Figure out a better approach for this
|
||||||
|
get currentWorkspaceAppliedDisplayFilters() {
|
||||||
|
const workspaceSlug = this.rootStore.app.router.workspaceSlug;
|
||||||
|
if (!workspaceSlug) return;
|
||||||
|
const displayFilters = this.displayFilters[workspaceSlug];
|
||||||
|
return Object.keys(displayFilters).filter(
|
||||||
|
(key): key is TProjectAppliedDisplayFilterKeys =>
|
||||||
|
["my_projects", "archived_projects"].includes(key) && !!displayFilters[key as keyof TProjectDisplayFilters]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description get filters of the current workspace
|
* @description get filters of the current workspace
|
||||||
*/
|
*/
|
||||||
@ -143,4 +163,17 @@ export class ProjectFilterStore implements IProjectFilterStore {
|
|||||||
this.filters[workspaceSlug] = {};
|
this.filters[workspaceSlug] = {};
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description clear project display filters of a workspace
|
||||||
|
* @param {string} workspaceSlug
|
||||||
|
*/
|
||||||
|
clearAllAppliedDisplayFilters = (workspaceSlug: string) => {
|
||||||
|
runInAction(() => {
|
||||||
|
if (!this.currentWorkspaceAppliedDisplayFilters) return;
|
||||||
|
this.currentWorkspaceAppliedDisplayFilters.forEach((key) => {
|
||||||
|
set(this.displayFilters, [workspaceSlug, key], false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user