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 ( - )} +
+ {enableReorder && ( + + )} + {enableSelection && selectionHelpers && ( + + )} +
diff --git a/web/components/gantt-chart/sidebar/issues/sidebar.tsx b/web/components/gantt-chart/sidebar/issues/sidebar.tsx index 7da30216d4e..508bbb4caa3 100644 --- a/web/components/gantt-chart/sidebar/issues/sidebar.tsx +++ b/web/components/gantt-chart/sidebar/issues/sidebar.tsx @@ -1,22 +1,26 @@ import { MutableRefObject } from "react"; -// components // ui import { Loader } from "@plane/ui"; -// types +// components import { IGanttBlock, IBlockUpdateData } from "@/components/gantt-chart/types"; +// hooks +import { TSelectionHelper } from "@/hooks/use-multiple-select"; import { GanttDnDHOC } from "../gantt-dnd-HOC"; import { handleOrderChange } from "../utils"; +// types import { IssuesSidebarBlock } from "./block"; type Props = { blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void; blocks: IGanttBlock[] | null; + enableSelection: boolean; enableReorder: boolean; showAllBlocks?: boolean; + selectionHelpers?: TSelectionHelper; }; export const IssueGanttSidebar: React.FC = (props) => { - const { blockUpdateHandler, blocks, enableReorder, showAllBlocks = false } = props; + const { blockUpdateHandler, blocks, enableSelection, enableReorder, showAllBlocks = false, selectionHelpers } = props; const handleOnDrop = ( draggingBlockId: string | undefined, @@ -47,8 +51,10 @@ export const IssueGanttSidebar: React.FC = (props) => { )} diff --git a/web/components/gantt-chart/sidebar/modules/block.tsx b/web/components/gantt-chart/sidebar/modules/block.tsx index e79a6540193..e6b28d54ae5 100644 --- a/web/components/gantt-chart/sidebar/modules/block.tsx +++ b/web/components/gantt-chart/sidebar/modules/block.tsx @@ -38,7 +38,7 @@ export const ModulesSidebarBlock: React.FC = observer((props) => {
void; enableReorder: boolean; + enableSelection: boolean; sidebarToRender: (props: any) => React.ReactNode; title: string; quickAdd?: React.JSX.Element | undefined; + selectionHelpers: TSelectionHelper; }; -export const GanttChartSidebar: React.FC = (props) => { - const { blocks, blockUpdateHandler, enableReorder, sidebarToRender, title, quickAdd } = props; +export const GanttChartSidebar: React.FC = observer((props) => { + const { + blocks, + blockUpdateHandler, + enableReorder, + enableSelection, + sidebarToRender, + title, + quickAdd, + selectionHelpers, + } = props; + + const isGroupSelectionEmpty = selectionHelpers.isGroupSelected(GANTT_SELECT_GROUP) === "empty"; return (
= (props) => { }} >
-
{title}
+
+ {enableSelection && ( +
+ +
+ )} +
{title}
+
Duration
- {sidebarToRender && sidebarToRender({ title, blockUpdateHandler, blocks, enableReorder })} + {sidebarToRender && + sidebarToRender({ title, blockUpdateHandler, blocks, enableReorder, enableSelection, selectionHelpers })}
{quickAdd ? quickAdd : null}
); -}; +}); diff --git a/web/components/inbox/modals/decline-issue-modal.tsx b/web/components/inbox/modals/decline-issue-modal.tsx index 5c7b35a1c2e..4ca784ec150 100644 --- a/web/components/inbox/modals/decline-issue-modal.tsx +++ b/web/components/inbox/modals/decline-issue-modal.tsx @@ -36,7 +36,7 @@ export const DeclineIssueModal: React.FC = (props) => { = observer(({ isOpen, onClos = (props) => { handleDeletion(data.id)} - isDeleting={loader} + isSubmitting={loader} isOpen={isOpen} title="Delete attachment" content={ diff --git a/web/components/issues/bulk-operations/actions/archive.tsx b/web/components/issues/bulk-operations/actions/archive.tsx new file mode 100644 index 00000000000..3e10b717085 --- /dev/null +++ b/web/components/issues/bulk-operations/actions/archive.tsx @@ -0,0 +1,67 @@ +import { useState } from "react"; +import { observer } from "mobx-react"; +// ui +import { ArchiveIcon, Tooltip } from "@plane/ui"; +// components +import { BulkArchiveConfirmationModal } from "@/components/issues"; +// constants +import { ARCHIVABLE_STATE_GROUPS } from "@/constants/state"; +// helpers +import { cn } from "@/helpers/common.helper"; +// hooks +import { useAppRouter, useIssueDetail, useProjectState } from "@/hooks/store"; + +type Props = { + handleClearSelection: () => void; + selectedEntityIds: string[]; +}; + +export const BulkArchiveIssues: React.FC = observer((props) => { + const { handleClearSelection, selectedEntityIds } = props; + // states + const [isBulkArchiveModalOpen, setIsBulkArchiveModalOpen] = useState(false); + // store hooks + const { projectId, workspaceSlug } = useAppRouter(); + const { + issue: { getIssueById }, + } = useIssueDetail(); + const { getStateById } = useProjectState(); + const canAllIssuesBeArchived = selectedEntityIds.every((issueId) => { + const issueDetails = getIssueById(issueId); + if (!issueDetails) return false; + const stateDetails = getStateById(issueDetails.state_id); + if (!stateDetails) return false; + return ARCHIVABLE_STATE_GROUPS.includes(stateDetails.group); + }); + + return ( + <> + {projectId && workspaceSlug && ( + setIsBulkArchiveModalOpen(false)} + issueIds={selectedEntityIds} + onSubmit={handleClearSelection} + projectId={projectId.toString()} + workspaceSlug={workspaceSlug.toString()} + /> + )} + + + + + ); +}); diff --git a/web/components/issues/bulk-operations/actions/delete.tsx b/web/components/issues/bulk-operations/actions/delete.tsx new file mode 100644 index 00000000000..7227684657c --- /dev/null +++ b/web/components/issues/bulk-operations/actions/delete.tsx @@ -0,0 +1,46 @@ +import { useState } from "react"; +import { observer } from "mobx-react"; +import { Trash2 } from "lucide-react"; +// ui +import { Tooltip } from "@plane/ui"; +// components +import { BulkDeleteConfirmationModal } from "@/components/issues"; +// hooks +import { useAppRouter } from "@/hooks/store"; + +type Props = { + handleClearSelection: () => void; + selectedEntityIds: string[]; +}; + +export const BulkDeleteIssues: React.FC = observer((props) => { + const { handleClearSelection, selectedEntityIds } = props; + // states + const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false); + // store hooks + const { projectId, workspaceSlug } = useAppRouter(); + + return ( + <> + {projectId && workspaceSlug && ( + setIsBulkDeleteModalOpen(false)} + issueIds={selectedEntityIds} + onSubmit={handleClearSelection} + projectId={projectId.toString()} + workspaceSlug={workspaceSlug.toString()} + /> + )} + + + + + ); +}); diff --git a/web/components/issues/bulk-operations/actions/index.ts b/web/components/issues/bulk-operations/actions/index.ts new file mode 100644 index 00000000000..87fd75bc888 --- /dev/null +++ b/web/components/issues/bulk-operations/actions/index.ts @@ -0,0 +1,3 @@ +export * from "./archive"; +export * from "./delete"; +export * from "./root"; diff --git a/web/components/issues/bulk-operations/actions/root.tsx b/web/components/issues/bulk-operations/actions/root.tsx new file mode 100644 index 00000000000..8d530fac5c6 --- /dev/null +++ b/web/components/issues/bulk-operations/actions/root.tsx @@ -0,0 +1,17 @@ +import { BulkArchiveIssues, BulkDeleteIssues } from "@/components/issues"; + +type Props = { + handleClearSelection: () => void; + selectedEntityIds: string[]; +}; + +export const BulkOperationsActionsRoot: React.FC = (props) => { + const { handleClearSelection, selectedEntityIds } = props; + + return ( +
+ + +
+ ); +}; diff --git a/web/components/issues/bulk-operations/bulk-archive-modal.tsx b/web/components/issues/bulk-operations/bulk-archive-modal.tsx new file mode 100644 index 00000000000..a99cadf94d4 --- /dev/null +++ b/web/components/issues/bulk-operations/bulk-archive-modal.tsx @@ -0,0 +1,81 @@ +import { useState } from "react"; +import { observer } from "mobx-react"; +// ui +import { TOAST_TYPE, setToast } from "@plane/ui"; +// components +import { AlertModalCore, EModalPosition, EModalWidth } from "@/components/core"; +// constants +import { EErrorCodes, ERROR_DETAILS } from "@/constants/errors"; +import { EIssuesStoreType } from "@/constants/issue"; +// hooks +import { useIssues } from "@/hooks/store"; + +type Props = { + handleClose: () => void; + isOpen: boolean; + issueIds: string[]; + onSubmit?: () => void; + projectId: string; + workspaceSlug: string; +}; + +export const BulkArchiveConfirmationModal: React.FC = observer((props) => { + const { handleClose, isOpen, issueIds, onSubmit, projectId, workspaceSlug } = props; + // states + const [isArchiving, setIsDeleting] = useState(false); + // store hooks + const { + issues: { archiveBulkIssues }, + } = useIssues(EIssuesStoreType.PROJECT); + + const handleSubmit = async () => { + setIsDeleting(true); + + await archiveBulkIssues(workspaceSlug, projectId, issueIds) + .then(() => { + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Success!", + message: "Issues archived successfully.", + }); + onSubmit?.(); + handleClose(); + }) + .catch((error) => { + const errorInfo = ERROR_DETAILS[error?.error_code as EErrorCodes] ?? undefined; + setToast({ + type: TOAST_TYPE.ERROR, + title: errorInfo?.title ?? "Error!", + message: errorInfo?.message ?? "Something went wrong. Please try again.", + }); + }) + .finally(() => setIsDeleting(false)); + }; + + const issueVariant = issueIds.length > 1 ? "issues" : "issue"; + + return ( + + Are you sure you want to archive {issueIds.length} {issueVariant}? Sub issues of selected {issueVariant} will + also be archived. Once archived {issueIds.length > 1 ? "they" : "it"} can be restored later via the archives + section. + + } + primaryButtonText={{ + loading: "Archiving", + default: `Archive ${issueVariant}`, + }} + hideIcon + /> + ); +}); diff --git a/web/components/issues/bulk-operations/bulk-delete-modal.tsx b/web/components/issues/bulk-operations/bulk-delete-modal.tsx new file mode 100644 index 00000000000..ab8fad452b0 --- /dev/null +++ b/web/components/issues/bulk-operations/bulk-delete-modal.tsx @@ -0,0 +1,78 @@ +import { useState } from "react"; +import { observer } from "mobx-react"; +// ui +import { TOAST_TYPE, setToast } from "@plane/ui"; +// components +import { AlertModalCore, EModalPosition, EModalWidth } from "@/components/core"; +// constants +import { EIssuesStoreType } from "@/constants/issue"; +// hooks +import { useIssues } from "@/hooks/store"; + +type Props = { + handleClose: () => void; + isOpen: boolean; + issueIds: string[]; + onSubmit?: () => void; + projectId: string; + workspaceSlug: string; +}; + +export const BulkDeleteConfirmationModal: React.FC = observer((props) => { + const { handleClose, isOpen, issueIds, onSubmit, projectId, workspaceSlug } = props; + // states + const [isDeleting, setIsDeleting] = useState(false); + // store hooks + const { + issues: { removeBulkIssues }, + } = useIssues(EIssuesStoreType.PROJECT); + + const handleSubmit = async () => { + setIsDeleting(true); + + await removeBulkIssues(workspaceSlug, projectId, issueIds) + .then(() => { + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Success!", + message: "Issues deleted successfully.", + }); + onSubmit?.(); + handleClose(); + }) + .catch(() => + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: "Something went wrong. Please try again.", + }) + ) + .finally(() => setIsDeleting(false)); + }; + + const issueVariant = issueIds.length > 1 ? "issues" : "issue"; + + return ( + + Are you sure you want to delete {issueIds.length} {issueVariant}? Sub issues of selected {issueVariant} will + also be deleted. All of the data related to the {issueVariant} will be permanently removed. This action cannot + be undone. + + } + primaryButtonText={{ + loading: "Deleting", + default: `Delete ${issueVariant}`, + }} + /> + ); +}); diff --git a/web/components/issues/bulk-operations/exrtra-properties.tsx b/web/components/issues/bulk-operations/exrtra-properties.tsx new file mode 100644 index 00000000000..dc57b37029a --- /dev/null +++ b/web/components/issues/bulk-operations/exrtra-properties.tsx @@ -0,0 +1 @@ +export const BulkOperationsExtraProperties = () => null; diff --git a/web/components/issues/bulk-operations/index.ts b/web/components/issues/bulk-operations/index.ts new file mode 100644 index 00000000000..6c7c6afd75e --- /dev/null +++ b/web/components/issues/bulk-operations/index.ts @@ -0,0 +1,6 @@ +export * from "./actions"; +export * from "./bulk-archive-modal"; +export * from "./bulk-delete-modal"; +export * from "./exrtra-properties"; +export * from "./properties"; +export * from "./root"; diff --git a/web/components/issues/bulk-operations/properties.tsx b/web/components/issues/bulk-operations/properties.tsx new file mode 100644 index 00000000000..db70b11745d --- /dev/null +++ b/web/components/issues/bulk-operations/properties.tsx @@ -0,0 +1,203 @@ +import { useState } from "react"; +import { useRouter } from "next/router"; +import { Controller, useForm } from "react-hook-form"; +import { CalendarCheck2, CalendarClock } from "lucide-react"; +// types +import { TBulkIssueProperties } from "@plane/types"; +// ui +import { Button, TOAST_TYPE, setToast } from "@plane/ui"; +// components +import { DateDropdown, MemberDropdown, PriorityDropdown, StateDropdown } from "@/components/dropdowns"; +import { BulkOperationsExtraProperties } from "@/components/issues"; +import { IssueLabelSelect } from "@/components/issues/select"; +import { CreateLabelModal } from "@/components/labels"; +// constants +import { EErrorCodes, ERROR_DETAILS } from "@/constants/errors"; +import { EIssuesStoreType } from "@/constants/issue"; +// helpers +import { getDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper"; +// hooks +import { useIssues } from "@/hooks/store"; +import { TSelectionHelper, TSelectionSnapshot } from "@/hooks/use-multiple-select"; + +type Props = { + selectionHelpers: TSelectionHelper; + snapshot: TSelectionSnapshot; +}; + +const defaultValues: TBulkIssueProperties = { + state_id: "", + // @ts-expect-error priority should not be undefined, but it should be, in this case + priority: undefined, + assignee_ids: [], + start_date: null, + target_date: null, + label_ids: [], +}; + +export const IssueBulkOperationsProperties: React.FC = (props) => { + const { snapshot } = props; + // states + const [createLabelModal, setCreateLabelModal] = useState(false); + // router + const router = useRouter(); + const { workspaceSlug, projectId } = router.query; + // store hooks + const { + issues: { bulkUpdateProperties }, + } = useIssues(EIssuesStoreType.PROJECT); + // form info + const { + control, + formState: { dirtyFields, isDirty, isSubmitting }, + handleSubmit, + reset, + watch, + } = useForm({ + defaultValues, + }); + + const handleBulkOperations = async (data: TBulkIssueProperties) => { + if (!workspaceSlug || !projectId) return; + + const payload: Partial = {}; + Object.keys(dirtyFields).forEach((key) => { + const payloadKey = key as keyof typeof dirtyFields; + // @ts-expect-error values might not match + payload[payloadKey] = data[payloadKey]; + }); + + await bulkUpdateProperties(workspaceSlug.toString(), projectId.toString(), { + issue_ids: snapshot.selectedEntityIds, + properties: payload, + }) + .then(() => { + reset(defaultValues); + }) + .catch((error) => { + const errorInfo = ERROR_DETAILS[error?.error_code as EErrorCodes] ?? undefined; + setToast({ + type: TOAST_TYPE.ERROR, + title: errorInfo?.title ?? "Error!", + message: errorInfo?.message ?? "Something went wrong. Please try again.", + }); + }); + }; + const isUpdateDisabled = !snapshot.isSelectionActive; + + const startDate = watch("start_date"); + const targetDate = watch("target_date"); + + const minDate = getDate(startDate); + minDate?.setDate(minDate.getDate()); + + const maxDate = getDate(targetDate); + maxDate?.setDate(maxDate.getDate()); + + return ( +
+
+ ( + + )} + /> + ( + + )} + /> + ( + 0 ? "transparent-without-text" : "border-with-text"} + buttonClassName={value?.length > 0 ? "hover:bg-transparent" : ""} + placeholder="Assignees" + multiple + disabled={isUpdateDisabled} + /> + )} + /> + ( + onChange(val ? renderFormattedPayloadDate(val) : null)} + buttonVariant="border-with-text" + placeholder="Start date" + icon={} + disabled={isUpdateDisabled} + maxDate={maxDate ?? undefined} + /> + )} + /> + ( + onChange(val ? renderFormattedPayloadDate(val) : null)} + buttonVariant="border-with-text" + placeholder="Due date" + icon={} + disabled={isUpdateDisabled} + minDate={minDate ?? undefined} + /> + )} + /> + {projectId && ( + ( + <> + setCreateLabelModal(false)} + projectId={projectId.toString()} + onSuccess={(res) => onChange([...value, res.id])} + /> + setCreateLabelModal(true)} + buttonClassName="text-custom-text-300" + /> + + )} + /> + )} + +
+ {isDirty && ( + + )} +
+ ); +}; diff --git a/web/components/issues/bulk-operations/root.tsx b/web/components/issues/bulk-operations/root.tsx new file mode 100644 index 00000000000..f2a63b90736 --- /dev/null +++ b/web/components/issues/bulk-operations/root.tsx @@ -0,0 +1,56 @@ +import { observer } from "mobx-react"; +// ui +import { Checkbox } from "@plane/ui"; +// components +import { BulkOperationsActionsRoot, IssueBulkOperationsProperties } from "@/components/issues"; +// helpers +import { cn } from "@/helpers/common.helper"; +// hooks +import { useMultipleSelectStore } from "@/hooks/store"; +import { TSelectionHelper } from "@/hooks/use-multiple-select"; + +type Props = { + className?: string; + selectionHelpers: TSelectionHelper; +}; + +export const IssueBulkOperationsRoot: React.FC = observer((props) => { + const { className, selectionHelpers } = props; + // store hooks + const { isSelectionActive, selectedEntityIds } = useMultipleSelectStore(); + // derived values + const { handleClearSelection } = selectionHelpers; + + if (!isSelectionActive) return null; + + return ( +
+
+
+ +
+ + {selectedEntityIds.length} + + selected +
+
+ +
+ +
+
+
+ ); +}); diff --git a/web/components/issues/delete-issue-modal.tsx b/web/components/issues/delete-issue-modal.tsx index 49c1a870099..46f8e733de8 100644 --- a/web/components/issues/delete-issue-modal.tsx +++ b/web/components/issues/delete-issue-modal.tsx @@ -66,7 +66,7 @@ export const DeleteIssueModal: React.FC = (props) => { = observer((props: IBaseGan enableBlockRightResize={isAllowed} enableBlockMove={isAllowed} enableReorder={appliedDisplayFilters?.order_by === "sort_order" && isAllowed} + enableSelection={isAllowed} enableAddBlock={isAllowed} quickAdd={ enableIssueCreation && isAllowed ? ( 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 b58bdce2c38..2a0351eebda 100644 --- a/web/components/issues/issue-layouts/list/base-list-root.tsx +++ b/web/components/issues/issue-layouts/list/base-list-root.tsx @@ -1,16 +1,16 @@ import { FC, useCallback } from "react"; import { observer } from "mobx-react-lite"; -// types +// constants import { EIssuesStoreType } from "@/constants/issue"; import { EUserProjectRoles } from "@/constants/project"; +// hooks import { useIssues, useUser } from "@/hooks/store"; import { useGroupIssuesDragNDrop } from "@/hooks/use-group-dragndrop"; import { useIssuesActions } from "@/hooks/use-issues-actions"; // components import { List } from "./default"; +// types import { IQuickActionProps, TRenderQuickActions } from "./list-view-types"; -// constants -// hooks type ListStoreType = | EIssuesStoreType.PROJECT @@ -37,22 +37,20 @@ export const BaseListRoot = observer((props: IBaseListRoot) => { canEditPropertiesBasedOnProject, isCompletedCycle = false, } = props; - // router - //stores + // stores hooks const { issuesFilter, issues } = useIssues(storeType); const { updateIssue, removeIssue, removeIssueFromView, archiveIssue, restoreIssue } = useIssuesActions(storeType); // mobx store const { membership: { currentProjectRole }, } = useUser(); - const { issueMap } = useIssues(); - - const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; - + // derived values const issueIds = issues?.groupedIssueIds || []; - + // auth + const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; const { enableInlineEditing, enableQuickAdd, enableIssueCreation } = issues?.viewFlags || {}; + const canEditProperties = useCallback( (projectId: string | undefined) => { const isEditingAllowedBasedOnProject = @@ -90,7 +88,7 @@ export const BaseListRoot = observer((props: IBaseListRoot) => { ); return ( -
+
; + selectionHelpers: TSelectionHelper; groupId: string; isDragAllowed: boolean; canDropOverIssue: boolean; @@ -50,6 +53,7 @@ export const IssueBlockRoot: FC = observer((props) => { canDropOverIssue, isParentIssueBeingDragged = false, isLastChild = false, + selectionHelpers, } = props; // states const [isExpanded, setExpanded] = useState(false); @@ -132,6 +136,7 @@ export const IssueBlockRoot: FC = observer((props) => { setExpanded={setExpanded} nestingLevel={nestingLevel} spacingLeft={spacingLeft} + selectionHelpers={selectionHelpers} canDrag={!isSubIssue && isDragAllowed} isCurrentBlockDragging={isParentIssueBeingDragged || isCurrentBlockDragging} setIsCurrentBlockDragging={setIsCurrentBlockDragging} @@ -139,9 +144,7 @@ export const IssueBlockRoot: FC = observer((props) => { {isExpanded && - subIssues && - subIssues.length > 0 && - subIssues.map((subIssueId: string) => ( + subIssues?.map((subIssueId) => ( = observer((props) => { nestingLevel={nestingLevel + 1} spacingLeft={spacingLeft + (displayProperties?.key ? 12 : 0)} containerRef={containerRef} + selectionHelpers={selectionHelpers} groupId={groupId} isDragAllowed={isDragAllowed} canDropOverIssue={canDropOverIssue} diff --git a/web/components/issues/issue-layouts/list/block.tsx b/web/components/issues/issue-layouts/list/block.tsx index 4f5c7784d44..f0f56482fbf 100644 --- a/web/components/issues/issue-layouts/list/block.tsx +++ b/web/components/issues/issue-layouts/list/block.tsx @@ -8,11 +8,13 @@ import { TIssue, IIssueDisplayProperties, TIssueMap } from "@plane/types"; // ui import { Spinner, Tooltip, ControlLink, DragHandle } from "@plane/ui"; // components +import { MultipleSelectEntityAction } from "@/components/core"; import { IssueProperties } from "@/components/issues/issue-layouts/properties"; // helpers import { cn } from "@/helpers/common.helper"; // hooks import { useAppRouter, useIssueDetail, useProject } from "@/hooks/store"; +import { TSelectionHelper } from "@/hooks/use-multiple-select"; import { usePlatformOS } from "@/hooks/use-platform-os"; // types import { TRenderQuickActions } from "./list-view-types"; @@ -29,6 +31,7 @@ interface IssueBlockProps { spacingLeft?: number; isExpanded: boolean; setExpanded: Dispatch>; + selectionHelpers: TSelectionHelper; isCurrentBlockDragging: boolean; setIsCurrentBlockDragging: React.Dispatch>; canDrag: boolean; @@ -47,6 +50,7 @@ export const IssueBlock = observer((props: IssueBlockProps) => { spacingLeft = 14, isExpanded, setExpanded, + selectionHelpers, isCurrentBlockDragging, setIsCurrentBlockDragging, canDrag, @@ -55,7 +59,7 @@ export const IssueBlock = observer((props: IssueBlockProps) => { const issueRef = useRef(null); const dragHandleRef = useRef(null); // hooks - const { workspaceSlug } = useAppRouter(); + const { workspaceSlug, projectId } = useAppRouter(); const { getProjectIdentifierById } = useProject(); const { getIsIssuePeeked, peekIssue, setPeekIssue, subIssues: subIssuesStore } = useIssueDetail(); @@ -67,8 +71,8 @@ export const IssueBlock = observer((props: IssueBlockProps) => { !getIsIssuePeeked(issue.id) && setPeekIssue({ workspaceSlug, projectId: issue.project_id, issueId: issue.id, nestingLevel: nestingLevel }); - const issue = issuesMap[issueId]; - const subIssuesCount = issue?.sub_issues_count ?? 0; + const issue = issuesMap[issueId]; + const subIssuesCount = issue?.sub_issues_count ?? 0; const { isMobile } = usePlatformOS(); @@ -98,8 +102,14 @@ export const IssueBlock = observer((props: IssueBlockProps) => { const canEditIssueProperties = canEditProperties(issue.project_id); const projectIdentifier = getProjectIdentifierById(issue.project_id); + const isIssueSelected = selectionHelpers.getIsEntitySelected(issue.id); + const isIssueActive = selectionHelpers.getIsEntityActive(issue.id); + const isSubIssue = nestingLevel !== 0; - const paddingLeft = `${spacingLeft}px`; + // if sub issues have been fetched for the issue, use that for count or use issue's sub_issues_count + // const subIssuesCount = subIssues ? subIssues.length : issue.sub_issues_count; + + const marginLeft = `${spacingLeft}px`; const handleToggleExpand = (e: MouseEvent) => { e.stopPropagation(); @@ -119,33 +129,76 @@ export const IssueBlock = observer((props: IssueBlockProps) => {
-
+
-
- -
- {subIssuesCount > 0 && ( - - )} -
+ {/* drag handle */} +
+ +
+ {/* select checkbox */} + {projectId && canEditIssueProperties && ( + + Only issues within the current +
+ project can be selected. + + } + disabled={issue.project_id === projectId} + > +
+ +
+
+ )} + {/* sub-issues chevron */} +
+ {subIssuesCount > 0 && ( + + )}
{displayProperties && displayProperties?.key && ( -
+
{projectIdentifier}-{issue.sequence_id}
)} @@ -177,7 +230,7 @@ export const IssueBlock = observer((props: IssueBlockProps) => { )}
{!issue?.tempId && ( -
+
{quickActions({ issue, parentRef: issueRef, diff --git a/web/components/issues/issue-layouts/list/blocks-list.tsx b/web/components/issues/issue-layouts/list/blocks-list.tsx index 1d773001eb7..ba06697312f 100644 --- a/web/components/issues/issue-layouts/list/blocks-list.tsx +++ b/web/components/issues/issue-layouts/list/blocks-list.tsx @@ -2,6 +2,8 @@ import { FC, MutableRefObject } from "react"; // components import { TIssue, IIssueDisplayProperties, TIssueMap, TUnGroupedIssues } from "@plane/types"; import { IssueBlockRoot } from "@/components/issues/issue-layouts/list"; +// hooks +import { TSelectionHelper } from "@/hooks/use-multiple-select"; // types import { TRenderQuickActions } from "./list-view-types"; @@ -14,6 +16,7 @@ interface Props { quickActions: TRenderQuickActions; displayProperties: IIssueDisplayProperties | undefined; containerRef: MutableRefObject; + selectionHelpers: TSelectionHelper; isDragAllowed: boolean; canDropOverIssue: boolean; } @@ -28,33 +31,33 @@ export const IssueBlocksList: FC = (props) => { displayProperties, canEditProperties, containerRef, + selectionHelpers, isDragAllowed, canDropOverIssue, } = props; return ( -
- {issueIds && - issueIds.length > 0 && - issueIds.map((issueId: string, index) => ( - - ))} +
+ {issueIds?.map((issueId, index) => ( + + ))}
); }; diff --git a/web/components/issues/issue-layouts/list/default.tsx b/web/components/issues/issue-layouts/list/default.tsx index 66bc47c2895..e56a0f28e68 100644 --- a/web/components/issues/issue-layouts/list/default.tsx +++ b/web/components/issues/issue-layouts/list/default.tsx @@ -1,7 +1,8 @@ import { useEffect, useRef } from "react"; import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"; import { autoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element"; -// components +import { observer } from "mobx-react"; +// types import { GroupByColumnTypes, TGroupedIssues, @@ -9,12 +10,16 @@ import { IIssueDisplayProperties, TIssueMap, TUnGroupedIssues, - IGroupByColumn, - TIssueOrderByOptions, TIssueGroupByOptions, + TIssueOrderByOptions, + IGroupByColumn, } from "@plane/types"; +// components +import { MultipleSelectGroup } from "@/components/core"; +import { IssueBulkOperationsRoot } from "@/components/issues"; // hooks import { EIssuesStoreType } from "@/constants/issue"; +// hooks import { useCycle, useLabel, useMember, useModule, useProject, useProjectState } from "@/hooks/store"; // utils import { getGroupByColumns, isWorkspaceLevel, GroupDropLocation } from "../utils"; @@ -46,7 +51,7 @@ export interface IGroupByList { isCompletedCycle?: boolean; } -const GroupByList: React.FC = (props) => { +const GroupByList: React.FC = observer((props) => { const { issueIds, issuesMap, @@ -113,43 +118,69 @@ const GroupByList: React.FC = (props) => { const is_list = group_by === null ? true : false; + // create groupIds array and entities object for bulk ops + const groupIds = groups.map((g) => g.id); + const orderedGroups: Record = {}; + groupIds.forEach((gID) => { + orderedGroups[gID] = []; + }); + let entities: Record = {}; + + if (is_list) { + entities = Object.assign(orderedGroups, { [groupIds[0]]: issueIds }); + } else { + entities = Object.assign(orderedGroups, { ...issueIds }); + } + return ( -
- {groups && - groups.length > 0 && - groups.map( - (group: IGroupByColumn) => - validateEmptyIssueGroups(is_list ? issueIds : issueIds?.[group.id]) && ( - - ) - )} +
+ {groups && ( + + {(helpers) => ( + <> +
+ {groups.map( + (group: IGroupByColumn) => + validateEmptyIssueGroups(is_list ? issueIds : issueIds?.[group.id]) && ( + + ) + )} +
+ + + )} +
+ )}
); -}; +}); + +GroupByList.displayName = "GroupByList"; export interface IList { issueIds: TGroupedIssues | TUnGroupedIssues | any; diff --git a/web/components/issues/issue-layouts/list/headers/group-by-card.tsx b/web/components/issues/issue-layouts/list/headers/group-by-card.tsx index d479bbeaa1d..913473b1fdf 100644 --- a/web/components/issues/issue-layouts/list/headers/group-by-card.tsx +++ b/web/components/issues/issue-layouts/list/headers/group-by-card.tsx @@ -1,138 +1,169 @@ import { useState } from "react"; import { observer } from "mobx-react"; import { useRouter } from "next/router"; -// lucide icons import { CircleDashed, Plus } from "lucide-react"; +// types import { TIssue, ISearchIssueResponse } from "@plane/types"; -// components +// ui import { CustomMenu, TOAST_TYPE, setToast } from "@plane/ui"; -import { ExistingIssuesListModal } from "@/components/core"; +// components +import { ExistingIssuesListModal, MultipleSelectGroupAction } from "@/components/core"; import { CreateUpdateIssueModal } from "@/components/issues"; -// ui -// mobx -// hooks +// constants import { EIssuesStoreType } from "@/constants/issue"; +// helpers +import { cn } from "@/helpers/common.helper"; +// hooks import { useEventTracker } from "@/hooks/store"; -// types +import { TSelectionHelper } from "@/hooks/use-multiple-select"; interface IHeaderGroupByCard { + groupID: string; icon?: React.ReactNode; title: string; count: number; issuePayload: Partial; + canEditProperties: (projectId: string | undefined) => boolean; disableIssueCreation?: boolean; storeType: EIssuesStoreType; addIssuesToView?: (issueIds: string[]) => Promise; + selectionHelpers: TSelectionHelper; } -export const HeaderGroupByCard = observer( - ({ icon, title, count, issuePayload, disableIssueCreation, storeType, addIssuesToView }: IHeaderGroupByCard) => { - const router = useRouter(); - const { workspaceSlug, projectId, moduleId, cycleId } = router.query; - // hooks - const { setTrackElement } = useEventTracker(); - - const [isOpen, setIsOpen] = useState(false); - - const [openExistingIssueListModal, setOpenExistingIssueListModal] = useState(false); - - const isDraftIssue = router.pathname.includes("draft-issue"); +export const HeaderGroupByCard = observer((props: IHeaderGroupByCard) => { + const { + groupID, + icon, + title, + count, + issuePayload, + canEditProperties, + disableIssueCreation, + storeType, + addIssuesToView, + selectionHelpers, + } = props; + // states + const [isOpen, setIsOpen] = useState(false); + const [openExistingIssueListModal, setOpenExistingIssueListModal] = useState(false); + // router + const router = useRouter(); + const { workspaceSlug, projectId, moduleId, cycleId } = router.query; + // hooks + const { setTrackElement } = useEventTracker(); + // derived values + const isDraftIssue = router.pathname.includes("draft-issue"); + const renderExistingIssueModal = moduleId || cycleId; + const existingIssuesListModalPayload = moduleId ? { module: moduleId.toString() } : { cycle: true }; + const isGroupSelectionEmpty = selectionHelpers.isGroupSelected(groupID) === "empty"; + // auth + const canSelectIssues = canEditProperties(projectId?.toString()); - const renderExistingIssueModal = moduleId || cycleId; - const ExistingIssuesListModalPayload = moduleId ? { module: moduleId.toString() } : { cycle: true }; + const handleAddIssuesToView = async (data: ISearchIssueResponse[]) => { + if (!workspaceSlug || !projectId) return; - const handleAddIssuesToView = async (data: ISearchIssueResponse[]) => { - if (!workspaceSlug || !projectId) return; + const issues = data.map((i) => i.id); - const issues = data.map((i) => i.id); + try { + await addIssuesToView?.(issues); - try { - await addIssuesToView?.(issues); + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Success!", + message: "Issues added to the cycle successfully.", + }); + } catch (error) { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: "Selected issues could not be added to the cycle. Please try again.", + }); + } + }; - setToast({ - type: TOAST_TYPE.SUCCESS, - title: "Success!", - message: "Issues added to the cycle successfully.", - }); - } catch (error) { - setToast({ - type: TOAST_TYPE.ERROR, - title: "Error!", - message: "Selected issues could not be added to the cycle. Please try again.", - }); - } - }; - - return ( - <> -
-
- {icon ? icon : } + return ( + <> +
+ {canSelectIssues && ( +
+
+ )} +
+ {icon ?? } +
-
-
{title}
-
{count || 0}
-
+
+
{title}
+
{count || 0}
+
- {!disableIssueCreation && - (renderExistingIssueModal ? ( - - - - } - > - { - setTrackElement("List layout"); - setIsOpen(true); - }} - > - Create issue - - { - setTrackElement("List layout"); - setOpenExistingIssueListModal(true); - }} - > - Add an existing issue - - - ) : ( -
+ + + } + > + { setTrackElement("List layout"); setIsOpen(true); }} > - -
- ))} + Create issue + + { + setTrackElement("List layout"); + setOpenExistingIssueListModal(true); + }} + > + Add an existing issue + + + ) : ( +
{ + setTrackElement("List layout"); + setIsOpen(true); + }} + > + +
+ ))} - setIsOpen(false)} - data={issuePayload} - storeType={storeType} - isDraft={isDraftIssue} - /> + setIsOpen(false)} + data={issuePayload} + storeType={storeType} + isDraft={isDraftIssue} + /> - {renderExistingIssueModal && ( - setOpenExistingIssueListModal(false)} - searchParams={ExistingIssuesListModalPayload} - handleOnSubmit={handleAddIssuesToView} - /> - )} -
- - ); - } -); + {renderExistingIssueModal && ( + setOpenExistingIssueListModal(false)} + searchParams={existingIssuesListModalPayload} + handleOnSubmit={handleAddIssuesToView} + /> + )} +
+ + ); +}); diff --git a/web/components/issues/issue-layouts/list/list-group.tsx b/web/components/issues/issue-layouts/list/list-group.tsx index 43c5f990e3e..04457327e35 100644 --- a/web/components/issues/issue-layouts/list/list-group.tsx +++ b/web/components/issues/issue-layouts/list/list-group.tsx @@ -19,6 +19,7 @@ import { TOAST_TYPE, setToast } from "@plane/ui"; import { DRAG_ALLOWED_GROUPS, EIssuesStoreType } from "@/constants/issue"; // hooks import { useProjectState } from "@/hooks/store"; +import { TSelectionHelper } from "@/hooks/use-multiple-select"; // components import { GroupDragOverlay } from "../group-drag-overlay"; import { @@ -58,6 +59,7 @@ type Props = { addIssuesToView?: (issueIds: string[]) => Promise; viewId?: string; isCompletedCycle?: boolean; + selectionHelpers: TSelectionHelper; }; export const ListGroup = observer((props: Props) => { @@ -81,6 +83,7 @@ export const ListGroup = observer((props: Props) => { enableIssueQuickAdd, isCompletedCycle, storeType, + selectionHelpers, } = props; const [isDraggingOverColumn, setIsDraggingOverColumn] = useState(false); @@ -190,15 +193,18 @@ export const ListGroup = observer((props: Props) => { "border-custom-error-200": isDraggingOverColumn && !!group.isDropDisabled, })} > -
+
@@ -224,6 +230,7 @@ export const ListGroup = observer((props: Props) => { containerRef={containerRef} isDragAllowed={isDragAllowed} canDropOverIssue={!canOverlayBeVisible} + selectionHelpers={selectionHelpers} /> )} diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/assignee-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/assignee-column.tsx index 9fefd47594c..d0fb5812ddf 100644 --- a/web/components/issues/issue-layouts/spreadsheet/columns/assignee-column.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/columns/assignee-column.tsx @@ -1,9 +1,9 @@ import React from "react"; import { observer } from "mobx-react-lite"; +// types import { TIssue } from "@plane/types"; // components import { MemberDropdown } from "@/components/dropdowns"; -// types type Props = { issue: TIssue; @@ -36,7 +36,7 @@ export const SpreadsheetAssigneeColumn: React.FC = observer((props: Props buttonVariant={ issue?.assignee_ids && issue.assignee_ids.length > 0 ? "transparent-without-text" : "transparent-with-text" } - buttonClassName="text-left" + buttonClassName="text-left rounded-none group-[.selected-issue-row]:bg-custom-primary-100/5 group-[.selected-issue-row]:hover:bg-custom-primary-100/10" buttonContainerClassName="w-full" onClose={onClose} /> diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/attachment-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/attachment-column.tsx index 7fb7ef7e2df..0be34526269 100644 --- a/web/components/issues/issue-layouts/spreadsheet/columns/attachment-column.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/columns/attachment-column.tsx @@ -1,7 +1,7 @@ import React from "react"; import { observer } from "mobx-react-lite"; -import { TIssue } from "@plane/types"; // types +import { TIssue } from "@plane/types"; type Props = { issue: TIssue; @@ -11,7 +11,7 @@ export const SpreadsheetAttachmentColumn: React.FC = observer((props) => const { issue } = props; return ( -
+
{issue?.attachment_count} {issue?.attachment_count === 1 ? "attachment" : "attachments"}
); diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/created-on-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/created-on-column.tsx index eea39478a52..a7845400c51 100644 --- a/web/components/issues/issue-layouts/spreadsheet/columns/created-on-column.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/columns/created-on-column.tsx @@ -1,9 +1,9 @@ import React from "react"; import { observer } from "mobx-react-lite"; +// types import { TIssue } from "@plane/types"; // helpers import { renderFormattedDate } from "@/helpers/date-time.helper"; -// types type Props = { issue: TIssue; @@ -11,8 +11,9 @@ type Props = { export const SpreadsheetCreatedOnColumn: React.FC = observer((props: Props) => { const { issue } = props; + return ( -
+
{renderFormattedDate(issue.created_at)}
); diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/cycle-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/cycle-column.tsx index 8cb2f43fb0b..574ab6feacc 100644 --- a/web/components/issues/issue-layouts/spreadsheet/columns/cycle-column.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/columns/cycle-column.tsx @@ -1,14 +1,14 @@ import React, { useCallback } from "react"; import { observer } from "mobx-react"; import { useRouter } from "next/router"; +// types import { TIssue } from "@plane/types"; -// hooks +// components import { CycleDropdown } from "@/components/dropdowns"; +// constants import { EIssuesStoreType } from "@/constants/issue"; +// hooks import { useEventTracker, useIssues } from "@/hooks/store"; -// components -// types -// constants type Props = { issue: TIssue; @@ -17,11 +17,10 @@ type Props = { }; export const SpreadsheetCycleColumn: React.FC = observer((props) => { + const { issue, disabled, onClose } = props; // router const router = useRouter(); const { workspaceSlug } = router.query; - // props - const { issue, disabled, onClose } = props; // hooks const { captureIssueEvent } = useEventTracker(); const { @@ -56,8 +55,8 @@ export const SpreadsheetCycleColumn: React.FC = observer((props) => { disabled={disabled} placeholder="Select cycle" buttonVariant="transparent-with-text" - buttonContainerClassName="w-full relative flex items-center p-2" - buttonClassName="relative leading-4 h-4.5 bg-transparent" + buttonContainerClassName="w-full relative flex items-center p-2 group-[.selected-issue-row]:bg-custom-primary-100/5 group-[.selected-issue-row]:hover:bg-custom-primary-100/10" + buttonClassName="relative leading-4 h-4.5 bg-transparent hover:bg-transparent" onClose={onClose} />
diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/due-date-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/due-date-column.tsx index 58ebac58ecf..f0c43a4573b 100644 --- a/web/components/issues/issue-layouts/spreadsheet/columns/due-date-column.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/columns/due-date-column.tsx @@ -1,16 +1,16 @@ import React from "react"; import { observer } from "mobx-react"; import { CalendarCheck2 } from "lucide-react"; +// types import { TIssue } from "@plane/types"; -// hooks // components import { DateDropdown } from "@/components/dropdowns"; // helpers import { cn } from "@/helpers/common.helper"; import { getDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper"; import { shouldHighlightIssueDueDate } from "@/helpers/issue.helper"; +// hooks import { useProjectState } from "@/hooks/store"; -// types type Props = { issue: TIssue; @@ -47,9 +47,12 @@ export const SpreadsheetDueDateColumn: React.FC = observer((props: Props) icon={} buttonVariant="transparent-with-text" buttonContainerClassName="w-full" - buttonClassName={cn("rounded-none text-left", { - "text-red-500": shouldHighlightIssueDueDate(issue.target_date, stateDetails?.group), - })} + buttonClassName={cn( + "rounded-none text-left group-[.selected-issue-row]:bg-custom-primary-100/5 group-[.selected-issue-row]:hover:bg-custom-primary-100/10", + { + "text-red-500": shouldHighlightIssueDueDate(issue.target_date, stateDetails?.group), + } + )} clearIconClassName="!text-custom-text-100" onClose={onClose} /> diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/estimate-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/estimate-column.tsx index 6acc0f6a5e5..2e90cd2ba51 100644 --- a/web/components/issues/issue-layouts/spreadsheet/columns/estimate-column.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/columns/estimate-column.tsx @@ -1,8 +1,8 @@ -// components import { observer } from "mobx-react-lite"; +// types import { TIssue } from "@plane/types"; +// components import { EstimateDropdown } from "@/components/dropdowns"; -// types type Props = { issue: TIssue; @@ -25,7 +25,7 @@ export const SpreadsheetEstimateColumn: React.FC = observer((props: Props projectId={issue.project_id} disabled={disabled} buttonVariant="transparent-with-text" - buttonClassName="rounded-none text-left" + buttonClassName="text-left rounded-none group-[.selected-issue-row]:bg-custom-primary-100/5 group-[.selected-issue-row]:hover:bg-custom-primary-100/10" buttonContainerClassName="w-full" onClose={onClose} /> diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/label-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/label-column.tsx index 439abf5f39e..bb409d5637c 100644 --- a/web/components/issues/issue-layouts/spreadsheet/columns/label-column.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/columns/label-column.tsx @@ -1,10 +1,10 @@ import React from "react"; import { observer } from "mobx-react-lite"; +// types import { TIssue } from "@plane/types"; -// components // hooks import { useLabel } from "@/hooks/store"; -// types +// components import { IssuePropertyLabels } from "../../properties"; type Props = { @@ -27,8 +27,8 @@ export const SpreadsheetLabelColumn: React.FC = observer((props: Props) = value={issue.label_ids} defaultOptions={defaultLabelOptions} onChange={(data) => onChange(issue, { label_ids: data }, { changed_property: "labels", change_details: data })} - className="h-11 w-full border-b-[0.5px] border-custom-border-200 hover:bg-custom-background-80" - buttonClassName="px-2.5 h-full" + className="h-11 w-full border-b-[0.5px] border-custom-border-200" + buttonClassName="px-2.5 h-full group-[.selected-issue-row]:bg-custom-primary-100/5 group-[.selected-issue-row]:hover:bg-custom-primary-100/10" hideDropdownArrow maxRender={1} disabled={disabled} diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/link-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/link-column.tsx index f2c11ab0f61..f8c63942976 100644 --- a/web/components/issues/issue-layouts/spreadsheet/columns/link-column.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/columns/link-column.tsx @@ -1,7 +1,7 @@ import React from "react"; import { observer } from "mobx-react-lite"; -import { TIssue } from "@plane/types"; // types +import { TIssue } from "@plane/types"; type Props = { issue: TIssue; @@ -11,7 +11,7 @@ export const SpreadsheetLinkColumn: React.FC = observer((props: Props) => const { issue } = props; return ( -
+
{issue?.link_count} {issue?.link_count === 1 ? "link" : "links"}
); diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/module-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/module-column.tsx index efae44e840f..2357a6791a7 100644 --- a/web/components/issues/issue-layouts/spreadsheet/columns/module-column.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/columns/module-column.tsx @@ -2,14 +2,14 @@ import React, { useCallback } from "react"; import xor from "lodash/xor"; import { observer } from "mobx-react"; import { useRouter } from "next/router"; +// types import { TIssue } from "@plane/types"; -// hooks +// components import { ModuleDropdown } from "@/components/dropdowns"; +// constants import { EIssuesStoreType } from "@/constants/issue"; +// hooks import { useEventTracker, useIssues } from "@/hooks/store"; -// components -// types -// constants type Props = { issue: TIssue; @@ -18,11 +18,10 @@ type Props = { }; export const SpreadsheetModuleColumn: React.FC = observer((props) => { + const { issue, disabled, onClose } = props; // router const router = useRouter(); const { workspaceSlug } = router.query; - // props - const { issue, disabled, onClose } = props; // hooks const { captureIssueEvent } = useEventTracker(); const { @@ -65,8 +64,8 @@ export const SpreadsheetModuleColumn: React.FC = observer((props) => { disabled={disabled} placeholder="Select modules" buttonVariant="transparent-with-text" - buttonContainerClassName="w-full relative flex items-center p-2" - buttonClassName="relative leading-4 h-4.5 bg-transparent" + buttonContainerClassName="w-full relative flex items-center p-2 group-[.selected-issue-row]:bg-custom-primary-100/5 group-[.selected-issue-row]:hover:bg-custom-primary-100/10" + buttonClassName="relative leading-4 h-4.5 bg-transparent hover:bg-transparent" onClose={onClose} multiple showCount diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/priority-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/priority-column.tsx index 8058b70236a..1e072a736a5 100644 --- a/web/components/issues/issue-layouts/spreadsheet/columns/priority-column.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/columns/priority-column.tsx @@ -1,9 +1,9 @@ import React from "react"; import { observer } from "mobx-react-lite"; +// types import { TIssue } from "@plane/types"; // components import { PriorityDropdown } from "@/components/dropdowns"; -// types type Props = { issue: TIssue; @@ -22,7 +22,7 @@ export const SpreadsheetPriorityColumn: React.FC = observer((props: Props onChange={(data) => onChange(issue, { priority: data }, { changed_property: "priority", change_details: data })} disabled={disabled} buttonVariant="transparent-with-text" - buttonClassName="rounded-none text-left" + buttonClassName="text-left rounded-none group-[.selected-issue-row]:bg-custom-primary-100/5 group-[.selected-issue-row]:hover:bg-custom-primary-100/10" buttonContainerClassName="w-full" onClose={onClose} /> diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/start-date-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/start-date-column.tsx index aff8c7dfa76..9a17d34d4d2 100644 --- a/web/components/issues/issue-layouts/spreadsheet/columns/start-date-column.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/columns/start-date-column.tsx @@ -1,12 +1,12 @@ import React from "react"; import { observer } from "mobx-react"; import { CalendarClock } from "lucide-react"; +// types import { TIssue } from "@plane/types"; // components import { DateDropdown } from "@/components/dropdowns"; // helpers import { getDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper"; -// types type Props = { issue: TIssue; @@ -38,7 +38,7 @@ export const SpreadsheetStartDateColumn: React.FC = observer((props: Prop placeholder="Start date" icon={} buttonVariant="transparent-with-text" - buttonClassName="rounded-none text-left" + buttonClassName="text-left rounded-none group-[.selected-issue-row]:bg-custom-primary-100/5 group-[.selected-issue-row]:hover:bg-custom-primary-100/10" buttonContainerClassName="w-full" onClose={onClose} /> diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/state-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/state-column.tsx index 20158572843..f50ab4fced6 100644 --- a/web/components/issues/issue-layouts/spreadsheet/columns/state-column.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/columns/state-column.tsx @@ -1,9 +1,9 @@ import React from "react"; import { observer } from "mobx-react-lite"; +// types import { TIssue } from "@plane/types"; // components import { StateDropdown } from "@/components/dropdowns"; -// types type Props = { issue: TIssue; @@ -23,7 +23,7 @@ export const SpreadsheetStateColumn: React.FC = observer((props) => { onChange={(data) => onChange(issue, { state_id: data }, { changed_property: "state", change_details: data })} disabled={disabled} buttonVariant="transparent-with-text" - buttonClassName="rounded-none text-left" + buttonClassName="text-left rounded-none group-[.selected-issue-row]:bg-custom-primary-100/5 group-[.selected-issue-row]:hover:bg-custom-primary-100/10" buttonContainerClassName="w-full" onClose={onClose} /> diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/sub-issue-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/sub-issue-column.tsx index 8a6d26ac6c1..70595454123 100644 --- a/web/components/issues/issue-layouts/spreadsheet/columns/sub-issue-column.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/columns/sub-issue-column.tsx @@ -34,7 +34,7 @@ export const SpreadsheetSubIssueColumn: React.FC = observer((props: Props
{}} className={cn( - "flex h-11 w-full items-center border-b-[0.5px] border-custom-border-200 px-2.5 py-1 text-xs hover:bg-custom-background-80", + "flex h-11 w-full items-center border-b-[0.5px] border-custom-border-200 px-2.5 py-1 text-xs hover:bg-custom-background-80 group-[.selected-issue-row]:bg-custom-primary-100/5 group-[.selected-issue-row]:hover:bg-custom-primary-100/10", { "cursor-pointer": subIssueCount, } diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/updated-on-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/updated-on-column.tsx index 60a0e6e5368..08d7162d72c 100644 --- a/web/components/issues/issue-layouts/spreadsheet/columns/updated-on-column.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/columns/updated-on-column.tsx @@ -1,9 +1,9 @@ import React from "react"; import { observer } from "mobx-react-lite"; +// types import { TIssue } from "@plane/types"; // helpers import { renderFormattedDate } from "@/helpers/date-time.helper"; -// types type Props = { issue: TIssue; @@ -11,8 +11,9 @@ type Props = { export const SpreadsheetUpdatedOnColumn: React.FC = observer((props: Props) => { const { issue } = props; + return ( -
+
{renderFormattedDate(issue.updated_at)}
); diff --git a/web/components/issues/issue-layouts/spreadsheet/issue-column.tsx b/web/components/issues/issue-layouts/spreadsheet/issue-column.tsx index 161dd6514f4..086d0fe3e0f 100644 --- a/web/components/issues/issue-layouts/spreadsheet/issue-column.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/issue-column.tsx @@ -1,13 +1,14 @@ import { useRef } from "react"; import { observer } from "mobx-react"; import { useRouter } from "next/router"; -import { IIssueDisplayProperties, TIssue } from "@plane/types"; // types +import { IIssueDisplayProperties, TIssue } from "@plane/types"; +// constants import { SPREADSHEET_PROPERTY_DETAILS } from "@/constants/spreadsheet"; +// hooks import { useEventTracker } from "@/hooks/store"; -import { WithDisplayPropertiesHOC } from "../properties/with-display-properties-HOC"; -// constants // components +import { WithDisplayPropertiesHOC } from "../properties/with-display-properties-HOC"; type Props = { displayProperties: IIssueDisplayProperties; @@ -37,7 +38,7 @@ export const IssueColumn = observer((props: Props) => { > { }) } disabled={disableUserActions} - onClose={() => { - tableCellRef?.current?.focus(); - }} + onClose={() => tableCellRef?.current?.focus()} /> diff --git a/web/components/issues/issue-layouts/spreadsheet/issue-row.tsx b/web/components/issues/issue-layouts/spreadsheet/issue-row.tsx index eb33a13f35a..78088015e57 100644 --- a/web/components/issues/issue-layouts/spreadsheet/issue-row.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/issue-row.tsx @@ -7,11 +7,15 @@ import { IIssueDisplayProperties, TIssue } from "@plane/types"; // ui import { ControlLink, Tooltip } from "@plane/ui"; // components +import { MultipleSelectEntityAction } from "@/components/core"; import RenderIfVisible from "@/components/core/render-if-visible-HOC"; +// constants +import { SPREADSHEET_SELECT_GROUP } from "@/constants/spreadsheet"; // helper import { cn } from "@/helpers/common.helper"; // hooks import { useIssueDetail, useProject } from "@/hooks/store"; +import { TSelectionHelper } from "@/hooks/use-multiple-select"; import useOutsideClickDetector from "@/hooks/use-outside-click-detector"; import { usePlatformOS } from "@/hooks/use-platform-os"; // types @@ -34,6 +38,7 @@ interface Props { issueIds: string[]; spreadsheetColumnsList: (keyof IIssueDisplayProperties)[]; spacingLeft?: number; + selectionHelpers: TSelectionHelper; } export const SpreadsheetIssueRow = observer((props: Props) => { @@ -51,12 +56,16 @@ export const SpreadsheetIssueRow = observer((props: Props) => { issueIds, spreadsheetColumnsList, spacingLeft = 6, + selectionHelpers, } = props; - + // states const [isExpanded, setExpanded] = useState(false); + // store hooks const { subIssues: subIssuesStore } = useIssueDetail(); - + // derived values const subIssues = subIssuesStore.subIssuesByIssueId(issueId); + const isIssueSelected = selectionHelpers.getIsEntitySelected(issueId); + const isIssueActive = selectionHelpers.getIsEntityActive(issueId); return ( <> @@ -65,7 +74,13 @@ export const SpreadsheetIssueRow = observer((props: Props) => { as="tr" defaultHeight="calc(2.75rem - 1px)" root={containerRef} - placeholderChildren={} + placeholderChildren={ + + } + classNames={cn("bg-custom-background-100 transition-[background-color]", { + "group selected-issue-row": isIssueSelected, + "border-[0.5px] border-custom-border-400": isIssueActive, + })} > { isExpanded={isExpanded} setExpanded={setExpanded} spreadsheetColumnsList={spreadsheetColumnsList} + selectionHelpers={selectionHelpers} /> {isExpanded && - subIssues && - subIssues.length > 0 && - subIssues.map((subIssueId: string) => ( + subIssues?.map((subIssueId) => ( { containerRef={containerRef} issueIds={issueIds} spreadsheetColumnsList={spreadsheetColumnsList} + selectionHelpers={selectionHelpers} /> ))} @@ -123,6 +138,7 @@ interface IssueRowDetailsProps { setExpanded: Dispatch>; spreadsheetColumnsList: (keyof IIssueDisplayProperties)[]; spacingLeft?: number; + selectionHelpers: TSelectionHelper; } const IssueRowDetails = observer((props: IssueRowDetailsProps) => { @@ -140,6 +156,7 @@ const IssueRowDetails = observer((props: IssueRowDetailsProps) => { setExpanded, spreadsheetColumnsList, spacingLeft = 6, + selectionHelpers, } = props; // states const [isMenuActive, setIsMenuActive] = useState(false); @@ -148,7 +165,7 @@ const IssueRowDetails = observer((props: IssueRowDetailsProps) => { const menuActionRef = useRef(null); // router const router = useRouter(); - const { workspaceSlug } = router.query; + const { workspaceSlug, projectId } = router.query; // hooks const { getProjectIdentifierById } = useProject(); const { getIsIssuePeeked, peekIssue, setPeekIssue } = useIssueDetail(); @@ -171,7 +188,7 @@ const IssueRowDetails = observer((props: IssueRowDetailsProps) => { const issueDetail = issue.getIssueById(issueId); - const paddingLeft = `${spacingLeft}px`; + const marginLeft = `${spacingLeft}px`; useOutsideClickDetector(menuActionRef, () => setIsMenuActive(false)); @@ -204,16 +221,22 @@ const IssueRowDetails = observer((props: IssueRowDetailsProps) => { const disableUserActions = !canEditProperties(issueDetail.project_id); const subIssuesCount = issueDetail?.sub_issues_count ?? 0; + const isIssueSelected = selectionHelpers.getIsEntitySelected(issueDetail.id); return ( <> - + handleIssuePeekOverview(issueDetail)} className={cn( - "group clickable cursor-pointer h-11 w-[28rem] flex items-center bg-custom-background-100 text-sm after:absolute border-r-[0.5px] z-10 border-custom-border-200", + "group clickable cursor-pointer h-11 w-[28rem] flex items-center text-sm after:absolute border-r-[0.5px] z-10 border-custom-border-200 bg-transparent group-[.selected-issue-row]:bg-custom-primary-100/5 group-[.selected-issue-row]:hover:bg-custom-primary-100/10", { "border-b-[0.5px]": !getIsIssuePeeked(issueDetail.id), "border border-custom-primary-70 hover:border-custom-primary-70": @@ -223,23 +246,51 @@ const IssueRowDetails = observer((props: IssueRowDetailsProps) => { )} disabled={!!issueDetail?.tempId} > -
-
- {/* bulk ops */} - -
- {subIssuesCount > 0 && ( - - )} -
+
+ {/* select checkbox */} + {projectId && !disableUserActions && ( + + Only issues within the current +
+ project can be selected. + + } + disabled={issueDetail.project_id === projectId} + > +
+ +
+
+ )} + {/* sub-issues chevron */} +
+ {subIssuesCount > 0 && ( + + )}
diff --git a/web/components/issues/issue-layouts/spreadsheet/spreadsheet-header.tsx b/web/components/issues/issue-layouts/spreadsheet/spreadsheet-header.tsx index 63017f0e72e..9585d0bf9c6 100644 --- a/web/components/issues/issue-layouts/spreadsheet/spreadsheet-header.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/spreadsheet-header.tsx @@ -1,33 +1,68 @@ -// ui -import { IIssueDisplayFilterOptions, IIssueDisplayProperties } from "@plane/types"; +import { observer } from "mobx-react"; +import { useRouter } from "next/router"; // types -import { LayersIcon } from "@plane/ui"; +import { IIssueDisplayFilterOptions, IIssueDisplayProperties } from "@plane/types"; // components +import { MultipleSelectGroupAction } from "@/components/core"; import { SpreadsheetHeaderColumn } from "@/components/issues/issue-layouts"; +// constants +import { SPREADSHEET_SELECT_GROUP } from "@/constants/spreadsheet"; +// helpers +import { cn } from "@/helpers/common.helper"; +// hooks +import { TSelectionHelper } from "@/hooks/use-multiple-select"; interface Props { displayProperties: IIssueDisplayProperties; displayFilters: IIssueDisplayFilterOptions; handleDisplayFilterUpdate: (data: Partial) => void; + canEditProperties: (projectId: string | undefined) => boolean; isEstimateEnabled: boolean; spreadsheetColumnsList: (keyof IIssueDisplayProperties)[]; + selectionHelpers: TSelectionHelper; } -export const SpreadsheetHeader = (props: Props) => { - const { displayProperties, displayFilters, handleDisplayFilterUpdate, isEstimateEnabled, spreadsheetColumnsList } = - props; +export const SpreadsheetHeader = observer((props: Props) => { + const { + displayProperties, + displayFilters, + handleDisplayFilterUpdate, + canEditProperties, + isEstimateEnabled, + spreadsheetColumnsList, + selectionHelpers, + } = props; + // router + const router = useRouter(); + const { projectId } = router.query; + // derived values + const isGroupSelectionEmpty = selectionHelpers.isGroupSelected(SPREADSHEET_SELECT_GROUP) === "empty"; + // auth + const canSelectIssues = canEditProperties(projectId?.toString()); return ( - - - Issue - + {canSelectIssues && ( +
+ +
+ )} +
+ Issues {spreadsheetColumnsList.map((property) => ( @@ -43,4 +78,4 @@ export const SpreadsheetHeader = (props: Props) => { ); -}; +}); diff --git a/web/components/issues/issue-layouts/spreadsheet/spreadsheet-table.tsx b/web/components/issues/issue-layouts/spreadsheet/spreadsheet-table.tsx index f548c69a5f5..008b499db37 100644 --- a/web/components/issues/issue-layouts/spreadsheet/spreadsheet-table.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/spreadsheet-table.tsx @@ -1,9 +1,11 @@ import { MutableRefObject, useCallback, useEffect, useRef } from "react"; import { observer } from "mobx-react-lite"; +// types import { IIssueDisplayFilterOptions, IIssueDisplayProperties, TIssue } from "@plane/types"; -//types +// hooks +import { TSelectionHelper } from "@/hooks/use-multiple-select"; import { useTableKeyboardNavigation } from "@/hooks/use-table-keyboard-navigation"; -//components +// components import { TRenderQuickActions } from "../list/list-view-types"; import { SpreadsheetIssueRow } from "./issue-row"; import { SpreadsheetHeader } from "./spreadsheet-header"; @@ -20,6 +22,7 @@ type Props = { portalElement: React.MutableRefObject; containerRef: MutableRefObject; spreadsheetColumnsList: (keyof IIssueDisplayProperties)[]; + selectionHelpers: TSelectionHelper; }; export const SpreadsheetTable = observer((props: Props) => { @@ -35,6 +38,7 @@ export const SpreadsheetTable = observer((props: Props) => { canEditProperties, containerRef, spreadsheetColumnsList, + selectionHelpers, } = props; // states @@ -81,8 +85,10 @@ export const SpreadsheetTable = observer((props: Props) => { displayProperties={displayProperties} displayFilters={displayFilters} handleDisplayFilterUpdate={handleDisplayFilterUpdate} + canEditProperties={canEditProperties} isEstimateEnabled={isEstimateEnabled} spreadsheetColumnsList={spreadsheetColumnsList} + selectionHelpers={selectionHelpers} /> {issueIds.map((id) => ( @@ -100,6 +106,7 @@ export const SpreadsheetTable = observer((props: Props) => { isScrolled={isScrolled} issueIds={issueIds} spreadsheetColumnsList={spreadsheetColumnsList} + selectionHelpers={selectionHelpers} /> ))} diff --git a/web/components/issues/issue-layouts/spreadsheet/spreadsheet-view.tsx b/web/components/issues/issue-layouts/spreadsheet/spreadsheet-view.tsx index a1f44d7f1e9..24060270921 100644 --- a/web/components/issues/issue-layouts/spreadsheet/spreadsheet-view.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/spreadsheet-view.tsx @@ -1,15 +1,16 @@ import React, { useRef } from "react"; import { observer } from "mobx-react-lite"; +// types import { TIssue, IIssueDisplayFilterOptions, IIssueDisplayProperties } from "@plane/types"; // components import { LogoSpinner } from "@/components/common"; -import { SpreadsheetQuickAddIssueForm } from "@/components/issues"; -import { SPREADSHEET_PROPERTY_LIST } from "@/constants/spreadsheet"; +import { MultipleSelectGroup } from "@/components/core"; +import { IssueBulkOperationsRoot, SpreadsheetQuickAddIssueForm } from "@/components/issues"; +import { SPREADSHEET_PROPERTY_LIST, SPREADSHEET_SELECT_GROUP } from "@/constants/spreadsheet"; +// hooks import { useProject } from "@/hooks/store"; import { TRenderQuickActions } from "../list/list-view-types"; import { SpreadsheetTable } from "./spreadsheet-table"; -// types -//hooks type Props = { displayProperties: IIssueDisplayProperties; @@ -73,28 +74,41 @@ export const SpreadsheetView: React.FC = observer((props) => { return (
-
- -
-
-
- {enableQuickCreateIssue && !disableIssueCreation && ( - - )} -
-
+ + {(helpers) => ( + <> +
+ +
+
+
+ {enableQuickCreateIssue && !disableIssueCreation && ( + + )} +
+
+ + + )} +
); }); diff --git a/web/components/issues/select/label.tsx b/web/components/issues/select/label.tsx index fee060d1b86..ddc0e41b3fd 100644 --- a/web/components/issues/select/label.tsx +++ b/web/components/issues/select/label.tsx @@ -4,13 +4,14 @@ import { useRouter } from "next/router"; import { usePopper } from "react-popper"; import { Check, Component, Plus, Search, Tag } from "lucide-react"; import { Combobox } from "@headlessui/react"; -// hooks +// components import { IssueLabelsList } from "@/components/ui"; +// helpers +import { cn } from "@/helpers/common.helper"; +// hooks import { useLabel } from "@/hooks/store"; import { useDropdownKeyDown } from "@/hooks/use-dropdown-key-down"; import useOutsideClickDetector from "@/hooks/use-outside-click-detector"; -// ui -// icons type Props = { setIsOpen: React.Dispatch>; @@ -21,10 +22,21 @@ type Props = { disabled?: boolean; tabIndex?: number; createLabelEnabled?: boolean; + buttonClassName?: string; }; export const IssueLabelSelect: React.FC = observer((props) => { - const { setIsOpen, value, onChange, projectId, label, disabled = false, tabIndex, createLabelEnabled = true } = props; + const { + setIsOpen, + value, + onChange, + projectId, + label, + disabled = false, + tabIndex, + createLabelEnabled = true, + buttonClassName, + } = props; // router const router = useRouter(); const { workspaceSlug } = router.query; @@ -101,7 +113,7 @@ export const IssueLabelSelect: React.FC = observer((props) => {
@@ -137,7 +137,7 @@ export const EmailNotificationForm: FC = (props) => control={control} name="comment" render={({ field: { value, onChange } }) => ( - onChange(!value)} className="mx-2" /> + onChange(!value)} containerClassName="mx-2" /> )} />
@@ -154,7 +154,7 @@ export const EmailNotificationForm: FC = (props) => control={control} name="mention" render={({ field: { value, onChange } }) => ( - onChange(!value)} className="mx-2" /> + onChange(!value)} containerClassName="mx-2" /> )} />
diff --git a/web/components/states/delete-state-modal.tsx b/web/components/states/delete-state-modal.tsx index 1fd256d6528..353f88a45fd 100644 --- a/web/components/states/delete-state-modal.tsx +++ b/web/components/states/delete-state-modal.tsx @@ -81,7 +81,7 @@ export const DeleteStateModal: React.FC = observer((props) => { = observer((props) => { = (props) => { = observe return ( <>
{!isCollapsed && ( diff --git a/web/components/workspace/views/delete-view-modal.tsx b/web/components/workspace/views/delete-view-modal.tsx index b38799d9135..fadf62271ce 100644 --- a/web/components/workspace/views/delete-view-modal.tsx +++ b/web/components/workspace/views/delete-view-modal.tsx @@ -67,7 +67,7 @@ export const DeleteGlobalViewModal: React.FC = observer((props) => { { + const context = useContext(StoreContext); + if (context === undefined) throw new Error("useMultipleSelectStore must be used within StoreProvider"); + return context.multipleSelect; +}; diff --git a/web/hooks/use-multiple-select.ts b/web/hooks/use-multiple-select.ts new file mode 100644 index 00000000000..9dcc0e17c3c --- /dev/null +++ b/web/hooks/use-multiple-select.ts @@ -0,0 +1,365 @@ +import { useCallback, useEffect, useMemo } from "react"; +import { useRouter } from "next/router"; +// hooks +import { useMultipleSelectStore } from "@/hooks/store"; + +export type TEntityDetails = { + entityID: string; + groupID: string; +}; + +type Props = { + containerRef: React.MutableRefObject; + entities: Record; // { groupID: entityIds[] } +}; + +export type TSelectionSnapshot = { + isSelectionActive: boolean; + selectedEntityIds: string[]; +}; + +export type TSelectionHelper = { + handleClearSelection: () => void; + handleEntityClick: (event: React.MouseEvent, entityID: string, groupId: string) => void; + getIsEntitySelected: (entityID: string) => boolean; + getIsEntityActive: (entityID: string) => boolean; + handleGroupClick: (groupID: string) => void; + isGroupSelected: (groupID: string) => "empty" | "partial" | "complete"; +}; + +export const useMultipleSelect = (props: Props) => { + const { containerRef, entities } = props; + // router + const router = useRouter(); + // store hooks + const { + updateSelectedEntityDetails, + bulkUpdateSelectedEntityDetails, + getActiveEntityDetails, + updateActiveEntityDetails, + getPreviousActiveEntity, + updatePreviousActiveEntity, + getNextActiveEntity, + updateNextActiveEntity, + getLastSelectedEntityDetails, + clearSelection, + getIsEntitySelected, + getIsEntityActive, + } = useMultipleSelectStore(); + + const groups = useMemo(() => Object.keys(entities), [entities]); + + const entitiesList: TEntityDetails[] = useMemo( + () => + groups + .map((groupID) => + entities[groupID].map((entityID) => ({ + entityID, + groupID, + })) + ) + .flat(1), + [entities, groups] + ); + + const getPreviousAndNextEntities = useCallback( + (entityID: string) => { + const currentEntityIndex = entitiesList.findIndex((entity) => entity?.entityID === entityID); + + // entity position + const isFirstEntity = currentEntityIndex === 0; + const isLastEntity = currentEntityIndex === entitiesList.length - 1; + + let previousEntity: TEntityDetails | null = null; + let nextEntity: TEntityDetails | null = null; + + if (isLastEntity) { + nextEntity = null; + } else { + nextEntity = entitiesList[currentEntityIndex + 1]; + } + + if (isFirstEntity) { + previousEntity = null; + } else { + previousEntity = entitiesList[currentEntityIndex - 1]; + } + + return { + previousEntity, + nextEntity, + }; + }, + [entitiesList] + ); + + const handleActiveEntityChange = useCallback( + (entityDetails: TEntityDetails | null, shouldScroll: boolean = true) => { + if (!entityDetails) { + updateActiveEntityDetails(null); + updatePreviousActiveEntity(null); + updateNextActiveEntity(null); + return; + } + + updateActiveEntityDetails(entityDetails); + + // scroll to get the active element in view + const activeElement = document.querySelector( + `[data-entity-id="${entityDetails.entityID}"][data-entity-group-id="${entityDetails.groupID}"]` + ); + if (activeElement && containerRef.current && shouldScroll) { + const SCROLL_OFFSET = 200; + const containerRect = containerRef.current.getBoundingClientRect(); + const elementRect = activeElement.getBoundingClientRect(); + + const isInView = + elementRect.top >= containerRect.top + SCROLL_OFFSET && + elementRect.bottom <= containerRect.bottom - SCROLL_OFFSET; + + if (!isInView) { + containerRef.current.scrollBy({ + top: elementRect.top < containerRect.top + SCROLL_OFFSET ? -50 : 50, + }); + } + } + + const { previousEntity: previousActiveEntity, nextEntity: nextActiveEntity } = getPreviousAndNextEntities( + entityDetails.entityID + ); + updatePreviousActiveEntity(previousActiveEntity); + updateNextActiveEntity(nextActiveEntity); + }, + [ + containerRef, + getPreviousAndNextEntities, + updateActiveEntityDetails, + updateNextActiveEntity, + updatePreviousActiveEntity, + ] + ); + + const handleEntitySelection = useCallback( + ( + entityDetails: TEntityDetails | TEntityDetails[], + shouldScroll: boolean = true, + forceAction: "force-add" | "force-remove" | null = null + ) => { + if (Array.isArray(entityDetails)) { + bulkUpdateSelectedEntityDetails(entityDetails, forceAction === "force-add" ? "add" : "remove"); + if (forceAction === "force-add" && entityDetails.length > 0) { + handleActiveEntityChange(entityDetails[entityDetails.length - 1], shouldScroll); + } + return; + } + + if (forceAction) { + if (forceAction === "force-add") { + console.log("force adding"); + updateSelectedEntityDetails(entityDetails, "add"); + handleActiveEntityChange(entityDetails, shouldScroll); + } + if (forceAction === "force-remove") { + updateSelectedEntityDetails(entityDetails, "remove"); + } + return; + } + + const isSelected = getIsEntitySelected(entityDetails.entityID); + if (isSelected) { + updateSelectedEntityDetails(entityDetails, "remove"); + handleActiveEntityChange(entityDetails, shouldScroll); + } else { + updateSelectedEntityDetails(entityDetails, "add"); + handleActiveEntityChange(entityDetails, shouldScroll); + } + }, + [bulkUpdateSelectedEntityDetails, getIsEntitySelected, handleActiveEntityChange, updateSelectedEntityDetails] + ); + + /** + * @description toggle entity selection + * @param {React.MouseEvent} event + * @param {string} entityID + * @param {string} groupID + */ + const handleEntityClick = useCallback( + (e: React.MouseEvent, entityID: string, groupID: string) => { + const lastSelectedEntityDetails = getLastSelectedEntityDetails(); + if (e.shiftKey && lastSelectedEntityDetails) { + const currentEntityIndex = entitiesList.findIndex((entity) => entity?.entityID === entityID); + + const lastEntityIndex = entitiesList.findIndex( + (entity) => entity?.entityID === lastSelectedEntityDetails.entityID + ); + if (lastEntityIndex < currentEntityIndex) { + for (let i = lastEntityIndex + 1; i <= currentEntityIndex; i++) { + const entityDetails = entitiesList[i]; + if (entityDetails) { + handleEntitySelection(entityDetails, false); + } + } + } else if (lastEntityIndex > currentEntityIndex) { + for (let i = currentEntityIndex; i <= lastEntityIndex - 1; i++) { + const entityDetails = entitiesList[i]; + if (entityDetails) { + handleEntitySelection(entityDetails, false); + } + } + } else { + const startIndex = lastEntityIndex + 1; + const endIndex = currentEntityIndex; + for (let i = startIndex; i <= endIndex; i++) { + const entityDetails = entitiesList[i]; + if (entityDetails) { + handleEntitySelection(entityDetails, false); + } + } + } + return; + } + + handleEntitySelection({ entityID, groupID }, false); + }, + [entitiesList, handleEntitySelection, getLastSelectedEntityDetails] + ); + + /** + * @description check if any entity of the group is selected + * @param {string} groupID + * @returns {boolean} + */ + const isGroupSelected = useCallback( + (groupID: string) => { + const groupEntities = entitiesList.filter((entity) => entity.groupID === groupID); + const totalSelected = groupEntities.filter((entity) => getIsEntitySelected(entity.entityID ?? "")).length; + if (totalSelected === 0) return "empty"; + if (totalSelected === groupEntities.length) return "complete"; + return "partial"; + }, + [entitiesList, getIsEntitySelected] + ); + + /** + * @description toggle group selection + * @param {string} groupID + */ + const handleGroupClick = useCallback( + (groupID: string) => { + const groupEntities = entitiesList.filter((entity) => entity.groupID === groupID); + const groupSelectionStatus = isGroupSelected(groupID); + // groupEntities.map((entity) => { + // console.log("group click"); + // handleEntitySelection(entity, false, groupSelectionStatus === "empty" ? "force-add" : "force-remove"); + // }); + handleEntitySelection(groupEntities, false, groupSelectionStatus === "empty" ? "force-add" : "force-remove"); + }, + [entitiesList, handleEntitySelection, isGroupSelected] + ); + + // clear selection on escape key press + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") clearSelection(); + }; + + window.addEventListener("keydown", handleKeyDown); + return () => { + window.removeEventListener("keydown", handleKeyDown); + }; + }, [clearSelection]); + + // select entities on shift + arrow up/down key press + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (!e.shiftKey) return; + + const activeEntityDetails = getActiveEntityDetails(); + const nextActiveEntity = getNextActiveEntity(); + const previousActiveEntity = getPreviousActiveEntity(); + + if (e.key === "ArrowDown" && activeEntityDetails) { + if (!nextActiveEntity) return; + handleEntitySelection(nextActiveEntity); + } + if (e.key === "ArrowUp" && activeEntityDetails) { + if (!previousActiveEntity) return; + handleEntitySelection(previousActiveEntity); + } + }; + window.addEventListener("keydown", handleKeyDown); + + return () => { + window.removeEventListener("keydown", handleKeyDown); + }; + }, [ + getActiveEntityDetails, + handleEntitySelection, + getLastSelectedEntityDetails, + getNextActiveEntity, + getPreviousActiveEntity, + ]); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.shiftKey) return; + const activeEntityDetails = getActiveEntityDetails(); + // set active entity id to the first entity + if (["ArrowUp", "ArrowDown"].includes(e.key) && !activeEntityDetails) { + const firstElementDetails = entitiesList[0]; + if (!firstElementDetails) return; + handleActiveEntityChange(firstElementDetails); + } + + if (e.key === "ArrowDown" && activeEntityDetails) { + if (!activeEntityDetails) return; + const { nextEntity: nextActiveEntity } = getPreviousAndNextEntities(activeEntityDetails.entityID); + if (nextActiveEntity) { + handleActiveEntityChange(nextActiveEntity); + } + } + + if (e.key === "ArrowUp" && activeEntityDetails) { + if (!activeEntityDetails) return; + const { previousEntity: previousActiveEntity } = getPreviousAndNextEntities(activeEntityDetails.entityID); + if (previousActiveEntity) { + handleActiveEntityChange(previousActiveEntity); + } + } + }; + + window.addEventListener("keydown", handleKeyDown); + + return () => { + window.removeEventListener("keydown", handleKeyDown); + }; + }, [getActiveEntityDetails, entitiesList, groups, getPreviousAndNextEntities, handleActiveEntityChange]); + + // clear selection on route change + useEffect(() => { + const handleRouteChange = () => clearSelection(); + + router.events.on("routeChangeComplete", handleRouteChange); + + return () => { + router.events.off("routeChangeComplete", handleRouteChange); + }; + }, [clearSelection, router.events]); + + /** + * @description helper functions for selection + */ + const helpers: TSelectionHelper = useMemo( + () => ({ + handleClearSelection: clearSelection, + handleEntityClick, + getIsEntitySelected, + getIsEntityActive, + handleGroupClick, + isGroupSelected, + }), + [clearSelection, getIsEntityActive, getIsEntitySelected, handleEntityClick, handleGroupClick, isGroupSelected] + ); + + return helpers; +}; diff --git a/web/services/issue/issue.service.ts b/web/services/issue/issue.service.ts index 1f6d95fd67e..7c4ba405289 100644 --- a/web/services/issue/issue.service.ts +++ b/web/services/issue/issue.service.ts @@ -1,5 +1,12 @@ // types -import type { TIssue, IIssueDisplayProperties, TIssueLink, TIssueSubIssues, TIssueActivity } from "@plane/types"; +import type { + TIssue, + IIssueDisplayProperties, + TIssueLink, + TIssueSubIssues, + TIssueActivity, + TBulkOperationsPayload, +} from "@plane/types"; // helpers import { API_BASE_URL } from "@/helpers/common.helper"; // services @@ -162,14 +169,6 @@ export class IssueService extends APIService { }); } - async bulkDeleteIssues(workspaceSlug: string, projectId: string, data: any): Promise { - return this.delete(`/api/workspaces/${workspaceSlug}/projects/${projectId}/bulk-delete-issues/`, data) - .then((response) => response?.data) - .catch((error) => { - throw error?.response?.data; - }); - } - async subIssues(workspaceSlug: string, projectId: string, issueId: string): Promise { return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}/sub-issues/`) .then((response) => response?.data) @@ -238,4 +237,42 @@ export class IssueService extends APIService { throw error?.response?.data; }); } + + async bulkOperations(workspaceSlug: string, projectId: string, data: TBulkOperationsPayload): Promise { + return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/bulk-operation-issues/`, data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async bulkDeleteIssues( + workspaceSlug: string, + projectId: string, + data: { + issue_ids: string[]; + } + ): Promise { + return this.delete(`/api/workspaces/${workspaceSlug}/projects/${projectId}/bulk-delete-issues/`, data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async bulkArchiveIssues( + workspaceSlug: string, + projectId: string, + data: { + issue_ids: string[]; + } + ): Promise<{ + archived_at: string; + }> { + return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/bulk-archive-issues/`, data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } } diff --git a/web/store/issue/project/issue.store.ts b/web/store/issue/project/issue.store.ts index 1e1f9a515b2..ab0aaca1017 100644 --- a/web/store/issue/project/issue.store.ts +++ b/web/store/issue/project/issue.store.ts @@ -4,7 +4,15 @@ import set from "lodash/set"; import update from "lodash/update"; import { action, makeObservable, observable, runInAction, computed } from "mobx"; // types -import { TIssue, TGroupedIssues, TSubGroupedIssues, TLoader, TUnGroupedIssues, ViewFlags } from "@plane/types"; +import { + TIssue, + TGroupedIssues, + TSubGroupedIssues, + TLoader, + TUnGroupedIssues, + ViewFlags, + TBulkOperationsPayload, +} from "@plane/types"; // helpers import { issueCountBasedOnFilters } from "@/helpers/issue.helper"; // base class @@ -30,6 +38,8 @@ export interface IProjectIssues { archiveIssue: (workspaceSlug: string, projectId: string, issueId: string) => Promise; quickAddIssue: (workspaceSlug: string, projectId: string, data: TIssue) => Promise; removeBulkIssues: (workspaceSlug: string, projectId: string, issueIds: string[]) => Promise; + archiveBulkIssues: (workspaceSlug: string, projectId: string, issueIds: string[]) => Promise; + bulkUpdateProperties: (workspaceSlug: string, projectId: string, data: TBulkOperationsPayload) => Promise; } export class ProjectIssues extends IssueHelperStore implements IProjectIssues { @@ -63,6 +73,8 @@ export class ProjectIssues extends IssueHelperStore implements IProjectIssues { removeIssue: action, archiveIssue: action, removeBulkIssues: action, + archiveBulkIssues: action, + bulkUpdateProperties: action, quickAddIssue: action, }); // root store @@ -244,7 +256,7 @@ export class ProjectIssues extends IssueHelperStore implements IProjectIssues { const response = await this.createIssue(workspaceSlug, projectId, data); const quickAddIssueIndex = this.issues[projectId].findIndex((_issueId) => _issueId === data.id); - + if (quickAddIssueIndex >= 0) { runInAction(() => { this.issues[projectId].splice(quickAddIssueIndex, 1); @@ -254,7 +266,7 @@ export class ProjectIssues extends IssueHelperStore implements IProjectIssues { //TODO: error handling needs to be improved for rare cases if (data.cycle_id && data.cycle_id !== "") { - await this.rootStore.cycleIssues.addCycleToIssue(workspaceSlug, projectId, data.cycle_id, response.id) + await this.rootStore.cycleIssues.addCycleToIssue(workspaceSlug, projectId, data.cycle_id, response.id); } if (data.module_ids && data.module_ids.length > 0) { @@ -264,7 +276,7 @@ export class ProjectIssues extends IssueHelperStore implements IProjectIssues { response.id, data.module_ids, [] - ) + ); } return response; } catch (error) { @@ -291,4 +303,60 @@ export class ProjectIssues extends IssueHelperStore implements IProjectIssues { throw error; } }; + + archiveBulkIssues = async (workspaceSlug: string, projectId: string, issueIds: string[]) => { + try { + const response = await this.issueService.bulkArchiveIssues(workspaceSlug, projectId, { issue_ids: issueIds }); + + runInAction(() => { + issueIds.forEach((issueId) => { + this.rootStore.issues.updateIssue(issueId, { + archived_at: response.archived_at, + }); + }); + }); + } catch (error) { + throw error; + } + }; + + /** + * @description bulk update properties of selected issues + * @param {TBulkOperationsPayload} data + */ + bulkUpdateProperties = async (workspaceSlug: string, projectId: string, data: TBulkOperationsPayload) => { + const issueIds = data.issue_ids; + try { + // make request to update issue properties + await this.issueService.bulkOperations(workspaceSlug, projectId, data); + // update issues in the store + runInAction(() => { + issueIds.forEach((issueId) => { + const issueDetails = this.rootIssueStore.issues.getIssueById(issueId); + if (!issueDetails) throw new Error("Issue not found"); + Object.keys(data.properties).forEach((key) => { + const property = key as keyof TBulkOperationsPayload["properties"]; + const propertyValue = data.properties[property]; + // update root issue map properties + if (Array.isArray(propertyValue)) { + // if property value is array, append it to the existing values + const existingValue = issueDetails[property]; + // convert existing value to an array + const newExistingValue = Array.isArray(existingValue) ? existingValue : []; + this.rootIssueStore.issues.updateIssue(issueId, { + [property]: [newExistingValue, ...propertyValue], + }); + } else { + // if property value is not an array, simply update the value + this.rootIssueStore.issues.updateIssue(issueId, { + [property]: propertyValue, + }); + } + }); + }); + }); + } catch (error) { + throw error; + } + }; } diff --git a/web/store/multiple_select.store.ts b/web/store/multiple_select.store.ts new file mode 100644 index 00000000000..14750f31a59 --- /dev/null +++ b/web/store/multiple_select.store.ts @@ -0,0 +1,220 @@ +import differenceWith from "lodash/differenceWith"; +import isEqual from "lodash/isEqual"; +import remove from "lodash/remove"; +import { action, computed, makeObservable, observable, runInAction } from "mobx"; +import { computedFn } from "mobx-utils"; +// hooks +import { TEntityDetails } from "@/hooks/use-multiple-select"; +// services +import { IssueService } from "@/services/issue"; + +export type IMultipleSelectStore = { + // computed functions + isSelectionActive: boolean; + selectedEntityIds: string[]; + // helper actions + getIsEntitySelected: (entityID: string) => boolean; + getIsEntityActive: (entityID: string) => boolean; + getLastSelectedEntityDetails: () => TEntityDetails | null; + getPreviousActiveEntity: () => TEntityDetails | null; + getNextActiveEntity: () => TEntityDetails | null; + getActiveEntityDetails: () => TEntityDetails | null; + // entity actions + updateSelectedEntityDetails: (entityDetails: TEntityDetails, action: "add" | "remove") => void; + bulkUpdateSelectedEntityDetails: (entitiesList: TEntityDetails[], action: "add" | "remove") => void; + updateLastSelectedEntityDetails: (entityDetails: TEntityDetails | null) => void; + updatePreviousActiveEntity: (entityDetails: TEntityDetails | null) => void; + updateNextActiveEntity: (entityDetails: TEntityDetails | null) => void; + updateActiveEntityDetails: (entityDetails: TEntityDetails | null) => void; + clearSelection: () => void; +}; + +/** + * @description the MultipleSelectStore manages multiple selection states by keeping track of the selected entities and providing a bunch of helper functions and actions to maintain the selected states + * @description use the useMultipleSelectStore custom hook to access the observables + * @description use the useMultipleSelect custom hook for added functionality on top of the store, including- + * 1. Keyboard and mouse interaction + * 2. Clear state on route change + */ +export class MultipleSelectStore implements IMultipleSelectStore { + // observables + selectedEntityDetails: TEntityDetails[] = []; + lastSelectedEntityDetails: TEntityDetails | null = null; + previousActiveEntity: TEntityDetails | null = null; + nextActiveEntity: TEntityDetails | null = null; + activeEntityDetails: TEntityDetails | null = null; + // service + issueService; + + constructor() { + makeObservable(this, { + // observables + selectedEntityDetails: observable, + lastSelectedEntityDetails: observable, + previousActiveEntity: observable, + nextActiveEntity: observable, + activeEntityDetails: observable, + // computed functions + isSelectionActive: computed, + selectedEntityIds: computed, + // actions + updateSelectedEntityDetails: action, + bulkUpdateSelectedEntityDetails: action, + updateLastSelectedEntityDetails: action, + updatePreviousActiveEntity: action, + updateNextActiveEntity: action, + updateActiveEntityDetails: action, + clearSelection: action, + }); + + this.issueService = new IssueService(); + } + + get isSelectionActive() { + return this.selectedEntityDetails.length > 0; + } + + get selectedEntityIds() { + return this.selectedEntityDetails.map((en) => en.entityID); + } + + // helper actions + /** + * @description returns if the entity is selected or not + * @param {string} entityID + * @returns {boolean} + */ + getIsEntitySelected = computedFn((entityID: string): boolean => + this.selectedEntityDetails.some((en) => en.entityID === entityID) + ); + + /** + * @description returns if the entity is active or not + * @param {string} entityID + * @returns {boolean} + */ + getIsEntityActive = computedFn((entityID: string): boolean => this.activeEntityDetails?.entityID === entityID); + + /** + * @description get the last selected entity details + * @returns {TEntityDetails} + */ + getLastSelectedEntityDetails = computedFn(() => this.lastSelectedEntityDetails); + + /** + * @description get the details of the entity preceding the active entity + * @returns {TEntityDetails} + */ + getPreviousActiveEntity = computedFn(() => this.previousActiveEntity); + + /** + * @description get the details of the entity succeeding the active entity + * @returns {TEntityDetails} + */ + getNextActiveEntity = computedFn(() => this.nextActiveEntity); + + /** + * @description get the active entity details + * @returns {TEntityDetails} + */ + getActiveEntityDetails = computedFn(() => this.activeEntityDetails); + + // entity actions + /** + * @description add or remove entities + * @param {TEntityDetails} entityDetails + * @param {"add" | "remove"} action + */ + updateSelectedEntityDetails = (entityDetails: TEntityDetails, action: "add" | "remove") => { + if (action === "add") { + runInAction(() => { + if (this.getIsEntitySelected(entityDetails.entityID)) { + remove(this.selectedEntityDetails, (en) => en.entityID === entityDetails.entityID); + } + this.selectedEntityDetails.push(entityDetails); + this.updateLastSelectedEntityDetails(entityDetails); + }); + } else { + let currentSelection = [...this.selectedEntityDetails]; + currentSelection = currentSelection.filter((en) => en.entityID !== entityDetails.entityID); + runInAction(() => { + remove(this.selectedEntityDetails, (en) => en.entityID === entityDetails.entityID); + this.updateLastSelectedEntityDetails(currentSelection[currentSelection.length - 1] ?? null); + }); + } + }; + + /** + * @description add or remove multiple entities + * @param {TEntityDetails[]} entitiesList + * @param {"add" | "remove"} action + */ + bulkUpdateSelectedEntityDetails = (entitiesList: TEntityDetails[], action: "add" | "remove") => { + if (action === "add") { + runInAction(() => { + let newEntities: TEntityDetails[] = []; + newEntities = differenceWith(this.selectedEntityDetails, entitiesList, isEqual); + newEntities = newEntities.concat(entitiesList); + this.selectedEntityDetails = newEntities; + if (entitiesList.length > 0) this.updateLastSelectedEntityDetails(entitiesList[entitiesList.length - 1]); + }); + } else { + runInAction(() => { + this.selectedEntityDetails = differenceWith(this.selectedEntityDetails, entitiesList, isEqual); + }); + } + }; + + /** + * @description update last selected entity + * @param {TEntityDetails} entityDetails + */ + updateLastSelectedEntityDetails = (entityDetails: TEntityDetails | null) => { + runInAction(() => { + this.lastSelectedEntityDetails = entityDetails; + }); + }; + + /** + * @description update previous active entity + * @param {TEntityDetails} entityDetails + */ + updatePreviousActiveEntity = (entityDetails: TEntityDetails | null) => { + runInAction(() => { + this.previousActiveEntity = entityDetails; + }); + }; + + /** + * @description update next active entity + * @param {TEntityDetails} entityDetails + */ + updateNextActiveEntity = (entityDetails: TEntityDetails | null) => { + runInAction(() => { + this.nextActiveEntity = entityDetails; + }); + }; + + /** + * @description update active entity + * @param {TEntityDetails} entityDetails + */ + updateActiveEntityDetails = (entityDetails: TEntityDetails | null) => { + runInAction(() => { + this.activeEntityDetails = entityDetails; + }); + }; + + /** + * @description clear selection and reset all the observables + */ + clearSelection = () => { + runInAction(() => { + this.selectedEntityDetails = []; + this.lastSelectedEntityDetails = null; + this.previousActiveEntity = null; + this.nextActiveEntity = null; + this.activeEntityDetails = null; + }); + }; +} diff --git a/web/store/root.store.ts b/web/store/root.store.ts index 4e6a7481532..83ed43e823c 100644 --- a/web/store/root.store.ts +++ b/web/store/root.store.ts @@ -14,6 +14,7 @@ import { ILabelStore, LabelStore } from "./label.store"; import { IMemberRootStore, MemberRootStore } from "./member"; import { IModuleStore, ModulesStore } from "./module.store"; import { IModuleFilterStore, ModuleFilterStore } from "./module_filter.store"; +import { IMultipleSelectStore, MultipleSelectStore } from "./multiple_select.store"; import { IProjectPageStore, ProjectPageStore } from "./pages/project-page.store"; import { IProjectRootStore, ProjectRootStore } from "./project"; import { IProjectViewStore, ProjectViewStore } from "./project-view.store"; @@ -48,6 +49,7 @@ export class RootStore { instance: IInstanceStore; user: IUserStore; projectInbox: IProjectInboxStore; + multipleSelect: IMultipleSelectStore; constructor() { this.router = new RouterStore(); @@ -70,6 +72,7 @@ export class RootStore { this.theme = new ThemeStore(this); this.eventTracker = new EventTrackerStore(this); this.instance = new InstanceStore(); + this.multipleSelect = new MultipleSelectStore(); // inbox this.projectInbox = new ProjectInboxStore(this); this.projectPages = new ProjectPageStore(this); @@ -101,5 +104,6 @@ export class RootStore { this.user = new UserStore(this); this.projectInbox = new ProjectInboxStore(this); this.projectPages = new ProjectPageStore(this); + this.multipleSelect = new MultipleSelectStore(); } } diff --git a/web/styles/globals.css b/web/styles/globals.css index 953127cc48c..751be587dac 100644 --- a/web/styles/globals.css +++ b/web/styles/globals.css @@ -236,7 +236,7 @@ --color-text-100: 229, 229, 229; /* primary text */ --color-text-200: 163, 163, 163; /* secondary text */ --color-text-300: 115, 115, 115; /* tertiary text */ - --color-text-350: 130, 130, 130; + --color-text-350: 130, 130, 130; --color-text-400: 82, 82, 82; /* placeholder text */ --color-scrollbar: 82, 82, 82; /* scrollbar thumb */ @@ -293,8 +293,7 @@ --color-text-100: 250, 250, 250; /* primary text */ --color-text-200: 241, 241, 241; /* secondary text */ --color-text-300: 212, 212, 212; /* tertiary text */ - --color-text-350: 190, 190, 190 - --color-text-400: 115, 115, 115; /* placeholder text */ + --color-text-350: 190, 190, 190 --color-text-400: 115, 115, 115; /* placeholder text */ --color-scrollbar: 115, 115, 115; /* scrollbar thumb */