[WEB-419] feat: manual issue archival (#3801)

* fix: issue archive without automation

* fix: unarchive issue endpoint change

* chore: archiving logic implemented in the quick-actions dropdowns

* chore: peek overview archive button

* chore: issue archive completed at state

* chore: updated archiving icon and added archive option everywhere

* chore: all issues quick actions dropdown

* chore: archive and unarchive response

* fix: archival mutation

* fix: restore issue from peek overview

* chore: update notification content for archive/restore

* refactor: activity user name

* fix: all issues mutation

* fix: restore issue auth

* chore: close peek overview on archival

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
Co-authored-by: gurusainath <gurusainath007@gmail.com>
This commit is contained in:
Aaryan Khandelwal 2024-02-28 16:53:26 +05:30 committed by GitHub
parent b1520783cf
commit 30cc923fdb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
77 changed files with 1402 additions and 691 deletions

View File

@ -259,23 +259,15 @@ urlpatterns = [
name="project-issue-archive", name="project-issue-archive",
), ),
path( path(
"workspaces/<str:slug>/projects/<uuid:project_id>/archived-issues/<uuid:pk>/", "workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:pk>/archive/",
IssueArchiveViewSet.as_view( IssueArchiveViewSet.as_view(
{ {
"get": "retrieve", "get": "retrieve",
"delete": "destroy", "post": "archive",
"delete": "unarchive",
} }
), ),
name="project-issue-archive", name="project-issue-archive-unarchive",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/unarchive/<uuid:pk>/",
IssueArchiveViewSet.as_view(
{
"post": "unarchive",
}
),
name="project-issue-archive",
), ),
## End Issue Archives ## End Issue Archives
## Issue Relation ## Issue Relation

View File

@ -1647,6 +1647,36 @@ class IssueArchiveViewSet(BaseViewSet):
serializer = IssueDetailSerializer(issue, expand=self.expand) serializer = IssueDetailSerializer(issue, expand=self.expand)
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)
def archive(self, request, slug, project_id, pk=None):
issue = Issue.issue_objects.get(
workspace__slug=slug,
project_id=project_id,
pk=pk,
)
if issue.state.group not in ["completed", "cancelled"]:
return Response(
{"error": "Can only archive completed or cancelled state group issue"},
status=status.HTTP_400_BAD_REQUEST,
)
issue_activity.delay(
type="issue.activity.updated",
requested_data=json.dumps({"archived_at": str(timezone.now().date()), "automation": False}),
actor_id=str(request.user.id),
issue_id=str(issue.id),
project_id=str(project_id),
current_instance=json.dumps(
IssueSerializer(issue).data, cls=DjangoJSONEncoder
),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
issue.archived_at = timezone.now().date()
issue.save()
return Response({"archived_at": str(issue.archived_at)}, status=status.HTTP_200_OK)
def unarchive(self, request, slug, project_id, pk=None): def unarchive(self, request, slug, project_id, pk=None):
issue = Issue.objects.get( issue = Issue.objects.get(
workspace__slug=slug, workspace__slug=slug,
@ -1670,7 +1700,7 @@ class IssueArchiveViewSet(BaseViewSet):
issue.archived_at = None issue.archived_at = None
issue.save() issue.save()
return Response(IssueSerializer(issue).data, status=status.HTTP_200_OK) return Response(status=status.HTTP_204_NO_CONTENT)
class IssueSubscriberViewSet(BaseViewSet): class IssueSubscriberViewSet(BaseViewSet):

View File

@ -483,17 +483,23 @@ def track_archive_at(
) )
) )
else: else:
if requested_data.get("automation"):
comment = "Plane has archived the issue"
new_value = "archive"
else:
comment = "Actor has archived the issue"
new_value = "manual_archive"
issue_activities.append( issue_activities.append(
IssueActivity( IssueActivity(
issue_id=issue_id, issue_id=issue_id,
project_id=project_id, project_id=project_id,
workspace_id=workspace_id, workspace_id=workspace_id,
comment="Plane has archived the issue", comment=comment,
verb="updated", verb="updated",
actor_id=actor_id, actor_id=actor_id,
field="archived_at", field="archived_at",
old_value=None, old_value=None,
new_value="archive", new_value=new_value,
epoch=epoch, epoch=epoch,
) )
) )

View File

@ -79,7 +79,7 @@ def archive_old_issues():
issue_activity.delay( issue_activity.delay(
type="issue.activity.updated", type="issue.activity.updated",
requested_data=json.dumps( requested_data=json.dumps(
{"archived_at": str(archive_at)} {"archived_at": str(archive_at), "automation": True}
), ),
actor_id=str(project.created_by_id), actor_id=str(project.created_by_id),
issue_id=issue.id, issue_id=issue.id,

View File

@ -12,27 +12,27 @@ export interface PaginatedUserNotification {
} }
export interface IUserNotification { export interface IUserNotification {
id: string; archived_at: string | null;
created_at: Date; created_at: string;
updated_at: Date; created_by: null;
data: Data; data: Data;
entity_identifier: string; entity_identifier: string;
entity_name: string; entity_name: string;
title: string; id: string;
message: null; message: null;
message_html: string; message_html: string;
message_stripped: null; message_stripped: null;
sender: string;
read_at: Date | null;
archived_at: Date | null;
snoozed_till: Date | null;
created_by: null;
updated_by: null;
workspace: string;
project: string; project: string;
read_at: Date | null;
receiver: string;
sender: string;
snoozed_till: Date | null;
title: string;
triggered_by: string; triggered_by: string;
triggered_by_details: IUserLite; triggered_by_details: IUserLite;
receiver: string; updated_at: Date;
updated_by: null;
workspace: string;
} }
export interface Data { export interface Data {

View File

@ -177,17 +177,18 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
}; };
const MenuItem: React.FC<ICustomMenuItemProps> = (props) => { const MenuItem: React.FC<ICustomMenuItemProps> = (props) => {
const { children, onClick, className = "" } = props; const { children, disabled = false, onClick, className } = props;
return ( return (
<Menu.Item as="div"> <Menu.Item as="div" disabled={disabled}>
{({ active, close }) => ( {({ active, close }) => (
<button <button
type="button" type="button"
className={cn( className={cn(
"w-full select-none truncate rounded px-1 py-1.5 text-left text-custom-text-200", "w-full select-none truncate rounded px-1 py-1.5 text-left text-custom-text-200",
{ {
"bg-custom-background-80": active, "bg-custom-background-80": active && !disabled,
"text-custom-text-400": disabled,
}, },
className className
)} )}
@ -195,6 +196,7 @@ const MenuItem: React.FC<ICustomMenuItemProps> = (props) => {
close(); close();
onClick && onClick(e); onClick && onClick(e);
}} }}
disabled={disabled}
> >
{children} {children}
</button> </button>

View File

@ -64,6 +64,7 @@ export type ICustomSearchSelectProps = IDropdownProps &
export interface ICustomMenuItemProps { export interface ICustomMenuItemProps {
children: React.ReactNode; children: React.ReactNode;
disabled?: boolean;
onClick?: (args?: any) => void; onClick?: (args?: any) => void;
className?: string; className?: string;
} }

View File

@ -48,7 +48,7 @@ export const AutoArchiveAutomation: React.FC<Props> = observer((props) => {
<div className=""> <div className="">
<h4 className="text-sm font-medium">Auto-archive closed issues</h4> <h4 className="text-sm font-medium">Auto-archive closed issues</h4>
<p className="text-sm tracking-tight text-custom-text-200"> <p className="text-sm tracking-tight text-custom-text-200">
Plane will auto archive issues that have been completed or cancelled. Plane will auto archive issues that have been completed or canceled.
</p> </p>
</div> </div>
</div> </div>
@ -73,7 +73,7 @@ export const AutoArchiveAutomation: React.FC<Props> = observer((props) => {
<CustomSelect <CustomSelect
value={currentProjectDetails?.archive_in} value={currentProjectDetails?.archive_in}
label={`${currentProjectDetails?.archive_in} ${ label={`${currentProjectDetails?.archive_in} ${
currentProjectDetails?.archive_in === 1 ? "Month" : "Months" currentProjectDetails?.archive_in === 1 ? "month" : "months"
}`} }`}
onChange={(val: number) => { onChange={(val: number) => {
handleChange({ archive_in: val }); handleChange({ archive_in: val });
@ -93,7 +93,7 @@ export const AutoArchiveAutomation: React.FC<Props> = observer((props) => {
className="flex w-full select-none items-center rounded px-1 py-1.5 text-sm text-custom-text-200 hover:bg-custom-background-80" className="flex w-full select-none items-center rounded px-1 py-1.5 text-sm text-custom-text-200 hover:bg-custom-background-80"
onClick={() => setmonthModal(true)} onClick={() => setmonthModal(true)}
> >
Customise Time Range Customize time range
</button> </button>
</> </>
</CustomSelect> </CustomSelect>

View File

@ -74,7 +74,7 @@ export const AutoCloseAutomation: React.FC<Props> = observer((props) => {
<div className=""> <div className="">
<h4 className="text-sm font-medium">Auto-close issues</h4> <h4 className="text-sm font-medium">Auto-close issues</h4>
<p className="text-sm tracking-tight text-custom-text-200"> <p className="text-sm tracking-tight text-custom-text-200">
Plane will automatically close issue that haven{"'"}t been completed or cancelled. Plane will automatically close issue that haven{"'"}t been completed or canceled.
</p> </p>
</div> </div>
</div> </div>
@ -100,7 +100,7 @@ export const AutoCloseAutomation: React.FC<Props> = observer((props) => {
<CustomSelect <CustomSelect
value={currentProjectDetails?.close_in} value={currentProjectDetails?.close_in}
label={`${currentProjectDetails?.close_in} ${ label={`${currentProjectDetails?.close_in} ${
currentProjectDetails?.close_in === 1 ? "Month" : "Months" currentProjectDetails?.close_in === 1 ? "month" : "months"
}`} }`}
onChange={(val: number) => { onChange={(val: number) => {
handleChange({ close_in: val }); handleChange({ close_in: val });
@ -119,7 +119,7 @@ export const AutoCloseAutomation: React.FC<Props> = observer((props) => {
className="flex w-full select-none items-center rounded px-1 py-1.5 text-custom-text-200 hover:bg-custom-background-80" className="flex w-full select-none items-center rounded px-1 py-1.5 text-custom-text-200 hover:bg-custom-background-80"
onClick={() => setmonthModal(true)} onClick={() => setmonthModal(true)}
> >
Customize Time Range Customize time range
</button> </button>
</> </>
</CustomSelect> </CustomSelect>

View File

@ -72,7 +72,7 @@ export const SelectMonthModal: React.FC<Props> = ({ type, initialValues, isOpen,
<form onSubmit={handleSubmit(onSubmit)}> <form onSubmit={handleSubmit(onSubmit)}>
<div> <div>
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-custom-text-100"> <Dialog.Title as="h3" className="text-lg font-medium leading-6 text-custom-text-100">
Customise Time Range Customize time range
</Dialog.Title> </Dialog.Title>
<div className="mt-8 flex items-center gap-2"> <div className="mt-8 flex items-center gap-2">
<div className="flex w-full flex-col justify-center gap-1"> <div className="flex w-full flex-col justify-center gap-1">

View File

@ -147,7 +147,7 @@ export const DateDropdown: React.FC<Props> = (props) => {
{BUTTON_VARIANTS_WITH_TEXT.includes(buttonVariant) && ( {BUTTON_VARIANTS_WITH_TEXT.includes(buttonVariant) && (
<span className="flex-grow truncate">{value ? renderFormattedDate(value) : placeholder}</span> <span className="flex-grow truncate">{value ? renderFormattedDate(value) : placeholder}</span>
)} )}
{isClearable && isDateSelected && ( {isClearable && !disabled && isDateSelected && (
<X <X
className={cn("h-2 w-2 flex-shrink-0", clearIconClassName)} className={cn("h-2 w-2 flex-shrink-0", clearIconClassName)}
onClick={(e) => { onClick={(e) => {

View File

@ -71,7 +71,7 @@ export const ProjectArchivedIssueDetailsHeader: FC = observer(() => {
link={ link={
<BreadcrumbLink <BreadcrumbLink
href={`/${workspaceSlug}/projects/${projectId}/archived-issues`} href={`/${workspaceSlug}/projects/${projectId}/archived-issues`}
label="Archived Issues" label="Archived issues"
icon={<LayersIcon className="h-4 w-4 text-custom-text-300" />} icon={<LayersIcon className="h-4 w-4 text-custom-text-300" />}
/> />
} }

View File

@ -109,7 +109,7 @@ export const ProjectArchivedIssuesHeader: FC = observer(() => {
type="text" type="text"
link={ link={
<BreadcrumbLink <BreadcrumbLink
label="Archived Issues" label="Archived issues"
icon={<LayersIcon className="h-4 w-4 text-custom-text-300" />} icon={<LayersIcon className="h-4 w-4 text-custom-text-300" />}
/> />
} }

View File

@ -0,0 +1,106 @@
import { useState, Fragment } from "react";
import { Dialog, Transition } from "@headlessui/react";
// hooks
import { useProject } from "hooks/store";
import { useIssues } from "hooks/store/use-issues";
import useToast from "hooks/use-toast";
// ui
import { Button } from "@plane/ui";
// types
import { TIssue } from "@plane/types";
type Props = {
data?: TIssue;
dataId?: string | null | undefined;
handleClose: () => void;
isOpen: boolean;
onSubmit?: () => Promise<void>;
};
export const ArchiveIssueModal: React.FC<Props> = (props) => {
const { dataId, data, isOpen, handleClose, onSubmit } = props;
// states
const [isArchiving, setIsArchiving] = useState(false);
// store hooks
const { getProjectById } = useProject();
const { issueMap } = useIssues();
// toast alert
const { setToastAlert } = useToast();
if (!dataId && !data) return null;
const issue = data ? data : issueMap[dataId!];
const projectDetails = getProjectById(issue.project_id);
const onClose = () => {
setIsArchiving(false);
handleClose();
};
const handleArchiveIssue = async () => {
if (!onSubmit) return;
setIsArchiving(true);
await onSubmit()
.then(() => onClose())
.catch(() =>
setToastAlert({
type: "error",
title: "Error!",
message: "Issue could not be archived. Please try again.",
})
)
.finally(() => setIsArchiving(false));
};
return (
<Transition.Root show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-20" onClose={onClose}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-custom-backdrop transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-custom-background-100 text-left shadow-custom-shadow-md transition-all sm:my-8 sm:w-full sm:max-w-lg">
<div className="px-5 py-4">
<h3 className="text-xl font-medium 2xl:text-2xl">
Archive issue {projectDetails?.identifier} {issue.sequence_id}
</h3>
<p className="text-sm text-custom-text-200 mt-3">
Are you sure you want to archive the issue? All your archived issues can be restored later.
</p>
<div className="flex justify-end gap-2 mt-3">
<Button variant="neutral-primary" size="sm" onClick={onClose}>
Cancel
</Button>
<Button size="sm" tabIndex={1} onClick={handleArchiveIssue} loading={isArchiving}>
{isArchiving ? "Archiving" : "Archive"}
</Button>
</div>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
};

View File

@ -1,139 +0,0 @@
import { useEffect, useState, Fragment } from "react";
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
import { Dialog, Transition } from "@headlessui/react";
import { AlertTriangle } from "lucide-react";
// hooks
import useToast from "hooks/use-toast";
import { useIssues, useProject } from "hooks/store";
// ui
import { Button } from "@plane/ui";
// types
import type { TIssue } from "@plane/types";
import { EIssuesStoreType } from "constants/issue";
type Props = {
isOpen: boolean;
handleClose: () => void;
data: TIssue;
onSubmit?: () => Promise<void>;
};
export const DeleteArchivedIssueModal: React.FC<Props> = observer((props) => {
const { data, isOpen, handleClose, onSubmit } = props;
const router = useRouter();
const { workspaceSlug } = router.query;
const { setToastAlert } = useToast();
const { getProjectById } = useProject();
const {
issues: { removeIssue },
} = useIssues(EIssuesStoreType.ARCHIVED);
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
useEffect(() => {
setIsDeleteLoading(false);
}, [isOpen]);
const onClose = () => {
setIsDeleteLoading(false);
handleClose();
};
const handleIssueDelete = async () => {
if (!workspaceSlug) return;
setIsDeleteLoading(true);
await removeIssue(workspaceSlug.toString(), data.project_id, data.id)
.then(() => {
if (onSubmit) onSubmit();
})
.catch((err) => {
const error = err?.detail;
const errorString = Array.isArray(error) ? error[0] : error;
setToastAlert({
title: "Error",
type: "error",
message: errorString || "Something went wrong.",
});
})
.finally(() => {
setIsDeleteLoading(false);
onClose();
});
};
return (
<Transition.Root show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-20" onClose={onClose}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-custom-backdrop transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-custom-background-100 text-left shadow-custom-shadow-md transition-all sm:my-8 sm:w-full sm:max-w-2xl">
<div className="flex flex-col gap-6 p-6">
<div className="flex w-full items-center justify-start gap-6">
<span className="place-items-center rounded-full bg-red-500/20 p-4">
<AlertTriangle className="h-6 w-6 text-red-600" aria-hidden="true" />
</span>
<span className="flex items-center justify-start">
<h3 className="text-xl font-medium 2xl:text-2xl">Delete Archived Issue</h3>
</span>
</div>
<span>
<p className="text-sm text-custom-text-200">
Are you sure you want to delete issue{" "}
<span className="break-words font-medium text-custom-text-100">
{getProjectById(data?.project_id)?.identifier}-{data?.sequence_id}
</span>
{""}? All of the data related to the archived issue will be permanently removed. This action
cannot be undone.
</p>
</span>
<div className="flex justify-end gap-2">
<Button variant="neutral-primary" size="sm" onClick={onClose}>
Cancel
</Button>
<Button
variant="danger"
size="sm"
tabIndex={1}
onClick={handleIssueDelete}
loading={isDeleteLoading}
>
{isDeleteLoading ? "Deleting..." : "Delete Issue"}
</Button>
</div>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
});

View File

@ -23,14 +23,14 @@ export const DeleteIssueModal: React.FC<Props> = (props) => {
const { issueMap } = useIssues(); const { issueMap } = useIssues();
const [isDeleteLoading, setIsDeleteLoading] = useState(false); const [isDeleting, setIsDeleting] = useState(false);
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
// hooks // hooks
const { getProjectById } = useProject(); const { getProjectById } = useProject();
useEffect(() => { useEffect(() => {
setIsDeleteLoading(false); setIsDeleting(false);
}, [isOpen]); }, [isOpen]);
if (!dataId && !data) return null; if (!dataId && !data) return null;
@ -38,12 +38,12 @@ export const DeleteIssueModal: React.FC<Props> = (props) => {
const issue = data ? data : issueMap[dataId!]; const issue = data ? data : issueMap[dataId!];
const onClose = () => { const onClose = () => {
setIsDeleteLoading(false); setIsDeleting(false);
handleClose(); handleClose();
}; };
const handleIssueDelete = async () => { const handleIssueDelete = async () => {
setIsDeleteLoading(true); setIsDeleting(true);
if (onSubmit) if (onSubmit)
await onSubmit() await onSubmit()
.then(() => { .then(() => {
@ -56,7 +56,7 @@ export const DeleteIssueModal: React.FC<Props> = (props) => {
message: "Failed to delete issue", message: "Failed to delete issue",
}); });
}) })
.finally(() => setIsDeleteLoading(false)); .finally(() => setIsDeleting(false));
}; };
return ( return (
@ -109,14 +109,8 @@ export const DeleteIssueModal: 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 <Button variant="danger" size="sm" tabIndex={1} onClick={handleIssueDelete} loading={isDeleting}>
variant="danger" {isDeleting ? "Deleting" : "Delete"}
size="sm"
tabIndex={1}
onClick={handleIssueDelete}
loading={isDeleteLoading}
>
{isDeleteLoading ? "Deleting..." : "Delete Issue"}
</Button> </Button>
</div> </div>
</div> </div>

View File

@ -15,4 +15,4 @@ export * from "./issue-detail";
export * from "./peek-overview"; export * from "./peek-overview";
// archived issue // archived issue
export * from "./delete-archived-issue-modal"; export * from "./archive-issue-modal";

View File

@ -1,10 +1,12 @@
import { FC } from "react"; import { FC } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { MessageSquare } from "lucide-react"; import { RotateCcw } from "lucide-react";
// hooks // hooks
import { useIssueDetail } from "hooks/store"; import { useIssueDetail } from "hooks/store";
// components // components
import { IssueActivityBlockComponent } from "./"; import { IssueActivityBlockComponent } from "./";
// ui
import { ArchiveIcon } from "@plane/ui";
type TIssueArchivedAtActivity = { activityId: string; ends: "top" | "bottom" | undefined }; type TIssueArchivedAtActivity = { activityId: string; ends: "top" | "bottom" | undefined };
@ -18,13 +20,21 @@ export const IssueArchivedAtActivity: FC<TIssueArchivedAtActivity> = observer((p
const activity = getActivityById(activityId); const activity = getActivityById(activityId);
if (!activity) return <></>; if (!activity) return <></>;
return ( return (
<IssueActivityBlockComponent <IssueActivityBlockComponent
icon={<MessageSquare size={14} color="#6b7280" aria-hidden="true" />} icon={
activity.new_value === "restore" ? (
<RotateCcw className="h-3.5 w-3.5" color="#6b7280" aria-hidden="true" />
) : (
<ArchiveIcon className="h-3.5 w-3.5" color="#6b7280" aria-hidden="true" />
)
}
activityId={activityId} activityId={activityId}
ends={ends} ends={ends}
customUserName={activity.new_value === "archive" ? "Plane" : undefined}
> >
{activity.new_value === "restore" ? `restored the issue` : `archived the issue`}. {activity.new_value === "restore" ? "restored the issue" : "archived the issue"}.
</IssueActivityBlockComponent> </IssueActivityBlockComponent>
); );
}); });

View File

@ -14,10 +14,11 @@ type TIssueActivityBlockComponent = {
activityId: string; activityId: string;
ends: "top" | "bottom" | undefined; ends: "top" | "bottom" | undefined;
children: ReactNode; children: ReactNode;
customUserName?: string;
}; };
export const IssueActivityBlockComponent: FC<TIssueActivityBlockComponent> = (props) => { export const IssueActivityBlockComponent: FC<TIssueActivityBlockComponent> = (props) => {
const { icon, activityId, ends, children } = props; const { icon, activityId, ends, children, customUserName } = props;
// hooks // hooks
const { const {
activity: { getActivityById }, activity: { getActivityById },
@ -37,7 +38,7 @@ export const IssueActivityBlockComponent: FC<TIssueActivityBlockComponent> = (pr
{icon ? icon : <Network className="w-3.5 h-3.5" />} {icon ? icon : <Network className="w-3.5 h-3.5" />}
</div> </div>
<div className="w-full text-custom-text-200"> <div className="w-full text-custom-text-200">
<IssueUser activityId={activityId} /> <IssueUser activityId={activityId} customUserName={customUserName} />
<span> {children} </span> <span> {children} </span>
<span> <span>
<Tooltip <Tooltip

View File

@ -1,15 +1,15 @@
import { FC } from "react"; import { FC } from "react";
import Link from "next/link";
// hooks // hooks
import { useIssueDetail } from "hooks/store"; import { useIssueDetail } from "hooks/store";
// ui
type TIssueUser = { type TIssueUser = {
activityId: string; activityId: string;
customUserName?: string;
}; };
export const IssueUser: FC<TIssueUser> = (props) => { export const IssueUser: FC<TIssueUser> = (props) => {
const { activityId } = props; const { activityId, customUserName } = props;
// hooks // hooks
const { const {
activity: { getActivityById }, activity: { getActivityById },
@ -18,12 +18,19 @@ export const IssueUser: FC<TIssueUser> = (props) => {
const activity = getActivityById(activityId); const activity = getActivityById(activityId);
if (!activity) return <></>; if (!activity) return <></>;
return ( return (
<a <>
href={`/${activity?.workspace_detail?.slug}/profile/${activity?.actor_detail?.id}`} {customUserName ? (
className="hover:underline text-custom-text-100 font-medium capitalize" <span className="text-custom-text-100 font-medium">{customUserName}</span>
> ) : (
{activity.actor_detail?.display_name} <Link
</a> href={`/${activity?.workspace_detail?.slug}/profile/${activity?.actor_detail?.id}`}
className="hover:underline text-custom-text-100 font-medium"
>
{activity.actor_detail?.display_name}
</Link>
)}
</>
); );
}; };

View File

@ -20,7 +20,7 @@ type TActivityTabs = "all" | "activity" | "comments";
const activityTabs: { key: TActivityTabs; title: string; icon: LucideIcon }[] = [ const activityTabs: { key: TActivityTabs; title: string; icon: LucideIcon }[] = [
{ {
key: "all", key: "all",
title: "All Activity", title: "All activity",
icon: History, icon: History,
}, },
{ {

View File

@ -16,7 +16,7 @@ import { TIssue } from "@plane/types";
// constants // constants
import { EUserProjectRoles } from "constants/project"; import { EUserProjectRoles } from "constants/project";
import { EIssuesStoreType } from "constants/issue"; import { EIssuesStoreType } from "constants/issue";
import { ISSUE_UPDATED, ISSUE_DELETED } from "constants/event-tracker"; import { ISSUE_UPDATED, ISSUE_DELETED, ISSUE_ARCHIVED } from "constants/event-tracker";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
export type TIssueOperations = { export type TIssueOperations = {
@ -29,6 +29,8 @@ export type TIssueOperations = {
showToast?: boolean showToast?: boolean
) => Promise<void>; ) => Promise<void>;
remove: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>; remove: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>;
archive?: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>;
restore?: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>;
addIssueToCycle?: (workspaceSlug: string, projectId: string, cycleId: string, issueIds: string[]) => Promise<void>; addIssueToCycle?: (workspaceSlug: string, projectId: string, cycleId: string, issueIds: string[]) => Promise<void>;
removeIssueFromCycle?: (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => Promise<void>; removeIssueFromCycle?: (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => Promise<void>;
addModulesToIssue?: (workspaceSlug: string, projectId: string, issueId: string, moduleIds: string[]) => Promise<void>; addModulesToIssue?: (workspaceSlug: string, projectId: string, issueId: string, moduleIds: string[]) => Promise<void>;
@ -63,6 +65,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => {
fetchIssue, fetchIssue,
updateIssue, updateIssue,
removeIssue, removeIssue,
archiveIssue,
addIssueToCycle, addIssueToCycle,
removeIssueFromCycle, removeIssueFromCycle,
addModulesToIssue, addModulesToIssue,
@ -158,6 +161,32 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => {
}); });
} }
}, },
archive: async (workspaceSlug: string, projectId: string, issueId: string) => {
try {
await archiveIssue(workspaceSlug, projectId, issueId);
setToastAlert({
type: "success",
title: "Success!",
message: "Issue archived successfully.",
});
captureIssueEvent({
eventName: ISSUE_ARCHIVED,
payload: { id: issueId, state: "SUCCESS", element: "Issue details page" },
path: router.asPath,
});
} catch (error) {
setToastAlert({
type: "error",
title: "Error!",
message: "Issue could not be archived. Please try again.",
});
captureIssueEvent({
eventName: ISSUE_ARCHIVED,
payload: { id: issueId, state: "FAILED", element: "Issue details page" },
path: router.asPath,
});
}
},
addIssueToCycle: async (workspaceSlug: string, projectId: string, cycleId: string, issueIds: string[]) => { addIssueToCycle: async (workspaceSlug: string, projectId: string, cycleId: string, issueIds: string[]) => {
try { try {
await addIssueToCycle(workspaceSlug, projectId, cycleId, issueIds); await addIssueToCycle(workspaceSlug, projectId, cycleId, issueIds);
@ -321,6 +350,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => {
fetchIssue, fetchIssue,
updateIssue, updateIssue,
removeIssue, removeIssue,
archiveIssue,
removeArchivedIssue, removeArchivedIssue,
addIssueToCycle, addIssueToCycle,
removeIssueFromCycle, removeIssueFromCycle,
@ -350,7 +380,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => {
/> />
) : ( ) : (
<div className="flex w-full h-full overflow-hidden"> <div className="flex w-full h-full overflow-hidden">
<div className="h-full w-full max-w-2/3 space-y-5 divide-y-2 divide-custom-border-300 overflow-y-auto p-5"> <div className="h-full w-full max-w-2/3 space-y-5 divide-y-2 divide-custom-border-200 overflow-y-auto p-5">
<IssueMainContent <IssueMainContent
workspaceSlug={workspaceSlug} workspaceSlug={workspaceSlug}
projectId={projectId} projectId={projectId}
@ -360,7 +390,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => {
/> />
</div> </div>
<div <div
className="h-full w-full min-w-[300px] lg:min-w-80 xl:min-w-96 sm:w-1/2 md:w-1/3 space-y-5 overflow-hidden border-l border-custom-border-300 py-5 fixed md:relative bg-custom-sidebar-background-100 right-0 z-[5]" className="h-full w-full min-w-[300px] lg:min-w-80 xl:min-w-96 sm:w-1/2 md:w-1/3 space-y-5 overflow-hidden border-l border-custom-border-200 py-5 fixed md:relative bg-custom-sidebar-background-100 right-0 z-[5]"
style={themeStore.issueDetailSidebarCollapsed ? { right: `-${window?.innerWidth || 0}px` } : {}} style={themeStore.issueDetailSidebarCollapsed ? { right: `-${window?.innerWidth || 0}px` } : {}}
> >
<IssueDetailsSidebar <IssueDetailsSidebar

View File

@ -25,11 +25,12 @@ import {
IssueModuleSelect, IssueModuleSelect,
IssueParentSelect, IssueParentSelect,
IssueLabel, IssueLabel,
ArchiveIssueModal,
} from "components/issues"; } from "components/issues";
import { IssueSubscription } from "./subscription"; import { IssueSubscription } from "./subscription";
import { DateDropdown, EstimateDropdown, PriorityDropdown, MemberDropdown, StateDropdown } from "components/dropdowns"; import { DateDropdown, EstimateDropdown, PriorityDropdown, MemberDropdown, StateDropdown } from "components/dropdowns";
// icons // icons
import { ContrastIcon, DiceIcon, DoubleCircleIcon, RelatedIcon, UserGroupIcon } from "@plane/ui"; import { ArchiveIcon, ContrastIcon, DiceIcon, DoubleCircleIcon, RelatedIcon, Tooltip, UserGroupIcon } from "@plane/ui";
// helpers // helpers
import { renderFormattedPayloadDate } from "helpers/date-time.helper"; import { renderFormattedPayloadDate } from "helpers/date-time.helper";
import { copyTextToClipboard } from "helpers/string.helper"; import { copyTextToClipboard } from "helpers/string.helper";
@ -37,6 +38,7 @@ import { cn } from "helpers/common.helper";
import { shouldHighlightIssueDueDate } from "helpers/issue.helper"; import { shouldHighlightIssueDueDate } from "helpers/issue.helper";
// types // types
import type { TIssueOperations } from "./root"; import type { TIssueOperations } from "./root";
import { STATE_GROUPS } from "constants/state";
type Props = { type Props = {
workspaceSlug: string; workspaceSlug: string;
@ -49,6 +51,9 @@ type Props = {
export const IssueDetailsSidebar: React.FC<Props> = observer((props) => { export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
const { workspaceSlug, projectId, issueId, issueOperations, is_archived, is_editable } = props; const { workspaceSlug, projectId, issueId, issueOperations, is_archived, is_editable } = props;
// states
const [deleteIssueModal, setDeleteIssueModal] = useState(false);
const [archiveIssueModal, setArchiveIssueModal] = useState(false);
// router // router
const router = useRouter(); const router = useRouter();
// store hooks // store hooks
@ -60,8 +65,6 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
issue: { getIssueById }, issue: { getIssueById },
} = useIssueDetail(); } = useIssueDetail();
const { getStateById } = useProjectState(); const { getStateById } = useProjectState();
// states
const [deleteIssueModal, setDeleteIssueModal] = useState(false);
const issue = getIssueById(issueId); const issue = getIssueById(issueId);
if (!issue) return <></>; if (!issue) return <></>;
@ -77,8 +80,23 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
}); });
}; };
const projectDetails = issue ? getProjectById(issue.project_id) : null; const handleDeleteIssue = async () => {
await issueOperations.remove(workspaceSlug, projectId, issueId);
router.push(`/${workspaceSlug}/projects/${projectId}/issues`);
};
const handleArchiveIssue = async () => {
if (!issueOperations.archive) return;
await issueOperations.archive(workspaceSlug, projectId, issueId);
router.push(`/${workspaceSlug}/projects/${projectId}/archived-issues/${issue.id}`);
};
// derived values
const projectDetails = getProjectById(issue.project_id);
const stateDetails = getStateById(issue.state_id); const stateDetails = getStateById(issue.state_id);
// auth
const isArchivingAllowed = !is_archived && issueOperations.archive && is_editable;
const isInArchivableGroup =
!!stateDetails && [STATE_GROUPS.completed.key, STATE_GROUPS.cancelled.key].includes(stateDetails?.group);
const minDate = issue.start_date ? new Date(issue.start_date) : null; const minDate = issue.start_date ? new Date(issue.start_date) : null;
minDate?.setDate(minDate.getDate()); minDate?.setDate(minDate.getDate());
@ -88,42 +106,68 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
return ( return (
<> <>
{workspaceSlug && projectId && issue && ( <DeleteIssueModal
<DeleteIssueModal handleClose={() => setDeleteIssueModal(false)}
handleClose={() => setDeleteIssueModal(false)} isOpen={deleteIssueModal}
isOpen={deleteIssueModal} data={issue}
data={issue} onSubmit={handleDeleteIssue}
onSubmit={async () => { />
await issueOperations.remove(workspaceSlug, projectId, issueId); <ArchiveIssueModal
router.push(`/${workspaceSlug}/projects/${projectId}/issues`); isOpen={archiveIssueModal}
}} handleClose={() => setArchiveIssueModal(false)}
/> data={issue}
)} onSubmit={handleArchiveIssue}
/>
<div className="flex h-full w-full flex-col divide-y-2 divide-custom-border-200 overflow-hidden"> <div className="flex h-full w-full flex-col divide-y-2 divide-custom-border-200 overflow-hidden">
<div className="flex items-center justify-end px-5 pb-3"> <div className="flex items-center justify-end px-5 pb-3">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-4">
{currentUser && !is_archived && ( {currentUser && !is_archived && (
<IssueSubscription workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} /> <IssueSubscription workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} />
)} )}
<div className="flex items-center flex-wrap gap-2.5 text-custom-text-300">
<button <Tooltip tooltipContent="Copy link">
type="button" <button
className="rounded-md border border-custom-border-200 p-2 shadow-sm duration-300 hover:bg-custom-background-90 focus:border-custom-primary focus:outline-none focus:ring-1 focus:ring-custom-primary" type="button"
onClick={handleCopyText} className="h-5 w-5 grid place-items-center hover:text-custom-text-200 rounded focus:outline-none focus:ring-2 focus:ring-custom-primary"
> onClick={handleCopyText}
<LinkIcon className="h-3.5 w-3.5" /> >
</button> <LinkIcon className="h-4 w-4" />
</button>
{is_editable && ( </Tooltip>
<button {isArchivingAllowed && (
type="button" <Tooltip
className="rounded-md border border-red-500 p-2 text-red-500 shadow-sm duration-300 hover:bg-red-500/20 focus:outline-none" tooltipContent={isInArchivableGroup ? "Archive" : "Only completed or canceled issues can be archived"}
onClick={() => setDeleteIssueModal(true)} >
> <button
<Trash2 className="h-3.5 w-3.5" /> type="button"
</button> className={cn(
)} "h-5 w-5 grid place-items-center rounded focus:outline-none focus:ring-2 focus:ring-custom-primary",
{
"hover:text-custom-text-200": isInArchivableGroup,
"cursor-not-allowed text-custom-text-400": !isInArchivableGroup,
}
)}
onClick={() => {
if (!isInArchivableGroup) return;
setArchiveIssueModal(true);
}}
>
<ArchiveIcon className="h-4 w-4" />
</button>
</Tooltip>
)}
{is_editable && (
<Tooltip tooltipContent="Delete">
<button
type="button"
className="h-5 w-5 grid place-items-center hover:text-custom-text-200 rounded focus:outline-none focus:ring-2 focus:ring-custom-primary"
onClick={() => setDeleteIssueModal(true)}
>
<Trash2 className="h-4 w-4" />
</button>
</Tooltip>
)}
</div>
</div> </div>
</div> </div>

View File

@ -26,6 +26,8 @@ interface IBaseCalendarRoot {
[EIssueActions.DELETE]: (issue: TIssue) => Promise<void>; [EIssueActions.DELETE]: (issue: TIssue) => Promise<void>;
[EIssueActions.UPDATE]?: (issue: TIssue) => Promise<void>; [EIssueActions.UPDATE]?: (issue: TIssue) => Promise<void>;
[EIssueActions.REMOVE]?: (issue: TIssue) => Promise<void>; [EIssueActions.REMOVE]?: (issue: TIssue) => Promise<void>;
[EIssueActions.ARCHIVE]?: (issue: TIssue) => Promise<void>;
[EIssueActions.RESTORE]?: (issue: TIssue) => Promise<void>;
}; };
viewId?: string; viewId?: string;
isCompletedCycle?: boolean; isCompletedCycle?: boolean;
@ -114,6 +116,16 @@ export const BaseCalendarRoot = observer((props: IBaseCalendarRoot) => {
? async () => handleIssues(issue.target_date ?? "", issue, EIssueActions.REMOVE) ? async () => handleIssues(issue.target_date ?? "", issue, EIssueActions.REMOVE)
: undefined : undefined
} }
handleArchive={
issueActions[EIssueActions.ARCHIVE]
? async () => handleIssues(issue.target_date ?? "", issue, EIssueActions.ARCHIVE)
: undefined
}
handleRestore={
issueActions[EIssueActions.RESTORE]
? async () => handleIssues(issue.target_date ?? "", issue, EIssueActions.RESTORE)
: undefined
}
readOnly={!isEditingAllowed || isCompletedCycle} readOnly={!isEditingAllowed || isCompletedCycle}
/> />
)} )}

View File

@ -33,6 +33,10 @@ export const CycleCalendarLayout: React.FC = observer(() => {
if (!workspaceSlug || !cycleId || !projectId) return; if (!workspaceSlug || !cycleId || !projectId) return;
await issues.removeIssueFromCycle(workspaceSlug.toString(), issue.project_id, cycleId.toString(), issue.id); await issues.removeIssueFromCycle(workspaceSlug.toString(), issue.project_id, cycleId.toString(), issue.id);
}, },
[EIssueActions.ARCHIVE]: async (issue: TIssue) => {
if (!workspaceSlug || !cycleId) return;
await issues.archiveIssue(workspaceSlug.toString(), issue.project_id, issue.id, cycleId.toString());
},
}), }),
[issues, workspaceSlug, cycleId, projectId] [issues, workspaceSlug, cycleId, projectId]
); );

View File

@ -34,6 +34,10 @@ export const ModuleCalendarLayout: React.FC = observer(() => {
if (!workspaceSlug || !moduleId) return; if (!workspaceSlug || !moduleId) return;
await issues.removeIssueFromModule(workspaceSlug, issue.project_id, moduleId, issue.id); await issues.removeIssueFromModule(workspaceSlug, issue.project_id, moduleId, issue.id);
}, },
[EIssueActions.ARCHIVE]: async (issue: TIssue) => {
if (!workspaceSlug || !moduleId) return;
await issues.archiveIssue(workspaceSlug, issue.project_id, issue.id, moduleId);
},
}), }),
[issues, workspaceSlug, moduleId] [issues, workspaceSlug, moduleId]
); );

View File

@ -28,6 +28,11 @@ export const CalendarLayout: React.FC = observer(() => {
await issues.removeIssue(workspaceSlug.toString(), issue.project_id, issue.id); await issues.removeIssue(workspaceSlug.toString(), issue.project_id, issue.id);
}, },
[EIssueActions.ARCHIVE]: async (issue: TIssue) => {
if (!workspaceSlug) return;
await issues.archiveIssue(workspaceSlug.toString(), issue.project_id, issue.id);
},
}), }),
[issues, workspaceSlug] [issues, workspaceSlug]
); );

View File

@ -16,6 +16,7 @@ export interface IViewCalendarLayout {
[EIssueActions.DELETE]: (issue: TIssue) => Promise<void>; [EIssueActions.DELETE]: (issue: TIssue) => Promise<void>;
[EIssueActions.UPDATE]?: (issue: TIssue) => Promise<void>; [EIssueActions.UPDATE]?: (issue: TIssue) => Promise<void>;
[EIssueActions.REMOVE]?: (issue: TIssue) => Promise<void>; [EIssueActions.REMOVE]?: (issue: TIssue) => Promise<void>;
[EIssueActions.ARCHIVE]?: (issue: TIssue) => Promise<void>;
}; };
} }

View File

@ -41,6 +41,8 @@ export interface IBaseKanBanLayout {
[EIssueActions.DELETE]: (issue: TIssue) => Promise<void>; [EIssueActions.DELETE]: (issue: TIssue) => Promise<void>;
[EIssueActions.UPDATE]?: (issue: TIssue) => Promise<void>; [EIssueActions.UPDATE]?: (issue: TIssue) => Promise<void>;
[EIssueActions.REMOVE]?: (issue: TIssue) => Promise<void>; [EIssueActions.REMOVE]?: (issue: TIssue) => Promise<void>;
[EIssueActions.ARCHIVE]?: (issue: TIssue) => Promise<void>;
[EIssueActions.RESTORE]?: (issue: TIssue) => Promise<void>;
}; };
showLoader?: boolean; showLoader?: boolean;
viewId?: string; viewId?: string;
@ -188,6 +190,12 @@ export const BaseKanBanRoot: React.FC<IBaseKanBanLayout> = observer((props: IBas
handleRemoveFromView={ handleRemoveFromView={
issueActions[EIssueActions.REMOVE] ? async () => handleIssues(issue, EIssueActions.REMOVE) : undefined issueActions[EIssueActions.REMOVE] ? async () => handleIssues(issue, EIssueActions.REMOVE) : undefined
} }
handleArchive={
issueActions[EIssueActions.ARCHIVE] ? async () => handleIssues(issue, EIssueActions.ARCHIVE) : undefined
}
handleRestore={
issueActions[EIssueActions.RESTORE] ? async () => handleIssues(issue, EIssueActions.RESTORE) : undefined
}
readOnly={!isEditingAllowed || isCompletedCycle} readOnly={!isEditingAllowed || isCompletedCycle}
/> />
), ),

View File

@ -39,6 +39,11 @@ export const CycleKanBanLayout: React.FC = observer(() => {
await issues.removeIssueFromCycle(workspaceSlug.toString(), issue.project_id, cycleId.toString(), issue.id); await issues.removeIssueFromCycle(workspaceSlug.toString(), issue.project_id, cycleId.toString(), issue.id);
}, },
[EIssueActions.ARCHIVE]: async (issue: TIssue) => {
if (!workspaceSlug || !cycleId) return;
await issues.archiveIssue(workspaceSlug.toString(), issue.project_id, issue.id, cycleId.toString());
},
}), }),
[issues, workspaceSlug, cycleId] [issues, workspaceSlug, cycleId]
); );

View File

@ -38,6 +38,11 @@ export const ModuleKanBanLayout: React.FC = observer(() => {
await issues.removeIssueFromModule(workspaceSlug.toString(), issue.project_id, moduleId.toString(), issue.id); await issues.removeIssueFromModule(workspaceSlug.toString(), issue.project_id, moduleId.toString(), issue.id);
}, },
[EIssueActions.ARCHIVE]: async (issue: TIssue) => {
if (!workspaceSlug || !moduleId) return;
await issues.archiveIssue(workspaceSlug.toString(), issue.project_id, issue.id, moduleId.toString());
},
}), }),
[issues, workspaceSlug, moduleId] [issues, workspaceSlug, moduleId]
); );

View File

@ -35,6 +35,11 @@ export const ProfileIssuesKanBanLayout: React.FC = observer(() => {
await issues.removeIssue(workspaceSlug, issue.project_id, issue.id, userId); await issues.removeIssue(workspaceSlug, issue.project_id, issue.id, userId);
}, },
[EIssueActions.ARCHIVE]: async (issue: TIssue) => {
if (!workspaceSlug || !userId) return;
await issues.archiveIssue(workspaceSlug, issue.project_id, issue.id, userId);
},
}), }),
[issues, workspaceSlug, userId] [issues, workspaceSlug, userId]
); );

View File

@ -32,6 +32,11 @@ export const KanBanLayout: React.FC = observer(() => {
await issues.removeIssue(workspaceSlug, issue.project_id, issue.id); await issues.removeIssue(workspaceSlug, issue.project_id, issue.id);
}, },
[EIssueActions.ARCHIVE]: async (issue: TIssue) => {
if (!workspaceSlug) return;
await issues.archiveIssue(workspaceSlug, issue.project_id, issue.id);
},
}), }),
[issues, workspaceSlug] [issues, workspaceSlug]
); );

View File

@ -17,6 +17,7 @@ export interface IViewKanBanLayout {
[EIssueActions.DELETE]: (issue: TIssue) => Promise<void>; [EIssueActions.DELETE]: (issue: TIssue) => Promise<void>;
[EIssueActions.UPDATE]?: (issue: TIssue) => Promise<void>; [EIssueActions.UPDATE]?: (issue: TIssue) => Promise<void>;
[EIssueActions.REMOVE]?: (issue: TIssue) => Promise<void>; [EIssueActions.REMOVE]?: (issue: TIssue) => Promise<void>;
[EIssueActions.ARCHIVE]?: (issue: TIssue) => Promise<void>;
}; };
} }

View File

@ -41,6 +41,8 @@ interface IBaseListRoot {
[EIssueActions.DELETE]: (issue: TIssue) => Promise<void>; [EIssueActions.DELETE]: (issue: TIssue) => Promise<void>;
[EIssueActions.UPDATE]?: (issue: TIssue) => Promise<void>; [EIssueActions.UPDATE]?: (issue: TIssue) => Promise<void>;
[EIssueActions.REMOVE]?: (issue: TIssue) => Promise<void>; [EIssueActions.REMOVE]?: (issue: TIssue) => Promise<void>;
[EIssueActions.ARCHIVE]?: (issue: TIssue) => Promise<void>;
[EIssueActions.RESTORE]?: (issue: TIssue) => Promise<void>;
}; };
viewId?: string; viewId?: string;
storeType: TCreateModalStoreTypes; storeType: TCreateModalStoreTypes;
@ -109,6 +111,12 @@ export const BaseListRoot = observer((props: IBaseListRoot) => {
handleRemoveFromView={ handleRemoveFromView={
issueActions[EIssueActions.REMOVE] ? async () => handleIssues(issue, EIssueActions.REMOVE) : undefined issueActions[EIssueActions.REMOVE] ? async () => handleIssues(issue, EIssueActions.REMOVE) : undefined
} }
handleArchive={
issueActions[EIssueActions.ARCHIVE] ? async () => handleIssues(issue, EIssueActions.ARCHIVE) : undefined
}
handleRestore={
issueActions[EIssueActions.RESTORE] ? async () => handleIssues(issue, EIssueActions.RESTORE) : undefined
}
readOnly={!isEditingAllowed || isCompletedCycle} readOnly={!isEditingAllowed || isCompletedCycle}
/> />
), ),

View File

@ -5,6 +5,8 @@ export interface IQuickActionProps {
handleDelete: () => Promise<void>; handleDelete: () => Promise<void>;
handleUpdate?: (data: TIssue) => Promise<void>; handleUpdate?: (data: TIssue) => Promise<void>;
handleRemoveFromView?: () => Promise<void>; handleRemoveFromView?: () => Promise<void>;
handleArchive?: () => Promise<void>;
handleRestore?: () => Promise<void>;
customActionButton?: React.ReactElement; customActionButton?: React.ReactElement;
portalElement?: HTMLDivElement | null; portalElement?: HTMLDivElement | null;
readOnly?: boolean; readOnly?: boolean;

View File

@ -24,6 +24,11 @@ export const ArchivedIssueListLayout: FC = observer(() => {
await issues.removeIssue(workspaceSlug, projectId, issue.id); await issues.removeIssue(workspaceSlug, projectId, issue.id);
}, },
[EIssueActions.RESTORE]: async (issue: TIssue) => {
if (!workspaceSlug || !projectId) return;
await issues.restoreIssue(workspaceSlug, projectId, issue.id);
},
}), }),
[issues, workspaceSlug, projectId] [issues, workspaceSlug, projectId]
); );

View File

@ -38,6 +38,11 @@ export const CycleListLayout: React.FC = observer(() => {
await issues.removeIssueFromCycle(workspaceSlug.toString(), issue.project_id, cycleId.toString(), issue.id); await issues.removeIssueFromCycle(workspaceSlug.toString(), issue.project_id, cycleId.toString(), issue.id);
}, },
[EIssueActions.ARCHIVE]: async (issue: TIssue) => {
if (!workspaceSlug || !cycleId) return;
await issues.archiveIssue(workspaceSlug.toString(), issue.project_id, issue.id, cycleId.toString());
},
}), }),
[issues, workspaceSlug, cycleId] [issues, workspaceSlug, cycleId]
); );

View File

@ -37,6 +37,11 @@ export const ModuleListLayout: React.FC = observer(() => {
await issues.removeIssueFromModule(workspaceSlug.toString(), issue.project_id, moduleId.toString(), issue.id); await issues.removeIssueFromModule(workspaceSlug.toString(), issue.project_id, moduleId.toString(), issue.id);
}, },
[EIssueActions.ARCHIVE]: async (issue: TIssue) => {
if (!workspaceSlug || !moduleId) return;
await issues.archiveIssue(workspaceSlug.toString(), issue.project_id, issue.id, moduleId.toString());
},
}), }),
[issues, workspaceSlug, moduleId] [issues, workspaceSlug, moduleId]
); );

View File

@ -36,6 +36,11 @@ export const ProfileIssuesListLayout: FC = observer(() => {
await issues.removeIssue(workspaceSlug, issue.project_id, issue.id, userId); await issues.removeIssue(workspaceSlug, issue.project_id, issue.id, userId);
}, },
[EIssueActions.ARCHIVE]: async (issue: TIssue) => {
if (!workspaceSlug || !userId) return;
await issues.archiveIssue(workspaceSlug, issue.project_id, issue.id, userId);
},
}), }),
[issues, workspaceSlug, userId] [issues, workspaceSlug, userId]
); );

View File

@ -33,6 +33,11 @@ export const ListLayout: FC = observer(() => {
await issues.removeIssue(workspaceSlug, projectId, issue.id); await issues.removeIssue(workspaceSlug, projectId, issue.id);
}, },
[EIssueActions.ARCHIVE]: async (issue: TIssue) => {
if (!workspaceSlug || !projectId) return;
await issues.archiveIssue(workspaceSlug, projectId, issue.id);
},
}), }),
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
[issues] [issues]

View File

@ -17,6 +17,7 @@ export interface IViewListLayout {
[EIssueActions.DELETE]: (issue: TIssue) => Promise<void>; [EIssueActions.DELETE]: (issue: TIssue) => Promise<void>;
[EIssueActions.UPDATE]?: (issue: TIssue) => Promise<void>; [EIssueActions.UPDATE]?: (issue: TIssue) => Promise<void>;
[EIssueActions.REMOVE]?: (issue: TIssue) => Promise<void>; [EIssueActions.REMOVE]?: (issue: TIssue) => Promise<void>;
[EIssueActions.ARCHIVE]?: (issue: TIssue) => Promise<void>;
}; };
} }

View File

@ -1,13 +1,14 @@
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { CustomMenu } from "@plane/ui"; import { ArchiveIcon, CustomMenu } from "@plane/ui";
import { Copy, Link, Pencil, Trash2 } from "lucide-react"; import { observer } from "mobx-react";
import { Copy, ExternalLink, Link, Pencil, Trash2 } from "lucide-react";
import omit from "lodash/omit"; import omit from "lodash/omit";
// hooks // hooks
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
import { useEventTracker } from "hooks/store"; import { useEventTracker, useProjectState } from "hooks/store";
// components // components
import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues"; import { ArchiveIssueModal, CreateUpdateIssueModal, DeleteIssueModal } from "components/issues";
// helpers // helpers
import { copyUrlToClipboard } from "helpers/string.helper"; import { copyUrlToClipboard } from "helpers/string.helper";
// types // types
@ -15,30 +16,50 @@ import { TIssue } from "@plane/types";
import { IQuickActionProps } from "../list/list-view-types"; import { IQuickActionProps } from "../list/list-view-types";
// constants // constants
import { EIssuesStoreType } from "constants/issue"; import { EIssuesStoreType } from "constants/issue";
import { STATE_GROUPS } from "constants/state";
export const AllIssueQuickActions: React.FC<IQuickActionProps> = (props) => { export const AllIssueQuickActions: React.FC<IQuickActionProps> = observer((props) => {
const { issue, handleDelete, handleUpdate, customActionButton, portalElement, readOnly = false } = props; const {
issue,
handleDelete,
handleUpdate,
handleArchive,
customActionButton,
portalElement,
readOnly = false,
} = props;
// states // states
const [createUpdateIssueModal, setCreateUpdateIssueModal] = useState(false); const [createUpdateIssueModal, setCreateUpdateIssueModal] = useState(false);
const [issueToEdit, setIssueToEdit] = useState<TIssue | undefined>(undefined); const [issueToEdit, setIssueToEdit] = useState<TIssue | undefined>(undefined);
const [deleteIssueModal, setDeleteIssueModal] = useState(false); const [deleteIssueModal, setDeleteIssueModal] = useState(false);
const [archiveIssueModal, setArchiveIssueModal] = useState(false);
// router // router
const router = useRouter(); const router = useRouter();
const { workspaceSlug } = router.query; const { workspaceSlug } = router.query;
// hooks // store hooks
const { setTrackElement } = useEventTracker(); const { setTrackElement } = useEventTracker();
const { getStateById } = useProjectState();
// toast alert // toast alert
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
// derived values
const stateDetails = getStateById(issue.state_id);
const isEditingAllowed = !readOnly;
// auth
const isArchivingAllowed = handleArchive && isEditingAllowed;
const isInArchivableGroup =
!!stateDetails && [STATE_GROUPS.completed.key, STATE_GROUPS.cancelled.key].includes(stateDetails?.group);
const handleCopyIssueLink = () => { const issueLink = `${workspaceSlug}/projects/${issue.project_id}/issues/${issue.id}`;
copyUrlToClipboard(`/${workspaceSlug}/projects/${issue.project_id}/issues/${issue.id}`).then(() =>
const handleOpenInNewTab = () => window.open(`/${issueLink}`, "_blank");
const handleCopyIssueLink = () =>
copyUrlToClipboard(issueLink).then(() =>
setToastAlert({ setToastAlert({
type: "success", type: "success",
title: "Link copied", title: "Link copied",
message: "Issue link copied to clipboard", message: "Issue link copied to clipboard",
}) })
); );
};
const duplicateIssuePayload = omit( const duplicateIssuePayload = omit(
{ {
@ -50,6 +71,12 @@ export const AllIssueQuickActions: React.FC<IQuickActionProps> = (props) => {
return ( return (
<> <>
<ArchiveIssueModal
data={issue}
isOpen={archiveIssueModal}
handleClose={() => setArchiveIssueModal(false)}
onSubmit={handleArchive}
/>
<DeleteIssueModal <DeleteIssueModal
data={issue} data={issue}
isOpen={deleteIssueModal} isOpen={deleteIssueModal}
@ -75,55 +102,81 @@ export const AllIssueQuickActions: React.FC<IQuickActionProps> = (props) => {
closeOnSelect closeOnSelect
ellipsis ellipsis
> >
<CustomMenu.MenuItem {isEditingAllowed && (
onClick={() => { <CustomMenu.MenuItem
handleCopyIssueLink(); onClick={() => {
}} setTrackElement("Global issues");
> setIssueToEdit(issue);
setCreateUpdateIssueModal(true);
}}
>
<div className="flex items-center gap-2">
<Pencil className="h-3 w-3" />
Edit
</div>
</CustomMenu.MenuItem>
)}
<CustomMenu.MenuItem onClick={handleOpenInNewTab}>
<div className="flex items-center gap-2">
<ExternalLink className="h-3 w-3" />
Open in new tab
</div>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={handleCopyIssueLink}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Link className="h-3 w-3" /> <Link className="h-3 w-3" />
Copy link Copy link
</div> </div>
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
{!readOnly && ( {isEditingAllowed && (
<> <CustomMenu.MenuItem
<CustomMenu.MenuItem onClick={() => {
onClick={() => { setTrackElement("Global issues");
setTrackElement("Global issues"); setCreateUpdateIssueModal(true);
setIssueToEdit(issue); }}
setCreateUpdateIssueModal(true); >
}} <div className="flex items-center gap-2">
> <Copy className="h-3 w-3" />
Make a copy
</div>
</CustomMenu.MenuItem>
)}
{isArchivingAllowed && (
<CustomMenu.MenuItem onClick={() => setArchiveIssueModal(true)} disabled={!isInArchivableGroup}>
{isInArchivableGroup ? (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Pencil className="h-3 w-3" /> <ArchiveIcon className="h-3 w-3" />
Edit issue Archive
</div> </div>
</CustomMenu.MenuItem> ) : (
<CustomMenu.MenuItem <div className="flex items-start gap-2">
onClick={() => { <ArchiveIcon className="h-3 w-3" />
setTrackElement("Global issues"); <div className="-mt-1">
setCreateUpdateIssueModal(true); <p>Archive</p>
}} <p className="text-xs text-custom-text-400">
> Only completed or canceled
<div className="flex items-center gap-2"> <br />
<Copy className="h-3 w-3" /> issues can be archived
Make a copy </p>
</div>
</div> </div>
</CustomMenu.MenuItem> )}
<CustomMenu.MenuItem </CustomMenu.MenuItem>
onClick={() => { )}
setTrackElement("Global issues"); {isEditingAllowed && (
setDeleteIssueModal(true); <CustomMenu.MenuItem
}} onClick={() => {
> setTrackElement("Global issues");
<div className="flex items-center gap-2"> setDeleteIssueModal(true);
<Trash2 className="h-3 w-3" /> }}
Delete issue >
</div> <div className="flex items-center gap-2">
</CustomMenu.MenuItem> <Trash2 className="h-3 w-3" />
</> Delete
</div>
</CustomMenu.MenuItem>
)} )}
</CustomMenu> </CustomMenu>
</> </>
); );
}; });

View File

@ -1,12 +1,12 @@
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { CustomMenu } from "@plane/ui"; import { CustomMenu } from "@plane/ui";
import { Link, Trash2 } from "lucide-react"; import { ExternalLink, Link, RotateCcw, Trash2 } from "lucide-react";
// hooks // hooks
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
import { useEventTracker, useIssues, useUser } from "hooks/store"; import { useEventTracker, useIssues, useUser } from "hooks/store";
// components // components
import { DeleteArchivedIssueModal } from "components/issues"; import { DeleteIssueModal } from "components/issues";
// helpers // helpers
import { copyUrlToClipboard } from "helpers/string.helper"; import { copyUrlToClipboard } from "helpers/string.helper";
// types // types
@ -15,40 +15,41 @@ import { EUserProjectRoles } from "constants/project";
import { EIssuesStoreType } from "constants/issue"; import { EIssuesStoreType } from "constants/issue";
export const ArchivedIssueQuickActions: React.FC<IQuickActionProps> = (props) => { export const ArchivedIssueQuickActions: React.FC<IQuickActionProps> = (props) => {
const { issue, handleDelete, customActionButton, portalElement, readOnly = false } = props; const { issue, handleDelete, handleRestore, customActionButton, portalElement, readOnly = false } = props;
// states
const [deleteIssueModal, setDeleteIssueModal] = useState(false);
// router // router
const router = useRouter(); const router = useRouter();
const { workspaceSlug } = router.query; const { workspaceSlug } = router.query;
// states
const [deleteIssueModal, setDeleteIssueModal] = useState(false);
// toast alert
const { setToastAlert } = useToast();
// store hooks // store hooks
const { const {
membership: { currentProjectRole }, membership: { currentProjectRole },
} = useUser(); } = useUser();
const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER;
// store hooks
const { setTrackElement } = useEventTracker(); const { setTrackElement } = useEventTracker();
const { issuesFilter } = useIssues(EIssuesStoreType.ARCHIVED); const { issuesFilter } = useIssues(EIssuesStoreType.ARCHIVED);
// derived values
const activeLayout = `${issuesFilter.issueFilters?.displayFilters?.layout} layout`; const activeLayout = `${issuesFilter.issueFilters?.displayFilters?.layout} layout`;
// auth
const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER && !readOnly;
const isRestoringAllowed = handleRestore && isEditingAllowed;
// toast alert
const { setToastAlert } = useToast();
const handleCopyIssueLink = () => { const issueLink = `${workspaceSlug}/projects/${issue.project_id}/archived-issues/${issue.id}`;
copyUrlToClipboard(`${workspaceSlug}/projects/${issue.project_id}/archived-issues/${issue.id}`).then(() =>
const handleOpenInNewTab = () => window.open(`/${issueLink}`, "_blank");
const handleCopyIssueLink = () =>
copyUrlToClipboard(issueLink).then(() =>
setToastAlert({ setToastAlert({
type: "success", type: "success",
title: "Link copied", title: "Link copied",
message: "Issue link copied to clipboard", message: "Issue link copied to clipboard",
}) })
); );
};
return ( return (
<> <>
<DeleteArchivedIssueModal <DeleteIssueModal
data={issue} data={issue}
isOpen={deleteIssueModal} isOpen={deleteIssueModal}
handleClose={() => setDeleteIssueModal(false)} handleClose={() => setDeleteIssueModal(false)}
@ -61,17 +62,27 @@ export const ArchivedIssueQuickActions: React.FC<IQuickActionProps> = (props) =>
closeOnSelect closeOnSelect
ellipsis ellipsis
> >
<CustomMenu.MenuItem {isRestoringAllowed && (
onClick={() => { <CustomMenu.MenuItem onClick={handleRestore}>
handleCopyIssueLink(); <div className="flex items-center gap-2">
}} <RotateCcw className="h-3 w-3" />
> Restore
</div>
</CustomMenu.MenuItem>
)}
<CustomMenu.MenuItem onClick={handleOpenInNewTab}>
<div className="flex items-center gap-2">
<ExternalLink className="h-3 w-3" />
Open in new tab
</div>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={handleCopyIssueLink}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Link className="h-3 w-3" /> <Link className="h-3 w-3" />
Copy link Copy link
</div> </div>
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
{isEditingAllowed && !readOnly && ( {isEditingAllowed && (
<CustomMenu.MenuItem <CustomMenu.MenuItem
onClick={() => { onClick={() => {
setTrackElement(activeLayout); setTrackElement(activeLayout);

View File

@ -1,13 +1,14 @@
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { CustomMenu } from "@plane/ui"; import { ArchiveIcon, CustomMenu } from "@plane/ui";
import { Copy, Link, Pencil, Trash2, XCircle } from "lucide-react"; import { observer } from "mobx-react";
import { Copy, ExternalLink, Link, Pencil, Trash2, XCircle } from "lucide-react";
import omit from "lodash/omit"; import omit from "lodash/omit";
// hooks // hooks
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
import { useEventTracker, useIssues, useUser } from "hooks/store"; import { useEventTracker, useIssues, useProjectState, useUser } from "hooks/store";
// components // components
import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues"; import { ArchiveIssueModal, CreateUpdateIssueModal, DeleteIssueModal } from "components/issues";
// helpers // helpers
import { copyUrlToClipboard } from "helpers/string.helper"; import { copyUrlToClipboard } from "helpers/string.helper";
// types // types
@ -16,13 +17,15 @@ import { IQuickActionProps } from "../list/list-view-types";
// constants // constants
import { EIssuesStoreType } from "constants/issue"; import { EIssuesStoreType } from "constants/issue";
import { EUserProjectRoles } from "constants/project"; import { EUserProjectRoles } from "constants/project";
import { STATE_GROUPS } from "constants/state";
export const CycleIssueQuickActions: React.FC<IQuickActionProps> = (props) => { export const CycleIssueQuickActions: React.FC<IQuickActionProps> = observer((props) => {
const { const {
issue, issue,
handleDelete, handleDelete,
handleUpdate, handleUpdate,
handleRemoveFromView, handleRemoveFromView,
handleArchive,
customActionButton, customActionButton,
portalElement, portalElement,
readOnly = false, readOnly = false,
@ -31,33 +34,42 @@ export const CycleIssueQuickActions: React.FC<IQuickActionProps> = (props) => {
const [createUpdateIssueModal, setCreateUpdateIssueModal] = useState(false); const [createUpdateIssueModal, setCreateUpdateIssueModal] = useState(false);
const [issueToEdit, setIssueToEdit] = useState<TIssue | undefined>(undefined); const [issueToEdit, setIssueToEdit] = useState<TIssue | undefined>(undefined);
const [deleteIssueModal, setDeleteIssueModal] = useState(false); const [deleteIssueModal, setDeleteIssueModal] = useState(false);
const [archiveIssueModal, setArchiveIssueModal] = useState(false);
// router // router
const router = useRouter(); const router = useRouter();
const { workspaceSlug, cycleId } = router.query; const { workspaceSlug, cycleId } = router.query;
// store hooks // store hooks
const { setTrackElement } = useEventTracker(); const { setTrackElement } = useEventTracker();
const { issuesFilter } = useIssues(EIssuesStoreType.CYCLE); const { issuesFilter } = useIssues(EIssuesStoreType.CYCLE);
// toast alert
const { setToastAlert } = useToast();
// store hooks
const { const {
membership: { currentProjectRole }, membership: { currentProjectRole },
} = useUser(); } = useUser();
const { getStateById } = useProjectState();
const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; // toast alert
const { setToastAlert } = useToast();
// derived values
const stateDetails = getStateById(issue.state_id);
// auth
const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER && !readOnly;
const isArchivingAllowed = handleArchive && isEditingAllowed;
const isInArchivableGroup =
!!stateDetails && [STATE_GROUPS.completed.key, STATE_GROUPS.cancelled.key].includes(stateDetails?.group);
const isDeletingAllowed = isEditingAllowed;
const activeLayout = `${issuesFilter.issueFilters?.displayFilters?.layout} layout`; const activeLayout = `${issuesFilter.issueFilters?.displayFilters?.layout} layout`;
const handleCopyIssueLink = () => { const issueLink = `${workspaceSlug}/projects/${issue.project_id}/issues/${issue.id}`;
copyUrlToClipboard(`${workspaceSlug}/projects/${issue.project_id}/issues/${issue.id}`).then(() =>
const handleOpenInNewTab = () => window.open(`/${issueLink}`, "_blank");
const handleCopyIssueLink = () =>
copyUrlToClipboard(issueLink).then(() =>
setToastAlert({ setToastAlert({
type: "success", type: "success",
title: "Link copied", title: "Link copied",
message: "Issue link copied to clipboard", message: "Issue link copied to clipboard",
}) })
); );
};
const duplicateIssuePayload = omit( const duplicateIssuePayload = omit(
{ {
@ -69,6 +81,12 @@ export const CycleIssueQuickActions: React.FC<IQuickActionProps> = (props) => {
return ( return (
<> <>
<ArchiveIssueModal
data={issue}
isOpen={archiveIssueModal}
handleClose={() => setArchiveIssueModal(false)}
onSubmit={handleArchive}
/>
<DeleteIssueModal <DeleteIssueModal
data={issue} data={issue}
isOpen={deleteIssueModal} isOpen={deleteIssueModal}
@ -94,68 +112,96 @@ export const CycleIssueQuickActions: React.FC<IQuickActionProps> = (props) => {
closeOnSelect closeOnSelect
ellipsis ellipsis
> >
<CustomMenu.MenuItem {isEditingAllowed && (
onClick={() => { <CustomMenu.MenuItem
handleCopyIssueLink(); onClick={() => {
}} setIssueToEdit({
> ...issue,
cycle_id: cycleId?.toString() ?? null,
});
setTrackElement(activeLayout);
setCreateUpdateIssueModal(true);
}}
>
<div className="flex items-center gap-2">
<Pencil className="h-3 w-3" />
Edit
</div>
</CustomMenu.MenuItem>
)}
<CustomMenu.MenuItem onClick={handleOpenInNewTab}>
<div className="flex items-center gap-2">
<ExternalLink className="h-3 w-3" />
Open in new tab
</div>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={handleCopyIssueLink}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Link className="h-3 w-3" /> <Link className="h-3 w-3" />
Copy link Copy link
</div> </div>
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
{isEditingAllowed && !readOnly && ( {isEditingAllowed && (
<> <CustomMenu.MenuItem
<CustomMenu.MenuItem onClick={() => {
onClick={() => { setTrackElement(activeLayout);
setIssueToEdit({ setCreateUpdateIssueModal(true);
...issue, }}
cycle_id: cycleId?.toString() ?? null, >
}); <div className="flex items-center gap-2">
setTrackElement(activeLayout); <Copy className="h-3 w-3" />
setCreateUpdateIssueModal(true); Make a copy
}} </div>
> </CustomMenu.MenuItem>
)}
{isEditingAllowed && (
<CustomMenu.MenuItem
onClick={() => {
handleRemoveFromView && handleRemoveFromView();
}}
>
<div className="flex items-center gap-2">
<XCircle className="h-3 w-3" />
Remove from cycle
</div>
</CustomMenu.MenuItem>
)}
{isArchivingAllowed && (
<CustomMenu.MenuItem onClick={() => setArchiveIssueModal(true)} disabled={!isInArchivableGroup}>
{isInArchivableGroup ? (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Pencil className="h-3 w-3" /> <ArchiveIcon className="h-3 w-3" />
Edit issue Archive
</div> </div>
</CustomMenu.MenuItem> ) : (
<CustomMenu.MenuItem <div className="flex items-start gap-2">
onClick={() => { <ArchiveIcon className="h-3 w-3" />
handleRemoveFromView && handleRemoveFromView(); <div className="-mt-1">
}} <p>Archive</p>
> <p className="text-xs text-custom-text-400">
<div className="flex items-center gap-2"> Only completed or canceled
<XCircle className="h-3 w-3" /> <br />
Remove from cycle issues can be archived
</p>
</div>
</div> </div>
</CustomMenu.MenuItem> )}
<CustomMenu.MenuItem </CustomMenu.MenuItem>
onClick={() => { )}
setTrackElement(activeLayout); {isDeletingAllowed && (
setCreateUpdateIssueModal(true); <CustomMenu.MenuItem
}} onClick={() => {
> setTrackElement(activeLayout);
<div className="flex items-center gap-2"> setDeleteIssueModal(true);
<Copy className="h-3 w-3" /> }}
Make a copy >
</div> <div className="flex items-center gap-2">
</CustomMenu.MenuItem> <Trash2 className="h-3 w-3" />
<CustomMenu.MenuItem Delete
onClick={() => { </div>
setTrackElement(activeLayout); </CustomMenu.MenuItem>
setDeleteIssueModal(true);
}}
>
<div className="flex items-center gap-2">
<Trash2 className="h-3 w-3" />
Delete issue
</div>
</CustomMenu.MenuItem>
</>
)} )}
</CustomMenu> </CustomMenu>
</> </>
); );
}; });

View File

@ -1,13 +1,14 @@
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { CustomMenu } from "@plane/ui"; import { ArchiveIcon, CustomMenu } from "@plane/ui";
import { Copy, Link, Pencil, Trash2, XCircle } from "lucide-react"; import { observer } from "mobx-react";
import { Copy, ExternalLink, Link, Pencil, Trash2, XCircle } from "lucide-react";
import omit from "lodash/omit"; import omit from "lodash/omit";
// hooks // hooks
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
import { useIssues, useEventTracker, useUser } from "hooks/store"; import { useIssues, useEventTracker, useUser, useProjectState } from "hooks/store";
// components // components
import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues"; import { ArchiveIssueModal, CreateUpdateIssueModal, DeleteIssueModal } from "components/issues";
// helpers // helpers
import { copyUrlToClipboard } from "helpers/string.helper"; import { copyUrlToClipboard } from "helpers/string.helper";
// types // types
@ -16,13 +17,15 @@ import { IQuickActionProps } from "../list/list-view-types";
// constants // constants
import { EIssuesStoreType } from "constants/issue"; import { EIssuesStoreType } from "constants/issue";
import { EUserProjectRoles } from "constants/project"; import { EUserProjectRoles } from "constants/project";
import { STATE_GROUPS } from "constants/state";
export const ModuleIssueQuickActions: React.FC<IQuickActionProps> = (props) => { export const ModuleIssueQuickActions: React.FC<IQuickActionProps> = observer((props) => {
const { const {
issue, issue,
handleDelete, handleDelete,
handleUpdate, handleUpdate,
handleRemoveFromView, handleRemoveFromView,
handleArchive,
customActionButton, customActionButton,
portalElement, portalElement,
readOnly = false, readOnly = false,
@ -31,33 +34,42 @@ export const ModuleIssueQuickActions: React.FC<IQuickActionProps> = (props) => {
const [createUpdateIssueModal, setCreateUpdateIssueModal] = useState(false); const [createUpdateIssueModal, setCreateUpdateIssueModal] = useState(false);
const [issueToEdit, setIssueToEdit] = useState<TIssue | undefined>(undefined); const [issueToEdit, setIssueToEdit] = useState<TIssue | undefined>(undefined);
const [deleteIssueModal, setDeleteIssueModal] = useState(false); const [deleteIssueModal, setDeleteIssueModal] = useState(false);
const [archiveIssueModal, setArchiveIssueModal] = useState(false);
// router // router
const router = useRouter(); const router = useRouter();
const { workspaceSlug, moduleId } = router.query; const { workspaceSlug, moduleId } = router.query;
// store hooks // store hooks
const { setTrackElement } = useEventTracker(); const { setTrackElement } = useEventTracker();
const { issuesFilter } = useIssues(EIssuesStoreType.MODULE); const { issuesFilter } = useIssues(EIssuesStoreType.MODULE);
// toast alert
const { setToastAlert } = useToast();
// store hooks
const { const {
membership: { currentProjectRole }, membership: { currentProjectRole },
} = useUser(); } = useUser();
const { getStateById } = useProjectState();
const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; // toast alert
const { setToastAlert } = useToast();
// derived values
const stateDetails = getStateById(issue.state_id);
// auth
const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER && !readOnly;
const isArchivingAllowed = handleArchive && isEditingAllowed;
const isInArchivableGroup =
!!stateDetails && [STATE_GROUPS.completed.key, STATE_GROUPS.cancelled.key].includes(stateDetails?.group);
const isDeletingAllowed = isEditingAllowed;
const activeLayout = `${issuesFilter.issueFilters?.displayFilters?.layout} layout`; const activeLayout = `${issuesFilter.issueFilters?.displayFilters?.layout} layout`;
const handleCopyIssueLink = () => { const issueLink = `${workspaceSlug}/projects/${issue.project_id}/issues/${issue.id}`;
copyUrlToClipboard(`${workspaceSlug}/projects/${issue.project_id}/issues/${issue.id}`).then(() =>
const handleOpenInNewTab = () => window.open(`/${issueLink}`, "_blank");
const handleCopyIssueLink = () =>
copyUrlToClipboard(issueLink).then(() =>
setToastAlert({ setToastAlert({
type: "success", type: "success",
title: "Link copied", title: "Link copied",
message: "Issue link copied to clipboard", message: "Issue link copied to clipboard",
}) })
); );
};
const duplicateIssuePayload = omit( const duplicateIssuePayload = omit(
{ {
@ -69,6 +81,12 @@ export const ModuleIssueQuickActions: React.FC<IQuickActionProps> = (props) => {
return ( return (
<> <>
<ArchiveIssueModal
data={issue}
isOpen={archiveIssueModal}
handleClose={() => setArchiveIssueModal(false)}
onSubmit={handleArchive}
/>
<DeleteIssueModal <DeleteIssueModal
data={issue} data={issue}
isOpen={deleteIssueModal} isOpen={deleteIssueModal}
@ -94,67 +112,95 @@ export const ModuleIssueQuickActions: React.FC<IQuickActionProps> = (props) => {
closeOnSelect closeOnSelect
ellipsis ellipsis
> >
<CustomMenu.MenuItem {isEditingAllowed && (
onClick={() => { <CustomMenu.MenuItem
handleCopyIssueLink(); onClick={() => {
}} setIssueToEdit({ ...issue, module_ids: moduleId ? [moduleId.toString()] : [] });
> setTrackElement(activeLayout);
setCreateUpdateIssueModal(true);
}}
>
<div className="flex items-center gap-2">
<Pencil className="h-3 w-3" />
Edit
</div>
</CustomMenu.MenuItem>
)}
<CustomMenu.MenuItem onClick={handleOpenInNewTab}>
<div className="flex items-center gap-2">
<ExternalLink className="h-3 w-3" />
Open in new tab
</div>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={handleCopyIssueLink}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Link className="h-3 w-3" /> <Link className="h-3 w-3" />
Copy link Copy link
</div> </div>
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
{isEditingAllowed && !readOnly && ( {isEditingAllowed && (
<> <CustomMenu.MenuItem
<CustomMenu.MenuItem onClick={() => {
onClick={() => { setTrackElement(activeLayout);
setIssueToEdit({ ...issue, module_ids: moduleId ? [moduleId.toString()] : [] }); setCreateUpdateIssueModal(true);
setTrackElement(activeLayout); }}
setCreateUpdateIssueModal(true); >
}} <div className="flex items-center gap-2">
> <Copy className="h-3 w-3" />
Make a copy
</div>
</CustomMenu.MenuItem>
)}
{isEditingAllowed && (
<CustomMenu.MenuItem
onClick={() => {
handleRemoveFromView && handleRemoveFromView();
}}
>
<div className="flex items-center gap-2">
<XCircle className="h-3 w-3" />
Remove from module
</div>
</CustomMenu.MenuItem>
)}
{isArchivingAllowed && (
<CustomMenu.MenuItem onClick={() => setArchiveIssueModal(true)} disabled={!isInArchivableGroup}>
{isInArchivableGroup ? (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Pencil className="h-3 w-3" /> <ArchiveIcon className="h-3 w-3" />
Edit issue Archive
</div> </div>
</CustomMenu.MenuItem> ) : (
<CustomMenu.MenuItem <div className="flex items-start gap-2">
onClick={() => { <ArchiveIcon className="h-3 w-3" />
handleRemoveFromView && handleRemoveFromView(); <div className="-mt-1">
}} <p>Archive</p>
> <p className="text-xs text-custom-text-400">
<div className="flex items-center gap-2"> Only completed or canceled
<XCircle className="h-3 w-3" /> <br />
Remove from module issues can be archived
</p>
</div>
</div> </div>
</CustomMenu.MenuItem> )}
<CustomMenu.MenuItem </CustomMenu.MenuItem>
onClick={() => { )}
setTrackElement(activeLayout); {isDeletingAllowed && (
setCreateUpdateIssueModal(true); <CustomMenu.MenuItem
}} onClick={(e) => {
> e.preventDefault();
<div className="flex items-center gap-2"> e.stopPropagation();
<Copy className="h-3 w-3" /> setTrackElement(activeLayout);
Make a copy setDeleteIssueModal(true);
</div> }}
</CustomMenu.MenuItem> >
<CustomMenu.MenuItem <div className="flex items-center gap-2">
onClick={(e) => { <Trash2 className="h-3 w-3" />
e.preventDefault(); Delete
e.stopPropagation(); </div>
setTrackElement(activeLayout); </CustomMenu.MenuItem>
setDeleteIssueModal(true);
}}
>
<div className="flex items-center gap-2">
<Trash2 className="h-3 w-3" />
Delete issue
</div>
</CustomMenu.MenuItem>
</>
)} )}
</CustomMenu> </CustomMenu>
</> </>
); );
}; });

View File

@ -1,13 +1,14 @@
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { CustomMenu } from "@plane/ui"; import { ArchiveIcon, CustomMenu } from "@plane/ui";
import { Copy, Link, Pencil, Trash2 } from "lucide-react"; import { observer } from "mobx-react";
import { Copy, ExternalLink, Link, Pencil, Trash2 } from "lucide-react";
import omit from "lodash/omit"; import omit from "lodash/omit";
// hooks // hooks
import { useEventTracker, useIssues, useUser } from "hooks/store"; import { useEventTracker, useIssues, useProjectState, useUser } from "hooks/store";
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
// components // components
import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues"; import { ArchiveIssueModal, CreateUpdateIssueModal, DeleteIssueModal } from "components/issues";
// helpers // helpers
import { copyUrlToClipboard } from "helpers/string.helper"; import { copyUrlToClipboard } from "helpers/string.helper";
// types // types
@ -16,9 +17,18 @@ import { IQuickActionProps } from "../list/list-view-types";
// constant // constant
import { EUserProjectRoles } from "constants/project"; import { EUserProjectRoles } from "constants/project";
import { EIssuesStoreType } from "constants/issue"; import { EIssuesStoreType } from "constants/issue";
import { STATE_GROUPS } from "constants/state";
export const ProjectIssueQuickActions: React.FC<IQuickActionProps> = (props) => { export const ProjectIssueQuickActions: React.FC<IQuickActionProps> = observer((props) => {
const { issue, handleDelete, handleUpdate, customActionButton, portalElement, readOnly = false } = props; const {
issue,
handleDelete,
handleUpdate,
handleArchive,
customActionButton,
portalElement,
readOnly = false,
} = props;
// router // router
const router = useRouter(); const router = useRouter();
const { workspaceSlug } = router.query; const { workspaceSlug } = router.query;
@ -26,28 +36,38 @@ export const ProjectIssueQuickActions: React.FC<IQuickActionProps> = (props) =>
const [createUpdateIssueModal, setCreateUpdateIssueModal] = useState(false); const [createUpdateIssueModal, setCreateUpdateIssueModal] = useState(false);
const [issueToEdit, setIssueToEdit] = useState<TIssue | undefined>(undefined); const [issueToEdit, setIssueToEdit] = useState<TIssue | undefined>(undefined);
const [deleteIssueModal, setDeleteIssueModal] = useState(false); const [deleteIssueModal, setDeleteIssueModal] = useState(false);
const [archiveIssueModal, setArchiveIssueModal] = useState(false);
// store hooks // store hooks
const { const {
membership: { currentProjectRole }, membership: { currentProjectRole },
} = useUser(); } = useUser();
const { setTrackElement } = useEventTracker(); const { setTrackElement } = useEventTracker();
const { issuesFilter } = useIssues(EIssuesStoreType.PROJECT); const { issuesFilter } = useIssues(EIssuesStoreType.PROJECT);
const { getStateById } = useProjectState();
// derived values
const activeLayout = `${issuesFilter.issueFilters?.displayFilters?.layout} layout`; const activeLayout = `${issuesFilter.issueFilters?.displayFilters?.layout} layout`;
const stateDetails = getStateById(issue.state_id);
const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; // auth
const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER && !readOnly;
const isArchivingAllowed = handleArchive && isEditingAllowed;
const isInArchivableGroup =
!!stateDetails && [STATE_GROUPS.completed.key, STATE_GROUPS.cancelled.key].includes(stateDetails?.group);
const isDeletingAllowed = isEditingAllowed;
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
const handleCopyIssueLink = () => { const issueLink = `${workspaceSlug}/projects/${issue.project_id}/issues/${issue.id}`;
copyUrlToClipboard(`${workspaceSlug}/projects/${issue.project_id}/issues/${issue.id}`).then(() =>
const handleOpenInNewTab = () => window.open(`/${issueLink}`, "_blank");
const handleCopyIssueLink = () =>
copyUrlToClipboard(issueLink).then(() =>
setToastAlert({ setToastAlert({
type: "success", type: "success",
title: "Link copied", title: "Link copied",
message: "Issue link copied to clipboard", message: "Issue link copied to clipboard",
}) })
); );
};
const isDraftIssue = router?.asPath?.includes("draft-issues") || false; const isDraftIssue = router?.asPath?.includes("draft-issues") || false;
@ -62,13 +82,18 @@ export const ProjectIssueQuickActions: React.FC<IQuickActionProps> = (props) =>
return ( return (
<> <>
<ArchiveIssueModal
data={issue}
isOpen={archiveIssueModal}
handleClose={() => setArchiveIssueModal(false)}
onSubmit={handleArchive}
/>
<DeleteIssueModal <DeleteIssueModal
data={issue} data={issue}
isOpen={deleteIssueModal} isOpen={deleteIssueModal}
handleClose={() => setDeleteIssueModal(false)} handleClose={() => setDeleteIssueModal(false)}
onSubmit={handleDelete} onSubmit={handleDelete}
/> />
<CreateUpdateIssueModal <CreateUpdateIssueModal
isOpen={createUpdateIssueModal} isOpen={createUpdateIssueModal}
onClose={() => { onClose={() => {
@ -82,7 +107,6 @@ export const ProjectIssueQuickActions: React.FC<IQuickActionProps> = (props) =>
storeType={EIssuesStoreType.PROJECT} storeType={EIssuesStoreType.PROJECT}
isDraft={isDraftIssue} isDraft={isDraftIssue}
/> />
<CustomMenu <CustomMenu
placement="bottom-start" placement="bottom-start"
customButton={customActionButton} customButton={customActionButton}
@ -90,55 +114,81 @@ export const ProjectIssueQuickActions: React.FC<IQuickActionProps> = (props) =>
closeOnSelect closeOnSelect
ellipsis ellipsis
> >
<CustomMenu.MenuItem {isEditingAllowed && (
onClick={() => { <CustomMenu.MenuItem
handleCopyIssueLink(); onClick={() => {
}} setTrackElement(activeLayout);
> setIssueToEdit(issue);
setCreateUpdateIssueModal(true);
}}
>
<div className="flex items-center gap-2">
<Pencil className="h-3 w-3" />
Edit
</div>
</CustomMenu.MenuItem>
)}
<CustomMenu.MenuItem onClick={handleOpenInNewTab}>
<div className="flex items-center gap-2">
<ExternalLink className="h-3 w-3" />
Open in new tab
</div>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={handleCopyIssueLink}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Link className="h-3 w-3" /> <Link className="h-3 w-3" />
Copy link Copy link
</div> </div>
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
{isEditingAllowed && !readOnly && ( {isEditingAllowed && (
<> <CustomMenu.MenuItem
<CustomMenu.MenuItem onClick={() => {
onClick={() => { setTrackElement(activeLayout);
setTrackElement(activeLayout); setCreateUpdateIssueModal(true);
setIssueToEdit(issue); }}
setCreateUpdateIssueModal(true); >
}} <div className="flex items-center gap-2">
> <Copy className="h-3 w-3" />
Make a copy
</div>
</CustomMenu.MenuItem>
)}
{isArchivingAllowed && (
<CustomMenu.MenuItem onClick={() => setArchiveIssueModal(true)} disabled={!isInArchivableGroup}>
{isInArchivableGroup ? (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Pencil className="h-3 w-3" /> <ArchiveIcon className="h-3 w-3" />
Edit issue Archive
</div> </div>
</CustomMenu.MenuItem> ) : (
<CustomMenu.MenuItem <div className="flex items-start gap-2">
onClick={() => { <ArchiveIcon className="h-3 w-3" />
setTrackElement(activeLayout); <div className="-mt-1">
setCreateUpdateIssueModal(true); <p>Archive</p>
}} <p className="text-xs text-custom-text-400">
> Only completed or canceled
<div className="flex items-center gap-2"> <br />
<Copy className="h-3 w-3" /> issues can be archived
Make a copy </p>
</div>
</div> </div>
</CustomMenu.MenuItem> )}
<CustomMenu.MenuItem </CustomMenu.MenuItem>
onClick={() => { )}
setTrackElement(activeLayout); {isDeletingAllowed && (
setDeleteIssueModal(true); <CustomMenu.MenuItem
}} onClick={() => {
> setTrackElement(activeLayout);
<div className="flex items-center gap-2"> setDeleteIssueModal(true);
<Trash2 className="h-3 w-3" /> }}
Delete issue >
</div> <div className="flex items-center gap-2">
</CustomMenu.MenuItem> <Trash2 className="h-3 w-3" />
</> Delete
</div>
</CustomMenu.MenuItem>
)} )}
</CustomMenu> </CustomMenu>
</> </>
); );
}; });

View File

@ -34,7 +34,7 @@ export const AllIssueLayoutRoot: React.FC = observer(() => {
const { commandPalette: commandPaletteStore } = useApplication(); const { commandPalette: commandPaletteStore } = useApplication();
const { const {
issuesFilter: { filters, fetchFilters, updateFilters }, issuesFilter: { filters, fetchFilters, updateFilters },
issues: { loader, groupedIssueIds, fetchIssues, updateIssue, removeIssue }, issues: { loader, groupedIssueIds, fetchIssues, updateIssue, removeIssue, archiveIssue },
} = useIssues(EIssuesStoreType.GLOBAL); } = useIssues(EIssuesStoreType.GLOBAL);
const { dataViewId, issueIds } = groupedIssueIds; const { dataViewId, issueIds } = groupedIssueIds;
@ -138,6 +138,12 @@ export const AllIssueLayoutRoot: React.FC = observer(() => {
await removeIssue(workspaceSlug.toString(), projectId, issue.id, globalViewId.toString()); await removeIssue(workspaceSlug.toString(), projectId, issue.id, globalViewId.toString());
}, },
[EIssueActions.ARCHIVE]: async (issue: TIssue) => {
const projectId = issue.project_id;
if (!workspaceSlug || !projectId || !globalViewId) return;
await archiveIssue(workspaceSlug.toString(), projectId, issue.id, globalViewId.toString());
},
}), }),
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
[updateIssue, removeIssue, workspaceSlug] [updateIssue, removeIssue, workspaceSlug]
@ -147,6 +153,7 @@ export const AllIssueLayoutRoot: React.FC = observer(() => {
async (issue: TIssue, action: EIssueActions) => { async (issue: TIssue, action: EIssueActions) => {
if (action === EIssueActions.UPDATE) await issueActions[action]!(issue); if (action === EIssueActions.UPDATE) await issueActions[action]!(issue);
if (action === EIssueActions.DELETE) await issueActions[action]!(issue); if (action === EIssueActions.DELETE) await issueActions[action]!(issue);
if (action === EIssueActions.ARCHIVE) await issueActions[action]!(issue);
}, },
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
[] []
@ -174,10 +181,12 @@ export const AllIssueLayoutRoot: React.FC = observer(() => {
issue={issue} issue={issue}
handleUpdate={async () => handleIssues({ ...issue }, EIssueActions.UPDATE)} handleUpdate={async () => handleIssues({ ...issue }, EIssueActions.UPDATE)}
handleDelete={async () => handleIssues(issue, EIssueActions.DELETE)} handleDelete={async () => handleIssues(issue, EIssueActions.DELETE)}
handleArchive={async () => handleIssues(issue, EIssueActions.ARCHIVE)}
portalElement={portalElement} portalElement={portalElement}
readOnly={!canEditProperties(issue.project_id)}
/> />
), ),
[handleIssues] [canEditProperties, handleIssues]
); );
const isEditingAllowed = !!currentWorkspaceRole && currentWorkspaceRole >= EUserWorkspaceRoles.MEMBER; const isEditingAllowed = !!currentWorkspaceRole && currentWorkspaceRole >= EUserWorkspaceRoles.MEMBER;

View File

@ -26,6 +26,8 @@ interface IBaseSpreadsheetRoot {
[EIssueActions.DELETE]: (issue: TIssue) => void; [EIssueActions.DELETE]: (issue: TIssue) => void;
[EIssueActions.UPDATE]?: (issue: TIssue) => void; [EIssueActions.UPDATE]?: (issue: TIssue) => void;
[EIssueActions.REMOVE]?: (issue: TIssue) => void; [EIssueActions.REMOVE]?: (issue: TIssue) => void;
[EIssueActions.ARCHIVE]?: (issue: TIssue) => void;
[EIssueActions.RESTORE]?: (issue: TIssue) => Promise<void>;
}; };
canEditPropertiesBasedOnProject?: (projectId: string) => boolean; canEditPropertiesBasedOnProject?: (projectId: string) => boolean;
isCompletedCycle?: boolean; isCompletedCycle?: boolean;
@ -103,6 +105,12 @@ export const BaseSpreadsheetRoot = observer((props: IBaseSpreadsheetRoot) => {
handleRemoveFromView={ handleRemoveFromView={
issueActions[EIssueActions.REMOVE] ? async () => handleIssues(issue, EIssueActions.REMOVE) : undefined issueActions[EIssueActions.REMOVE] ? async () => handleIssues(issue, EIssueActions.REMOVE) : undefined
} }
handleArchive={
issueActions[EIssueActions.ARCHIVE] ? async () => handleIssues(issue, EIssueActions.ARCHIVE) : undefined
}
handleRestore={
issueActions[EIssueActions.RESTORE] ? async () => handleIssues(issue, EIssueActions.RESTORE) : undefined
}
portalElement={portalElement} portalElement={portalElement}
readOnly={!isEditingAllowed || isCompletedCycle} readOnly={!isEditingAllowed || isCompletedCycle}
/> />

View File

@ -216,11 +216,9 @@ const IssueRowDetails = observer((props: IssueRowDetailsProps) => {
{getProjectIdentifierById(issueDetail.project_id)}-{issueDetail.sequence_id} {getProjectIdentifierById(issueDetail.project_id)}-{issueDetail.sequence_id}
</span> </span>
{canEditProperties(issueDetail.project_id) && ( <div className={`absolute left-2.5 top-0 hidden group-hover:block ${isMenuActive ? "!block" : ""}`}>
<div className={`absolute left-2.5 top-0 hidden group-hover:block ${isMenuActive ? "!block" : ""}`}> {quickActions(issueDetail, customActionButton, portalElement.current)}
{quickActions(issueDetail, customActionButton, portalElement.current)} </div>
</div>
)}
</div> </div>
{issueDetail.sub_issues_count > 0 && ( {issueDetail.sub_issues_count > 0 && (

View File

@ -32,6 +32,10 @@ export const CycleSpreadsheetLayout: React.FC = observer(() => {
if (!workspaceSlug || !cycleId) return; if (!workspaceSlug || !cycleId) return;
issues.removeIssueFromCycle(workspaceSlug, issue.project_id, cycleId, issue.id); issues.removeIssueFromCycle(workspaceSlug, issue.project_id, cycleId, issue.id);
}, },
[EIssueActions.ARCHIVE]: async (issue: TIssue) => {
if (!workspaceSlug || !cycleId) return;
issues.archiveIssue(workspaceSlug, issue.project_id, issue.id, cycleId);
},
}), }),
[issues, workspaceSlug, cycleId] [issues, workspaceSlug, cycleId]
); );

View File

@ -31,6 +31,10 @@ export const ModuleSpreadsheetLayout: React.FC = observer(() => {
if (!workspaceSlug || !moduleId) return; if (!workspaceSlug || !moduleId) return;
issues.removeIssueFromModule(workspaceSlug, issue.project_id, moduleId, issue.id); issues.removeIssueFromModule(workspaceSlug, issue.project_id, moduleId, issue.id);
}, },
[EIssueActions.ARCHIVE]: async (issue: TIssue) => {
if (!workspaceSlug || !moduleId) return;
issues.archiveIssue(workspaceSlug, issue.project_id, issue.id, moduleId);
},
}), }),
[issues, workspaceSlug, moduleId] [issues, workspaceSlug, moduleId]
); );

View File

@ -28,6 +28,11 @@ export const ProjectSpreadsheetLayout: React.FC = observer(() => {
await issues.removeIssue(workspaceSlug, issue.project_id, issue.id); await issues.removeIssue(workspaceSlug, issue.project_id, issue.id);
}, },
[EIssueActions.ARCHIVE]: async (issue: TIssue) => {
if (!workspaceSlug) return;
await issues.archiveIssue(workspaceSlug, issue.project_id, issue.id);
},
}), }),
[issues, workspaceSlug] [issues, workspaceSlug]
); );

View File

@ -17,6 +17,7 @@ export interface IViewSpreadsheetLayout {
[EIssueActions.DELETE]: (issue: TIssue) => Promise<void>; [EIssueActions.DELETE]: (issue: TIssue) => Promise<void>;
[EIssueActions.UPDATE]?: (issue: TIssue) => Promise<void>; [EIssueActions.UPDATE]?: (issue: TIssue) => Promise<void>;
[EIssueActions.REMOVE]?: (issue: TIssue) => Promise<void>; [EIssueActions.REMOVE]?: (issue: TIssue) => Promise<void>;
[EIssueActions.ARCHIVE]?: (issue: TIssue) => Promise<void>;
}; };
} }

View File

@ -2,4 +2,6 @@ export enum EIssueActions {
UPDATE = "update", UPDATE = "update",
DELETE = "delete", DELETE = "delete",
REMOVE = "remove", REMOVE = "remove",
ARCHIVE = "archive",
RESTORE = "restore",
} }

View File

@ -1,17 +1,20 @@
import { FC } from "react"; import { FC } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { MoveRight, MoveDiagonal, Link2, Trash2 } from "lucide-react"; import { MoveRight, MoveDiagonal, Link2, Trash2, RotateCcw } from "lucide-react";
// ui // ui
import { CenterPanelIcon, CustomSelect, FullScreenPanelIcon, SidePanelIcon } from "@plane/ui"; import { ArchiveIcon, CenterPanelIcon, CustomSelect, FullScreenPanelIcon, SidePanelIcon, Tooltip } from "@plane/ui";
// helpers // helpers
import { copyUrlToClipboard } from "helpers/string.helper"; import { copyUrlToClipboard } from "helpers/string.helper";
// hooks // hooks
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
// store hooks // store hooks
import { useUser } from "hooks/store"; import { useIssueDetail, useProjectState, useUser } from "hooks/store";
// helpers
import { cn } from "helpers/common.helper";
// components // components
import { IssueSubscription, IssueUpdateStatus } from "components/issues"; import { IssueSubscription, IssueUpdateStatus } from "components/issues";
import { STATE_GROUPS } from "constants/state";
export type TPeekModes = "side-peek" | "modal" | "full-screen"; export type TPeekModes = "side-peek" | "modal" | "full-screen";
@ -43,6 +46,8 @@ export type PeekOverviewHeaderProps = {
isArchived: boolean; isArchived: boolean;
disabled: boolean; disabled: boolean;
toggleDeleteIssueModal: (value: boolean) => void; toggleDeleteIssueModal: (value: boolean) => void;
toggleArchiveIssueModal: (value: boolean) => void;
handleRestoreIssue: () => void;
isSubmitting: "submitting" | "submitted" | "saved"; isSubmitting: "submitting" | "submitted" | "saved";
}; };
@ -57,23 +62,31 @@ export const IssuePeekOverviewHeader: FC<PeekOverviewHeaderProps> = observer((pr
disabled, disabled,
removeRoutePeekId, removeRoutePeekId,
toggleDeleteIssueModal, toggleDeleteIssueModal,
toggleArchiveIssueModal,
handleRestoreIssue,
isSubmitting, isSubmitting,
} = props; } = props;
// router // router
const router = useRouter(); const router = useRouter();
// store hooks // store hooks
const { currentUser } = useUser(); const { currentUser } = useUser();
const {
issue: { getIssueById },
} = useIssueDetail();
const { getStateById } = useProjectState();
// hooks // hooks
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
// derived values // derived values
const issueDetails = getIssueById(issueId);
const stateDetails = issueDetails ? getStateById(issueDetails?.state_id) : undefined;
const currentMode = PEEK_OPTIONS.find((m) => m.key === peekMode); const currentMode = PEEK_OPTIONS.find((m) => m.key === peekMode);
const issueLink = `${workspaceSlug}/projects/${projectId}/${isArchived ? "archived-issues" : "issues"}/${issueId}`;
const handleCopyText = (e: React.MouseEvent<HTMLButtonElement>) => { const handleCopyText = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
copyUrlToClipboard( copyUrlToClipboard(issueLink).then(() => {
`${workspaceSlug}/projects/${projectId}/${isArchived ? "archived-issues" : "issues"}/${issueId}`
).then(() => {
setToastAlert({ setToastAlert({
type: "success", type: "success",
title: "Link Copied!", title: "Link Copied!",
@ -81,13 +94,15 @@ export const IssuePeekOverviewHeader: FC<PeekOverviewHeaderProps> = observer((pr
}); });
}); });
}; };
const redirectToIssueDetail = () => { const redirectToIssueDetail = () => {
router.push({ router.push({ pathname: `/${issueLink}` });
pathname: `/${workspaceSlug}/projects/${projectId}/${isArchived ? "archived-issues" : "issues"}/${issueId}`,
});
removeRoutePeekId(); removeRoutePeekId();
}; };
// auth
const isArchivingAllowed = !isArchived && !disabled;
const isInArchivableGroup =
!!stateDetails && [STATE_GROUPS.completed.key, STATE_GROUPS.cancelled.key].includes(stateDetails?.group);
const isRestoringAllowed = isArchived && !disabled;
return ( return (
<div <div
@ -97,11 +112,11 @@ export const IssuePeekOverviewHeader: FC<PeekOverviewHeaderProps> = observer((pr
> >
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<button onClick={removeRoutePeekId}> <button onClick={removeRoutePeekId}>
<MoveRight className="h-4 w-4 text-custom-text-400 hover:text-custom-text-200" /> <MoveRight className="h-4 w-4 text-custom-text-300 hover:text-custom-text-200" />
</button> </button>
<button onClick={redirectToIssueDetail}> <button onClick={redirectToIssueDetail}>
<MoveDiagonal className="h-4 w-4 text-custom-text-400 hover:text-custom-text-200" /> <MoveDiagonal className="h-4 w-4 text-custom-text-300 hover:text-custom-text-200" />
</button> </button>
{currentMode && ( {currentMode && (
<div className="flex flex-shrink-0 items-center gap-2"> <div className="flex flex-shrink-0 items-center gap-2">
@ -110,7 +125,7 @@ export const IssuePeekOverviewHeader: FC<PeekOverviewHeaderProps> = observer((pr
onChange={(val: any) => setPeekMode(val)} onChange={(val: any) => setPeekMode(val)}
customButton={ customButton={
<button type="button" className=""> <button type="button" className="">
<currentMode.icon className="h-4 w-4 text-custom-text-400 hover:text-custom-text-200" /> <currentMode.icon className="h-4 w-4 text-custom-text-300 hover:text-custom-text-200" />
</button> </button>
} }
> >
@ -138,13 +153,43 @@ export const IssuePeekOverviewHeader: FC<PeekOverviewHeaderProps> = observer((pr
{currentUser && !isArchived && ( {currentUser && !isArchived && (
<IssueSubscription workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} /> <IssueSubscription workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} />
)} )}
<button onClick={handleCopyText}> <Tooltip tooltipContent="Copy link">
<Link2 className="h-4 w-4 -rotate-45 text-custom-text-300 hover:text-custom-text-200" /> <button type="button" onClick={handleCopyText}>
</button> <Link2 className="h-4 w-4 -rotate-45 text-custom-text-300 hover:text-custom-text-200" />
{!disabled && (
<button onClick={() => toggleDeleteIssueModal(true)}>
<Trash2 className="h-4 w-4 text-custom-text-300 hover:text-custom-text-200" />
</button> </button>
</Tooltip>
{isArchivingAllowed && (
<Tooltip
tooltipContent={isInArchivableGroup ? "Archive" : "Only completed or canceled issues can be archived"}
>
<button
type="button"
className={cn("text-custom-text-300", {
"hover:text-custom-text-200": isInArchivableGroup,
"cursor-not-allowed text-custom-text-400": !isInArchivableGroup,
})}
onClick={() => {
if (!isInArchivableGroup) return;
toggleArchiveIssueModal(true);
}}
>
<ArchiveIcon className="h-4 w-4" />
</button>
</Tooltip>
)}
{isRestoringAllowed && (
<Tooltip tooltipContent="Restore">
<button type="button" onClick={handleRestoreIssue}>
<RotateCcw className="h-4 w-4 text-custom-text-300 hover:text-custom-text-200" />
</button>
</Tooltip>
)}
{!disabled && (
<Tooltip tooltipContent="Delete">
<button type="button" onClick={() => toggleDeleteIssueModal(true)}>
<Trash2 className="h-4 w-4 text-custom-text-300 hover:text-custom-text-200" />
</button>
</Tooltip>
)} )}
</div> </div>
</div> </div>

View File

@ -1,4 +1,4 @@
import { FC, Fragment, useEffect, useState, useMemo } from "react"; import { FC, useEffect, useState, useMemo } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
// hooks // hooks
@ -11,7 +11,7 @@ import { TIssue } from "@plane/types";
// constants // constants
import { EUserProjectRoles } from "constants/project"; import { EUserProjectRoles } from "constants/project";
import { EIssuesStoreType } from "constants/issue"; import { EIssuesStoreType } from "constants/issue";
import { ISSUE_UPDATED, ISSUE_DELETED } from "constants/event-tracker"; import { ISSUE_UPDATED, ISSUE_DELETED, ISSUE_ARCHIVED, ISSUE_RESTORED } from "constants/event-tracker";
interface IIssuePeekOverview { interface IIssuePeekOverview {
is_archived?: boolean; is_archived?: boolean;
@ -28,6 +28,8 @@ export type TIssuePeekOperations = {
showToast?: boolean showToast?: boolean
) => Promise<void>; ) => Promise<void>;
remove: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>; remove: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>;
archive: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>;
restore: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>;
addIssueToCycle: (workspaceSlug: string, projectId: string, cycleId: string, issueIds: string[]) => Promise<void>; addIssueToCycle: (workspaceSlug: string, projectId: string, cycleId: string, issueIds: string[]) => Promise<void>;
removeIssueFromCycle: (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => Promise<void>; removeIssueFromCycle: (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => Promise<void>;
addModulesToIssue?: (workspaceSlug: string, projectId: string, issueId: string, moduleIds: string[]) => Promise<void>; addModulesToIssue?: (workspaceSlug: string, projectId: string, issueId: string, moduleIds: string[]) => Promise<void>;
@ -55,12 +57,13 @@ export const IssuePeekOverview: FC<IIssuePeekOverview> = observer((props) => {
membership: { currentWorkspaceAllProjectsRole }, membership: { currentWorkspaceAllProjectsRole },
} = useUser(); } = useUser();
const { const {
issues: { removeIssue: removeArchivedIssue }, issues: { restoreIssue },
} = useIssues(EIssuesStoreType.ARCHIVED); } = useIssues(EIssuesStoreType.ARCHIVED);
const { const {
peekIssue, peekIssue,
updateIssue, updateIssue,
removeIssue, removeIssue,
archiveIssue,
issue: { getIssueById, fetchIssue }, issue: { getIssueById, fetchIssue },
} = useIssueDetail(); } = useIssueDetail();
const { addIssueToCycle, removeIssueFromCycle, addModulesToIssue, removeIssueFromModule, removeModulesFromIssue } = const { addIssueToCycle, removeIssueFromCycle, addModulesToIssue, removeIssueFromModule, removeModulesFromIssue } =
@ -91,7 +94,7 @@ export const IssuePeekOverview: FC<IIssuePeekOverview> = observer((props) => {
showToast: boolean = true showToast: boolean = true
) => { ) => {
try { try {
const response = await updateIssue(workspaceSlug, projectId, issueId, data); await updateIssue(workspaceSlug, projectId, issueId, data);
if (showToast) if (showToast)
setToastAlert({ setToastAlert({
title: "Issue updated successfully", title: "Issue updated successfully",
@ -122,9 +125,7 @@ export const IssuePeekOverview: FC<IIssuePeekOverview> = observer((props) => {
}, },
remove: async (workspaceSlug: string, projectId: string, issueId: string) => { remove: async (workspaceSlug: string, projectId: string, issueId: string) => {
try { try {
let response; removeIssue(workspaceSlug, projectId, issueId);
if (is_archived) response = await removeArchivedIssue(workspaceSlug, projectId, issueId);
else response = await removeIssue(workspaceSlug, projectId, issueId);
setToastAlert({ setToastAlert({
title: "Issue deleted successfully", title: "Issue deleted successfully",
type: "success", type: "success",
@ -148,6 +149,58 @@ export const IssuePeekOverview: FC<IIssuePeekOverview> = observer((props) => {
}); });
} }
}, },
archive: async (workspaceSlug: string, projectId: string, issueId: string) => {
try {
await archiveIssue(workspaceSlug, projectId, issueId);
setToastAlert({
type: "success",
title: "Success!",
message: "Issue archived successfully.",
});
captureIssueEvent({
eventName: ISSUE_ARCHIVED,
payload: { id: issueId, state: "SUCCESS", element: "Issue peek-overview" },
path: router.asPath,
});
} catch (error) {
setToastAlert({
type: "error",
title: "Error!",
message: "Issue could not be archived. Please try again.",
});
captureIssueEvent({
eventName: ISSUE_ARCHIVED,
payload: { id: issueId, state: "FAILED", element: "Issue peek-overview" },
path: router.asPath,
});
}
},
restore: async (workspaceSlug: string, projectId: string, issueId: string) => {
try {
await restoreIssue(workspaceSlug, projectId, issueId);
setToastAlert({
type: "success",
title: "Success!",
message: "Issue restored successfully.",
});
captureIssueEvent({
eventName: ISSUE_RESTORED,
payload: { id: issueId, state: "SUCCESS", element: "Issue peek-overview" },
path: router.asPath,
});
} catch (error) {
setToastAlert({
type: "error",
title: "Error!",
message: "Issue could not be restored. Please try again.",
});
captureIssueEvent({
eventName: ISSUE_RESTORED,
payload: { id: issueId, state: "FAILED", element: "Issue peek-overview" },
path: router.asPath,
});
}
},
addIssueToCycle: async (workspaceSlug: string, projectId: string, cycleId: string, issueIds: string[]) => { addIssueToCycle: async (workspaceSlug: string, projectId: string, cycleId: string, issueIds: string[]) => {
try { try {
await addIssueToCycle(workspaceSlug, projectId, cycleId, issueIds); await addIssueToCycle(workspaceSlug, projectId, cycleId, issueIds);
@ -312,7 +365,8 @@ export const IssuePeekOverview: FC<IIssuePeekOverview> = observer((props) => {
fetchIssue, fetchIssue,
updateIssue, updateIssue,
removeIssue, removeIssue,
removeArchivedIssue, archiveIssue,
restoreIssue,
addIssueToCycle, addIssueToCycle,
removeIssueFromCycle, removeIssueFromCycle,
addModulesToIssue, addModulesToIssue,
@ -343,16 +397,14 @@ export const IssuePeekOverview: FC<IIssuePeekOverview> = observer((props) => {
const isLoading = !issue || loader ? true : false; const isLoading = !issue || loader ? true : false;
return ( return (
<Fragment> <IssueView
<IssueView workspaceSlug={peekIssue.workspaceSlug}
workspaceSlug={peekIssue.workspaceSlug} projectId={peekIssue.projectId}
projectId={peekIssue.projectId} issueId={peekIssue.issueId}
issueId={peekIssue.issueId} isLoading={isLoading}
isLoading={isLoading} is_archived={is_archived}
is_archived={is_archived} disabled={!is_editable}
disabled={is_archived || !is_editable} issueOperations={issueOperations}
issueOperations={issueOperations} />
/>
</Fragment>
); );
}); });

View File

@ -10,13 +10,13 @@ import useToast from "hooks/use-toast";
import { useIssueDetail } from "hooks/store"; import { useIssueDetail } from "hooks/store";
// components // components
import { import {
DeleteArchivedIssueModal,
DeleteIssueModal, DeleteIssueModal,
IssuePeekOverviewHeader, IssuePeekOverviewHeader,
TPeekModes, TPeekModes,
PeekOverviewIssueDetails, PeekOverviewIssueDetails,
PeekOverviewProperties, PeekOverviewProperties,
TIssueOperations, TIssueOperations,
ArchiveIssueModal,
} from "components/issues"; } from "components/issues";
import { IssueActivity } from "../issue-detail/issue-activity"; import { IssueActivity } from "../issue-detail/issue-activity";
// ui // ui
@ -44,7 +44,9 @@ export const IssueView: FC<IIssueView> = observer((props) => {
setPeekIssue, setPeekIssue,
isAnyModalOpen, isAnyModalOpen,
isDeleteIssueModalOpen, isDeleteIssueModalOpen,
isArchiveIssueModalOpen,
toggleDeleteIssueModal, toggleDeleteIssueModal,
toggleArchiveIssueModal,
issue: { getIssueById }, issue: { getIssueById },
} = useIssueDetail(); } = useIssueDetail();
const issue = getIssueById(issueId); const issue = getIssueById(issueId);
@ -69,8 +71,26 @@ export const IssueView: FC<IIssueView> = observer((props) => {
}; };
useKeypress("Escape", handleKeyDown); useKeypress("Escape", handleKeyDown);
const handleRestore = async () => {
if (!issueOperations.restore) return;
await issueOperations.restore(workspaceSlug, projectId, issueId);
removeRoutePeekId();
};
return ( return (
<> <>
{issue && !is_archived && (
<ArchiveIssueModal
isOpen={isArchiveIssueModalOpen}
handleClose={() => toggleArchiveIssueModal(false)}
data={issue}
onSubmit={async () => {
if (issueOperations.archive) await issueOperations.archive(workspaceSlug, projectId, issueId);
removeRoutePeekId();
}}
/>
)}
{issue && !is_archived && ( {issue && !is_archived && (
<DeleteIssueModal <DeleteIssueModal
isOpen={isDeleteIssueModalOpen} isOpen={isDeleteIssueModalOpen}
@ -84,7 +104,7 @@ export const IssueView: FC<IIssueView> = observer((props) => {
)} )}
{issue && is_archived && ( {issue && is_archived && (
<DeleteArchivedIssueModal <DeleteIssueModal
data={issue} data={issue}
isOpen={isDeleteIssueModalOpen} isOpen={isDeleteIssueModalOpen}
handleClose={() => toggleDeleteIssueModal(false)} handleClose={() => toggleDeleteIssueModal(false)}
@ -109,11 +129,11 @@ export const IssueView: FC<IIssueView> = observer((props) => {
{/* header */} {/* header */}
<IssuePeekOverviewHeader <IssuePeekOverviewHeader
peekMode={peekMode} peekMode={peekMode}
setPeekMode={(value: TPeekModes) => { setPeekMode={(value) => setPeekMode(value)}
setPeekMode(value);
}}
removeRoutePeekId={removeRoutePeekId} removeRoutePeekId={removeRoutePeekId}
toggleDeleteIssueModal={toggleDeleteIssueModal} toggleDeleteIssueModal={toggleDeleteIssueModal}
toggleArchiveIssueModal={toggleArchiveIssueModal}
handleRestoreIssue={handleRestore}
isArchived={is_archived} isArchived={is_archived}
issueId={issueId} issueId={issueId}
workspaceSlug={workspaceSlug} workspaceSlug={workspaceSlug}
@ -137,7 +157,7 @@ export const IssueView: FC<IIssueView> = observer((props) => {
projectId={projectId} projectId={projectId}
issueId={issueId} issueId={issueId}
issueOperations={issueOperations} issueOperations={issueOperations}
disabled={disabled} disabled={disabled || is_archived}
isSubmitting={isSubmitting} isSubmitting={isSubmitting}
setIsSubmitting={(value) => setIsSubmitting(value)} setIsSubmitting={(value) => setIsSubmitting(value)}
/> />
@ -147,7 +167,7 @@ export const IssueView: FC<IIssueView> = observer((props) => {
projectId={projectId} projectId={projectId}
issueId={issueId} issueId={issueId}
issueOperations={issueOperations} issueOperations={issueOperations}
disabled={disabled} disabled={disabled || is_archived}
/> />
<IssueActivity workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} /> <IssueActivity workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} />
@ -161,7 +181,7 @@ export const IssueView: FC<IIssueView> = observer((props) => {
projectId={projectId} projectId={projectId}
issueId={issueId} issueId={issueId}
issueOperations={issueOperations} issueOperations={issueOperations}
disabled={disabled} disabled={disabled || is_archived}
isSubmitting={isSubmitting} isSubmitting={isSubmitting}
setIsSubmitting={(value) => setIsSubmitting(value)} setIsSubmitting={(value) => setIsSubmitting(value)}
/> />
@ -179,7 +199,7 @@ export const IssueView: FC<IIssueView> = observer((props) => {
projectId={projectId} projectId={projectId}
issueId={issueId} issueId={issueId}
issueOperations={issueOperations} issueOperations={issueOperations}
disabled={disabled} disabled={disabled || is_archived}
/> />
</div> </div>
</div> </div>

View File

@ -49,7 +49,7 @@ export const NotificationCard: React.FC<NotificationCardProps> = (props) => {
const router = useRouter(); const router = useRouter();
const { workspaceSlug } = router.query; const { workspaceSlug } = router.query;
// states // states
const [showSnoozeOptions, setshowSnoozeOptions] = React.useState(false); const [showSnoozeOptions, setShowSnoozeOptions] = React.useState(false);
// toast alert // toast alert
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
// refs // refs
@ -105,7 +105,7 @@ export const NotificationCard: React.FC<NotificationCardProps> = (props) => {
useEffect(() => { useEffect(() => {
const handleClickOutside = (event: any) => { const handleClickOutside = (event: any) => {
if (snoozeRef.current && !snoozeRef.current?.contains(event.target)) { if (snoozeRef.current && !snoozeRef.current?.contains(event.target)) {
setshowSnoozeOptions(false); setShowSnoozeOptions(false);
} }
}; };
document.addEventListener("mousedown", handleClickOutside, true); document.addEventListener("mousedown", handleClickOutside, true);
@ -116,6 +116,9 @@ export const NotificationCard: React.FC<NotificationCardProps> = (props) => {
}; };
}, []); }, []);
const notificationField = notification.data.issue_activity.field;
const notificationTriggeredBy = notification.triggered_by_details;
if (isSnoozedTabOpen && new Date(notification.snoozed_till!) < new Date()) return null; if (isSnoozedTabOpen && new Date(notification.snoozed_till!) < new Date()) return null;
return ( return (
@ -129,7 +132,7 @@ export const NotificationCard: React.FC<NotificationCardProps> = (props) => {
closePopover(); closePopover();
}} }}
href={`/${workspaceSlug}/projects/${notification.project}/${ href={`/${workspaceSlug}/projects/${notification.project}/${
notification.data.issue_activity.field === "archived_at" ? "archived-issues" : "issues" notificationField === "archived_at" ? "archived-issues" : "issues"
}/${notification.data.issue.id}`} }/${notification.data.issue.id}`}
className={`group relative flex w-full cursor-pointer items-center gap-4 p-3 pl-6 ${ className={`group relative flex w-full cursor-pointer items-center gap-4 p-3 pl-6 ${
notification.read_at === null ? "bg-custom-primary-70/5" : "hover:bg-custom-background-200" notification.read_at === null ? "bg-custom-primary-70/5" : "hover:bg-custom-background-200"
@ -139,10 +142,10 @@ export const NotificationCard: React.FC<NotificationCardProps> = (props) => {
<span className="absolute left-2 top-1/2 h-1.5 w-1.5 -translate-y-1/2 rounded-full bg-custom-primary-100" /> <span className="absolute left-2 top-1/2 h-1.5 w-1.5 -translate-y-1/2 rounded-full bg-custom-primary-100" />
)} )}
<div className="relative h-12 w-12 rounded-full"> <div className="relative h-12 w-12 rounded-full">
{notification.triggered_by_details.avatar && notification.triggered_by_details.avatar !== "" ? ( {notificationTriggeredBy.avatar && notificationTriggeredBy.avatar !== "" ? (
<div className="h-12 w-12 rounded-full"> <div className="h-12 w-12 rounded-full">
<Image <Image
src={notification.triggered_by_details.avatar} src={notificationTriggeredBy.avatar}
alt="Profile Image" alt="Profile Image"
layout="fill" layout="fill"
objectFit="cover" objectFit="cover"
@ -152,10 +155,10 @@ export const NotificationCard: React.FC<NotificationCardProps> = (props) => {
) : ( ) : (
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-custom-background-80"> <div className="flex h-12 w-12 items-center justify-center rounded-full bg-custom-background-80">
<span className="text-lg font-medium text-custom-text-100"> <span className="text-lg font-medium text-custom-text-100">
{notification.triggered_by_details.is_bot ? ( {notificationTriggeredBy.is_bot ? (
notification.triggered_by_details.first_name?.[0]?.toUpperCase() notificationTriggeredBy.first_name?.[0]?.toUpperCase()
) : notification.triggered_by_details.display_name?.[0] ? ( ) : notificationTriggeredBy.display_name?.[0] ? (
notification.triggered_by_details.display_name?.[0]?.toUpperCase() notificationTriggeredBy.display_name?.[0]?.toUpperCase()
) : ( ) : (
<User2 className="h-4 w-4" /> <User2 className="h-4 w-4" />
)} )}
@ -168,30 +171,32 @@ export const NotificationCard: React.FC<NotificationCardProps> = (props) => {
{!notification.message ? ( {!notification.message ? (
<div className="w-full break-words text-sm"> <div className="w-full break-words text-sm">
<span className="font-semibold"> <span className="font-semibold">
{notification.triggered_by_details.is_bot {notificationTriggeredBy.is_bot
? notification.triggered_by_details.first_name ? notificationTriggeredBy.first_name
: notification.triggered_by_details.display_name}{" "} : notificationTriggeredBy.display_name}{" "}
</span> </span>
{notification.data.issue_activity.field !== "comment" && notification.data.issue_activity.verb}{" "} {!["comment", "archived_at"].includes(notificationField) && notification.data.issue_activity.verb}{" "}
{notification.data.issue_activity.field === "comment" {notificationField === "comment"
? "commented" ? "commented"
: notification.data.issue_activity.field === "None" : notificationField === "archived_at"
? notification.data.issue_activity.new_value === "restore"
? "restored the issue"
: "archived the issue"
: notificationField === "None"
? null ? null
: replaceUnderscoreIfSnakeCase(notification.data.issue_activity.field)}{" "} : replaceUnderscoreIfSnakeCase(notificationField)}{" "}
{notification.data.issue_activity.field !== "comment" && notification.data.issue_activity.field !== "None" {!["comment", "archived_at", "None"].includes(notificationField) ? "to" : ""}
? "to"
: ""}
<span className="font-semibold"> <span className="font-semibold">
{" "} {" "}
{notification.data.issue_activity.field !== "None" ? ( {notificationField !== "None" ? (
notification.data.issue_activity.field !== "comment" ? ( notificationField !== "comment" ? (
notification.data.issue_activity.field === "target_date" ? ( notificationField === "target_date" ? (
renderFormattedDate(notification.data.issue_activity.new_value) renderFormattedDate(notification.data.issue_activity.new_value)
) : notification.data.issue_activity.field === "attachment" ? ( ) : notificationField === "attachment" ? (
"the issue" "the issue"
) : notification.data.issue_activity.field === "description" ? ( ) : notificationField === "description" ? (
stripAndTruncateHTML(notification.data.issue_activity.new_value, 55) stripAndTruncateHTML(notification.data.issue_activity.new_value, 55)
) : ( ) : notificationField === "archived_at" ? null : (
notification.data.issue_activity.new_value notification.data.issue_activity.new_value
) )
) : ( ) : (
@ -255,7 +260,7 @@ export const NotificationCard: React.FC<NotificationCardProps> = (props) => {
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
setshowSnoozeOptions(true); setShowSnoozeOptions(true);
}} }}
className="flex gap-x-2 items-center p-1.5" className="flex gap-x-2 items-center p-1.5"
> >
@ -280,7 +285,7 @@ export const NotificationCard: React.FC<NotificationCardProps> = (props) => {
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
setshowSnoozeOptions(false); setShowSnoozeOptions(false);
snoozeOptionOnClick(item.value); snoozeOptionOnClick(item.value);
}} }}
> >

View File

@ -84,7 +84,7 @@ export const ProjectSidebarListItem: React.FC<Props> = observer((props) => {
// store hooks // store hooks
const { theme: themeStore } = useApplication(); const { theme: themeStore } = useApplication();
const { setTrackElement } = useEventTracker(); const { setTrackElement } = useEventTracker();
const { currentProjectDetails, addProjectToFavorites, removeProjectFromFavorites, getProjectById } = useProject(); const { addProjectToFavorites, removeProjectFromFavorites, getProjectById } = useProject();
const { getInboxesByProjectId, getInboxById } = useInbox(); const { getInboxesByProjectId, getInboxById } = useInbox();
// states // states
const [leaveProjectModalOpen, setLeaveProjectModal] = useState(false); const [leaveProjectModalOpen, setLeaveProjectModal] = useState(false);
@ -271,13 +271,12 @@ export const ProjectSidebarListItem: React.FC<Props> = observer((props) => {
</div> </div>
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
)} )}
{!isViewerOrGuest && (
{project.archive_in > 0 && (
<CustomMenu.MenuItem> <CustomMenu.MenuItem>
<Link href={`/${workspaceSlug}/projects/${project?.id}/archived-issues/`}> <Link href={`/${workspaceSlug}/projects/${project?.id}/archived-issues/`}>
<div className="flex items-center justify-start gap-2"> <div className="flex items-center justify-start gap-2">
<ArchiveIcon className="h-3.5 w-3.5 stroke-[1.5]" /> <ArchiveIcon className="h-3.5 w-3.5 stroke-[1.5]" />
<span>Archived Issues</span> <span>Archived issues</span>
</div> </div>
</Link> </Link>
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
@ -286,7 +285,7 @@ export const ProjectSidebarListItem: React.FC<Props> = observer((props) => {
<Link href={`/${workspaceSlug}/projects/${project?.id}/draft-issues/`}> <Link href={`/${workspaceSlug}/projects/${project?.id}/draft-issues/`}>
<div className="flex items-center justify-start gap-2"> <div className="flex items-center justify-start gap-2">
<PenSquare className="h-3.5 w-3.5 stroke-[1.5] text-custom-text-300" /> <PenSquare className="h-3.5 w-3.5 stroke-[1.5] text-custom-text-300" />
<span>Draft Issues</span> <span>Draft issues</span>
</div> </div>
</Link> </Link>
</CustomMenu.MenuItem> </CustomMenu.MenuItem>

View File

@ -244,9 +244,9 @@ export const EMPTY_ISSUE_STATE_DETAILS = {
key: "archived", key: "archived",
title: "No archived issues yet", title: "No archived issues yet",
description: description:
"Archived issues help you remove issues you completed or cancelled from focus. You can set automation to auto archive issues and find them here.", "Archived issues help you remove issues you completed or canceled from focus. You can set automation to auto archive issues and find them here.",
primaryButton: { primaryButton: {
text: "Set Automation", text: "Set automation",
}, },
}, },
draft: { draft: {

View File

@ -127,20 +127,18 @@ export const getIssueEventPayload = (props: IssueEventProps) => {
return eventPayload; return eventPayload;
}; };
export const getProjectStateEventPayload = (payload: any) => { export const getProjectStateEventPayload = (payload: any) => ({
return { workspace_id: payload.workspace_id,
workspace_id: payload.workspace_id, project_id: payload.id,
project_id: payload.id, state_id: payload.id,
state_id: payload.id, created_at: payload.created_at,
created_at: payload.created_at, updated_at: payload.updated_at,
updated_at: payload.updated_at, group: payload.group,
group: payload.group, color: payload.color,
color: payload.color, default: payload.default,
default: payload.default, state: payload.state,
state: payload.state, element: payload.element,
element: payload.element, });
};
};
// Workspace crud Events // Workspace crud Events
export const WORKSPACE_CREATED = "Workspace created"; export const WORKSPACE_CREATED = "Workspace created";
@ -169,6 +167,8 @@ export const MODULE_LINK_DELETED = "Module link deleted";
export const ISSUE_CREATED = "Issue created"; export const ISSUE_CREATED = "Issue created";
export const ISSUE_UPDATED = "Issue updated"; export const ISSUE_UPDATED = "Issue updated";
export const ISSUE_DELETED = "Issue deleted"; export const ISSUE_DELETED = "Issue deleted";
export const ISSUE_ARCHIVED = "Issue archived";
export const ISSUE_RESTORED = "Issue restored";
export const ISSUE_OPENED = "Issue opened"; export const ISSUE_OPENED = "Issue opened";
// Project State Events // Project State Events
export const STATE_CREATED = "State created"; export const STATE_CREATED = "State created";
@ -218,7 +218,7 @@ export const NOTIFICATION_SNOOZED = "Notification snoozed";
export const NOTIFICATION_READ = "Notification marked read"; export const NOTIFICATION_READ = "Notification marked read";
export const UNREAD_NOTIFICATIONS = "Unread notifications viewed"; export const UNREAD_NOTIFICATIONS = "Unread notifications viewed";
export const NOTIFICATIONS_READ = "All notifications marked read"; export const NOTIFICATIONS_READ = "All notifications marked read";
export const SNOOZED_NOTIFICATIONS= "Snoozed notifications viewed"; export const SNOOZED_NOTIFICATIONS = "Snoozed notifications viewed";
export const ARCHIVED_NOTIFICATIONS = "Archived notifications viewed"; export const ARCHIVED_NOTIFICATIONS = "Archived notifications viewed";
// Groups // Groups
export const GROUP_WORKSPACE = "Workspace_metrics"; export const GROUP_WORKSPACE = "Workspace_metrics";

View File

@ -57,11 +57,11 @@ export const MONTHS = [
export const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; export const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
export const PROJECT_AUTOMATION_MONTHS = [ export const PROJECT_AUTOMATION_MONTHS = [
{ label: "1 Month", value: 1 }, { label: "1 month", value: 1 },
{ label: "3 Months", value: 3 }, { label: "3 months", value: 3 },
{ label: "6 Months", value: 6 }, { label: "6 months", value: 6 },
{ label: "9 Months", value: 9 }, { label: "9 months", value: 9 },
{ label: "12 Months", value: 12 }, { label: "12 months", value: 12 },
]; ];
export const PROJECT_UNSPLASH_COVERS = [ export const PROJECT_UNSPLASH_COVERS = [

View File

@ -4,7 +4,7 @@ import { observer } from "mobx-react";
import useSWR from "swr"; import useSWR from "swr";
// hooks // hooks
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
import { useIssueDetail, useIssues, useProject } from "hooks/store"; import { useIssueDetail, useIssues, useProject, useUser } from "hooks/store";
// layouts // layouts
import { AppLayout } from "layouts/app-layout"; import { AppLayout } from "layouts/app-layout";
// components // components
@ -12,13 +12,14 @@ import { IssueDetailRoot } from "components/issues";
import { ProjectArchivedIssueDetailsHeader } from "components/headers"; import { ProjectArchivedIssueDetailsHeader } from "components/headers";
import { PageHead } from "components/core"; import { PageHead } from "components/core";
// ui // ui
import { ArchiveIcon, Loader } from "@plane/ui"; import { ArchiveIcon, Button, Loader } from "@plane/ui";
// icons // icons
import { History } from "lucide-react"; import { RotateCcw } from "lucide-react";
// types // types
import { NextPageWithLayout } from "lib/types"; import { NextPageWithLayout } from "lib/types";
// constants // constants
import { EIssuesStoreType } from "constants/issue"; import { EIssuesStoreType } from "constants/issue";
import { EUserProjectRoles } from "constants/project";
const ArchivedIssueDetailsPage: NextPageWithLayout = observer(() => { const ArchivedIssueDetailsPage: NextPageWithLayout = observer(() => {
// router // router
@ -32,10 +33,13 @@ const ArchivedIssueDetailsPage: NextPageWithLayout = observer(() => {
issue: { getIssueById }, issue: { getIssueById },
} = useIssueDetail(); } = useIssueDetail();
const { const {
issues: { removeIssueFromArchived }, issues: { restoreIssue },
} = useIssues(EIssuesStoreType.ARCHIVED); } = useIssues(EIssuesStoreType.ARCHIVED);
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
const { getProjectById } = useProject(); const { getProjectById } = useProject();
const {
membership: { currentProjectRole },
} = useUser();
const { isLoading } = useSWR( const { isLoading } = useSWR(
workspaceSlug && projectId && archivedIssueId workspaceSlug && projectId && archivedIssueId
@ -46,18 +50,21 @@ const ArchivedIssueDetailsPage: NextPageWithLayout = observer(() => {
: null : null
); );
const issue = getIssueById(archivedIssueId?.toString() || "") || undefined; // derived values
const project = (issue?.project_id && getProjectById(issue?.project_id)) || undefined; const issue = archivedIssueId ? getIssueById(archivedIssueId.toString()) : undefined;
const project = issue ? getProjectById(issue?.project_id) : undefined;
const pageTitle = project && issue ? `${project?.identifier}-${issue?.sequence_id} ${issue?.name}` : undefined; const pageTitle = project && issue ? `${project?.identifier}-${issue?.sequence_id} ${issue?.name}` : undefined;
// auth
const canRestoreIssue = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER;
if (!issue) return <></>; if (!issue) return <></>;
const handleUnArchive = async () => { const handleRestore = async () => {
if (!workspaceSlug || !projectId || !archivedIssueId) return; if (!workspaceSlug || !projectId || !archivedIssueId) return;
setIsRestoring(true); setIsRestoring(true);
await removeIssueFromArchived(workspaceSlug as string, projectId as string, archivedIssueId as string) await restoreIssue(workspaceSlug.toString(), projectId.toString(), archivedIssueId.toString())
.then(() => { .then(() => {
setToastAlert({ setToastAlert({
type: "success", type: "success",
@ -102,21 +109,22 @@ const ArchivedIssueDetailsPage: NextPageWithLayout = observer(() => {
</Loader> </Loader>
) : ( ) : (
<div className="flex h-full overflow-hidden"> <div className="flex h-full overflow-hidden">
<div className="h-full w-full space-y-2 divide-y-2 divide-custom-border-300 overflow-y-auto p-5"> <div className="h-full w-full space-y-3 divide-y-2 divide-custom-border-200 overflow-y-auto p-5">
{issue?.archived_at && ( {issue?.archived_at && canRestoreIssue && (
<div className="flex items-center justify-between gap-2 rounded-md border border-custom-border-200 bg-custom-background-90 px-2.5 py-2 text-sm text-custom-text-200"> <div className="flex items-center justify-between gap-2 rounded-md border border-custom-border-200 bg-custom-background-90 px-2.5 py-2 text-sm text-custom-text-200">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ArchiveIcon className="h-3.5 w-3.5" /> <ArchiveIcon className="h-3.5 w-3.5" />
<p>This issue has been archived by Plane.</p> <p>This issue has been archived.</p>
</div> </div>
<button <Button
className="flex items-center gap-2 rounded-md border border-custom-border-200 p-1.5 text-sm" className="flex items-center gap-1.5 rounded-md border border-custom-border-200 p-1.5 text-sm"
onClick={handleUnArchive} onClick={handleRestore}
disabled={isRestoring} disabled={isRestoring}
variant="neutral-primary"
> >
<History className="h-3.5 w-3.5" /> <RotateCcw className="h-3 w-3" />
<span>{isRestoring ? "Restoring..." : "Restore Issue"}</span> <span>{isRestoring ? "Restoring" : "Restore"}</span>
</button> </Button>
</div> </div>
)} )}
{workspaceSlug && projectId && archivedIssueId && ( {workspaceSlug && projectId && archivedIssueId && (

View File

@ -5,44 +5,28 @@ import { observer } from "mobx-react";
import { AppLayout } from "layouts/app-layout"; import { AppLayout } from "layouts/app-layout";
// contexts // contexts
import { ArchivedIssueLayoutRoot } from "components/issues"; import { ArchivedIssueLayoutRoot } from "components/issues";
// ui
import { ArchiveIcon } from "@plane/ui";
// components // components
import { ProjectArchivedIssuesHeader } from "components/headers"; import { ProjectArchivedIssuesHeader } from "components/headers";
import { PageHead } from "components/core"; import { PageHead } from "components/core";
// icons
import { X } from "lucide-react";
// types // types
import { NextPageWithLayout } from "lib/types"; import { NextPageWithLayout } from "lib/types";
// hooks // hooks
import { useProject } from "hooks/store"; import { useProject } from "hooks/store";
const ProjectArchivedIssuesPage: NextPageWithLayout = observer(() => { const ProjectArchivedIssuesPage: NextPageWithLayout = observer(() => {
// router
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId } = router.query; const { projectId } = router.query;
// store hooks // store hooks
const { getProjectById } = useProject(); const { getProjectById } = useProject();
// derived values // derived values
const project = projectId ? getProjectById(projectId.toString()) : undefined; const project = projectId ? getProjectById(projectId.toString()) : undefined;
const pageTitle = project?.name && `${project?.name} - Archived Issues`; const pageTitle = project?.name && `${project?.name} - Archived issues`;
return ( return (
<> <>
<PageHead title={pageTitle} /> <PageHead title={pageTitle} />
<div className="flex h-full w-full flex-col"> <ArchivedIssueLayoutRoot />
<div className="gap-1 flex items-center border-b border-custom-border-200 px-4 py-2.5 shadow-sm">
<button
type="button"
onClick={() => router.push(`/${workspaceSlug}/projects/${projectId}/issues/`)}
className="flex items-center gap-1.5 rounded-full border border-custom-border-200 px-3 py-1.5 text-xs"
>
<ArchiveIcon className="h-4 w-4" />
<span>Archived Issues</span>
<X className="h-3 w-3" />
</button>
</div>
<ArchivedIssueLayoutRoot />
</div>
</> </>
); );
}); });

View File

@ -1,7 +1,8 @@
import { APIService } from "services/api.service"; import { APIService } from "services/api.service";
// type // types
import { API_BASE_URL } from "helpers/common.helper";
import { TIssue } from "@plane/types"; import { TIssue } from "@plane/types";
// constants
import { API_BASE_URL } from "helpers/common.helper";
export class IssueArchiveService extends APIService { export class IssueArchiveService extends APIService {
constructor() { constructor() {
@ -18,8 +19,22 @@ export class IssueArchiveService extends APIService {
}); });
} }
async unarchiveIssue(workspaceSlug: string, projectId: string, issueId: string): Promise<any> { async archiveIssue(
return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/unarchive/${issueId}/`) workspaceSlug: string,
projectId: string,
issueId: string
): Promise<{
archived_at: string;
}> {
return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}/archive/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async restoreIssue(workspaceSlug: string, projectId: string, issueId: string): Promise<any> {
return this.delete(`/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}/archive/`)
.then((response) => response?.data) .then((response) => response?.data)
.catch((error) => { .catch((error) => {
throw error?.response?.data; throw error?.response?.data;
@ -32,7 +47,7 @@ export class IssueArchiveService extends APIService {
issueId: string, issueId: string,
queries?: any queries?: any
): Promise<TIssue> { ): Promise<TIssue> {
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/archived-issues/${issueId}/`, { return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}/archive/`, {
params: queries, params: queries,
}) })
.then((response) => response?.data) .then((response) => response?.data)
@ -40,12 +55,4 @@ export class IssueArchiveService extends APIService {
throw error?.response?.data; throw error?.response?.data;
}); });
} }
async deleteArchivedIssue(workspaceSlug: string, projectId: string, issuesId: string): Promise<any> {
return this.delete(`/api/workspaces/${workspaceSlug}/projects/${projectId}/archived-issues/${issuesId}/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
} }

View File

@ -1,5 +1,6 @@
import { action, observable, makeObservable, computed, runInAction } from "mobx"; import { action, observable, makeObservable, computed, runInAction } from "mobx";
import set from "lodash/set"; import set from "lodash/set";
import pull from "lodash/pull";
// base class // base class
import { IssueHelperStore } from "../helpers/issue-helper.store"; import { IssueHelperStore } from "../helpers/issue-helper.store";
// services // services
@ -18,7 +19,7 @@ export interface IArchivedIssues {
// actions // actions
fetchIssues: (workspaceSlug: string, projectId: string, loadType: TLoader) => Promise<TIssue>; fetchIssues: (workspaceSlug: string, projectId: string, loadType: TLoader) => Promise<TIssue>;
removeIssue: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>; removeIssue: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>;
removeIssueFromArchived: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>; restoreIssue: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>;
quickAddIssue: undefined; quickAddIssue: undefined;
} }
@ -48,7 +49,7 @@ export class ArchivedIssues extends IssueHelperStore implements IArchivedIssues
// action // action
fetchIssues: action, fetchIssues: action,
removeIssue: action, removeIssue: action,
removeIssueFromArchived: action, restoreIssue: action,
}); });
// root store // root store
this.rootIssueStore = _rootStore; this.rootIssueStore = _rootStore;
@ -70,7 +71,7 @@ export class ArchivedIssues extends IssueHelperStore implements IArchivedIssues
const archivedIssueIds = this.issues[projectId]; const archivedIssueIds = this.issues[projectId];
if (!archivedIssueIds) return undefined; if (!archivedIssueIds) return undefined;
const _issues = this.rootIssueStore.issues.getIssuesByIds(archivedIssueIds); const _issues = this.rootIssueStore.issues.getIssuesByIds(archivedIssueIds, "archived");
if (!_issues) return []; if (!_issues) return [];
let issues: TGroupedIssues | TSubGroupedIssues | TUnGroupedIssues | undefined = undefined; let issues: TGroupedIssues | TSubGroupedIssues | TUnGroupedIssues | undefined = undefined;
@ -113,25 +114,24 @@ export class ArchivedIssues extends IssueHelperStore implements IArchivedIssues
try { try {
await this.rootIssueStore.projectIssues.removeIssue(workspaceSlug, projectId, issueId); await this.rootIssueStore.projectIssues.removeIssue(workspaceSlug, projectId, issueId);
const issueIndex = this.issues[projectId].findIndex((_issueId) => _issueId === issueId); runInAction(() => {
if (issueIndex >= 0) pull(this.issues[projectId], issueId);
runInAction(() => { });
this.issues[projectId].splice(issueIndex, 1);
});
} catch (error) { } catch (error) {
throw error; throw error;
} }
}; };
removeIssueFromArchived = async (workspaceSlug: string, projectId: string, issueId: string) => { restoreIssue = async (workspaceSlug: string, projectId: string, issueId: string) => {
try { try {
const response = await this.archivedIssueService.unarchiveIssue(workspaceSlug, projectId, issueId); const response = await this.archivedIssueService.restoreIssue(workspaceSlug, projectId, issueId);
const issueIndex = this.issues[projectId]?.findIndex((_issueId) => _issueId === issueId); runInAction(() => {
if (issueIndex && issueIndex >= 0) this.rootStore.issues.updateIssue(issueId, {
runInAction(() => { archived_at: null,
this.issues[projectId].splice(issueIndex, 1);
}); });
pull(this.issues[projectId], issueId);
});
return response; return response;
} catch (error) { } catch (error) {

View File

@ -48,6 +48,12 @@ export interface ICycleIssues {
issueId: string, issueId: string,
cycleId?: string | undefined cycleId?: string | undefined
) => Promise<void>; ) => Promise<void>;
archiveIssue: (
workspaceSlug: string,
projectId: string,
issueId: string,
cycleId?: string | undefined
) => Promise<void>;
quickAddIssue: ( quickAddIssue: (
workspaceSlug: string, workspaceSlug: string,
projectId: string, projectId: string,
@ -100,6 +106,7 @@ export class CycleIssues extends IssueHelperStore implements ICycleIssues {
createIssue: action, createIssue: action,
updateIssue: action, updateIssue: action,
removeIssue: action, removeIssue: action,
archiveIssue: action,
quickAddIssue: action, quickAddIssue: action,
addIssueToCycle: action, addIssueToCycle: action,
removeIssueFromCycle: action, removeIssueFromCycle: action,
@ -127,7 +134,7 @@ export class CycleIssues extends IssueHelperStore implements ICycleIssues {
const cycleIssueIds = this.issues[cycleId]; const cycleIssueIds = this.issues[cycleId];
if (!cycleIssueIds) return; if (!cycleIssueIds) return;
const _issues = this.rootIssueStore.issues.getIssuesByIds(cycleIssueIds); const _issues = this.rootIssueStore.issues.getIssuesByIds(cycleIssueIds, "un-archived");
if (!_issues) return []; if (!_issues) return [];
let issues: TGroupedIssues | TSubGroupedIssues | TUnGroupedIssues = []; let issues: TGroupedIssues | TSubGroupedIssues | TUnGroupedIssues = [];
@ -237,6 +244,26 @@ export class CycleIssues extends IssueHelperStore implements ICycleIssues {
} }
}; };
archiveIssue = async (
workspaceSlug: string,
projectId: string,
issueId: string,
cycleId: string | undefined = undefined
) => {
try {
if (!cycleId) throw new Error("Cycle Id is required");
await this.rootIssueStore.projectIssues.archiveIssue(workspaceSlug, projectId, issueId);
this.rootIssueStore.rootStore.cycle.fetchCycleDetails(workspaceSlug, projectId, cycleId);
runInAction(() => {
pull(this.issues[cycleId], issueId);
});
} catch (error) {
throw error;
}
};
quickAddIssue = async ( quickAddIssue = async (
workspaceSlug: string, workspaceSlug: string,
projectId: string, projectId: string,

View File

@ -81,7 +81,7 @@ export class DraftIssues extends IssueHelperStore implements IDraftIssues {
const draftIssueIds = this.issues[projectId]; const draftIssueIds = this.issues[projectId];
if (!draftIssueIds) return undefined; if (!draftIssueIds) return undefined;
const _issues = this.rootIssueStore.issues.getIssuesByIds(draftIssueIds); const _issues = this.rootIssueStore.issues.getIssuesByIds(draftIssueIds, "un-archived");
if (!_issues) return []; if (!_issues) return [];
let issues: TGroupedIssues | TSubGroupedIssues | TUnGroupedIssues | undefined = undefined; let issues: TGroupedIssues | TSubGroupedIssues | TUnGroupedIssues | undefined = undefined;

View File

@ -16,6 +16,7 @@ export interface IIssueStoreActions {
) => Promise<TIssue>; ) => Promise<TIssue>;
updateIssue: (workspaceSlug: string, projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>; updateIssue: (workspaceSlug: string, projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>;
removeIssue: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>; removeIssue: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>;
archiveIssue: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>;
addIssueToCycle: (workspaceSlug: string, projectId: string, cycleId: string, issueIds: string[]) => Promise<void>; addIssueToCycle: (workspaceSlug: string, projectId: string, cycleId: string, issueIds: string[]) => Promise<void>;
removeIssueFromCycle: (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => Promise<TIssue>; removeIssueFromCycle: (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => Promise<TIssue>;
addModulesToIssue: (workspaceSlug: string, projectId: string, issueId: string, moduleIds: string[]) => Promise<any>; addModulesToIssue: (workspaceSlug: string, projectId: string, issueId: string, moduleIds: string[]) => Promise<any>;
@ -156,6 +157,9 @@ export class IssueStore implements IIssueStore {
removeIssue = async (workspaceSlug: string, projectId: string, issueId: string) => removeIssue = async (workspaceSlug: string, projectId: string, issueId: string) =>
this.rootIssueDetailStore.rootIssueStore.projectIssues.removeIssue(workspaceSlug, projectId, issueId); this.rootIssueDetailStore.rootIssueStore.projectIssues.removeIssue(workspaceSlug, projectId, issueId);
archiveIssue = async (workspaceSlug: string, projectId: string, issueId: string) =>
this.rootIssueDetailStore.rootIssueStore.projectIssues.archiveIssue(workspaceSlug, projectId, issueId);
addIssueToCycle = async (workspaceSlug: string, projectId: string, cycleId: string, issueIds: string[]) => { addIssueToCycle = async (workspaceSlug: string, projectId: string, cycleId: string, issueIds: string[]) => {
await this.rootIssueDetailStore.rootIssueStore.cycleIssues.addIssueToCycle( await this.rootIssueDetailStore.rootIssueStore.cycleIssues.addIssueToCycle(
workspaceSlug, workspaceSlug,

View File

@ -47,6 +47,7 @@ export interface IIssueDetail
isIssueLinkModalOpen: boolean; isIssueLinkModalOpen: boolean;
isParentIssueModalOpen: boolean; isParentIssueModalOpen: boolean;
isDeleteIssueModalOpen: boolean; isDeleteIssueModalOpen: boolean;
isArchiveIssueModalOpen: boolean;
isRelationModalOpen: TIssueRelationTypes | null; isRelationModalOpen: TIssueRelationTypes | null;
// computed // computed
isAnyModalOpen: boolean; isAnyModalOpen: boolean;
@ -55,6 +56,7 @@ export interface IIssueDetail
toggleIssueLinkModal: (value: boolean) => void; toggleIssueLinkModal: (value: boolean) => void;
toggleParentIssueModal: (value: boolean) => void; toggleParentIssueModal: (value: boolean) => void;
toggleDeleteIssueModal: (value: boolean) => void; toggleDeleteIssueModal: (value: boolean) => void;
toggleArchiveIssueModal: (value: boolean) => void;
toggleRelationModal: (value: TIssueRelationTypes | null) => void; toggleRelationModal: (value: TIssueRelationTypes | null) => void;
// store // store
rootIssueStore: IIssueRootStore; rootIssueStore: IIssueRootStore;
@ -76,6 +78,7 @@ export class IssueDetail implements IIssueDetail {
isIssueLinkModalOpen: boolean = false; isIssueLinkModalOpen: boolean = false;
isParentIssueModalOpen: boolean = false; isParentIssueModalOpen: boolean = false;
isDeleteIssueModalOpen: boolean = false; isDeleteIssueModalOpen: boolean = false;
isArchiveIssueModalOpen: boolean = false;
isRelationModalOpen: TIssueRelationTypes | null = null; isRelationModalOpen: TIssueRelationTypes | null = null;
// store // store
rootIssueStore: IIssueRootStore; rootIssueStore: IIssueRootStore;
@ -97,6 +100,7 @@ export class IssueDetail implements IIssueDetail {
isIssueLinkModalOpen: observable.ref, isIssueLinkModalOpen: observable.ref,
isParentIssueModalOpen: observable.ref, isParentIssueModalOpen: observable.ref,
isDeleteIssueModalOpen: observable.ref, isDeleteIssueModalOpen: observable.ref,
isArchiveIssueModalOpen: observable.ref,
isRelationModalOpen: observable.ref, isRelationModalOpen: observable.ref,
// computed // computed
isAnyModalOpen: computed, isAnyModalOpen: computed,
@ -105,6 +109,7 @@ export class IssueDetail implements IIssueDetail {
toggleIssueLinkModal: action, toggleIssueLinkModal: action,
toggleParentIssueModal: action, toggleParentIssueModal: action,
toggleDeleteIssueModal: action, toggleDeleteIssueModal: action,
toggleArchiveIssueModal: action,
toggleRelationModal: action, toggleRelationModal: action,
}); });
@ -128,6 +133,7 @@ export class IssueDetail implements IIssueDetail {
this.isIssueLinkModalOpen || this.isIssueLinkModalOpen ||
this.isParentIssueModalOpen || this.isParentIssueModalOpen ||
this.isDeleteIssueModalOpen || this.isDeleteIssueModalOpen ||
this.isArchiveIssueModalOpen ||
Boolean(this.isRelationModalOpen) Boolean(this.isRelationModalOpen)
); );
} }
@ -137,6 +143,7 @@ export class IssueDetail implements IIssueDetail {
toggleIssueLinkModal = (value: boolean) => (this.isIssueLinkModalOpen = value); toggleIssueLinkModal = (value: boolean) => (this.isIssueLinkModalOpen = value);
toggleParentIssueModal = (value: boolean) => (this.isParentIssueModalOpen = value); toggleParentIssueModal = (value: boolean) => (this.isParentIssueModalOpen = value);
toggleDeleteIssueModal = (value: boolean) => (this.isDeleteIssueModalOpen = value); toggleDeleteIssueModal = (value: boolean) => (this.isDeleteIssueModalOpen = value);
toggleArchiveIssueModal = (value: boolean) => (this.isArchiveIssueModalOpen = value);
toggleRelationModal = (value: TIssueRelationTypes | null) => (this.isRelationModalOpen = value); toggleRelationModal = (value: TIssueRelationTypes | null) => (this.isRelationModalOpen = value);
// issue // issue
@ -150,6 +157,8 @@ export class IssueDetail implements IIssueDetail {
this.issue.updateIssue(workspaceSlug, projectId, issueId, data); this.issue.updateIssue(workspaceSlug, projectId, issueId, data);
removeIssue = async (workspaceSlug: string, projectId: string, issueId: string) => removeIssue = async (workspaceSlug: string, projectId: string, issueId: string) =>
this.issue.removeIssue(workspaceSlug, projectId, issueId); this.issue.removeIssue(workspaceSlug, projectId, issueId);
archiveIssue = async (workspaceSlug: string, projectId: string, issueId: string) =>
this.issue.archiveIssue(workspaceSlug, projectId, issueId);
addIssueToCycle = async (workspaceSlug: string, projectId: string, cycleId: string, issueIds: string[]) => addIssueToCycle = async (workspaceSlug: string, projectId: string, cycleId: string, issueIds: string[]) =>
this.issue.addIssueToCycle(workspaceSlug, projectId, cycleId, issueIds); this.issue.addIssueToCycle(workspaceSlug, projectId, cycleId, issueIds);
removeIssueFromCycle = async (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => removeIssueFromCycle = async (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) =>

View File

@ -18,7 +18,7 @@ export type IIssueStore = {
removeIssue(issueId: string): void; removeIssue(issueId: string): void;
// helper methods // helper methods
getIssueById(issueId: string): undefined | TIssue; getIssueById(issueId: string): undefined | TIssue;
getIssuesByIds(issueIds: string[]): undefined | Record<string, TIssue>; // Record defines issue_id as key and TIssue as value getIssuesByIds(issueIds: string[], type: "archived" | "un-archived"): undefined | Record<string, TIssue>; // Record defines issue_id as key and TIssue as value
}; };
export class IssueStore implements IIssueStore { export class IssueStore implements IIssueStore {
@ -108,14 +108,17 @@ export class IssueStore implements IIssueStore {
/** /**
* @description This method will return the issues from the issuesMap * @description This method will return the issues from the issuesMap
* @param {string[]} issueIds * @param {string[]} issueIds
* @param {boolean} archivedIssues
* @returns {Record<string, TIssue> | undefined} * @returns {Record<string, TIssue> | undefined}
*/ */
getIssuesByIds = computedFn((issueIds: string[]) => { getIssuesByIds = computedFn((issueIds: string[], type: "archived" | "un-archived") => {
if (!issueIds || issueIds.length <= 0 || isEmpty(this.issuesMap)) return undefined; if (!issueIds || issueIds.length <= 0 || isEmpty(this.issuesMap)) return undefined;
const filteredIssues: { [key: string]: TIssue } = {}; const filteredIssues: { [key: string]: TIssue } = {};
Object.values(this.issuesMap).forEach((issue) => { Object.values(this.issuesMap).forEach((issue) => {
if (issueIds.includes(issue.id)) { // if type is archived then check archived_at is not null
filteredIssues[issue.id] = issue; // if type is un-archived then check archived_at is null
if ((type === "archived" && issue.archived_at) || (type === "un-archived" && !issue.archived_at)) {
if (issueIds.includes(issue.id)) filteredIssues[issue.id] = issue;
} }
}); });
return isEmpty(filteredIssues) ? undefined : filteredIssues; return isEmpty(filteredIssues) ? undefined : filteredIssues;

View File

@ -46,6 +46,12 @@ export interface IModuleIssues {
issueId: string, issueId: string,
moduleId?: string | undefined moduleId?: string | undefined
) => Promise<void>; ) => Promise<void>;
archiveIssue: (
workspaceSlug: string,
projectId: string,
issueId: string,
moduleId?: string | undefined
) => Promise<void>;
quickAddIssue: ( quickAddIssue: (
workspaceSlug: string, workspaceSlug: string,
projectId: string, projectId: string,
@ -103,6 +109,7 @@ export class ModuleIssues extends IssueHelperStore implements IModuleIssues {
createIssue: action, createIssue: action,
updateIssue: action, updateIssue: action,
removeIssue: action, removeIssue: action,
archiveIssue: action,
quickAddIssue: action, quickAddIssue: action,
addIssuesToModule: action, addIssuesToModule: action,
removeIssuesFromModule: action, removeIssuesFromModule: action,
@ -131,7 +138,7 @@ export class ModuleIssues extends IssueHelperStore implements IModuleIssues {
const moduleIssueIds = this.issues[moduleId]; const moduleIssueIds = this.issues[moduleId];
if (!moduleIssueIds) return; if (!moduleIssueIds) return;
const _issues = this.rootIssueStore.issues.getIssuesByIds(moduleIssueIds); const _issues = this.rootIssueStore.issues.getIssuesByIds(moduleIssueIds, "un-archived");
if (!_issues) return []; if (!_issues) return [];
let issues: TGroupedIssues | TSubGroupedIssues | TUnGroupedIssues = []; let issues: TGroupedIssues | TSubGroupedIssues | TUnGroupedIssues = [];
@ -242,6 +249,26 @@ export class ModuleIssues extends IssueHelperStore implements IModuleIssues {
} }
}; };
archiveIssue = async (
workspaceSlug: string,
projectId: string,
issueId: string,
moduleId: string | undefined = undefined
) => {
try {
if (!moduleId) throw new Error("Module Id is required");
await this.rootIssueStore.projectIssues.archiveIssue(workspaceSlug, projectId, issueId);
this.rootIssueStore.rootStore.module.fetchModuleDetails(workspaceSlug, projectId, moduleId);
runInAction(() => {
pull(this.issues[moduleId], issueId);
});
} catch (error) {
throw error;
}
};
quickAddIssue = async ( quickAddIssue = async (
workspaceSlug: string, workspaceSlug: string,
projectId: string, projectId: string,

View File

@ -1,5 +1,6 @@
import { action, observable, makeObservable, computed, runInAction } from "mobx"; import { action, observable, makeObservable, computed, runInAction } from "mobx";
import set from "lodash/set"; import set from "lodash/set";
import pull from "lodash/pull";
// base class // base class
import { IssueHelperStore } from "../helpers/issue-helper.store"; import { IssueHelperStore } from "../helpers/issue-helper.store";
// services // services
@ -48,6 +49,12 @@ export interface IProfileIssues {
issueId: string, issueId: string,
userId?: string | undefined userId?: string | undefined
) => Promise<void>; ) => Promise<void>;
archiveIssue: (
workspaceSlug: string,
projectId: string,
issueId: string,
userId?: string | undefined
) => Promise<void>;
quickAddIssue: undefined; quickAddIssue: undefined;
} }
@ -77,6 +84,7 @@ export class ProfileIssues extends IssueHelperStore implements IProfileIssues {
createIssue: action, createIssue: action,
updateIssue: action, updateIssue: action,
removeIssue: action, removeIssue: action,
archiveIssue: action,
}); });
// root store // root store
this.rootIssueStore = _rootStore; this.rootIssueStore = _rootStore;
@ -104,7 +112,7 @@ export class ProfileIssues extends IssueHelperStore implements IProfileIssues {
if (!userIssueIds) return; if (!userIssueIds) return;
const _issues = this.rootStore.issues.getIssuesByIds(userIssueIds); const _issues = this.rootStore.issues.getIssuesByIds(userIssueIds, "un-archived");
if (!_issues) return []; if (!_issues) return [];
let issues: TGroupedIssues | TSubGroupedIssues | TUnGroupedIssues | undefined = undefined; let issues: TGroupedIssues | TSubGroupedIssues | TUnGroupedIssues | undefined = undefined;
@ -249,4 +257,24 @@ export class ProfileIssues extends IssueHelperStore implements IProfileIssues {
throw error; throw error;
} }
}; };
archiveIssue = async (
workspaceSlug: string,
projectId: string,
issueId: string,
userId: string | undefined = undefined
) => {
if (!userId) return;
try {
await this.rootIssueStore.projectIssues.archiveIssue(workspaceSlug, projectId, issueId);
const uniqueViewId = `${workspaceSlug}_${this.currentView}`;
runInAction(() => {
pull(this.issues[userId][uniqueViewId], issueId);
});
} catch (error) {
throw error;
}
};
} }

View File

@ -1,5 +1,6 @@
import { action, observable, makeObservable, computed, runInAction } from "mobx"; import { action, observable, makeObservable, computed, runInAction } from "mobx";
import set from "lodash/set"; import set from "lodash/set";
import pull from "lodash/pull";
// base class // base class
import { IssueHelperStore } from "../helpers/issue-helper.store"; import { IssueHelperStore } from "../helpers/issue-helper.store";
// services // services
@ -41,6 +42,12 @@ export interface IProjectViewIssues {
issueId: string, issueId: string,
viewId?: string | undefined viewId?: string | undefined
) => Promise<void>; ) => Promise<void>;
archiveIssue: (
workspaceSlug: string,
projectId: string,
issueId: string,
viewId?: string | undefined
) => Promise<void>;
quickAddIssue: ( quickAddIssue: (
workspaceSlug: string, workspaceSlug: string,
projectId: string, projectId: string,
@ -75,6 +82,7 @@ export class ProjectViewIssues extends IssueHelperStore implements IProjectViewI
createIssue: action, createIssue: action,
updateIssue: action, updateIssue: action,
removeIssue: action, removeIssue: action,
archiveIssue: action,
quickAddIssue: action, quickAddIssue: action,
}); });
// root store // root store
@ -98,7 +106,7 @@ export class ProjectViewIssues extends IssueHelperStore implements IProjectViewI
const viewIssueIds = this.issues[viewId]; const viewIssueIds = this.issues[viewId];
if (!viewIssueIds) return; if (!viewIssueIds) return;
const _issues = this.rootStore.issues.getIssuesByIds(viewIssueIds); const _issues = this.rootStore.issues.getIssuesByIds(viewIssueIds, "un-archived");
if (!_issues) return []; if (!_issues) return [];
let issues: TGroupedIssues | TSubGroupedIssues | TUnGroupedIssues = []; let issues: TGroupedIssues | TSubGroupedIssues | TUnGroupedIssues = [];
@ -210,6 +218,26 @@ export class ProjectViewIssues extends IssueHelperStore implements IProjectViewI
} }
}; };
archiveIssue = async (
workspaceSlug: string,
projectId: string,
issueId: string,
viewId: string | undefined = undefined
) => {
try {
if (!viewId) throw new Error("View Id is required");
await this.rootIssueStore.projectIssues.archiveIssue(workspaceSlug, projectId, issueId);
runInAction(() => {
pull(this.issues[viewId], issueId);
});
} catch (error) {
this.fetchIssues(workspaceSlug, projectId, "mutation");
throw error;
}
};
quickAddIssue = async ( quickAddIssue = async (
workspaceSlug: string, workspaceSlug: string,
projectId: string, projectId: string,

View File

@ -6,7 +6,7 @@ import concat from "lodash/concat";
// base class // base class
import { IssueHelperStore } from "../helpers/issue-helper.store"; import { IssueHelperStore } from "../helpers/issue-helper.store";
// services // services
import { IssueService } from "services/issue/issue.service"; import { IssueService, IssueArchiveService } from "services/issue";
// types // types
import { IIssueRootStore } from "../root.store"; import { IIssueRootStore } from "../root.store";
import { TIssue, TGroupedIssues, TSubGroupedIssues, TLoader, TUnGroupedIssues, ViewFlags } from "@plane/types"; import { TIssue, TGroupedIssues, TSubGroupedIssues, TLoader, TUnGroupedIssues, ViewFlags } from "@plane/types";
@ -23,6 +23,7 @@ export interface IProjectIssues {
createIssue: (workspaceSlug: string, projectId: string, data: Partial<TIssue>) => Promise<TIssue>; createIssue: (workspaceSlug: string, projectId: string, data: Partial<TIssue>) => Promise<TIssue>;
updateIssue: (workspaceSlug: string, projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>; updateIssue: (workspaceSlug: string, projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>;
removeIssue: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>; removeIssue: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>;
archiveIssue: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>;
quickAddIssue: (workspaceSlug: string, projectId: string, data: TIssue) => Promise<TIssue>; quickAddIssue: (workspaceSlug: string, projectId: string, data: TIssue) => Promise<TIssue>;
removeBulkIssues: (workspaceSlug: string, projectId: string, issueIds: string[]) => Promise<void>; removeBulkIssues: (workspaceSlug: string, projectId: string, issueIds: string[]) => Promise<void>;
} }
@ -40,6 +41,7 @@ export class ProjectIssues extends IssueHelperStore implements IProjectIssues {
rootIssueStore: IIssueRootStore; rootIssueStore: IIssueRootStore;
// services // services
issueService; issueService;
issueArchiveService;
constructor(_rootStore: IIssueRootStore) { constructor(_rootStore: IIssueRootStore) {
super(_rootStore); super(_rootStore);
@ -54,6 +56,7 @@ export class ProjectIssues extends IssueHelperStore implements IProjectIssues {
createIssue: action, createIssue: action,
updateIssue: action, updateIssue: action,
removeIssue: action, removeIssue: action,
archiveIssue: action,
removeBulkIssues: action, removeBulkIssues: action,
quickAddIssue: action, quickAddIssue: action,
}); });
@ -61,6 +64,7 @@ export class ProjectIssues extends IssueHelperStore implements IProjectIssues {
this.rootIssueStore = _rootStore; this.rootIssueStore = _rootStore;
// services // services
this.issueService = new IssueService(); this.issueService = new IssueService();
this.issueArchiveService = new IssueArchiveService();
} }
get groupedIssueIds() { get groupedIssueIds() {
@ -78,7 +82,7 @@ export class ProjectIssues extends IssueHelperStore implements IProjectIssues {
const projectIssueIds = this.issues[projectId]; const projectIssueIds = this.issues[projectId];
if (!projectIssueIds) return; if (!projectIssueIds) return;
const _issues = this.rootStore.issues.getIssuesByIds(projectIssueIds); const _issues = this.rootStore.issues.getIssuesByIds(projectIssueIds, "un-archived");
if (!_issues) return []; if (!_issues) return [];
let issues: TGroupedIssues | TSubGroupedIssues | TUnGroupedIssues = []; let issues: TGroupedIssues | TSubGroupedIssues | TUnGroupedIssues = [];
@ -165,6 +169,21 @@ export class ProjectIssues extends IssueHelperStore implements IProjectIssues {
} }
}; };
archiveIssue = async (workspaceSlug: string, projectId: string, issueId: string) => {
try {
const response = await this.issueArchiveService.archiveIssue(workspaceSlug, projectId, issueId);
runInAction(() => {
this.rootStore.issues.updateIssue(issueId, {
archived_at: response.archived_at,
});
pull(this.issues[projectId], issueId);
});
} catch (error) {
throw error;
}
};
quickAddIssue = async (workspaceSlug: string, projectId: string, data: TIssue) => { quickAddIssue = async (workspaceSlug: string, projectId: string, data: TIssue) => {
try { try {
runInAction(() => { runInAction(() => {

View File

@ -1,10 +1,11 @@
import { action, observable, makeObservable, computed, runInAction } from "mobx"; import { action, observable, makeObservable, computed, runInAction } from "mobx";
import set from "lodash/set"; import set from "lodash/set";
import pull from "lodash/pull";
// base class // base class
import { IssueHelperStore } from "../helpers/issue-helper.store"; import { IssueHelperStore } from "../helpers/issue-helper.store";
// services // services
import { WorkspaceService } from "services/workspace.service"; import { WorkspaceService } from "services/workspace.service";
import { IssueService } from "services/issue"; import { IssueService, IssueArchiveService } from "services/issue";
// types // types
import { IIssueRootStore } from "../root.store"; import { IIssueRootStore } from "../root.store";
import { TIssue, TLoader, TUnGroupedIssues, ViewFlags } from "@plane/types"; import { TIssue, TLoader, TUnGroupedIssues, ViewFlags } from "@plane/types";
@ -37,6 +38,12 @@ export interface IWorkspaceIssues {
issueId: string, issueId: string,
viewId?: string | undefined viewId?: string | undefined
) => Promise<void>; ) => Promise<void>;
archiveIssue: (
workspaceSlug: string,
projectId: string,
issueId: string,
viewId?: string | undefined
) => Promise<void>;
} }
export class WorkspaceIssues extends IssueHelperStore implements IWorkspaceIssues { export class WorkspaceIssues extends IssueHelperStore implements IWorkspaceIssues {
@ -52,6 +59,7 @@ export class WorkspaceIssues extends IssueHelperStore implements IWorkspaceIssue
// service // service
workspaceService; workspaceService;
issueService; issueService;
issueArchiveService;
constructor(_rootStore: IIssueRootStore) { constructor(_rootStore: IIssueRootStore) {
super(_rootStore); super(_rootStore);
@ -67,12 +75,14 @@ export class WorkspaceIssues extends IssueHelperStore implements IWorkspaceIssue
createIssue: action, createIssue: action,
updateIssue: action, updateIssue: action,
removeIssue: action, removeIssue: action,
archiveIssue: action,
}); });
// root store // root store
this.rootIssueStore = _rootStore; this.rootIssueStore = _rootStore;
// services // services
this.workspaceService = new WorkspaceService(); this.workspaceService = new WorkspaceService();
this.issueService = new IssueService(); this.issueService = new IssueService();
this.issueArchiveService = new IssueArchiveService();
} }
get groupedIssueIds() { get groupedIssueIds() {
@ -91,7 +101,7 @@ export class WorkspaceIssues extends IssueHelperStore implements IWorkspaceIssue
if (!viewIssueIds) return { dataViewId: viewId, issueIds: undefined }; if (!viewIssueIds) return { dataViewId: viewId, issueIds: undefined };
const _issues = this.rootStore.issues.getIssuesByIds(viewIssueIds); const _issues = this.rootStore.issues.getIssuesByIds(viewIssueIds, "un-archived");
if (!_issues) return { dataViewId: viewId, issueIds: [] }; if (!_issues) return { dataViewId: viewId, issueIds: [] };
let issueIds: TIssue | TUnGroupedIssues | undefined = undefined; let issueIds: TIssue | TUnGroupedIssues | undefined = undefined;
@ -196,4 +206,28 @@ export class WorkspaceIssues extends IssueHelperStore implements IWorkspaceIssue
throw error; throw error;
} }
}; };
archiveIssue = async (
workspaceSlug: string,
projectId: string,
issueId: string,
viewId: string | undefined = undefined
) => {
try {
if (!viewId) throw new Error("View id is required");
const uniqueViewId = `${workspaceSlug}_${viewId}`;
const response = await this.issueArchiveService.archiveIssue(workspaceSlug, projectId, issueId);
runInAction(() => {
this.rootStore.issues.updateIssue(issueId, {
archived_at: response.archived_at,
});
pull(this.issues[uniqueViewId], issueId);
});
} catch (error) {
throw error;
}
};
} }