diff --git a/admin/components/admin-sidebar/help-section.tsx b/admin/components/admin-sidebar/help-section.tsx
index 56ccbcd8446..62e50393393 100644
--- a/admin/components/admin-sidebar/help-section.tsx
+++ b/admin/components/admin-sidebar/help-section.tsx
@@ -5,9 +5,11 @@ import { observer } from "mobx-react-lite";
import Link from "next/link";
import { ExternalLink, FileText, HelpCircle, MoveLeft } from "lucide-react";
import { Transition } from "@headlessui/react";
+// ui
import { DiscordIcon, GithubIcon, Tooltip } from "@plane/ui";
+// helpers
+import { cn, WEB_BASE_URL } from "@/helpers/common.helper";
// hooks
-import { WEB_BASE_URL } from "@/helpers/common.helper";
import { useTheme } from "@/hooks/store";
// assets
import packageJson from "package.json";
@@ -42,9 +44,12 @@ export const HelpSection: FC = observer(() => {
return (
diff --git a/apiserver/plane/app/urls/issue.py b/apiserver/plane/app/urls/issue.py
index 0d3b9e0634c..b7a4eaa4809 100644
--- a/apiserver/plane/app/urls/issue.py
+++ b/apiserver/plane/app/urls/issue.py
@@ -19,6 +19,8 @@
IssueUserDisplayPropertyEndpoint,
IssueViewSet,
LabelViewSet,
+ BulkIssueOperationsEndpoint,
+ BulkArchiveIssuesEndpoint,
)
urlpatterns = [
@@ -81,6 +83,11 @@
BulkDeleteIssuesEndpoint.as_view(),
name="project-issues-bulk",
),
+ path(
+ "workspaces//projects//bulk-archive-issues/",
+ BulkArchiveIssuesEndpoint.as_view(),
+ name="bulk-archive-issues",
+ ),
##
path(
"workspaces//projects//issues//sub-issues/",
@@ -298,4 +305,9 @@
),
name="project-issue-draft",
),
+ path(
+ "workspaces//projects//bulk-operation-issues/",
+ BulkIssueOperationsEndpoint.as_view(),
+ name="bulk-operations-issues",
+ ),
]
diff --git a/apiserver/plane/app/views/__init__.py b/apiserver/plane/app/views/__init__.py
index 0c489593d63..4394f2deacf 100644
--- a/apiserver/plane/app/views/__init__.py
+++ b/apiserver/plane/app/views/__init__.py
@@ -113,9 +113,7 @@
IssueActivityEndpoint,
)
-from .issue.archive import (
- IssueArchiveViewSet,
-)
+from .issue.archive import IssueArchiveViewSet, BulkArchiveIssuesEndpoint
from .issue.attachment import (
IssueAttachmentEndpoint,
@@ -154,6 +152,8 @@
)
+from .issue.bulk_operations import BulkIssueOperationsEndpoint
+
from .module.base import (
ModuleViewSet,
ModuleLinkViewSet,
diff --git a/apiserver/plane/app/views/issue/archive.py b/apiserver/plane/app/views/issue/archive.py
index cc3a343d2fd..684ad01b6df 100644
--- a/apiserver/plane/app/views/issue/archive.py
+++ b/apiserver/plane/app/views/issue/archive.py
@@ -29,7 +29,7 @@
from rest_framework import status
# Module imports
-from .. import BaseViewSet
+from .. import BaseViewSet, BaseAPIView
from plane.app.serializers import (
IssueSerializer,
IssueFlatSerializer,
@@ -49,6 +49,7 @@
from plane.utils.issue_filters import issue_filters
from plane.utils.user_timezone_converter import user_timezone_converter
+
class IssueArchiveViewSet(BaseViewSet):
permission_classes = [
ProjectEntityPermission,
@@ -351,3 +352,58 @@ def unarchive(self, request, slug, project_id, pk=None):
issue.save()
return Response(status=status.HTTP_204_NO_CONTENT)
+
+
+class BulkArchiveIssuesEndpoint(BaseAPIView):
+ permission_classes = [
+ ProjectEntityPermission,
+ ]
+
+ def post(self, request, slug, project_id):
+ issue_ids = request.data.get("issue_ids", [])
+
+ if not len(issue_ids):
+ return Response(
+ {"error": "Issue IDs are required"},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ issues = Issue.objects.filter(
+ workspace__slug=slug, project_id=project_id, pk__in=issue_ids
+ ).select_related("state")
+ bulk_archive_issues = []
+ for issue in issues:
+ if issue.state.group not in ["completed", "cancelled"]:
+ return Response(
+ {
+ "error_code": 4091,
+ "error_message": "INVALID_ARCHIVE_STATE_GROUP"
+ },
+ 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()
+ bulk_archive_issues.append(issue)
+ Issue.objects.bulk_update(bulk_archive_issues, ["archived_at"])
+
+ return Response(
+ {"archived_at": str(timezone.now().date())},
+ status=status.HTTP_200_OK,
+ )
diff --git a/apiserver/plane/app/views/issue/bulk_operations.py b/apiserver/plane/app/views/issue/bulk_operations.py
new file mode 100644
index 00000000000..ea663782607
--- /dev/null
+++ b/apiserver/plane/app/views/issue/bulk_operations.py
@@ -0,0 +1,288 @@
+# Python imports
+import json
+from datetime import datetime
+
+# Django imports
+from django.utils import timezone
+
+# Third Party imports
+from rest_framework.response import Response
+from rest_framework import status
+
+# Module imports
+from .. import BaseAPIView
+from plane.app.permissions import (
+ ProjectEntityPermission,
+)
+from plane.db.models import (
+ Project,
+ Issue,
+ IssueLabel,
+ IssueAssignee,
+)
+from plane.bgtasks.issue_activites_task import issue_activity
+
+
+class BulkIssueOperationsEndpoint(BaseAPIView):
+ permission_classes = [
+ ProjectEntityPermission,
+ ]
+
+ def post(self, request, slug, project_id):
+ issue_ids = request.data.get("issue_ids", [])
+ if not len(issue_ids):
+ return Response(
+ {"error": "Issue IDs are required"},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ # Get all the issues
+ issues = (
+ Issue.objects.filter(
+ workspace__slug=slug, project_id=project_id, pk__in=issue_ids
+ )
+ .select_related("state")
+ .prefetch_related("labels", "assignees")
+ )
+ # Current epoch
+ epoch = int(timezone.now().timestamp())
+
+ # Project details
+ project = Project.objects.get(workspace__slug=slug, pk=project_id)
+ workspace_id = project.workspace_id
+
+ # Initialize arrays
+ bulk_update_issues = []
+ bulk_issue_activities = []
+ bulk_update_issue_labels = []
+ bulk_update_issue_assignees = []
+
+ properties = request.data.get("properties", {})
+
+ if properties.get("start_date", False) and properties.get("target_date", False):
+ if (
+ datetime.strptime(properties.get("start_date"), "%Y-%m-%d").date()
+ > datetime.strptime(properties.get("target_date"), "%Y-%m-%d").date()
+ ):
+ return Response(
+ {
+ "error_code": 4100,
+ "error_message": "INVALID_ISSUE_DATES",
+ },
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ for issue in issues:
+
+ # Priority
+ if properties.get("priority", False):
+ bulk_issue_activities.append(
+ {
+ "type": "issue.activity.updated",
+ "requested_data": json.dumps(
+ {"priority": properties.get("priority")}
+ ),
+ "current_instance": json.dumps(
+ {"priority": (issue.priority)}
+ ),
+ "issue_id": str(issue.id),
+ "actor_id": str(request.user.id),
+ "project_id": str(project_id),
+ "epoch": epoch,
+ }
+ )
+ issue.priority = properties.get("priority")
+
+ # State
+ if properties.get("state_id", False):
+ bulk_issue_activities.append(
+ {
+ "type": "issue.activity.updated",
+ "requested_data": json.dumps(
+ {"state": properties.get("state")}
+ ),
+ "current_instance": json.dumps(
+ {"state": str(issue.state_id)}
+ ),
+ "issue_id": str(issue.id),
+ "actor_id": str(request.user.id),
+ "project_id": str(project_id),
+ "epoch": epoch,
+ }
+ )
+ issue.state_id = properties.get("state_id")
+
+ # Start date
+ if properties.get("start_date", False):
+ if (
+ issue.target_date
+ and not properties.get("target_date", False)
+ and issue.target_date
+ <= datetime.strptime(
+ properties.get("start_date"), "%Y-%m-%d"
+ ).date()
+ ):
+ return Response(
+ {
+ "error_code": 4101,
+ "error_message": "INVALID_ISSUE_START_DATE",
+ },
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+ bulk_issue_activities.append(
+ {
+ "type": "issue.activity.updated",
+ "requested_data": json.dumps(
+ {"start_date": properties.get("start_date")}
+ ),
+ "current_instance": json.dumps(
+ {"start_date": str(issue.start_date)}
+ ),
+ "issue_id": str(issue.id),
+ "actor_id": str(request.user.id),
+ "project_id": str(project_id),
+ "epoch": epoch,
+ }
+ )
+ issue.start_date = properties.get("start_date")
+
+ # Target date
+ if properties.get("target_date", False):
+ if (
+ issue.start_date
+ and not properties.get("start_date", False)
+ and issue.start_date
+ >= datetime.strptime(
+ properties.get("target_date"), "%Y-%m-%d"
+ ).date()
+ ):
+ return Response(
+ {
+ "error_code": 4102,
+ "error_message": "INVALID_ISSUE_TARGET_DATE",
+ },
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+ bulk_issue_activities.append(
+ {
+ "type": "issue.activity.updated",
+ "requested_data": json.dumps(
+ {"target_date": properties.get("target_date")}
+ ),
+ "current_instance": json.dumps(
+ {"target_date": str(issue.target_date)}
+ ),
+ "issue_id": str(issue.id),
+ "actor_id": str(request.user.id),
+ "project_id": str(project_id),
+ "epoch": epoch,
+ }
+ )
+ issue.target_date = properties.get("target_date")
+
+ bulk_update_issues.append(issue)
+
+ # Labels
+ if properties.get("label_ids", []):
+ for label_id in properties.get("label_ids", []):
+ bulk_update_issue_labels.append(
+ IssueLabel(
+ issue=issue,
+ label_id=label_id,
+ created_by=request.user,
+ project_id=project_id,
+ workspace_id=workspace_id,
+ )
+ )
+ bulk_issue_activities.append(
+ {
+ "type": "issue.activity.updated",
+ "requested_data": json.dumps(
+ {"label_ids": properties.get("label_ids", [])}
+ ),
+ "current_instance": json.dumps(
+ {
+ "label_ids": [
+ str(label.id)
+ for label in issue.labels.all()
+ ]
+ }
+ ),
+ "issue_id": str(issue.id),
+ "actor_id": str(request.user.id),
+ "project_id": str(project_id),
+ "epoch": epoch,
+ }
+ )
+
+ # Assignees
+ if properties.get("assignee_ids", []):
+ for assignee_id in properties.get(
+ "assignee_ids", issue.assignees
+ ):
+ bulk_update_issue_assignees.append(
+ IssueAssignee(
+ issue=issue,
+ assignee_id=assignee_id,
+ created_by=request.user,
+ project_id=project_id,
+ workspace_id=workspace_id,
+ )
+ )
+ bulk_issue_activities.append(
+ {
+ "type": "issue.activity.updated",
+ "requested_data": json.dumps(
+ {
+ "assignee_ids": properties.get(
+ "assignee_ids", []
+ )
+ }
+ ),
+ "current_instance": json.dumps(
+ {
+ "assignee_ids": [
+ str(assignee.id)
+ for assignee in issue.assignees.all()
+ ]
+ }
+ ),
+ "issue_id": str(issue.id),
+ "actor_id": str(request.user.id),
+ "project_id": str(project_id),
+ "epoch": epoch,
+ }
+ )
+
+ # Bulk update all the objects
+ Issue.objects.bulk_update(
+ bulk_update_issues,
+ [
+ "priority",
+ "start_date",
+ "target_date",
+ "state",
+ ],
+ batch_size=100,
+ )
+
+ # Create new labels
+ IssueLabel.objects.bulk_create(
+ bulk_update_issue_labels,
+ ignore_conflicts=True,
+ batch_size=100,
+ )
+
+ # Create new assignees
+ IssueAssignee.objects.bulk_create(
+ bulk_update_issue_assignees,
+ ignore_conflicts=True,
+ batch_size=100,
+ )
+ # update the issue activity
+ [
+ issue_activity.delay(**activity)
+ for activity in bulk_issue_activities
+ ]
+
+ return Response(status=status.HTTP_204_NO_CONTENT)
diff --git a/packages/types/src/issues/issue.d.ts b/packages/types/src/issues/issue.d.ts
index 42c95dc4e30..c52575ccb3f 100644
--- a/packages/types/src/issues/issue.d.ts
+++ b/packages/types/src/issues/issue.d.ts
@@ -51,3 +51,18 @@ export type TIssue = {
export type TIssueMap = {
[issue_id: string]: TIssue;
};
+
+export type TBulkIssueProperties = Pick<
+ TIssue,
+ | "state_id"
+ | "priority"
+ | "label_ids"
+ | "assignee_ids"
+ | "start_date"
+ | "target_date"
+>;
+
+export type TBulkOperationsPayload = {
+ issue_ids: string[];
+ properties: Partial;
+};
diff --git a/packages/ui/src/drag-handle.tsx b/packages/ui/src/drag-handle.tsx
index 0496f86dee4..89037a5ca40 100644
--- a/packages/ui/src/drag-handle.tsx
+++ b/packages/ui/src/drag-handle.tsx
@@ -1,14 +1,15 @@
-import React from "react";
-import { forwardRef } from "react";
+import React, { forwardRef } from "react";
import { MoreVertical } from "lucide-react";
+// helpers
+import { cn } from "../helpers";
interface IDragHandle {
- isDragging: boolean;
+ className?: string;
disabled?: boolean;
}
export const DragHandle = forwardRef((props, ref) => {
- const { isDragging, disabled = false } = props;
+ const { className, disabled = false } = props;
if (disabled) {
return ;
@@ -17,9 +18,10 @@ export const DragHandle = forwardRef((pro
return (