[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",
),
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(
{
"get": "retrieve",
"delete": "destroy",
"post": "archive",
"delete": "unarchive",
}
),
name="project-issue-archive",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/unarchive/<uuid:pk>/",
IssueArchiveViewSet.as_view(
{
"post": "unarchive",
}
),
name="project-issue-archive",
name="project-issue-archive-unarchive",
),
## End Issue Archives
## Issue Relation

View File

@ -1647,6 +1647,36 @@ class IssueArchiveViewSet(BaseViewSet):
serializer = IssueDetailSerializer(issue, expand=self.expand)
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):
issue = Issue.objects.get(
workspace__slug=slug,
@ -1670,7 +1700,7 @@ class IssueArchiveViewSet(BaseViewSet):
issue.archived_at = None
issue.save()
return Response(IssueSerializer(issue).data, status=status.HTTP_200_OK)
return Response(status=status.HTTP_204_NO_CONTENT)
class IssueSubscriberViewSet(BaseViewSet):

View File

@ -483,17 +483,23 @@ def track_archive_at(
)
)
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(
IssueActivity(
issue_id=issue_id,
project_id=project_id,
workspace_id=workspace_id,
comment="Plane has archived the issue",
comment=comment,
verb="updated",
actor_id=actor_id,
field="archived_at",
old_value=None,
new_value="archive",
new_value=new_value,
epoch=epoch,
)
)

View File

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

View File

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

View File

@ -177,17 +177,18 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
};
const MenuItem: React.FC<ICustomMenuItemProps> = (props) => {
const { children, onClick, className = "" } = props;
const { children, disabled = false, onClick, className } = props;
return (
<Menu.Item as="div">
<Menu.Item as="div" disabled={disabled}>
{({ active, close }) => (
<button
type="button"
className={cn(
"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
)}
@ -195,6 +196,7 @@ const MenuItem: React.FC<ICustomMenuItemProps> = (props) => {
close();
onClick && onClick(e);
}}
disabled={disabled}
>
{children}
</button>

View File

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

View File

@ -48,7 +48,7 @@ export const AutoArchiveAutomation: React.FC<Props> = observer((props) => {
<div className="">
<h4 className="text-sm font-medium">Auto-archive closed issues</h4>
<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>
</div>
</div>
@ -73,7 +73,7 @@ export const AutoArchiveAutomation: React.FC<Props> = observer((props) => {
<CustomSelect
value={currentProjectDetails?.archive_in}
label={`${currentProjectDetails?.archive_in} ${
currentProjectDetails?.archive_in === 1 ? "Month" : "Months"
currentProjectDetails?.archive_in === 1 ? "month" : "months"
}`}
onChange={(val: number) => {
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"
onClick={() => setmonthModal(true)}
>
Customise Time Range
Customize time range
</button>
</>
</CustomSelect>

View File

@ -74,7 +74,7 @@ export const AutoCloseAutomation: React.FC<Props> = observer((props) => {
<div className="">
<h4 className="text-sm font-medium">Auto-close issues</h4>
<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>
</div>
</div>
@ -100,7 +100,7 @@ export const AutoCloseAutomation: React.FC<Props> = observer((props) => {
<CustomSelect
value={currentProjectDetails?.close_in}
label={`${currentProjectDetails?.close_in} ${
currentProjectDetails?.close_in === 1 ? "Month" : "Months"
currentProjectDetails?.close_in === 1 ? "month" : "months"
}`}
onChange={(val: number) => {
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"
onClick={() => setmonthModal(true)}
>
Customize Time Range
Customize time range
</button>
</>
</CustomSelect>

View File

@ -72,7 +72,7 @@ export const SelectMonthModal: React.FC<Props> = ({ type, initialValues, isOpen,
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-custom-text-100">
Customise Time Range
Customize time range
</Dialog.Title>
<div className="mt-8 flex items-center gap-2">
<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) && (
<span className="flex-grow truncate">{value ? renderFormattedDate(value) : placeholder}</span>
)}
{isClearable && isDateSelected && (
{isClearable && !disabled && isDateSelected && (
<X
className={cn("h-2 w-2 flex-shrink-0", clearIconClassName)}
onClick={(e) => {

View File

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

View File

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

View File

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

View File

@ -1,10 +1,12 @@
import { FC } from "react";
import { observer } from "mobx-react";
import { MessageSquare } from "lucide-react";
import { RotateCcw } from "lucide-react";
// hooks
import { useIssueDetail } from "hooks/store";
// components
import { IssueActivityBlockComponent } from "./";
// ui
import { ArchiveIcon } from "@plane/ui";
type TIssueArchivedAtActivity = { activityId: string; ends: "top" | "bottom" | undefined };
@ -18,13 +20,21 @@ export const IssueArchivedAtActivity: FC<TIssueArchivedAtActivity> = observer((p
const activity = getActivityById(activityId);
if (!activity) return <></>;
return (
<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}
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>
);
});

View File

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

View File

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

View File

@ -16,7 +16,7 @@ import { TIssue } from "@plane/types";
// constants
import { EUserProjectRoles } from "constants/project";
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";
export type TIssueOperations = {
@ -29,6 +29,8 @@ export type TIssueOperations = {
showToast?: boolean
) => 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>;
removeIssueFromCycle?: (workspaceSlug: string, projectId: string, cycleId: string, issueId: 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,
updateIssue,
removeIssue,
archiveIssue,
addIssueToCycle,
removeIssueFromCycle,
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[]) => {
try {
await addIssueToCycle(workspaceSlug, projectId, cycleId, issueIds);
@ -321,6 +350,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => {
fetchIssue,
updateIssue,
removeIssue,
archiveIssue,
removeArchivedIssue,
addIssueToCycle,
removeIssueFromCycle,
@ -350,7 +380,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => {
/>
) : (
<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
workspaceSlug={workspaceSlug}
projectId={projectId}
@ -360,7 +390,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => {
/>
</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` } : {}}
>
<IssueDetailsSidebar

View File

@ -25,11 +25,12 @@ import {
IssueModuleSelect,
IssueParentSelect,
IssueLabel,
ArchiveIssueModal,
} from "components/issues";
import { IssueSubscription } from "./subscription";
import { DateDropdown, EstimateDropdown, PriorityDropdown, MemberDropdown, StateDropdown } from "components/dropdowns";
// icons
import { ContrastIcon, DiceIcon, DoubleCircleIcon, RelatedIcon, UserGroupIcon } from "@plane/ui";
import { ArchiveIcon, ContrastIcon, DiceIcon, DoubleCircleIcon, RelatedIcon, Tooltip, UserGroupIcon } from "@plane/ui";
// helpers
import { renderFormattedPayloadDate } from "helpers/date-time.helper";
import { copyTextToClipboard } from "helpers/string.helper";
@ -37,6 +38,7 @@ import { cn } from "helpers/common.helper";
import { shouldHighlightIssueDueDate } from "helpers/issue.helper";
// types
import type { TIssueOperations } from "./root";
import { STATE_GROUPS } from "constants/state";
type Props = {
workspaceSlug: string;
@ -49,6 +51,9 @@ type Props = {
export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
const { workspaceSlug, projectId, issueId, issueOperations, is_archived, is_editable } = props;
// states
const [deleteIssueModal, setDeleteIssueModal] = useState(false);
const [archiveIssueModal, setArchiveIssueModal] = useState(false);
// router
const router = useRouter();
// store hooks
@ -60,8 +65,6 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
issue: { getIssueById },
} = useIssueDetail();
const { getStateById } = useProjectState();
// states
const [deleteIssueModal, setDeleteIssueModal] = useState(false);
const issue = getIssueById(issueId);
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);
// 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;
minDate?.setDate(minDate.getDate());
@ -88,42 +106,68 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
return (
<>
{workspaceSlug && projectId && issue && (
<DeleteIssueModal
handleClose={() => setDeleteIssueModal(false)}
isOpen={deleteIssueModal}
data={issue}
onSubmit={async () => {
await issueOperations.remove(workspaceSlug, projectId, issueId);
router.push(`/${workspaceSlug}/projects/${projectId}/issues`);
}}
/>
)}
<DeleteIssueModal
handleClose={() => setDeleteIssueModal(false)}
isOpen={deleteIssueModal}
data={issue}
onSubmit={handleDeleteIssue}
/>
<ArchiveIssueModal
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 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 && (
<IssueSubscription workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} />
)}
<button
type="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"
onClick={handleCopyText}
>
<LinkIcon className="h-3.5 w-3.5" />
</button>
{is_editable && (
<button
type="button"
className="rounded-md border border-red-500 p-2 text-red-500 shadow-sm duration-300 hover:bg-red-500/20 focus:outline-none"
onClick={() => setDeleteIssueModal(true)}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
<div className="flex items-center flex-wrap gap-2.5 text-custom-text-300">
<Tooltip tooltipContent="Copy link">
<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={handleCopyText}
>
<LinkIcon className="h-4 w-4" />
</button>
</Tooltip>
{isArchivingAllowed && (
<Tooltip
tooltipContent={isInArchivableGroup ? "Archive" : "Only completed or canceled issues can be archived"}
>
<button
type="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>

View File

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

View File

@ -33,6 +33,10 @@ export const CycleCalendarLayout: React.FC = observer(() => {
if (!workspaceSlug || !cycleId || !projectId) return;
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]
);

View File

@ -34,6 +34,10 @@ export const ModuleCalendarLayout: React.FC = observer(() => {
if (!workspaceSlug || !moduleId) return;
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]
);

View File

@ -28,6 +28,11 @@ export const CalendarLayout: React.FC = observer(() => {
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]
);

View File

@ -16,6 +16,7 @@ export interface IViewCalendarLayout {
[EIssueActions.DELETE]: (issue: TIssue) => Promise<void>;
[EIssueActions.UPDATE]?: (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.UPDATE]?: (issue: TIssue) => Promise<void>;
[EIssueActions.REMOVE]?: (issue: TIssue) => Promise<void>;
[EIssueActions.ARCHIVE]?: (issue: TIssue) => Promise<void>;
[EIssueActions.RESTORE]?: (issue: TIssue) => Promise<void>;
};
showLoader?: boolean;
viewId?: string;
@ -188,6 +190,12 @@ export const BaseKanBanRoot: React.FC<IBaseKanBanLayout> = observer((props: IBas
handleRemoveFromView={
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}
/>
),

View File

@ -39,6 +39,11 @@ export const CycleKanBanLayout: React.FC = observer(() => {
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]
);

View File

@ -38,6 +38,11 @@ export const ModuleKanBanLayout: React.FC = observer(() => {
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]
);

View File

@ -35,6 +35,11 @@ export const ProfileIssuesKanBanLayout: React.FC = observer(() => {
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]
);

View File

@ -32,6 +32,11 @@ export const KanBanLayout: React.FC = observer(() => {
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]
);

View File

@ -17,6 +17,7 @@ export interface IViewKanBanLayout {
[EIssueActions.DELETE]: (issue: TIssue) => Promise<void>;
[EIssueActions.UPDATE]?: (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.UPDATE]?: (issue: TIssue) => Promise<void>;
[EIssueActions.REMOVE]?: (issue: TIssue) => Promise<void>;
[EIssueActions.ARCHIVE]?: (issue: TIssue) => Promise<void>;
[EIssueActions.RESTORE]?: (issue: TIssue) => Promise<void>;
};
viewId?: string;
storeType: TCreateModalStoreTypes;
@ -109,6 +111,12 @@ export const BaseListRoot = observer((props: IBaseListRoot) => {
handleRemoveFromView={
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}
/>
),

View File

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

View File

@ -24,6 +24,11 @@ export const ArchivedIssueListLayout: FC = observer(() => {
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]
);

View File

@ -38,6 +38,11 @@ export const CycleListLayout: React.FC = observer(() => {
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]
);

View File

@ -37,6 +37,11 @@ export const ModuleListLayout: React.FC = observer(() => {
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]
);

View File

@ -36,6 +36,11 @@ export const ProfileIssuesListLayout: FC = observer(() => {
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]
);

View File

@ -33,6 +33,11 @@ export const ListLayout: FC = observer(() => {
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
[issues]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -32,6 +32,10 @@ export const CycleSpreadsheetLayout: React.FC = observer(() => {
if (!workspaceSlug || !cycleId) return;
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]
);

View File

@ -31,6 +31,10 @@ export const ModuleSpreadsheetLayout: React.FC = observer(() => {
if (!workspaceSlug || !moduleId) return;
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]
);

View File

@ -28,6 +28,11 @@ export const ProjectSpreadsheetLayout: React.FC = observer(() => {
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]
);

View File

@ -17,6 +17,7 @@ export interface IViewSpreadsheetLayout {
[EIssueActions.DELETE]: (issue: TIssue) => Promise<void>;
[EIssueActions.UPDATE]?: (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",
DELETE = "delete",
REMOVE = "remove",
ARCHIVE = "archive",
RESTORE = "restore",
}

View File

@ -1,17 +1,20 @@
import { FC } from "react";
import { useRouter } from "next/router";
import { observer } from "mobx-react";
import { MoveRight, MoveDiagonal, Link2, Trash2 } from "lucide-react";
import { MoveRight, MoveDiagonal, Link2, Trash2, RotateCcw } from "lucide-react";
// ui
import { CenterPanelIcon, CustomSelect, FullScreenPanelIcon, SidePanelIcon } from "@plane/ui";
import { ArchiveIcon, CenterPanelIcon, CustomSelect, FullScreenPanelIcon, SidePanelIcon, Tooltip } from "@plane/ui";
// helpers
import { copyUrlToClipboard } from "helpers/string.helper";
// hooks
import useToast from "hooks/use-toast";
// store hooks
import { useUser } from "hooks/store";
import { useIssueDetail, useProjectState, useUser } from "hooks/store";
// helpers
import { cn } from "helpers/common.helper";
// components
import { IssueSubscription, IssueUpdateStatus } from "components/issues";
import { STATE_GROUPS } from "constants/state";
export type TPeekModes = "side-peek" | "modal" | "full-screen";
@ -43,6 +46,8 @@ export type PeekOverviewHeaderProps = {
isArchived: boolean;
disabled: boolean;
toggleDeleteIssueModal: (value: boolean) => void;
toggleArchiveIssueModal: (value: boolean) => void;
handleRestoreIssue: () => void;
isSubmitting: "submitting" | "submitted" | "saved";
};
@ -57,23 +62,31 @@ export const IssuePeekOverviewHeader: FC<PeekOverviewHeaderProps> = observer((pr
disabled,
removeRoutePeekId,
toggleDeleteIssueModal,
toggleArchiveIssueModal,
handleRestoreIssue,
isSubmitting,
} = props;
// router
const router = useRouter();
// store hooks
const { currentUser } = useUser();
const {
issue: { getIssueById },
} = useIssueDetail();
const { getStateById } = useProjectState();
// hooks
const { setToastAlert } = useToast();
// derived values
const issueDetails = getIssueById(issueId);
const stateDetails = issueDetails ? getStateById(issueDetails?.state_id) : undefined;
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>) => {
e.stopPropagation();
e.preventDefault();
copyUrlToClipboard(
`${workspaceSlug}/projects/${projectId}/${isArchived ? "archived-issues" : "issues"}/${issueId}`
).then(() => {
copyUrlToClipboard(issueLink).then(() => {
setToastAlert({
type: "success",
title: "Link Copied!",
@ -81,13 +94,15 @@ export const IssuePeekOverviewHeader: FC<PeekOverviewHeaderProps> = observer((pr
});
});
};
const redirectToIssueDetail = () => {
router.push({
pathname: `/${workspaceSlug}/projects/${projectId}/${isArchived ? "archived-issues" : "issues"}/${issueId}`,
});
router.push({ pathname: `/${issueLink}` });
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 (
<div
@ -97,11 +112,11 @@ export const IssuePeekOverviewHeader: FC<PeekOverviewHeaderProps> = observer((pr
>
<div className="flex items-center gap-4">
<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 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>
{currentMode && (
<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)}
customButton={
<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>
}
>
@ -138,13 +153,43 @@ export const IssuePeekOverviewHeader: FC<PeekOverviewHeaderProps> = observer((pr
{currentUser && !isArchived && (
<IssueSubscription workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} />
)}
<button onClick={handleCopyText}>
<Link2 className="h-4 w-4 -rotate-45 text-custom-text-300 hover:text-custom-text-200" />
</button>
{!disabled && (
<button onClick={() => toggleDeleteIssueModal(true)}>
<Trash2 className="h-4 w-4 text-custom-text-300 hover:text-custom-text-200" />
<Tooltip tooltipContent="Copy link">
<button type="button" onClick={handleCopyText}>
<Link2 className="h-4 w-4 -rotate-45 text-custom-text-300 hover:text-custom-text-200" />
</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>

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 { observer } from "mobx-react-lite";
// hooks
@ -11,7 +11,7 @@ import { TIssue } from "@plane/types";
// constants
import { EUserProjectRoles } from "constants/project";
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 {
is_archived?: boolean;
@ -28,6 +28,8 @@ export type TIssuePeekOperations = {
showToast?: boolean
) => 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>;
removeIssueFromCycle: (workspaceSlug: string, projectId: string, cycleId: string, issueId: 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 },
} = useUser();
const {
issues: { removeIssue: removeArchivedIssue },
issues: { restoreIssue },
} = useIssues(EIssuesStoreType.ARCHIVED);
const {
peekIssue,
updateIssue,
removeIssue,
archiveIssue,
issue: { getIssueById, fetchIssue },
} = useIssueDetail();
const { addIssueToCycle, removeIssueFromCycle, addModulesToIssue, removeIssueFromModule, removeModulesFromIssue } =
@ -91,7 +94,7 @@ export const IssuePeekOverview: FC<IIssuePeekOverview> = observer((props) => {
showToast: boolean = true
) => {
try {
const response = await updateIssue(workspaceSlug, projectId, issueId, data);
await updateIssue(workspaceSlug, projectId, issueId, data);
if (showToast)
setToastAlert({
title: "Issue updated successfully",
@ -122,9 +125,7 @@ export const IssuePeekOverview: FC<IIssuePeekOverview> = observer((props) => {
},
remove: async (workspaceSlug: string, projectId: string, issueId: string) => {
try {
let response;
if (is_archived) response = await removeArchivedIssue(workspaceSlug, projectId, issueId);
else response = await removeIssue(workspaceSlug, projectId, issueId);
removeIssue(workspaceSlug, projectId, issueId);
setToastAlert({
title: "Issue deleted successfully",
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[]) => {
try {
await addIssueToCycle(workspaceSlug, projectId, cycleId, issueIds);
@ -312,7 +365,8 @@ export const IssuePeekOverview: FC<IIssuePeekOverview> = observer((props) => {
fetchIssue,
updateIssue,
removeIssue,
removeArchivedIssue,
archiveIssue,
restoreIssue,
addIssueToCycle,
removeIssueFromCycle,
addModulesToIssue,
@ -343,16 +397,14 @@ export const IssuePeekOverview: FC<IIssuePeekOverview> = observer((props) => {
const isLoading = !issue || loader ? true : false;
return (
<Fragment>
<IssueView
workspaceSlug={peekIssue.workspaceSlug}
projectId={peekIssue.projectId}
issueId={peekIssue.issueId}
isLoading={isLoading}
is_archived={is_archived}
disabled={is_archived || !is_editable}
issueOperations={issueOperations}
/>
</Fragment>
<IssueView
workspaceSlug={peekIssue.workspaceSlug}
projectId={peekIssue.projectId}
issueId={peekIssue.issueId}
isLoading={isLoading}
is_archived={is_archived}
disabled={!is_editable}
issueOperations={issueOperations}
/>
);
});

View File

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

View File

@ -49,7 +49,7 @@ export const NotificationCard: React.FC<NotificationCardProps> = (props) => {
const router = useRouter();
const { workspaceSlug } = router.query;
// states
const [showSnoozeOptions, setshowSnoozeOptions] = React.useState(false);
const [showSnoozeOptions, setShowSnoozeOptions] = React.useState(false);
// toast alert
const { setToastAlert } = useToast();
// refs
@ -105,7 +105,7 @@ export const NotificationCard: React.FC<NotificationCardProps> = (props) => {
useEffect(() => {
const handleClickOutside = (event: any) => {
if (snoozeRef.current && !snoozeRef.current?.contains(event.target)) {
setshowSnoozeOptions(false);
setShowSnoozeOptions(false);
}
};
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;
return (
@ -129,7 +132,7 @@ export const NotificationCard: React.FC<NotificationCardProps> = (props) => {
closePopover();
}}
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}`}
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"
@ -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" />
)}
<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">
<Image
src={notification.triggered_by_details.avatar}
src={notificationTriggeredBy.avatar}
alt="Profile Image"
layout="fill"
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">
<span className="text-lg font-medium text-custom-text-100">
{notification.triggered_by_details.is_bot ? (
notification.triggered_by_details.first_name?.[0]?.toUpperCase()
) : notification.triggered_by_details.display_name?.[0] ? (
notification.triggered_by_details.display_name?.[0]?.toUpperCase()
{notificationTriggeredBy.is_bot ? (
notificationTriggeredBy.first_name?.[0]?.toUpperCase()
) : notificationTriggeredBy.display_name?.[0] ? (
notificationTriggeredBy.display_name?.[0]?.toUpperCase()
) : (
<User2 className="h-4 w-4" />
)}
@ -168,30 +171,32 @@ export const NotificationCard: React.FC<NotificationCardProps> = (props) => {
{!notification.message ? (
<div className="w-full break-words text-sm">
<span className="font-semibold">
{notification.triggered_by_details.is_bot
? notification.triggered_by_details.first_name
: notification.triggered_by_details.display_name}{" "}
{notificationTriggeredBy.is_bot
? notificationTriggeredBy.first_name
: notificationTriggeredBy.display_name}{" "}
</span>
{notification.data.issue_activity.field !== "comment" && notification.data.issue_activity.verb}{" "}
{notification.data.issue_activity.field === "comment"
{!["comment", "archived_at"].includes(notificationField) && notification.data.issue_activity.verb}{" "}
{notificationField === "comment"
? "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
: replaceUnderscoreIfSnakeCase(notification.data.issue_activity.field)}{" "}
{notification.data.issue_activity.field !== "comment" && notification.data.issue_activity.field !== "None"
? "to"
: ""}
: replaceUnderscoreIfSnakeCase(notificationField)}{" "}
{!["comment", "archived_at", "None"].includes(notificationField) ? "to" : ""}
<span className="font-semibold">
{" "}
{notification.data.issue_activity.field !== "None" ? (
notification.data.issue_activity.field !== "comment" ? (
notification.data.issue_activity.field === "target_date" ? (
{notificationField !== "None" ? (
notificationField !== "comment" ? (
notificationField === "target_date" ? (
renderFormattedDate(notification.data.issue_activity.new_value)
) : notification.data.issue_activity.field === "attachment" ? (
) : notificationField === "attachment" ? (
"the issue"
) : notification.data.issue_activity.field === "description" ? (
) : notificationField === "description" ? (
stripAndTruncateHTML(notification.data.issue_activity.new_value, 55)
) : (
) : notificationField === "archived_at" ? null : (
notification.data.issue_activity.new_value
)
) : (
@ -255,7 +260,7 @@ export const NotificationCard: React.FC<NotificationCardProps> = (props) => {
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
setshowSnoozeOptions(true);
setShowSnoozeOptions(true);
}}
className="flex gap-x-2 items-center p-1.5"
>
@ -280,7 +285,7 @@ export const NotificationCard: React.FC<NotificationCardProps> = (props) => {
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
setshowSnoozeOptions(false);
setShowSnoozeOptions(false);
snoozeOptionOnClick(item.value);
}}
>

View File

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

View File

@ -244,9 +244,9 @@ export const EMPTY_ISSUE_STATE_DETAILS = {
key: "archived",
title: "No archived issues yet",
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: {
text: "Set Automation",
text: "Set automation",
},
},
draft: {

View File

@ -127,20 +127,18 @@ export const getIssueEventPayload = (props: IssueEventProps) => {
return eventPayload;
};
export const getProjectStateEventPayload = (payload: any) => {
return {
workspace_id: payload.workspace_id,
project_id: payload.id,
state_id: payload.id,
created_at: payload.created_at,
updated_at: payload.updated_at,
group: payload.group,
color: payload.color,
default: payload.default,
state: payload.state,
element: payload.element,
};
};
export const getProjectStateEventPayload = (payload: any) => ({
workspace_id: payload.workspace_id,
project_id: payload.id,
state_id: payload.id,
created_at: payload.created_at,
updated_at: payload.updated_at,
group: payload.group,
color: payload.color,
default: payload.default,
state: payload.state,
element: payload.element,
});
// Workspace crud Events
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_UPDATED = "Issue updated";
export const ISSUE_DELETED = "Issue deleted";
export const ISSUE_ARCHIVED = "Issue archived";
export const ISSUE_RESTORED = "Issue restored";
export const ISSUE_OPENED = "Issue opened";
// Project State Events
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 UNREAD_NOTIFICATIONS = "Unread notifications viewed";
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";
// Groups
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 PROJECT_AUTOMATION_MONTHS = [
{ label: "1 Month", value: 1 },
{ label: "3 Months", value: 3 },
{ label: "6 Months", value: 6 },
{ label: "9 Months", value: 9 },
{ label: "12 Months", value: 12 },
{ label: "1 month", value: 1 },
{ label: "3 months", value: 3 },
{ label: "6 months", value: 6 },
{ label: "9 months", value: 9 },
{ label: "12 months", value: 12 },
];
export const PROJECT_UNSPLASH_COVERS = [

View File

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

View File

@ -5,44 +5,28 @@ import { observer } from "mobx-react";
import { AppLayout } from "layouts/app-layout";
// contexts
import { ArchivedIssueLayoutRoot } from "components/issues";
// ui
import { ArchiveIcon } from "@plane/ui";
// components
import { ProjectArchivedIssuesHeader } from "components/headers";
import { PageHead } from "components/core";
// icons
import { X } from "lucide-react";
// types
import { NextPageWithLayout } from "lib/types";
// hooks
import { useProject } from "hooks/store";
const ProjectArchivedIssuesPage: NextPageWithLayout = observer(() => {
// router
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { projectId } = router.query;
// store hooks
const { getProjectById } = useProject();
// derived values
const project = projectId ? getProjectById(projectId.toString()) : undefined;
const pageTitle = project?.name && `${project?.name} - Archived Issues`;
const pageTitle = project?.name && `${project?.name} - Archived issues`;
return (
<>
<PageHead title={pageTitle} />
<div className="flex h-full w-full flex-col">
<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>
<ArchivedIssueLayoutRoot />
</>
);
});

View File

@ -1,7 +1,8 @@
import { APIService } from "services/api.service";
// type
import { API_BASE_URL } from "helpers/common.helper";
// types
import { TIssue } from "@plane/types";
// constants
import { API_BASE_URL } from "helpers/common.helper";
export class IssueArchiveService extends APIService {
constructor() {
@ -18,8 +19,22 @@ export class IssueArchiveService extends APIService {
});
}
async unarchiveIssue(workspaceSlug: string, projectId: string, issueId: string): Promise<any> {
return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/unarchive/${issueId}/`)
async archiveIssue(
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)
.catch((error) => {
throw error?.response?.data;
@ -32,7 +47,7 @@ export class IssueArchiveService extends APIService {
issueId: string,
queries?: any
): 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,
})
.then((response) => response?.data)
@ -40,12 +55,4 @@ export class IssueArchiveService extends APIService {
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 set from "lodash/set";
import pull from "lodash/pull";
// base class
import { IssueHelperStore } from "../helpers/issue-helper.store";
// services
@ -18,7 +19,7 @@ export interface IArchivedIssues {
// actions
fetchIssues: (workspaceSlug: string, projectId: string, loadType: TLoader) => Promise<TIssue>;
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;
}
@ -48,7 +49,7 @@ export class ArchivedIssues extends IssueHelperStore implements IArchivedIssues
// action
fetchIssues: action,
removeIssue: action,
removeIssueFromArchived: action,
restoreIssue: action,
});
// root store
this.rootIssueStore = _rootStore;
@ -70,7 +71,7 @@ export class ArchivedIssues extends IssueHelperStore implements IArchivedIssues
const archivedIssueIds = this.issues[projectId];
if (!archivedIssueIds) return undefined;
const _issues = this.rootIssueStore.issues.getIssuesByIds(archivedIssueIds);
const _issues = this.rootIssueStore.issues.getIssuesByIds(archivedIssueIds, "archived");
if (!_issues) return [];
let issues: TGroupedIssues | TSubGroupedIssues | TUnGroupedIssues | undefined = undefined;
@ -113,25 +114,24 @@ export class ArchivedIssues extends IssueHelperStore implements IArchivedIssues
try {
await this.rootIssueStore.projectIssues.removeIssue(workspaceSlug, projectId, issueId);
const issueIndex = this.issues[projectId].findIndex((_issueId) => _issueId === issueId);
if (issueIndex >= 0)
runInAction(() => {
this.issues[projectId].splice(issueIndex, 1);
});
runInAction(() => {
pull(this.issues[projectId], issueId);
});
} catch (error) {
throw error;
}
};
removeIssueFromArchived = async (workspaceSlug: string, projectId: string, issueId: string) => {
restoreIssue = async (workspaceSlug: string, projectId: string, issueId: string) => {
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);
if (issueIndex && issueIndex >= 0)
runInAction(() => {
this.issues[projectId].splice(issueIndex, 1);
runInAction(() => {
this.rootStore.issues.updateIssue(issueId, {
archived_at: null,
});
pull(this.issues[projectId], issueId);
});
return response;
} catch (error) {

View File

@ -48,6 +48,12 @@ export interface ICycleIssues {
issueId: string,
cycleId?: string | undefined
) => Promise<void>;
archiveIssue: (
workspaceSlug: string,
projectId: string,
issueId: string,
cycleId?: string | undefined
) => Promise<void>;
quickAddIssue: (
workspaceSlug: string,
projectId: string,
@ -100,6 +106,7 @@ export class CycleIssues extends IssueHelperStore implements ICycleIssues {
createIssue: action,
updateIssue: action,
removeIssue: action,
archiveIssue: action,
quickAddIssue: action,
addIssueToCycle: action,
removeIssueFromCycle: action,
@ -127,7 +134,7 @@ export class CycleIssues extends IssueHelperStore implements ICycleIssues {
const cycleIssueIds = this.issues[cycleId];
if (!cycleIssueIds) return;
const _issues = this.rootIssueStore.issues.getIssuesByIds(cycleIssueIds);
const _issues = this.rootIssueStore.issues.getIssuesByIds(cycleIssueIds, "un-archived");
if (!_issues) return [];
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 (
workspaceSlug: string,
projectId: string,

View File

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

View File

@ -16,6 +16,7 @@ export interface IIssueStoreActions {
) => Promise<TIssue>;
updateIssue: (workspaceSlug: string, projectId: string, issueId: string, data: Partial<TIssue>) => 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>;
removeIssueFromCycle: (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => Promise<TIssue>;
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) =>
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[]) => {
await this.rootIssueDetailStore.rootIssueStore.cycleIssues.addIssueToCycle(
workspaceSlug,

View File

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

View File

@ -18,7 +18,7 @@ export type IIssueStore = {
removeIssue(issueId: string): void;
// helper methods
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 {
@ -108,14 +108,17 @@ export class IssueStore implements IIssueStore {
/**
* @description This method will return the issues from the issuesMap
* @param {string[]} issueIds
* @param {boolean} archivedIssues
* @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;
const filteredIssues: { [key: string]: TIssue } = {};
Object.values(this.issuesMap).forEach((issue) => {
if (issueIds.includes(issue.id)) {
filteredIssues[issue.id] = issue;
// if type is archived then check archived_at is not null
// 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;

View File

@ -46,6 +46,12 @@ export interface IModuleIssues {
issueId: string,
moduleId?: string | undefined
) => Promise<void>;
archiveIssue: (
workspaceSlug: string,
projectId: string,
issueId: string,
moduleId?: string | undefined
) => Promise<void>;
quickAddIssue: (
workspaceSlug: string,
projectId: string,
@ -103,6 +109,7 @@ export class ModuleIssues extends IssueHelperStore implements IModuleIssues {
createIssue: action,
updateIssue: action,
removeIssue: action,
archiveIssue: action,
quickAddIssue: action,
addIssuesToModule: action,
removeIssuesFromModule: action,
@ -131,7 +138,7 @@ export class ModuleIssues extends IssueHelperStore implements IModuleIssues {
const moduleIssueIds = this.issues[moduleId];
if (!moduleIssueIds) return;
const _issues = this.rootIssueStore.issues.getIssuesByIds(moduleIssueIds);
const _issues = this.rootIssueStore.issues.getIssuesByIds(moduleIssueIds, "un-archived");
if (!_issues) return [];
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 (
workspaceSlug: string,
projectId: string,

View File

@ -1,5 +1,6 @@
import { action, observable, makeObservable, computed, runInAction } from "mobx";
import set from "lodash/set";
import pull from "lodash/pull";
// base class
import { IssueHelperStore } from "../helpers/issue-helper.store";
// services
@ -48,6 +49,12 @@ export interface IProfileIssues {
issueId: string,
userId?: string | undefined
) => Promise<void>;
archiveIssue: (
workspaceSlug: string,
projectId: string,
issueId: string,
userId?: string | undefined
) => Promise<void>;
quickAddIssue: undefined;
}
@ -77,6 +84,7 @@ export class ProfileIssues extends IssueHelperStore implements IProfileIssues {
createIssue: action,
updateIssue: action,
removeIssue: action,
archiveIssue: action,
});
// root store
this.rootIssueStore = _rootStore;
@ -104,7 +112,7 @@ export class ProfileIssues extends IssueHelperStore implements IProfileIssues {
if (!userIssueIds) return;
const _issues = this.rootStore.issues.getIssuesByIds(userIssueIds);
const _issues = this.rootStore.issues.getIssuesByIds(userIssueIds, "un-archived");
if (!_issues) return [];
let issues: TGroupedIssues | TSubGroupedIssues | TUnGroupedIssues | undefined = undefined;
@ -249,4 +257,24 @@ export class ProfileIssues extends IssueHelperStore implements IProfileIssues {
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 set from "lodash/set";
import pull from "lodash/pull";
// base class
import { IssueHelperStore } from "../helpers/issue-helper.store";
// services
@ -41,6 +42,12 @@ export interface IProjectViewIssues {
issueId: string,
viewId?: string | undefined
) => Promise<void>;
archiveIssue: (
workspaceSlug: string,
projectId: string,
issueId: string,
viewId?: string | undefined
) => Promise<void>;
quickAddIssue: (
workspaceSlug: string,
projectId: string,
@ -75,6 +82,7 @@ export class ProjectViewIssues extends IssueHelperStore implements IProjectViewI
createIssue: action,
updateIssue: action,
removeIssue: action,
archiveIssue: action,
quickAddIssue: action,
});
// root store
@ -98,7 +106,7 @@ export class ProjectViewIssues extends IssueHelperStore implements IProjectViewI
const viewIssueIds = this.issues[viewId];
if (!viewIssueIds) return;
const _issues = this.rootStore.issues.getIssuesByIds(viewIssueIds);
const _issues = this.rootStore.issues.getIssuesByIds(viewIssueIds, "un-archived");
if (!_issues) return [];
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 (
workspaceSlug: string,
projectId: string,

View File

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

View File

@ -1,10 +1,11 @@
import { action, observable, makeObservable, computed, runInAction } from "mobx";
import set from "lodash/set";
import pull from "lodash/pull";
// base class
import { IssueHelperStore } from "../helpers/issue-helper.store";
// services
import { WorkspaceService } from "services/workspace.service";
import { IssueService } from "services/issue";
import { IssueService, IssueArchiveService } from "services/issue";
// types
import { IIssueRootStore } from "../root.store";
import { TIssue, TLoader, TUnGroupedIssues, ViewFlags } from "@plane/types";
@ -37,6 +38,12 @@ export interface IWorkspaceIssues {
issueId: string,
viewId?: string | undefined
) => Promise<void>;
archiveIssue: (
workspaceSlug: string,
projectId: string,
issueId: string,
viewId?: string | undefined
) => Promise<void>;
}
export class WorkspaceIssues extends IssueHelperStore implements IWorkspaceIssues {
@ -52,6 +59,7 @@ export class WorkspaceIssues extends IssueHelperStore implements IWorkspaceIssue
// service
workspaceService;
issueService;
issueArchiveService;
constructor(_rootStore: IIssueRootStore) {
super(_rootStore);
@ -67,12 +75,14 @@ export class WorkspaceIssues extends IssueHelperStore implements IWorkspaceIssue
createIssue: action,
updateIssue: action,
removeIssue: action,
archiveIssue: action,
});
// root store
this.rootIssueStore = _rootStore;
// services
this.workspaceService = new WorkspaceService();
this.issueService = new IssueService();
this.issueArchiveService = new IssueArchiveService();
}
get groupedIssueIds() {
@ -91,7 +101,7 @@ export class WorkspaceIssues extends IssueHelperStore implements IWorkspaceIssue
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: [] };
let issueIds: TIssue | TUnGroupedIssues | undefined = undefined;
@ -196,4 +206,28 @@ export class WorkspaceIssues extends IssueHelperStore implements IWorkspaceIssue
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;
}
};
}