From dd65d03d3383f32de617f2baafac6503efb88ede Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com> Date: Tue, 4 Jun 2024 11:12:24 +0530 Subject: [PATCH] [WEB-1184] feat: issue bulk operations (#4674) * feat: issue bulk operations * style: bulk operations action bar * chore: remove edition separation --- .../components/admin-sidebar/help-section.tsx | 13 +- web/components/gantt-chart/blocks/block.tsx | 22 +- .../gantt-chart/blocks/blocks-list.tsx | 15 +- .../gantt-chart/chart/main-content.tsx | 97 ++++--- web/components/gantt-chart/chart/root.tsx | 3 + web/components/gantt-chart/constants.ts | 2 + web/components/gantt-chart/root.tsx | 3 + .../gantt-chart/sidebar/cycles/block.tsx | 2 +- .../gantt-chart/sidebar/issues/block.tsx | 64 +++-- .../gantt-chart/sidebar/issues/sidebar.tsx | 12 +- .../gantt-chart/sidebar/modules/block.tsx | 2 +- web/components/gantt-chart/sidebar/root.tsx | 53 +++- .../issues/bulk-operations/index.ts | 2 + .../issues/bulk-operations/root.tsx | 19 ++ .../issues/bulk-operations/upgrade-banner.tsx | 32 +++ web/components/issues/index.ts | 1 + .../issue-layouts/gantt/base-gantt-root.tsx | 1 + .../issue-layouts/list/base-list-root.tsx | 21 +- .../issues/issue-layouts/list/block-root.tsx | 9 +- .../issues/issue-layouts/list/block.tsx | 86 ++++-- .../issues/issue-layouts/list/blocks-list.tsx | 46 ++-- .../issues/issue-layouts/list/default.tsx | 102 ++++--- .../list/headers/group-by-card.tsx | 249 ++++++++++-------- .../issues/issue-layouts/list/list-group.tsx | 9 +- .../spreadsheet/columns/assignee-column.tsx | 4 +- .../spreadsheet/columns/attachment-column.tsx | 4 +- .../spreadsheet/columns/created-on-column.tsx | 5 +- .../spreadsheet/columns/cycle-column.tsx | 19 +- .../spreadsheet/columns/due-date-column.tsx | 13 +- .../spreadsheet/columns/estimate-column.tsx | 8 +- .../spreadsheet/columns/label-column.tsx | 8 +- .../spreadsheet/columns/link-column.tsx | 4 +- .../spreadsheet/columns/module-column.tsx | 19 +- .../spreadsheet/columns/priority-column.tsx | 4 +- .../spreadsheet/columns/start-date-column.tsx | 4 +- .../spreadsheet/columns/state-column.tsx | 4 +- .../spreadsheet/columns/sub-issue-column.tsx | 2 +- .../spreadsheet/columns/updated-on-column.tsx | 5 +- .../spreadsheet/issue-column.tsx | 15 +- .../issue-layouts/spreadsheet/issue-row.tsx | 108 ++++++-- .../spreadsheet/spreadsheet-header.tsx | 57 +++- .../spreadsheet/spreadsheet-table.tsx | 6 + .../spreadsheet/spreadsheet-view.tsx | 68 +++-- web/components/workspace/help-section.tsx | 13 +- web/constants/common.ts | 2 + web/constants/spreadsheet.ts | 14 +- 46 files changed, 831 insertions(+), 420 deletions(-) create mode 100644 web/components/issues/bulk-operations/index.ts create mode 100644 web/components/issues/bulk-operations/root.tsx create mode 100644 web/components/issues/bulk-operations/upgrade-banner.tsx diff --git a/admin/components/admin-sidebar/help-section.tsx b/admin/components/admin-sidebar/help-section.tsx index 56ccbcd84..38edd06fc 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 { WEB_BASE_URL, cn } 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/web/components/gantt-chart/blocks/block.tsx b/web/components/gantt-chart/blocks/block.tsx index 0897b6b39..a33a0fd9f 100644 --- a/web/components/gantt-chart/blocks/block.tsx +++ b/web/components/gantt-chart/blocks/block.tsx @@ -1,15 +1,16 @@ import { observer } from "mobx-react"; -// hooks -// components // helpers import { cn } from "@/helpers/common.helper"; import { getDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper"; +// hooks import { useIssueDetail } from "@/hooks/store"; -// types +import { TSelectionHelper } from "@/hooks/use-multiple-select"; // constants import { BLOCK_HEIGHT } from "../constants"; +// components import { ChartAddBlock, ChartDraggable } from "../helpers"; import { useGanttChart } from "../hooks"; +// types import { IBlockUpdateData, IGanttBlock } from "../types"; type Props = { @@ -21,6 +22,7 @@ type Props = { enableBlockMove: boolean; enableAddBlock: boolean; ganttContainerRef: React.RefObject; + 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 8eb1d8772..6fd22b254 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 -import { HEADER_HEIGHT } from "../constants"; -import { IBlockUpdateData, IGanttBlock } from "../types"; -import { GanttChartBlock } from "./block"; -// types +// 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"; 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 2f5abc886..e3b972237 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 = { @@ -33,6 +37,7 @@ type Props = { enableBlockRightResize: boolean; enableReorder: boolean; enableAddBlock: boolean; + enableSelection: boolean; itemsContainerWidth: number; showAllBlocks: boolean; sidebarToRender: (props: any) => React.ReactNode; @@ -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 395e0771c..d961047e9 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, @@ -184,6 +186,7 @@ export const ChartViewRoot: FC = observer((props) => { enableBlockRightResize={enableBlockRightResize} enableReorder={enableReorder} enableAddBlock={enableAddBlock} + enableSelection={enableSelection} itemsContainerWidth={itemsContainerWidth} showAllBlocks={showAllBlocks} sidebarToRender={sidebarToRender} diff --git a/web/components/gantt-chart/constants.ts b/web/components/gantt-chart/constants.ts index 958985cf1..52167a498 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 10c1c0d98..267dfe5c5 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 1119e2e9c..7922e86a6 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 7da30216d..f01a12b6d 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; enableReorder: boolean; + enableSelection: boolean; showAllBlocks?: boolean; + selectionHelpers?: TSelectionHelper; }; export const IssueGanttSidebar: React.FC = (props) => { - const { blockUpdateHandler, blocks, enableReorder, showAllBlocks = false } = props; + const { blockUpdateHandler, blocks, enableReorder, enableSelection, 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 e79a65401..e6b28d54a 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?.({ title, blockUpdateHandler, blocks, enableReorder, enableSelection, selectionHelpers })}
{quickAdd ? quickAdd : null}
); -}; +}); diff --git a/web/components/issues/bulk-operations/index.ts b/web/components/issues/bulk-operations/index.ts new file mode 100644 index 000000000..f8c733c7b --- /dev/null +++ b/web/components/issues/bulk-operations/index.ts @@ -0,0 +1,2 @@ +export * from "./root"; +export * from "./upgrade-banner"; diff --git a/web/components/issues/bulk-operations/root.tsx b/web/components/issues/bulk-operations/root.tsx new file mode 100644 index 000000000..957f18609 --- /dev/null +++ b/web/components/issues/bulk-operations/root.tsx @@ -0,0 +1,19 @@ +import { observer } from "mobx-react"; +// components +import { BulkOperationsUpgradeBanner } from "@/components/issues"; +// hooks +import { useMultipleSelectStore } from "@/hooks/store"; + +type Props = { + className?: string; +}; + +export const IssueBulkOperationsRoot: React.FC = observer((props) => { + const { className } = props; + // store hooks + const { isSelectionActive } = useMultipleSelectStore(); + + if (!isSelectionActive) return null; + + return ; +}); diff --git a/web/components/issues/bulk-operations/upgrade-banner.tsx b/web/components/issues/bulk-operations/upgrade-banner.tsx new file mode 100644 index 000000000..c96e6d210 --- /dev/null +++ b/web/components/issues/bulk-operations/upgrade-banner.tsx @@ -0,0 +1,32 @@ +// ui +import { getButtonStyling } from "@plane/ui"; +// constants +import { MARKETING_PLANE_ONE_PAGE_LINK } from "@/constants/common"; +// helpers +import { cn } from "@/helpers/common.helper"; + +type Props = { + className?: string; +}; + +export const BulkOperationsUpgradeBanner: React.FC = (props) => { + const { className } = props; + + return ( +
+
+

+ Change state, priority, and more for several issues at once. Save three minutes on an average per operation. +

+ + Upgrade to One + +
+
+ ); +}; diff --git a/web/components/issues/index.ts b/web/components/issues/index.ts index 83431f5be..454d85110 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 30aba9e92..17df47163 100644 --- a/web/components/issues/issue-layouts/gantt/base-gantt-root.tsx +++ b/web/components/issues/issue-layouts/gantt/base-gantt-root.tsx @@ -72,6 +72,7 @@ export const BaseGanttRoot: React.FC = observer((props: IBaseGan enableBlockMove={isAllowed} enableReorder={appliedDisplayFilters?.order_by === "sort_order" && isAllowed} enableAddBlock={isAllowed} + enableSelection={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 b58bdce2c..73385a094 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,19 @@ export const BaseListRoot = observer((props: IBaseListRoot) => { canEditPropertiesBasedOnProject, isCompletedCycle = false, } = props; - // router - //stores + // store 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 +87,7 @@ export const BaseListRoot = observer((props: IBaseListRoot) => { ); return ( -
+
; + selectionHelpers: TSelectionHelper; groupId: string; isDragAllowed: boolean; canDropOverIssue: boolean; @@ -50,6 +52,7 @@ export const IssueBlockRoot: FC = observer((props) => { canDropOverIssue, isParentIssueBeingDragged = false, isLastChild = false, + selectionHelpers, } = props; // states const [isExpanded, setExpanded] = useState(false); @@ -132,6 +135,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 +143,7 @@ export const IssueBlockRoot: FC = observer((props) => { {isExpanded && - subIssues && - subIssues.length > 0 && - subIssues.map((subIssueId: string) => ( + subIssues?.map((subIssueId: string) => ( = 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 2ff31ab1d..56d88a730 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,11 @@ 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`; + const marginLeft = `${spacingLeft}px`; const handleToggleExpand = (e: MouseEvent) => { e.stopPropagation(); @@ -119,39 +126,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 +227,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 1d773001e..a1d2d0096 100644 --- a/web/components/issues/issue-layouts/list/blocks-list.tsx +++ b/web/components/issues/issue-layouts/list/blocks-list.tsx @@ -2,6 +2,7 @@ import { FC, MutableRefObject } from "react"; // components import { TIssue, IIssueDisplayProperties, TIssueMap, TUnGroupedIssues } from "@plane/types"; import { IssueBlockRoot } from "@/components/issues/issue-layouts/list"; +import { TSelectionHelper } from "@/hooks/use-multiple-select"; // types import { TRenderQuickActions } from "./list-view-types"; @@ -16,6 +17,7 @@ interface Props { containerRef: MutableRefObject; isDragAllowed: boolean; canDropOverIssue: boolean; + selectionHelpers: TSelectionHelper; } export const IssueBlocksList: FC = (props) => { @@ -28,33 +30,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 66bc47c28..c527276ef 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, @@ -13,6 +14,9 @@ import { TIssueOrderByOptions, TIssueGroupByOptions, } from "@plane/types"; +// components +import { MultipleSelectGroup } from "@/components/core"; +import { IssueBulkOperationsRoot } from "@/components/issues"; // hooks import { EIssuesStoreType } from "@/constants/issue"; import { useCycle, useLabel, useMember, useModule, useProject, useProjectState } from "@/hooks/store"; @@ -46,7 +50,7 @@ export interface IGroupByList { isCompletedCycle?: boolean; } -const GroupByList: React.FC = (props) => { +const GroupByList: React.FC = observer((props) => { const { issueIds, issuesMap, @@ -113,43 +117,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 d479bbeaa..feb99a8a5 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"; -import { TIssue, ISearchIssueResponse } from "@plane/types"; -// components -import { CustomMenu, TOAST_TYPE, setToast } from "@plane/ui"; -import { ExistingIssuesListModal } from "@/components/core"; -import { CreateUpdateIssueModal } from "@/components/issues"; -// ui -// mobx -// hooks -import { EIssuesStoreType } from "@/constants/issue"; -import { useEventTracker } from "@/hooks/store"; // types +import { TIssue, ISearchIssueResponse } from "@plane/types"; +// ui +import { CustomMenu, TOAST_TYPE, setToast } from "@plane/ui"; +// components +import { ExistingIssuesListModal, MultipleSelectGroupAction } from "@/components/core"; +import { CreateUpdateIssueModal } from "@/components/issues"; +// constants +import { EIssuesStoreType } from "@/constants/issue"; +// helpers +import { cn } from "@/helpers/common.helper"; +// hooks +import { useEventTracker } from "@/hooks/store"; +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(); +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 [isOpen, setIsOpen] = useState(false); + const handleAddIssuesToView = async (data: ISearchIssueResponse[]) => { + if (!workspaceSlug || !projectId) return; - const [openExistingIssueListModal, setOpenExistingIssueListModal] = useState(false); + const issues = data.map((i) => i.id); - const isDraftIssue = router.pathname.includes("draft-issue"); + try { + await addIssuesToView?.(issues); - const renderExistingIssueModal = moduleId || cycleId; - const ExistingIssuesListModalPayload = moduleId ? { module: moduleId.toString() } : { cycle: true }; + 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.", + }); + } + }; - const handleAddIssuesToView = async (data: ISearchIssueResponse[]) => { - if (!workspaceSlug || !projectId) return; - - const issues = data.map((i) => i.id); - - 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.", - }); - } - }; - - return ( - <> -
-
- {icon ? icon : } -
- -
-
{title}
-
{count || 0}
-
- - {!disableIssueCreation && - (renderExistingIssueModal ? ( - - - + return ( + <> +
+ {canSelectIssues && ( +
+ - { - setTrackElement("List layout"); - setIsOpen(true); - }} - > - Create issue - - { - setTrackElement("List layout"); - setOpenExistingIssueListModal(true); - }} - > - Add an existing issue - - - ) : ( -
+
+ )} +
+ {icon ?? } +
+ +
+
{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); + }} + > + +
+ ))} - 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 43c5f990e..04457327e 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 9fefd4759..d0fb5812d 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 7fb7ef7e2..0be345262 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 eea39478a..a7845400c 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 8cb2f43fb..574ab6fea 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"; -import { TIssue } from "@plane/types"; -// hooks -import { CycleDropdown } from "@/components/dropdowns"; -import { EIssuesStoreType } from "@/constants/issue"; -import { useEventTracker, useIssues } from "@/hooks/store"; -// components // types +import { TIssue } from "@plane/types"; +// components +import { CycleDropdown } from "@/components/dropdowns"; // constants +import { EIssuesStoreType } from "@/constants/issue"; +// hooks +import { useEventTracker, useIssues } from "@/hooks/store"; 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 58ebac58e..f0c43a457 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 6acc0f6a5..2e90cd2ba 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"; -import { TIssue } from "@plane/types"; -import { EstimateDropdown } from "@/components/dropdowns"; // types +import { TIssue } from "@plane/types"; +// components +import { EstimateDropdown } from "@/components/dropdowns"; 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 439abf5f3..bb409d563 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 f2c11ab0f..f8c639429 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 efae44e84..2357a6791 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"; -import { TIssue } from "@plane/types"; -// hooks -import { ModuleDropdown } from "@/components/dropdowns"; -import { EIssuesStoreType } from "@/constants/issue"; -import { useEventTracker, useIssues } from "@/hooks/store"; -// components // types +import { TIssue } from "@plane/types"; +// components +import { ModuleDropdown } from "@/components/dropdowns"; // constants +import { EIssuesStoreType } from "@/constants/issue"; +// hooks +import { useEventTracker, useIssues } from "@/hooks/store"; 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 8058b7023..1e072a736 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 aff8c7dfa..9a17d34d4 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 201585728..f50ab4fce 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 8a6d26ac6..705954541 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 60a0e6e53..08d7162d7 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 161dd6514..086d0fe3e 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 { SPREADSHEET_PROPERTY_DETAILS } from "@/constants/spreadsheet"; -import { useEventTracker } from "@/hooks/store"; -import { WithDisplayPropertiesHOC } from "../properties/with-display-properties-HOC"; +import { IIssueDisplayProperties, TIssue } from "@plane/types"; // constants +import { SPREADSHEET_PROPERTY_DETAILS } from "@/constants/spreadsheet"; +// hooks +import { useEventTracker } from "@/hooks/store"; // 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 eb33a13f3..771871bda 100644 --- a/web/components/issues/issue-layouts/spreadsheet/issue-row.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/issue-row.tsx @@ -1,20 +1,23 @@ import { Dispatch, MouseEvent, MutableRefObject, SetStateAction, useRef, useState } from "react"; import { observer } from "mobx-react"; import { useRouter } from "next/router"; -// icons import { ChevronRight, MoreHorizontal } from "lucide-react"; +// types 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 // local components import { TRenderQuickActions } from "../list/list-view-types"; import { WithDisplayPropertiesHOC } from "../properties/with-display-properties-HOC"; @@ -34,6 +37,7 @@ interface Props { issueIds: string[]; spreadsheetColumnsList: (keyof IIssueDisplayProperties)[]; spacingLeft?: number; + selectionHelpers: TSelectionHelper; } export const SpreadsheetIssueRow = observer((props: Props) => { @@ -51,12 +55,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 +73,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: string) => ( { containerRef={containerRef} issueIds={issueIds} spreadsheetColumnsList={spreadsheetColumnsList} + selectionHelpers={selectionHelpers} /> ))} @@ -123,6 +137,7 @@ interface IssueRowDetailsProps { setExpanded: Dispatch>; spreadsheetColumnsList: (keyof IIssueDisplayProperties)[]; spacingLeft?: number; + selectionHelpers: TSelectionHelper; } const IssueRowDetails = observer((props: IssueRowDetailsProps) => { @@ -140,6 +155,7 @@ const IssueRowDetails = observer((props: IssueRowDetailsProps) => { setExpanded, spreadsheetColumnsList, spacingLeft = 6, + selectionHelpers, } = props; // states const [isMenuActive, setIsMenuActive] = useState(false); @@ -148,7 +164,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 +187,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 +220,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 +245,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 63017f0e7..9de9a68e7 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 @@ +import { observer } from "mobx-react"; +import { useRouter } from "next/router"; // ui import { IIssueDisplayFilterOptions, IIssueDisplayProperties } from "@plane/types"; -// types -import { LayersIcon } from "@plane/ui"; // 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 f548c69a5..67bc99182 100644 --- a/web/components/issues/issue-layouts/spreadsheet/spreadsheet-table.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/spreadsheet-table.tsx @@ -2,6 +2,7 @@ import { MutableRefObject, useCallback, useEffect, useRef } from "react"; import { observer } from "mobx-react-lite"; import { IIssueDisplayFilterOptions, IIssueDisplayProperties, TIssue } from "@plane/types"; //types +import { TSelectionHelper } from "@/hooks/use-multiple-select"; import { useTableKeyboardNavigation } from "@/hooks/use-table-keyboard-navigation"; //components import { TRenderQuickActions } from "../list/list-view-types"; @@ -20,6 +21,7 @@ type Props = { portalElement: React.MutableRefObject; containerRef: MutableRefObject; spreadsheetColumnsList: (keyof IIssueDisplayProperties)[]; + selectionHelpers: TSelectionHelper; }; export const SpreadsheetTable = observer((props: Props) => { @@ -35,6 +37,7 @@ export const SpreadsheetTable = observer((props: Props) => { canEditProperties, containerRef, spreadsheetColumnsList, + selectionHelpers, } = props; // states @@ -81,8 +84,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 +105,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 a1f44d7f1..c4b6e7d69 100644 --- a/web/components/issues/issue-layouts/spreadsheet/spreadsheet-view.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/spreadsheet-view.tsx @@ -1,15 +1,18 @@ 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"; +// constants +import { SPREADSHEET_PROPERTY_LIST, SPREADSHEET_SELECT_GROUP } from "@/constants/spreadsheet"; +// hooks import { useProject } from "@/hooks/store"; +// types import { TRenderQuickActions } from "../list/list-view-types"; import { SpreadsheetTable } from "./spreadsheet-table"; -// types -//hooks type Props = { displayProperties: IIssueDisplayProperties; @@ -73,28 +76,41 @@ export const SpreadsheetView: React.FC = observer((props) => { return (
-
- -
-
-
- {enableQuickCreateIssue && !disableIssueCreation && ( - - )} -
-
+ + {(helpers) => ( + <> +
+ +
+
+
+ {enableQuickCreateIssue && !disableIssueCreation && ( + + )} +
+
+ + + )} +
); }); diff --git a/web/components/workspace/help-section.tsx b/web/components/workspace/help-section.tsx index 130a62c87..513be22a5 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/common.ts b/web/constants/common.ts index 71d65765c..0c1431740 100644 --- a/web/constants/common.ts +++ b/web/constants/common.ts @@ -3,3 +3,5 @@ export const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB export const MARKETING_PRICING_PAGE_LINK = "https://plane.so/pricing"; export const MARKETING_CONTACT_US_PAGE_LINK = "https://plane.so/contact"; + +export const MARKETING_PLANE_ONE_PAGE_LINK = "https://plane.so/one"; diff --git a/web/constants/spreadsheet.ts b/web/constants/spreadsheet.ts index d70a603a2..c45a1bf7c 100644 --- a/web/constants/spreadsheet.ts +++ b/web/constants/spreadsheet.ts @@ -1,6 +1,16 @@ 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 @@ -184,3 +194,5 @@ export const SPREADSHEET_PROPERTY_LIST: (keyof IIssueDisplayProperties)[] = [ "attachment_count", "sub_issue_count", ]; + +export const SPREADSHEET_SELECT_GROUP = "spreadsheet-issues";