From 30cc923fdb9fa7661a6a374e5a2a2274d496f9c0 Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com> Date: Wed, 28 Feb 2024 16:53:26 +0530 Subject: [PATCH] [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 Co-authored-by: gurusainath --- apiserver/plane/app/urls/issue.py | 16 +- apiserver/plane/app/views/issue.py | 32 +++- .../plane/bgtasks/issue_activites_task.py | 10 +- .../plane/bgtasks/issue_automation_task.py | 2 +- packages/types/src/notifications.d.ts | 24 +-- packages/ui/src/dropdowns/custom-menu.tsx | 8 +- packages/ui/src/dropdowns/helper.tsx | 1 + .../automation/auto-archive-automation.tsx | 6 +- .../automation/auto-close-automation.tsx | 6 +- .../automation/select-month-modal.tsx | 2 +- web/components/dropdowns/date.tsx | 2 +- .../project-archived-issue-details.tsx | 2 +- .../headers/project-archived-issues.tsx | 2 +- web/components/issues/archive-issue-modal.tsx | 106 +++++++++++ .../issues/delete-archived-issue-modal.tsx | 139 -------------- web/components/issues/delete-issue-modal.tsx | 20 +- web/components/issues/index.ts | 2 +- .../activity/actions/archived-at.tsx | 16 +- .../actions/helpers/activity-block.tsx | 5 +- .../activity/actions/helpers/issue-user.tsx | 25 ++- .../issue-detail/issue-activity/root.tsx | 2 +- web/components/issues/issue-detail/root.tsx | 36 +++- .../issues/issue-detail/sidebar.tsx | 114 +++++++---- .../calendar/base-calendar-root.tsx | 12 ++ .../calendar/roots/cycle-root.tsx | 4 + .../calendar/roots/module-root.tsx | 4 + .../calendar/roots/project-root.tsx | 5 + .../calendar/roots/project-view-root.tsx | 1 + .../issue-layouts/kanban/base-kanban-root.tsx | 8 + .../issue-layouts/kanban/roots/cycle-root.tsx | 5 + .../kanban/roots/module-root.tsx | 5 + .../kanban/roots/profile-issues-root.tsx | 5 + .../kanban/roots/project-root.tsx | 5 + .../kanban/roots/project-view-root.tsx | 1 + .../issue-layouts/list/base-list-root.tsx | 8 + .../issue-layouts/list/list-view-types.d.ts | 2 + .../list/roots/archived-issue-root.tsx | 5 + .../issue-layouts/list/roots/cycle-root.tsx | 5 + .../issue-layouts/list/roots/module-root.tsx | 5 + .../list/roots/profile-issues-root.tsx | 5 + .../issue-layouts/list/roots/project-root.tsx | 5 + .../list/roots/project-view-root.tsx | 1 + .../quick-action-dropdowns/all-issue.tsx | 153 ++++++++++----- .../quick-action-dropdowns/archived-issue.tsx | 55 +++--- .../quick-action-dropdowns/cycle-issue.tsx | 180 +++++++++++------- .../quick-action-dropdowns/module-issue.tsx | 178 ++++++++++------- .../quick-action-dropdowns/project-issue.tsx | 158 +++++++++------ .../roots/all-issue-layout-root.tsx | 13 +- .../spreadsheet/base-spreadsheet-root.tsx | 8 + .../issue-layouts/spreadsheet/issue-row.tsx | 8 +- .../spreadsheet/roots/cycle-root.tsx | 4 + .../spreadsheet/roots/module-root.tsx | 4 + .../spreadsheet/roots/project-root.tsx | 5 + .../spreadsheet/roots/project-view-root.tsx | 1 + web/components/issues/issue-layouts/types.ts | 2 + .../issues/peek-overview/header.tsx | 83 ++++++-- web/components/issues/peek-overview/root.tsx | 90 +++++++-- web/components/issues/peek-overview/view.tsx | 38 +++- .../notifications/notification-card.tsx | 59 +++--- web/components/project/sidebar-list-item.tsx | 9 +- web/constants/empty-state.ts | 4 +- web/constants/event-tracker.ts | 30 +-- web/constants/project.ts | 10 +- .../archived-issues/[archivedIssueId].tsx | 42 ++-- .../[projectId]/archived-issues/index.tsx | 24 +-- web/services/issue/issue_archive.service.ts | 33 ++-- web/store/issue/archived/issue.store.ts | 28 +-- web/store/issue/cycle/issue.store.ts | 29 ++- web/store/issue/draft/issue.store.ts | 2 +- web/store/issue/issue-details/issue.store.ts | 4 + web/store/issue/issue-details/root.store.ts | 9 + web/store/issue/issue.store.ts | 11 +- web/store/issue/module/issue.store.ts | 29 ++- web/store/issue/profile/issue.store.ts | 30 ++- web/store/issue/project-views/issue.store.ts | 30 ++- web/store/issue/project/issue.store.ts | 23 ++- web/store/issue/workspace/issue.store.ts | 38 +++- 77 files changed, 1402 insertions(+), 691 deletions(-) create mode 100644 web/components/issues/archive-issue-modal.tsx delete mode 100644 web/components/issues/delete-archived-issue-modal.tsx diff --git a/apiserver/plane/app/urls/issue.py b/apiserver/plane/app/urls/issue.py index 7d661b49e..4ee70450b 100644 --- a/apiserver/plane/app/urls/issue.py +++ b/apiserver/plane/app/urls/issue.py @@ -259,23 +259,15 @@ urlpatterns = [ name="project-issue-archive", ), path( - "workspaces//projects//archived-issues//", + "workspaces//projects//issues//archive/", IssueArchiveViewSet.as_view( { "get": "retrieve", - "delete": "destroy", + "post": "archive", + "delete": "unarchive", } ), - name="project-issue-archive", - ), - path( - "workspaces//projects//unarchive//", - IssueArchiveViewSet.as_view( - { - "post": "unarchive", - } - ), - name="project-issue-archive", + name="project-issue-archive-unarchive", ), ## End Issue Archives ## Issue Relation diff --git a/apiserver/plane/app/views/issue.py b/apiserver/plane/app/views/issue.py index 6d50a2aba..14e0b6a9a 100644 --- a/apiserver/plane/app/views/issue.py +++ b/apiserver/plane/app/views/issue.py @@ -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): diff --git a/apiserver/plane/bgtasks/issue_activites_task.py b/apiserver/plane/bgtasks/issue_activites_task.py index b86ab5e78..2a16ee911 100644 --- a/apiserver/plane/bgtasks/issue_activites_task.py +++ b/apiserver/plane/bgtasks/issue_activites_task.py @@ -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, ) ) diff --git a/apiserver/plane/bgtasks/issue_automation_task.py b/apiserver/plane/bgtasks/issue_automation_task.py index 974a545fc..c6c4d7515 100644 --- a/apiserver/plane/bgtasks/issue_automation_task.py +++ b/apiserver/plane/bgtasks/issue_automation_task.py @@ -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, diff --git a/packages/types/src/notifications.d.ts b/packages/types/src/notifications.d.ts index 8033c19a9..652e2776f 100644 --- a/packages/types/src/notifications.d.ts +++ b/packages/types/src/notifications.d.ts @@ -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 { diff --git a/packages/ui/src/dropdowns/custom-menu.tsx b/packages/ui/src/dropdowns/custom-menu.tsx index 37aba932a..cdfccbb4e 100644 --- a/packages/ui/src/dropdowns/custom-menu.tsx +++ b/packages/ui/src/dropdowns/custom-menu.tsx @@ -177,17 +177,18 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => { }; const MenuItem: React.FC = (props) => { - const { children, onClick, className = "" } = props; + const { children, disabled = false, onClick, className } = props; return ( - + {({ active, close }) => ( diff --git a/packages/ui/src/dropdowns/helper.tsx b/packages/ui/src/dropdowns/helper.tsx index 930f332b9..f600499fe 100644 --- a/packages/ui/src/dropdowns/helper.tsx +++ b/packages/ui/src/dropdowns/helper.tsx @@ -64,6 +64,7 @@ export type ICustomSearchSelectProps = IDropdownProps & export interface ICustomMenuItemProps { children: React.ReactNode; + disabled?: boolean; onClick?: (args?: any) => void; className?: string; } diff --git a/web/components/automation/auto-archive-automation.tsx b/web/components/automation/auto-archive-automation.tsx index 974efff3a..d871b64d0 100644 --- a/web/components/automation/auto-archive-automation.tsx +++ b/web/components/automation/auto-archive-automation.tsx @@ -48,7 +48,7 @@ export const AutoArchiveAutomation: React.FC = observer((props) => {

Auto-archive closed issues

- Plane will auto archive issues that have been completed or cancelled. + Plane will auto archive issues that have been completed or canceled.

@@ -73,7 +73,7 @@ export const AutoArchiveAutomation: React.FC = observer((props) => { { handleChange({ archive_in: val }); @@ -93,7 +93,7 @@ export const AutoArchiveAutomation: React.FC = 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 diff --git a/web/components/automation/auto-close-automation.tsx b/web/components/automation/auto-close-automation.tsx index 8d6662c11..2ae4d1f9c 100644 --- a/web/components/automation/auto-close-automation.tsx +++ b/web/components/automation/auto-close-automation.tsx @@ -74,7 +74,7 @@ export const AutoCloseAutomation: React.FC = observer((props) => {

Auto-close issues

- Plane will automatically close issue that haven{"'"}t been completed or cancelled. + Plane will automatically close issue that haven{"'"}t been completed or canceled.

@@ -100,7 +100,7 @@ export const AutoCloseAutomation: React.FC = observer((props) => { { handleChange({ close_in: val }); @@ -119,7 +119,7 @@ export const AutoCloseAutomation: React.FC = 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 diff --git a/web/components/automation/select-month-modal.tsx b/web/components/automation/select-month-modal.tsx index 1d306bb04..01d07f64a 100644 --- a/web/components/automation/select-month-modal.tsx +++ b/web/components/automation/select-month-modal.tsx @@ -72,7 +72,7 @@ export const SelectMonthModal: React.FC = ({ type, initialValues, isOpen,
- Customise Time Range + Customize time range
diff --git a/web/components/dropdowns/date.tsx b/web/components/dropdowns/date.tsx index 2ba5c8af3..570ea45da 100644 --- a/web/components/dropdowns/date.tsx +++ b/web/components/dropdowns/date.tsx @@ -147,7 +147,7 @@ export const DateDropdown: React.FC = (props) => { {BUTTON_VARIANTS_WITH_TEXT.includes(buttonVariant) && ( {value ? renderFormattedDate(value) : placeholder} )} - {isClearable && isDateSelected && ( + {isClearable && !disabled && isDateSelected && ( { diff --git a/web/components/headers/project-archived-issue-details.tsx b/web/components/headers/project-archived-issue-details.tsx index 3b3e05f1a..9d4596f83 100644 --- a/web/components/headers/project-archived-issue-details.tsx +++ b/web/components/headers/project-archived-issue-details.tsx @@ -71,7 +71,7 @@ export const ProjectArchivedIssueDetailsHeader: FC = observer(() => { link={ } /> } diff --git a/web/components/headers/project-archived-issues.tsx b/web/components/headers/project-archived-issues.tsx index b7ca78ede..d1da1c859 100644 --- a/web/components/headers/project-archived-issues.tsx +++ b/web/components/headers/project-archived-issues.tsx @@ -109,7 +109,7 @@ export const ProjectArchivedIssuesHeader: FC = observer(() => { type="text" link={ } /> } diff --git a/web/components/issues/archive-issue-modal.tsx b/web/components/issues/archive-issue-modal.tsx new file mode 100644 index 000000000..94c4c801a --- /dev/null +++ b/web/components/issues/archive-issue-modal.tsx @@ -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; +}; + +export const ArchiveIssueModal: React.FC = (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 ( + + + +
+ + +
+
+ + +
+

+ Archive issue {projectDetails?.identifier} {issue.sequence_id} +

+

+ Are you sure you want to archive the issue? All your archived issues can be restored later. +

+
+ + +
+
+
+
+
+
+
+
+ ); +}; diff --git a/web/components/issues/delete-archived-issue-modal.tsx b/web/components/issues/delete-archived-issue-modal.tsx deleted file mode 100644 index 49d9e19dd..000000000 --- a/web/components/issues/delete-archived-issue-modal.tsx +++ /dev/null @@ -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; -}; - -export const DeleteArchivedIssueModal: React.FC = 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 ( - - - -
- - -
-
- - -
-
- - - -

Delete Archived Issue

-
-
- -

- Are you sure you want to delete issue{" "} - - {getProjectById(data?.project_id)?.identifier}-{data?.sequence_id} - - {""}? All of the data related to the archived issue will be permanently removed. This action - cannot be undone. -

-
-
- - -
-
-
-
-
-
-
-
- ); -}); diff --git a/web/components/issues/delete-issue-modal.tsx b/web/components/issues/delete-issue-modal.tsx index a063980c0..3a9c0653e 100644 --- a/web/components/issues/delete-issue-modal.tsx +++ b/web/components/issues/delete-issue-modal.tsx @@ -23,14 +23,14 @@ export const DeleteIssueModal: React.FC = (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) => { 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) => { message: "Failed to delete issue", }); }) - .finally(() => setIsDeleteLoading(false)); + .finally(() => setIsDeleting(false)); }; return ( @@ -109,14 +109,8 @@ export const DeleteIssueModal: React.FC = (props) => { -
diff --git a/web/components/issues/index.ts b/web/components/issues/index.ts index cab935a5d..d001a29c2 100644 --- a/web/components/issues/index.ts +++ b/web/components/issues/index.ts @@ -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"; diff --git a/web/components/issues/issue-detail/issue-activity/activity/actions/archived-at.tsx b/web/components/issues/issue-detail/issue-activity/activity/actions/archived-at.tsx index 55f07870c..2335e4d32 100644 --- a/web/components/issues/issue-detail/issue-activity/activity/actions/archived-at.tsx +++ b/web/components/issues/issue-detail/issue-activity/activity/actions/archived-at.tsx @@ -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 = observer((p const activity = getActivityById(activityId); if (!activity) return <>; + return ( ); }); diff --git a/web/components/issues/issue-detail/issue-activity/activity/actions/helpers/activity-block.tsx b/web/components/issues/issue-detail/issue-activity/activity/actions/helpers/activity-block.tsx index c7b75340b..e209b4bbf 100644 --- a/web/components/issues/issue-detail/issue-activity/activity/actions/helpers/activity-block.tsx +++ b/web/components/issues/issue-detail/issue-activity/activity/actions/helpers/activity-block.tsx @@ -14,10 +14,11 @@ type TIssueActivityBlockComponent = { activityId: string; ends: "top" | "bottom" | undefined; children: ReactNode; + customUserName?: string; }; export const IssueActivityBlockComponent: FC = (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 = (pr {icon ? icon : }
- + {children} = (props) => { - const { activityId } = props; + const { activityId, customUserName } = props; // hooks const { activity: { getActivityById }, @@ -18,12 +18,19 @@ export const IssueUser: FC = (props) => { const activity = getActivityById(activityId); if (!activity) return <>; + return ( - - {activity.actor_detail?.display_name} - + <> + {customUserName ? ( + {customUserName} + ) : ( + + {activity.actor_detail?.display_name} + + )} + ); }; diff --git a/web/components/issues/issue-detail/issue-activity/root.tsx b/web/components/issues/issue-detail/issue-activity/root.tsx index 42d856b1e..695b248de 100644 --- a/web/components/issues/issue-detail/issue-activity/root.tsx +++ b/web/components/issues/issue-detail/issue-activity/root.tsx @@ -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, }, { diff --git a/web/components/issues/issue-detail/root.tsx b/web/components/issues/issue-detail/root.tsx index 02164cff9..0e343d9a8 100644 --- a/web/components/issues/issue-detail/root.tsx +++ b/web/components/issues/issue-detail/root.tsx @@ -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; remove: (workspaceSlug: string, projectId: string, issueId: string) => Promise; + archive?: (workspaceSlug: string, projectId: string, issueId: string) => Promise; + restore?: (workspaceSlug: string, projectId: string, issueId: string) => Promise; addIssueToCycle?: (workspaceSlug: string, projectId: string, cycleId: string, issueIds: string[]) => Promise; removeIssueFromCycle?: (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => Promise; addModulesToIssue?: (workspaceSlug: string, projectId: string, issueId: string, moduleIds: string[]) => Promise; @@ -63,6 +65,7 @@ export const IssueDetailRoot: FC = observer((props) => { fetchIssue, updateIssue, removeIssue, + archiveIssue, addIssueToCycle, removeIssueFromCycle, addModulesToIssue, @@ -158,6 +161,32 @@ export const IssueDetailRoot: FC = 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 = observer((props) => { fetchIssue, updateIssue, removeIssue, + archiveIssue, removeArchivedIssue, addIssueToCycle, removeIssueFromCycle, @@ -350,7 +380,7 @@ export const IssueDetailRoot: FC = observer((props) => { /> ) : (
-
+
= observer((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 = 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 = 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 = observer((props) => { return ( <> - {workspaceSlug && projectId && issue && ( - setDeleteIssueModal(false)} - isOpen={deleteIssueModal} - data={issue} - onSubmit={async () => { - await issueOperations.remove(workspaceSlug, projectId, issueId); - router.push(`/${workspaceSlug}/projects/${projectId}/issues`); - }} - /> - )} - + setDeleteIssueModal(false)} + isOpen={deleteIssueModal} + data={issue} + onSubmit={handleDeleteIssue} + /> + setArchiveIssueModal(false)} + data={issue} + onSubmit={handleArchiveIssue} + />
-
+
{currentUser && !is_archived && ( )} - - - - {is_editable && ( - - )} +
+ + + + {isArchivingAllowed && ( + + + + )} + {is_editable && ( + + + + )} +
diff --git a/web/components/issues/issue-layouts/calendar/base-calendar-root.tsx b/web/components/issues/issue-layouts/calendar/base-calendar-root.tsx index 472d68085..43f62e5be 100644 --- a/web/components/issues/issue-layouts/calendar/base-calendar-root.tsx +++ b/web/components/issues/issue-layouts/calendar/base-calendar-root.tsx @@ -26,6 +26,8 @@ interface IBaseCalendarRoot { [EIssueActions.DELETE]: (issue: TIssue) => Promise; [EIssueActions.UPDATE]?: (issue: TIssue) => Promise; [EIssueActions.REMOVE]?: (issue: TIssue) => Promise; + [EIssueActions.ARCHIVE]?: (issue: TIssue) => Promise; + [EIssueActions.RESTORE]?: (issue: TIssue) => Promise; }; 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} /> )} diff --git a/web/components/issues/issue-layouts/calendar/roots/cycle-root.tsx b/web/components/issues/issue-layouts/calendar/roots/cycle-root.tsx index 1ef08ea61..4daf68b9f 100644 --- a/web/components/issues/issue-layouts/calendar/roots/cycle-root.tsx +++ b/web/components/issues/issue-layouts/calendar/roots/cycle-root.tsx @@ -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] ); diff --git a/web/components/issues/issue-layouts/calendar/roots/module-root.tsx b/web/components/issues/issue-layouts/calendar/roots/module-root.tsx index d2b23e176..cb474d25e 100644 --- a/web/components/issues/issue-layouts/calendar/roots/module-root.tsx +++ b/web/components/issues/issue-layouts/calendar/roots/module-root.tsx @@ -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] ); diff --git a/web/components/issues/issue-layouts/calendar/roots/project-root.tsx b/web/components/issues/issue-layouts/calendar/roots/project-root.tsx index 40f72e7b8..d42a8c5d2 100644 --- a/web/components/issues/issue-layouts/calendar/roots/project-root.tsx +++ b/web/components/issues/issue-layouts/calendar/roots/project-root.tsx @@ -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] ); diff --git a/web/components/issues/issue-layouts/calendar/roots/project-view-root.tsx b/web/components/issues/issue-layouts/calendar/roots/project-view-root.tsx index 573a9cf20..0110aea2b 100644 --- a/web/components/issues/issue-layouts/calendar/roots/project-view-root.tsx +++ b/web/components/issues/issue-layouts/calendar/roots/project-view-root.tsx @@ -16,6 +16,7 @@ export interface IViewCalendarLayout { [EIssueActions.DELETE]: (issue: TIssue) => Promise; [EIssueActions.UPDATE]?: (issue: TIssue) => Promise; [EIssueActions.REMOVE]?: (issue: TIssue) => Promise; + [EIssueActions.ARCHIVE]?: (issue: TIssue) => Promise; }; } diff --git a/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx b/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx index 3b31f6b67..0d7a984b1 100644 --- a/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx +++ b/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx @@ -41,6 +41,8 @@ export interface IBaseKanBanLayout { [EIssueActions.DELETE]: (issue: TIssue) => Promise; [EIssueActions.UPDATE]?: (issue: TIssue) => Promise; [EIssueActions.REMOVE]?: (issue: TIssue) => Promise; + [EIssueActions.ARCHIVE]?: (issue: TIssue) => Promise; + [EIssueActions.RESTORE]?: (issue: TIssue) => Promise; }; showLoader?: boolean; viewId?: string; @@ -188,6 +190,12 @@ export const BaseKanBanRoot: React.FC = 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} /> ), diff --git a/web/components/issues/issue-layouts/kanban/roots/cycle-root.tsx b/web/components/issues/issue-layouts/kanban/roots/cycle-root.tsx index f496a5120..001169933 100644 --- a/web/components/issues/issue-layouts/kanban/roots/cycle-root.tsx +++ b/web/components/issues/issue-layouts/kanban/roots/cycle-root.tsx @@ -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] ); diff --git a/web/components/issues/issue-layouts/kanban/roots/module-root.tsx b/web/components/issues/issue-layouts/kanban/roots/module-root.tsx index c3af69e6e..07ad7eb83 100644 --- a/web/components/issues/issue-layouts/kanban/roots/module-root.tsx +++ b/web/components/issues/issue-layouts/kanban/roots/module-root.tsx @@ -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] ); diff --git a/web/components/issues/issue-layouts/kanban/roots/profile-issues-root.tsx b/web/components/issues/issue-layouts/kanban/roots/profile-issues-root.tsx index 2e189c9f4..c6c041654 100644 --- a/web/components/issues/issue-layouts/kanban/roots/profile-issues-root.tsx +++ b/web/components/issues/issue-layouts/kanban/roots/profile-issues-root.tsx @@ -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] ); diff --git a/web/components/issues/issue-layouts/kanban/roots/project-root.tsx b/web/components/issues/issue-layouts/kanban/roots/project-root.tsx index 89e2ee187..efd86bc8e 100644 --- a/web/components/issues/issue-layouts/kanban/roots/project-root.tsx +++ b/web/components/issues/issue-layouts/kanban/roots/project-root.tsx @@ -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] ); diff --git a/web/components/issues/issue-layouts/kanban/roots/project-view-root.tsx b/web/components/issues/issue-layouts/kanban/roots/project-view-root.tsx index 1cdf71d45..8dd33b728 100644 --- a/web/components/issues/issue-layouts/kanban/roots/project-view-root.tsx +++ b/web/components/issues/issue-layouts/kanban/roots/project-view-root.tsx @@ -17,6 +17,7 @@ export interface IViewKanBanLayout { [EIssueActions.DELETE]: (issue: TIssue) => Promise; [EIssueActions.UPDATE]?: (issue: TIssue) => Promise; [EIssueActions.REMOVE]?: (issue: TIssue) => Promise; + [EIssueActions.ARCHIVE]?: (issue: TIssue) => Promise; }; } diff --git a/web/components/issues/issue-layouts/list/base-list-root.tsx b/web/components/issues/issue-layouts/list/base-list-root.tsx index 6cec6d358..ffe9de661 100644 --- a/web/components/issues/issue-layouts/list/base-list-root.tsx +++ b/web/components/issues/issue-layouts/list/base-list-root.tsx @@ -41,6 +41,8 @@ interface IBaseListRoot { [EIssueActions.DELETE]: (issue: TIssue) => Promise; [EIssueActions.UPDATE]?: (issue: TIssue) => Promise; [EIssueActions.REMOVE]?: (issue: TIssue) => Promise; + [EIssueActions.ARCHIVE]?: (issue: TIssue) => Promise; + [EIssueActions.RESTORE]?: (issue: TIssue) => Promise; }; 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} /> ), diff --git a/web/components/issues/issue-layouts/list/list-view-types.d.ts b/web/components/issues/issue-layouts/list/list-view-types.d.ts index e369410af..f435d0639 100644 --- a/web/components/issues/issue-layouts/list/list-view-types.d.ts +++ b/web/components/issues/issue-layouts/list/list-view-types.d.ts @@ -5,6 +5,8 @@ export interface IQuickActionProps { handleDelete: () => Promise; handleUpdate?: (data: TIssue) => Promise; handleRemoveFromView?: () => Promise; + handleArchive?: () => Promise; + handleRestore?: () => Promise; customActionButton?: React.ReactElement; portalElement?: HTMLDivElement | null; readOnly?: boolean; diff --git a/web/components/issues/issue-layouts/list/roots/archived-issue-root.tsx b/web/components/issues/issue-layouts/list/roots/archived-issue-root.tsx index 2ba4ea7f5..6e70d00d0 100644 --- a/web/components/issues/issue-layouts/list/roots/archived-issue-root.tsx +++ b/web/components/issues/issue-layouts/list/roots/archived-issue-root.tsx @@ -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] ); diff --git a/web/components/issues/issue-layouts/list/roots/cycle-root.tsx b/web/components/issues/issue-layouts/list/roots/cycle-root.tsx index d7ea66904..5c15ebe60 100644 --- a/web/components/issues/issue-layouts/list/roots/cycle-root.tsx +++ b/web/components/issues/issue-layouts/list/roots/cycle-root.tsx @@ -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] ); diff --git a/web/components/issues/issue-layouts/list/roots/module-root.tsx b/web/components/issues/issue-layouts/list/roots/module-root.tsx index 520a2da32..95c62d34c 100644 --- a/web/components/issues/issue-layouts/list/roots/module-root.tsx +++ b/web/components/issues/issue-layouts/list/roots/module-root.tsx @@ -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] ); diff --git a/web/components/issues/issue-layouts/list/roots/profile-issues-root.tsx b/web/components/issues/issue-layouts/list/roots/profile-issues-root.tsx index 91e80382a..fa4a05bbc 100644 --- a/web/components/issues/issue-layouts/list/roots/profile-issues-root.tsx +++ b/web/components/issues/issue-layouts/list/roots/profile-issues-root.tsx @@ -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] ); diff --git a/web/components/issues/issue-layouts/list/roots/project-root.tsx b/web/components/issues/issue-layouts/list/roots/project-root.tsx index f0479b71f..9e1b5830b 100644 --- a/web/components/issues/issue-layouts/list/roots/project-root.tsx +++ b/web/components/issues/issue-layouts/list/roots/project-root.tsx @@ -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] diff --git a/web/components/issues/issue-layouts/list/roots/project-view-root.tsx b/web/components/issues/issue-layouts/list/roots/project-view-root.tsx index dd384ba93..5ecfd6da2 100644 --- a/web/components/issues/issue-layouts/list/roots/project-view-root.tsx +++ b/web/components/issues/issue-layouts/list/roots/project-view-root.tsx @@ -17,6 +17,7 @@ export interface IViewListLayout { [EIssueActions.DELETE]: (issue: TIssue) => Promise; [EIssueActions.UPDATE]?: (issue: TIssue) => Promise; [EIssueActions.REMOVE]?: (issue: TIssue) => Promise; + [EIssueActions.ARCHIVE]?: (issue: TIssue) => Promise; }; } diff --git a/web/components/issues/issue-layouts/quick-action-dropdowns/all-issue.tsx b/web/components/issues/issue-layouts/quick-action-dropdowns/all-issue.tsx index bc6518911..1d0472454 100644 --- a/web/components/issues/issue-layouts/quick-action-dropdowns/all-issue.tsx +++ b/web/components/issues/issue-layouts/quick-action-dropdowns/all-issue.tsx @@ -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 = (props) => { - const { issue, handleDelete, handleUpdate, customActionButton, portalElement, readOnly = false } = props; +export const AllIssueQuickActions: React.FC = observer((props) => { + const { + issue, + handleDelete, + handleUpdate, + handleArchive, + customActionButton, + portalElement, + readOnly = false, + } = props; // states const [createUpdateIssueModal, setCreateUpdateIssueModal] = useState(false); const [issueToEdit, setIssueToEdit] = useState(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 = (props) => { return ( <> + setArchiveIssueModal(false)} + onSubmit={handleArchive} + /> = (props) => { closeOnSelect ellipsis > - { - handleCopyIssueLink(); - }} - > + {isEditingAllowed && ( + { + setTrackElement("Global issues"); + setIssueToEdit(issue); + setCreateUpdateIssueModal(true); + }} + > +
+ + Edit +
+
+ )} + +
+ + Open in new tab +
+
+
Copy link
- {!readOnly && ( - <> - { - setTrackElement("Global issues"); - setIssueToEdit(issue); - setCreateUpdateIssueModal(true); - }} - > + {isEditingAllowed && ( + { + setTrackElement("Global issues"); + setCreateUpdateIssueModal(true); + }} + > +
+ + Make a copy +
+
+ )} + {isArchivingAllowed && ( + setArchiveIssueModal(true)} disabled={!isInArchivableGroup}> + {isInArchivableGroup ? (
- - Edit issue + + Archive
-
- { - setTrackElement("Global issues"); - setCreateUpdateIssueModal(true); - }} - > -
- - Make a copy + ) : ( +
+ +
+

Archive

+

+ Only completed or canceled +
+ issues can be archived +

+
- - { - setTrackElement("Global issues"); - setDeleteIssueModal(true); - }} - > -
- - Delete issue -
-
- + )} + + )} + {isEditingAllowed && ( + { + setTrackElement("Global issues"); + setDeleteIssueModal(true); + }} + > +
+ + Delete +
+
)} ); -}; +}); diff --git a/web/components/issues/issue-layouts/quick-action-dropdowns/archived-issue.tsx b/web/components/issues/issue-layouts/quick-action-dropdowns/archived-issue.tsx index e331d7182..9bad920c8 100644 --- a/web/components/issues/issue-layouts/quick-action-dropdowns/archived-issue.tsx +++ b/web/components/issues/issue-layouts/quick-action-dropdowns/archived-issue.tsx @@ -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 = (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 ( <> - setDeleteIssueModal(false)} @@ -61,17 +62,27 @@ export const ArchivedIssueQuickActions: React.FC = (props) => closeOnSelect ellipsis > - { - handleCopyIssueLink(); - }} - > + {isRestoringAllowed && ( + +
+ + Restore +
+
+ )} + +
+ + Open in new tab +
+
+
Copy link
- {isEditingAllowed && !readOnly && ( + {isEditingAllowed && ( { setTrackElement(activeLayout); diff --git a/web/components/issues/issue-layouts/quick-action-dropdowns/cycle-issue.tsx b/web/components/issues/issue-layouts/quick-action-dropdowns/cycle-issue.tsx index 4699b1c81..3c0ed9800 100644 --- a/web/components/issues/issue-layouts/quick-action-dropdowns/cycle-issue.tsx +++ b/web/components/issues/issue-layouts/quick-action-dropdowns/cycle-issue.tsx @@ -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 = (props) => { +export const CycleIssueQuickActions: React.FC = observer((props) => { const { issue, handleDelete, handleUpdate, handleRemoveFromView, + handleArchive, customActionButton, portalElement, readOnly = false, @@ -31,33 +34,42 @@ export const CycleIssueQuickActions: React.FC = (props) => { const [createUpdateIssueModal, setCreateUpdateIssueModal] = useState(false); const [issueToEdit, setIssueToEdit] = useState(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 = (props) => { return ( <> + setArchiveIssueModal(false)} + onSubmit={handleArchive} + /> = (props) => { closeOnSelect ellipsis > - { - handleCopyIssueLink(); - }} - > + {isEditingAllowed && ( + { + setIssueToEdit({ + ...issue, + cycle_id: cycleId?.toString() ?? null, + }); + setTrackElement(activeLayout); + setCreateUpdateIssueModal(true); + }} + > +
+ + Edit +
+
+ )} + +
+ + Open in new tab +
+
+
Copy link
- {isEditingAllowed && !readOnly && ( - <> - { - setIssueToEdit({ - ...issue, - cycle_id: cycleId?.toString() ?? null, - }); - setTrackElement(activeLayout); - setCreateUpdateIssueModal(true); - }} - > + {isEditingAllowed && ( + { + setTrackElement(activeLayout); + setCreateUpdateIssueModal(true); + }} + > +
+ + Make a copy +
+
+ )} + {isEditingAllowed && ( + { + handleRemoveFromView && handleRemoveFromView(); + }} + > +
+ + Remove from cycle +
+
+ )} + {isArchivingAllowed && ( + setArchiveIssueModal(true)} disabled={!isInArchivableGroup}> + {isInArchivableGroup ? (
- - Edit issue + + Archive
-
- { - handleRemoveFromView && handleRemoveFromView(); - }} - > -
- - Remove from cycle + ) : ( +
+ +
+

Archive

+

+ Only completed or canceled +
+ issues can be archived +

+
- - { - setTrackElement(activeLayout); - setCreateUpdateIssueModal(true); - }} - > -
- - Make a copy -
-
- { - setTrackElement(activeLayout); - setDeleteIssueModal(true); - }} - > -
- - Delete issue -
-
- + )} + + )} + {isDeletingAllowed && ( + { + setTrackElement(activeLayout); + setDeleteIssueModal(true); + }} + > +
+ + Delete +
+
)} ); -}; +}); diff --git a/web/components/issues/issue-layouts/quick-action-dropdowns/module-issue.tsx b/web/components/issues/issue-layouts/quick-action-dropdowns/module-issue.tsx index 6eabfda59..5532fa2a0 100644 --- a/web/components/issues/issue-layouts/quick-action-dropdowns/module-issue.tsx +++ b/web/components/issues/issue-layouts/quick-action-dropdowns/module-issue.tsx @@ -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 = (props) => { +export const ModuleIssueQuickActions: React.FC = observer((props) => { const { issue, handleDelete, handleUpdate, handleRemoveFromView, + handleArchive, customActionButton, portalElement, readOnly = false, @@ -31,33 +34,42 @@ export const ModuleIssueQuickActions: React.FC = (props) => { const [createUpdateIssueModal, setCreateUpdateIssueModal] = useState(false); const [issueToEdit, setIssueToEdit] = useState(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 = (props) => { return ( <> + setArchiveIssueModal(false)} + onSubmit={handleArchive} + /> = (props) => { closeOnSelect ellipsis > - { - handleCopyIssueLink(); - }} - > + {isEditingAllowed && ( + { + setIssueToEdit({ ...issue, module_ids: moduleId ? [moduleId.toString()] : [] }); + setTrackElement(activeLayout); + setCreateUpdateIssueModal(true); + }} + > +
+ + Edit +
+
+ )} + +
+ + Open in new tab +
+
+
Copy link
- {isEditingAllowed && !readOnly && ( - <> - { - setIssueToEdit({ ...issue, module_ids: moduleId ? [moduleId.toString()] : [] }); - setTrackElement(activeLayout); - setCreateUpdateIssueModal(true); - }} - > + {isEditingAllowed && ( + { + setTrackElement(activeLayout); + setCreateUpdateIssueModal(true); + }} + > +
+ + Make a copy +
+
+ )} + {isEditingAllowed && ( + { + handleRemoveFromView && handleRemoveFromView(); + }} + > +
+ + Remove from module +
+
+ )} + {isArchivingAllowed && ( + setArchiveIssueModal(true)} disabled={!isInArchivableGroup}> + {isInArchivableGroup ? (
- - Edit issue + + Archive
-
- { - handleRemoveFromView && handleRemoveFromView(); - }} - > -
- - Remove from module + ) : ( +
+ +
+

Archive

+

+ Only completed or canceled +
+ issues can be archived +

+
- - { - setTrackElement(activeLayout); - setCreateUpdateIssueModal(true); - }} - > -
- - Make a copy -
-
- { - e.preventDefault(); - e.stopPropagation(); - setTrackElement(activeLayout); - setDeleteIssueModal(true); - }} - > -
- - Delete issue -
-
- + )} + + )} + {isDeletingAllowed && ( + { + e.preventDefault(); + e.stopPropagation(); + setTrackElement(activeLayout); + setDeleteIssueModal(true); + }} + > +
+ + Delete +
+
)} ); -}; +}); diff --git a/web/components/issues/issue-layouts/quick-action-dropdowns/project-issue.tsx b/web/components/issues/issue-layouts/quick-action-dropdowns/project-issue.tsx index b12724301..b32929009 100644 --- a/web/components/issues/issue-layouts/quick-action-dropdowns/project-issue.tsx +++ b/web/components/issues/issue-layouts/quick-action-dropdowns/project-issue.tsx @@ -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 = (props) => { - const { issue, handleDelete, handleUpdate, customActionButton, portalElement, readOnly = false } = props; +export const ProjectIssueQuickActions: React.FC = 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 = (props) => const [createUpdateIssueModal, setCreateUpdateIssueModal] = useState(false); const [issueToEdit, setIssueToEdit] = useState(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 = (props) => return ( <> + setArchiveIssueModal(false)} + onSubmit={handleArchive} + /> setDeleteIssueModal(false)} onSubmit={handleDelete} /> - { @@ -82,7 +107,6 @@ export const ProjectIssueQuickActions: React.FC = (props) => storeType={EIssuesStoreType.PROJECT} isDraft={isDraftIssue} /> - = (props) => closeOnSelect ellipsis > - { - handleCopyIssueLink(); - }} - > + {isEditingAllowed && ( + { + setTrackElement(activeLayout); + setIssueToEdit(issue); + setCreateUpdateIssueModal(true); + }} + > +
+ + Edit +
+
+ )} + +
+ + Open in new tab +
+
+
Copy link
- {isEditingAllowed && !readOnly && ( - <> - { - setTrackElement(activeLayout); - setIssueToEdit(issue); - setCreateUpdateIssueModal(true); - }} - > + {isEditingAllowed && ( + { + setTrackElement(activeLayout); + setCreateUpdateIssueModal(true); + }} + > +
+ + Make a copy +
+
+ )} + {isArchivingAllowed && ( + setArchiveIssueModal(true)} disabled={!isInArchivableGroup}> + {isInArchivableGroup ? (
- - Edit issue + + Archive
-
- { - setTrackElement(activeLayout); - setCreateUpdateIssueModal(true); - }} - > -
- - Make a copy + ) : ( +
+ +
+

Archive

+

+ Only completed or canceled +
+ issues can be archived +

+
- - { - setTrackElement(activeLayout); - setDeleteIssueModal(true); - }} - > -
- - Delete issue -
-
- + )} + + )} + {isDeletingAllowed && ( + { + setTrackElement(activeLayout); + setDeleteIssueModal(true); + }} + > +
+ + Delete +
+
)} ); -}; +}); diff --git a/web/components/issues/issue-layouts/roots/all-issue-layout-root.tsx b/web/components/issues/issue-layouts/roots/all-issue-layout-root.tsx index d6a9374fe..3b098c8a1 100644 --- a/web/components/issues/issue-layouts/roots/all-issue-layout-root.tsx +++ b/web/components/issues/issue-layouts/roots/all-issue-layout-root.tsx @@ -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; diff --git a/web/components/issues/issue-layouts/spreadsheet/base-spreadsheet-root.tsx b/web/components/issues/issue-layouts/spreadsheet/base-spreadsheet-root.tsx index a94455a0b..2f09b55d6 100644 --- a/web/components/issues/issue-layouts/spreadsheet/base-spreadsheet-root.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/base-spreadsheet-root.tsx @@ -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; }; 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} /> diff --git a/web/components/issues/issue-layouts/spreadsheet/issue-row.tsx b/web/components/issues/issue-layouts/spreadsheet/issue-row.tsx index a2935c4d7..143c37fb3 100644 --- a/web/components/issues/issue-layouts/spreadsheet/issue-row.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/issue-row.tsx @@ -216,11 +216,9 @@ const IssueRowDetails = observer((props: IssueRowDetailsProps) => { {getProjectIdentifierById(issueDetail.project_id)}-{issueDetail.sequence_id} - {canEditProperties(issueDetail.project_id) && ( - - )} +
{issueDetail.sub_issues_count > 0 && ( diff --git a/web/components/issues/issue-layouts/spreadsheet/roots/cycle-root.tsx b/web/components/issues/issue-layouts/spreadsheet/roots/cycle-root.tsx index 0ac5db0aa..6d3037de0 100644 --- a/web/components/issues/issue-layouts/spreadsheet/roots/cycle-root.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/roots/cycle-root.tsx @@ -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] ); diff --git a/web/components/issues/issue-layouts/spreadsheet/roots/module-root.tsx b/web/components/issues/issue-layouts/spreadsheet/roots/module-root.tsx index 6538ab20b..af8abc801 100644 --- a/web/components/issues/issue-layouts/spreadsheet/roots/module-root.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/roots/module-root.tsx @@ -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] ); diff --git a/web/components/issues/issue-layouts/spreadsheet/roots/project-root.tsx b/web/components/issues/issue-layouts/spreadsheet/roots/project-root.tsx index e260daee1..4ce54cff5 100644 --- a/web/components/issues/issue-layouts/spreadsheet/roots/project-root.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/roots/project-root.tsx @@ -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] ); diff --git a/web/components/issues/issue-layouts/spreadsheet/roots/project-view-root.tsx b/web/components/issues/issue-layouts/spreadsheet/roots/project-view-root.tsx index 28b766cd1..d8b7571e5 100644 --- a/web/components/issues/issue-layouts/spreadsheet/roots/project-view-root.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/roots/project-view-root.tsx @@ -17,6 +17,7 @@ export interface IViewSpreadsheetLayout { [EIssueActions.DELETE]: (issue: TIssue) => Promise; [EIssueActions.UPDATE]?: (issue: TIssue) => Promise; [EIssueActions.REMOVE]?: (issue: TIssue) => Promise; + [EIssueActions.ARCHIVE]?: (issue: TIssue) => Promise; }; } diff --git a/web/components/issues/issue-layouts/types.ts b/web/components/issues/issue-layouts/types.ts index f4c2d8100..d1c3f4fd9 100644 --- a/web/components/issues/issue-layouts/types.ts +++ b/web/components/issues/issue-layouts/types.ts @@ -2,4 +2,6 @@ export enum EIssueActions { UPDATE = "update", DELETE = "delete", REMOVE = "remove", + ARCHIVE = "archive", + RESTORE = "restore", } diff --git a/web/components/issues/peek-overview/header.tsx b/web/components/issues/peek-overview/header.tsx index 8b51c977e..8db7fd0ac 100644 --- a/web/components/issues/peek-overview/header.tsx +++ b/web/components/issues/peek-overview/header.tsx @@ -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 = 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) => { 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 = 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 (
= observer((pr >
{currentMode && (
@@ -110,7 +125,7 @@ export const IssuePeekOverviewHeader: FC = observer((pr onChange={(val: any) => setPeekMode(val)} customButton={ } > @@ -138,13 +153,43 @@ export const IssuePeekOverviewHeader: FC = observer((pr {currentUser && !isArchived && ( )} - - {!disabled && ( - + + {isArchivingAllowed && ( + + + + )} + {isRestoringAllowed && ( + + + + )} + {!disabled && ( + + + )}
diff --git a/web/components/issues/peek-overview/root.tsx b/web/components/issues/peek-overview/root.tsx index e52d40ae2..466bffee7 100644 --- a/web/components/issues/peek-overview/root.tsx +++ b/web/components/issues/peek-overview/root.tsx @@ -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; remove: (workspaceSlug: string, projectId: string, issueId: string) => Promise; + archive: (workspaceSlug: string, projectId: string, issueId: string) => Promise; + restore: (workspaceSlug: string, projectId: string, issueId: string) => Promise; addIssueToCycle: (workspaceSlug: string, projectId: string, cycleId: string, issueIds: string[]) => Promise; removeIssueFromCycle: (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => Promise; addModulesToIssue?: (workspaceSlug: string, projectId: string, issueId: string, moduleIds: string[]) => Promise; @@ -55,12 +57,13 @@ export const IssuePeekOverview: FC = 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 = 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 = 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 = 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 = observer((props) => { fetchIssue, updateIssue, removeIssue, - removeArchivedIssue, + archiveIssue, + restoreIssue, addIssueToCycle, removeIssueFromCycle, addModulesToIssue, @@ -343,16 +397,14 @@ export const IssuePeekOverview: FC = observer((props) => { const isLoading = !issue || loader ? true : false; return ( - - - + ); }); diff --git a/web/components/issues/peek-overview/view.tsx b/web/components/issues/peek-overview/view.tsx index d5201bf8a..cb2100ce0 100644 --- a/web/components/issues/peek-overview/view.tsx +++ b/web/components/issues/peek-overview/view.tsx @@ -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 = observer((props) => { setPeekIssue, isAnyModalOpen, isDeleteIssueModalOpen, + isArchiveIssueModalOpen, toggleDeleteIssueModal, + toggleArchiveIssueModal, issue: { getIssueById }, } = useIssueDetail(); const issue = getIssueById(issueId); @@ -69,8 +71,26 @@ export const IssueView: FC = observer((props) => { }; useKeypress("Escape", handleKeyDown); + const handleRestore = async () => { + if (!issueOperations.restore) return; + await issueOperations.restore(workspaceSlug, projectId, issueId); + removeRoutePeekId(); + }; + return ( <> + {issue && !is_archived && ( + toggleArchiveIssueModal(false)} + data={issue} + onSubmit={async () => { + if (issueOperations.archive) await issueOperations.archive(workspaceSlug, projectId, issueId); + removeRoutePeekId(); + }} + /> + )} + {issue && !is_archived && ( = observer((props) => { )} {issue && is_archived && ( - toggleDeleteIssueModal(false)} @@ -109,11 +129,11 @@ export const IssueView: FC = observer((props) => { {/* header */} { - 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 = 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 = observer((props) => { projectId={projectId} issueId={issueId} issueOperations={issueOperations} - disabled={disabled} + disabled={disabled || is_archived} /> @@ -161,7 +181,7 @@ export const IssueView: FC = 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 = observer((props) => { projectId={projectId} issueId={issueId} issueOperations={issueOperations} - disabled={disabled} + disabled={disabled || is_archived} />
diff --git a/web/components/notifications/notification-card.tsx b/web/components/notifications/notification-card.tsx index d7ad61141..03d849a82 100644 --- a/web/components/notifications/notification-card.tsx +++ b/web/components/notifications/notification-card.tsx @@ -49,7 +49,7 @@ export const NotificationCard: React.FC = (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 = (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 = (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 = (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 = (props) => { )}
- {notification.triggered_by_details.avatar && notification.triggered_by_details.avatar !== "" ? ( + {notificationTriggeredBy.avatar && notificationTriggeredBy.avatar !== "" ? (
Profile Image = (props) => { ) : (
- {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() ) : ( )} @@ -168,30 +171,32 @@ export const NotificationCard: React.FC = (props) => { {!notification.message ? (
- {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}{" "} - {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" : ""} {" "} - {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 = (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 = (props) => { onClick={(e) => { e.stopPropagation(); e.preventDefault(); - setshowSnoozeOptions(false); + setShowSnoozeOptions(false); snoozeOptionOnClick(item.value); }} > diff --git a/web/components/project/sidebar-list-item.tsx b/web/components/project/sidebar-list-item.tsx index 79d632c1e..3dbf0d5d0 100644 --- a/web/components/project/sidebar-list-item.tsx +++ b/web/components/project/sidebar-list-item.tsx @@ -84,7 +84,7 @@ export const ProjectSidebarListItem: React.FC = 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 = observer((props) => {
)} - - {project.archive_in > 0 && ( + {!isViewerOrGuest && (
- Archived Issues + Archived issues
@@ -286,7 +285,7 @@ export const ProjectSidebarListItem: React.FC = observer((props) => {
- Draft Issues + Draft issues
diff --git a/web/constants/empty-state.ts b/web/constants/empty-state.ts index eaf7f4b05..a1b2b06f3 100644 --- a/web/constants/empty-state.ts +++ b/web/constants/empty-state.ts @@ -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: { diff --git a/web/constants/event-tracker.ts b/web/constants/event-tracker.ts index a0bf0b5bb..37a18a37d 100644 --- a/web/constants/event-tracker.ts +++ b/web/constants/event-tracker.ts @@ -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"; diff --git a/web/constants/project.ts b/web/constants/project.ts index 9e7bdee9e..6073e96be 100644 --- a/web/constants/project.ts +++ b/web/constants/project.ts @@ -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 = [ diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/archived-issues/[archivedIssueId].tsx b/web/pages/[workspaceSlug]/projects/[projectId]/archived-issues/[archivedIssueId].tsx index 17c002c3c..ee1be4ebb 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/archived-issues/[archivedIssueId].tsx +++ b/web/pages/[workspaceSlug]/projects/[projectId]/archived-issues/[archivedIssueId].tsx @@ -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(() => { ) : (
-
- {issue?.archived_at && ( +
+ {issue?.archived_at && canRestoreIssue && (
-

This issue has been archived by Plane.

+

This issue has been archived.

- + + {isRestoring ? "Restoring" : "Restore"} +
)} {workspaceSlug && projectId && archivedIssueId && ( diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/archived-issues/index.tsx b/web/pages/[workspaceSlug]/projects/[projectId]/archived-issues/index.tsx index 97583d16c..34019c026 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/archived-issues/index.tsx +++ b/web/pages/[workspaceSlug]/projects/[projectId]/archived-issues/index.tsx @@ -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 ( <> -
-
- -
- -
+ ); }); diff --git a/web/services/issue/issue_archive.service.ts b/web/services/issue/issue_archive.service.ts index 065f41d7e..e2a5132a5 100644 --- a/web/services/issue/issue_archive.service.ts +++ b/web/services/issue/issue_archive.service.ts @@ -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 { - 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 { + 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 { - 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 { - return this.delete(`/api/workspaces/${workspaceSlug}/projects/${projectId}/archived-issues/${issuesId}/`) - .then((response) => response?.data) - .catch((error) => { - throw error?.response?.data; - }); - } } diff --git a/web/store/issue/archived/issue.store.ts b/web/store/issue/archived/issue.store.ts index fa3a06f37..a0b26eb8b 100644 --- a/web/store/issue/archived/issue.store.ts +++ b/web/store/issue/archived/issue.store.ts @@ -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; removeIssue: (workspaceSlug: string, projectId: string, issueId: string) => Promise; - removeIssueFromArchived: (workspaceSlug: string, projectId: string, issueId: string) => Promise; + restoreIssue: (workspaceSlug: string, projectId: string, issueId: string) => Promise; 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) { diff --git a/web/store/issue/cycle/issue.store.ts b/web/store/issue/cycle/issue.store.ts index 41731e134..61b280da9 100644 --- a/web/store/issue/cycle/issue.store.ts +++ b/web/store/issue/cycle/issue.store.ts @@ -48,6 +48,12 @@ export interface ICycleIssues { issueId: string, cycleId?: string | undefined ) => Promise; + archiveIssue: ( + workspaceSlug: string, + projectId: string, + issueId: string, + cycleId?: string | undefined + ) => Promise; 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, diff --git a/web/store/issue/draft/issue.store.ts b/web/store/issue/draft/issue.store.ts index 824bbb3c1..a06213eb0 100644 --- a/web/store/issue/draft/issue.store.ts +++ b/web/store/issue/draft/issue.store.ts @@ -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; diff --git a/web/store/issue/issue-details/issue.store.ts b/web/store/issue/issue-details/issue.store.ts index 46b96c27e..f42c13376 100644 --- a/web/store/issue/issue-details/issue.store.ts +++ b/web/store/issue/issue-details/issue.store.ts @@ -16,6 +16,7 @@ export interface IIssueStoreActions { ) => Promise; updateIssue: (workspaceSlug: string, projectId: string, issueId: string, data: Partial) => Promise; removeIssue: (workspaceSlug: string, projectId: string, issueId: string) => Promise; + archiveIssue: (workspaceSlug: string, projectId: string, issueId: string) => Promise; addIssueToCycle: (workspaceSlug: string, projectId: string, cycleId: string, issueIds: string[]) => Promise; removeIssueFromCycle: (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => Promise; addModulesToIssue: (workspaceSlug: string, projectId: string, issueId: string, moduleIds: string[]) => Promise; @@ -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, diff --git a/web/store/issue/issue-details/root.store.ts b/web/store/issue/issue-details/root.store.ts index daaae749e..db5dab307 100644 --- a/web/store/issue/issue-details/root.store.ts +++ b/web/store/issue/issue-details/root.store.ts @@ -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) => diff --git a/web/store/issue/issue.store.ts b/web/store/issue/issue.store.ts index 8bdb18dad..cbda505ff 100644 --- a/web/store/issue/issue.store.ts +++ b/web/store/issue/issue.store.ts @@ -18,7 +18,7 @@ export type IIssueStore = { removeIssue(issueId: string): void; // helper methods getIssueById(issueId: string): undefined | TIssue; - getIssuesByIds(issueIds: string[]): undefined | Record; // Record defines issue_id as key and TIssue as value + getIssuesByIds(issueIds: string[], type: "archived" | "un-archived"): undefined | Record; // 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 | 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; diff --git a/web/store/issue/module/issue.store.ts b/web/store/issue/module/issue.store.ts index e9b96ac54..9e6ad3f49 100644 --- a/web/store/issue/module/issue.store.ts +++ b/web/store/issue/module/issue.store.ts @@ -46,6 +46,12 @@ export interface IModuleIssues { issueId: string, moduleId?: string | undefined ) => Promise; + archiveIssue: ( + workspaceSlug: string, + projectId: string, + issueId: string, + moduleId?: string | undefined + ) => Promise; 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, diff --git a/web/store/issue/profile/issue.store.ts b/web/store/issue/profile/issue.store.ts index 461928c64..c39b33a80 100644 --- a/web/store/issue/profile/issue.store.ts +++ b/web/store/issue/profile/issue.store.ts @@ -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; + archiveIssue: ( + workspaceSlug: string, + projectId: string, + issueId: string, + userId?: string | undefined + ) => Promise; 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; + } + }; } diff --git a/web/store/issue/project-views/issue.store.ts b/web/store/issue/project-views/issue.store.ts index 8327ffcce..b85465ec8 100644 --- a/web/store/issue/project-views/issue.store.ts +++ b/web/store/issue/project-views/issue.store.ts @@ -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; + archiveIssue: ( + workspaceSlug: string, + projectId: string, + issueId: string, + viewId?: string | undefined + ) => Promise; 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, diff --git a/web/store/issue/project/issue.store.ts b/web/store/issue/project/issue.store.ts index 76bf7bcc2..f3ee94783 100644 --- a/web/store/issue/project/issue.store.ts +++ b/web/store/issue/project/issue.store.ts @@ -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) => Promise; updateIssue: (workspaceSlug: string, projectId: string, issueId: string, data: Partial) => Promise; removeIssue: (workspaceSlug: string, projectId: string, issueId: string) => Promise; + archiveIssue: (workspaceSlug: string, projectId: string, issueId: string) => Promise; quickAddIssue: (workspaceSlug: string, projectId: string, data: TIssue) => Promise; removeBulkIssues: (workspaceSlug: string, projectId: string, issueIds: string[]) => Promise; } @@ -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(() => { diff --git a/web/store/issue/workspace/issue.store.ts b/web/store/issue/workspace/issue.store.ts index e2b8418c7..b7fe43b30 100644 --- a/web/store/issue/workspace/issue.store.ts +++ b/web/store/issue/workspace/issue.store.ts @@ -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; + archiveIssue: ( + workspaceSlug: string, + projectId: string, + issueId: string, + viewId?: string | undefined + ) => Promise; } 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; + } + }; }