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/form-fields/checkbox.tsx b/packages/ui/src/form-fields/checkbox.tsx index 3c45cf4f574..f41bece26c3 100644 --- a/packages/ui/src/form-fields/checkbox.tsx +++ b/packages/ui/src/form-fields/checkbox.tsx @@ -22,7 +22,7 @@ const Checkbox = React.forwardRef((props, ref) } = props; return ( -
+
; + selectionHelpers: TSelectionHelper; }; export const GanttChartBlock: React.FC = observer((props) => { @@ -33,6 +35,7 @@ export const GanttChartBlock: React.FC = observer((props) => { enableBlockMove, enableAddBlock, ganttContainerRef, + selectionHelpers, } = props; // store hooks const { updateActiveBlockId, isBlockActive } = useGanttChart(); @@ -70,6 +73,10 @@ export const GanttChartBlock: React.FC = observer((props) => { }); }; + const isBlockSelected = selectionHelpers.getIsEntitySelected(block.id); + const isBlockFocused = selectionHelpers.getIsEntityActive(block.id); + const isBlockHoveredOn = isBlockActive(block.id); + return (
= observer((props) => { >
updateActiveBlockId(block.id)} onMouseLeave={() => updateActiveBlockId(null)} diff --git a/web/components/gantt-chart/blocks/blocks-list.tsx b/web/components/gantt-chart/blocks/blocks-list.tsx index 8eb1d877252..6fd22b254e9 100644 --- a/web/components/gantt-chart/blocks/blocks-list.tsx +++ b/web/components/gantt-chart/blocks/blocks-list.tsx @@ -1,10 +1,12 @@ import { FC } from "react"; -// components +// hooks +import { TSelectionHelper } from "@/hooks/use-multiple-select"; +// constants import { HEADER_HEIGHT } from "../constants"; +// types import { IBlockUpdateData, IGanttBlock } from "../types"; +// components import { GanttChartBlock } from "./block"; -// types -// constants export type GanttChartBlocksProps = { itemsContainerWidth: number; @@ -17,6 +19,7 @@ export type GanttChartBlocksProps = { enableAddBlock: boolean; ganttContainerRef: React.RefObject; showAllBlocks: boolean; + selectionHelpers: TSelectionHelper; }; export const GanttChartBlocksList: FC = (props) => { @@ -31,6 +34,7 @@ export const GanttChartBlocksList: FC = (props) => { enableAddBlock, ganttContainerRef, showAllBlocks, + selectionHelpers, } = props; return ( @@ -56,6 +60,7 @@ export const GanttChartBlocksList: FC = (props) => { enableBlockMove={enableBlockMove} enableAddBlock={enableAddBlock} ganttContainerRef={ganttContainerRef} + selectionHelpers={selectionHelpers} /> ); })} diff --git a/web/components/gantt-chart/chart/main-content.tsx b/web/components/gantt-chart/chart/main-content.tsx index 2f5abc8863a..d601f101595 100644 --- a/web/components/gantt-chart/chart/main-content.tsx +++ b/web/components/gantt-chart/chart/main-content.tsx @@ -2,8 +2,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"; import { observer } from "mobx-react"; -// hooks // components +import { MultipleSelectGroup } from "@/components/core"; import { BiWeekChartView, DayChartView, @@ -18,8 +18,12 @@ import { WeekChartView, YearChartView, } from "@/components/gantt-chart"; +import { IssueBulkOperationsRoot } from "@/components/issues"; // helpers import { cn } from "@/helpers/common.helper"; +// constants +import { GANTT_SELECT_GROUP } from "../constants"; +// hooks import { useGanttChart } from "../hooks/use-gantt-chart"; type Props = { @@ -32,6 +36,7 @@ type Props = { enableBlockMove: boolean; enableBlockRightResize: boolean; enableReorder: boolean; + enableSelection: boolean; enableAddBlock: boolean; itemsContainerWidth: number; showAllBlocks: boolean; @@ -53,6 +58,7 @@ export const GanttChartMainContent: React.FC = observer((props) => { enableBlockRightResize, enableReorder, enableAddBlock, + enableSelection, itemsContainerWidth, showAllBlocks, sidebarToRender, @@ -107,43 +113,58 @@ export const GanttChartMainContent: React.FC = observer((props) => { const ActiveChartView = CHART_VIEW_COMPONENTS[currentView]; return ( -
block.id) ?? [], + }} > - -
- - {currentViewData && ( - - )} -
-
+ {(helpers) => ( + <> +
+ +
+ + {currentViewData && ( + + )} +
+
+ + + )} + ); }); diff --git a/web/components/gantt-chart/chart/root.tsx b/web/components/gantt-chart/chart/root.tsx index 395e0771cd5..ad206e76de5 100644 --- a/web/components/gantt-chart/chart/root.tsx +++ b/web/components/gantt-chart/chart/root.tsx @@ -32,6 +32,7 @@ type ChartViewRootProps = { enableBlockMove: boolean; enableReorder: boolean; enableAddBlock: boolean; + enableSelection: boolean; bottomSpacing: boolean; showAllBlocks: boolean; quickAdd?: React.JSX.Element | undefined; @@ -51,6 +52,7 @@ export const ChartViewRoot: FC = observer((props) => { enableBlockMove, enableReorder, enableAddBlock, + enableSelection, bottomSpacing, showAllBlocks, quickAdd, @@ -183,6 +185,7 @@ export const ChartViewRoot: FC = observer((props) => { enableBlockMove={enableBlockMove} enableBlockRightResize={enableBlockRightResize} enableReorder={enableReorder} + enableSelection={enableSelection} enableAddBlock={enableAddBlock} itemsContainerWidth={itemsContainerWidth} showAllBlocks={showAllBlocks} diff --git a/web/components/gantt-chart/constants.ts b/web/components/gantt-chart/constants.ts index 958985cf166..52167a49844 100644 --- a/web/components/gantt-chart/constants.ts +++ b/web/components/gantt-chart/constants.ts @@ -3,3 +3,5 @@ export const BLOCK_HEIGHT = 44; export const HEADER_HEIGHT = 60; export const SIDEBAR_WIDTH = 360; + +export const GANTT_SELECT_GROUP = "gantt-issues"; diff --git a/web/components/gantt-chart/root.tsx b/web/components/gantt-chart/root.tsx index 10c1c0d98de..267dfe5c584 100644 --- a/web/components/gantt-chart/root.tsx +++ b/web/components/gantt-chart/root.tsx @@ -18,6 +18,7 @@ type GanttChartRootProps = { enableBlockMove?: boolean; enableReorder?: boolean; enableAddBlock?: boolean; + enableSelection?: boolean; bottomSpacing?: boolean; showAllBlocks?: boolean; }; @@ -36,6 +37,7 @@ export const GanttChartRoot: FC = (props) => { enableBlockMove = false, enableReorder = false, enableAddBlock = false, + enableSelection = false, bottomSpacing = false, showAllBlocks = false, quickAdd, @@ -56,6 +58,7 @@ export const GanttChartRoot: FC = (props) => { enableBlockMove={enableBlockMove} enableReorder={enableReorder} enableAddBlock={enableAddBlock} + enableSelection={enableSelection} bottomSpacing={bottomSpacing} showAllBlocks={showAllBlocks} quickAdd={quickAdd} diff --git a/web/components/gantt-chart/sidebar/cycles/block.tsx b/web/components/gantt-chart/sidebar/cycles/block.tsx index 1119e2e9ca9..7922e86a62d 100644 --- a/web/components/gantt-chart/sidebar/cycles/block.tsx +++ b/web/components/gantt-chart/sidebar/cycles/block.tsx @@ -38,7 +38,7 @@ export const CyclesSidebarBlock: React.FC = observer((props) => {
; + selectionHelpers?: TSelectionHelper; }; export const IssuesSidebarBlock = observer((props: Props) => { - const { block, enableReorder, isDragging, dragHandleRef } = props; + const { block, enableReorder, enableSelection, isDragging, dragHandleRef, selectionHelpers } = props; // store hooks const { updateActiveBlockId, isBlockActive } = useGanttChart(); const { getIsIssuePeeked } = useIssueDetail(); const duration = findTotalDaysInRange(block.start_date, block.target_date); + const isIssueSelected = selectionHelpers?.getIsEntitySelected(block.id); + const isIssueFocused = selectionHelpers?.getIsEntityActive(block.id); + const isBlockHoveredOn = isBlockActive(block.id); + return (
updateActiveBlockId(block.id)} onMouseLeave={() => updateActiveBlockId(null)} >
- {enableReorder && ( - - )} +
+ {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/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/index.ts b/web/components/issues/index.ts index 83431f5beb9..454d851109d 100644 --- a/web/components/issues/index.ts +++ b/web/components/issues/index.ts @@ -1,4 +1,5 @@ export * from "./attachment"; +export * from "./bulk-operations"; export * from "./issue-modal"; export * from "./delete-issue-modal"; export * from "./issue-layouts"; diff --git a/web/components/issues/issue-layouts/gantt/base-gantt-root.tsx b/web/components/issues/issue-layouts/gantt/base-gantt-root.tsx index 30aba9e92e9..befe29f8f72 100644 --- a/web/components/issues/issue-layouts/gantt/base-gantt-root.tsx +++ b/web/components/issues/issue-layouts/gantt/base-gantt-root.tsx @@ -71,6 +71,7 @@ export const BaseGanttRoot: React.FC = 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 2ff31ab1d4c..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(); @@ -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,39 +129,76 @@ export const IssueBlock = observer((props: IssueBlockProps) => {
-
+
-
+ {/* drag handle */} +
-
- {subIssuesCount > 0 && ( - - )} -
+
+ {/* 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}
)} @@ -183,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/labels/label-block/label-item-block.tsx b/web/components/labels/label-block/label-item-block.tsx index 53e830e2a15..2aab3cc222b 100644 --- a/web/components/labels/label-block/label-item-block.tsx +++ b/web/components/labels/label-block/label-item-block.tsx @@ -1,16 +1,16 @@ import { MutableRefObject, useRef, useState } from "react"; import { LucideIcon, X } from "lucide-react"; +// types import { IIssueLabel } from "@plane/types"; -//ui +// ui import { CustomMenu, DragHandle } from "@plane/ui"; -//types +// helpers import { cn } from "@/helpers/common.helper"; +// hooks import useOutsideClickDetector from "@/hooks/use-outside-click-detector"; -//hooks -//components +// components import { LabelName } from "./label-name"; -//types export interface ICustomMenuItem { CustomIcon: LucideIcon; onClick: (label: IIssueLabel) => void; @@ -41,10 +41,10 @@ export const LabelItemBlock = (props: ILabelItemBlock) => {
diff --git a/web/components/workspace/help-section.tsx b/web/components/workspace/help-section.tsx index 130a62c8733..513be22a51a 100644 --- a/web/components/workspace/help-section.tsx +++ b/web/components/workspace/help-section.tsx @@ -1,12 +1,12 @@ import React, { useRef, useState } from "react"; import { observer } from "mobx-react"; import Link from "next/link"; -// headless ui import { FileText, HelpCircle, MessagesSquare, MoveLeft, Zap } from "lucide-react"; import { Transition } from "@headlessui/react"; -// icons // ui import { DiscordIcon, GithubIcon, Tooltip } from "@plane/ui"; +// helpers +import { cn } from "@/helpers/common.helper"; // hooks import { useAppTheme, useCommandPalette } from "@/hooks/store"; import useOutsideClickDetector from "@/hooks/use-outside-click-detector"; @@ -59,9 +59,12 @@ export const WorkspaceHelpSection: React.FC = observe return ( <>
{!isCollapsed && ( diff --git a/web/constants/errors.ts b/web/constants/errors.ts new file mode 100644 index 00000000000..b682c2ee109 --- /dev/null +++ b/web/constants/errors.ts @@ -0,0 +1,25 @@ +export enum EErrorCodes { + "INVALID_ARCHIVE_STATE_GROUP" = 4091, + "INVALID_ISSUE_START_DATE" = 4101, + "INVALID_ISSUE_TARGET_DATE" = 4102, +} + +export const ERROR_DETAILS: { + [key in EErrorCodes]: { + title: string; + message: string; + }; +} = { + [EErrorCodes.INVALID_ARCHIVE_STATE_GROUP]: { + title: "Unable to archive issues", + message: "Only issues belonging to Completed or Canceled state groups can be archived.", + }, + [EErrorCodes.INVALID_ISSUE_START_DATE]: { + title: "Unable to update issues", + message: "Start date selected succeeds the due date for some issues. Ensure start date to be before the due date.", + }, + [EErrorCodes.INVALID_ISSUE_TARGET_DATE]: { + title: "Unable to update issues", + message: "Due date selected precedes the start date for some issues. Ensure due date to be after the start date.", + }, +}; diff --git a/web/constants/spreadsheet.ts b/web/constants/spreadsheet.ts index d70a603a24e..d1ec24cd29d 100644 --- a/web/constants/spreadsheet.ts +++ b/web/constants/spreadsheet.ts @@ -1,12 +1,20 @@ import { FC } from "react"; -// icons -import { CalendarDays, Link2, Signal, Tag, Triangle, Paperclip, CalendarCheck2, CalendarClock, Users } from "lucide-react"; +import { + CalendarDays, + Link2, + Signal, + Tag, + Triangle, + Paperclip, + CalendarCheck2, + CalendarClock, + Users, +} from "lucide-react"; // types import { IIssueDisplayProperties, TIssue, TIssueOrderByOptions } from "@plane/types"; // ui import { LayersIcon, DoubleCircleIcon, DiceIcon, ContrastIcon } from "@plane/ui"; import { ISvgIcons } from "@plane/ui/src/icons/type"; -// components import { SpreadsheetAssigneeColumn, SpreadsheetAttachmentColumn, @@ -184,3 +192,5 @@ export const SPREADSHEET_PROPERTY_LIST: (keyof IIssueDisplayProperties)[] = [ "attachment_count", "sub_issue_count", ]; + +export const SPREADSHEET_SELECT_GROUP = "spreadsheet-issues"; 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/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 */