From 836e186634f9244cad08e8b06ece3eedc5829b0c Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal Date: Tue, 21 May 2024 14:24:13 +0530 Subject: [PATCH] chore: create hoc for multi-select groups --- .../bulk-operations/bulk-archive-modal.tsx | 8 +- .../bulk-operations/bulk-delete-modal.tsx | 8 +- .../issue-layouts/bulk-operations/index.ts | 2 + .../bulk-operations/properties.tsx | 24 +- .../issue-layouts/bulk-operations/root.tsx | 75 ++++-- .../bulk-operations/select-group.tsx | 16 ++ .../issue-layouts/bulk-operations/select.tsx | 34 +++ .../issues/issue-layouts/list/block-root.tsx | 9 + .../issues/issue-layouts/list/block.tsx | 27 +- .../issues/issue-layouts/list/blocks-list.tsx | 18 +- .../issues/issue-layouts/list/default.tsx | 112 ++++---- web/hooks/use-entity-selection.ts | 243 ++++++++++++++++-- web/store/issue/bulk-operations.store.ts | 46 +--- web/styles/globals.css | 28 ++ 14 files changed, 481 insertions(+), 169 deletions(-) create mode 100644 web/components/issues/issue-layouts/bulk-operations/select-group.tsx create mode 100644 web/components/issues/issue-layouts/bulk-operations/select.tsx diff --git a/web/components/issues/issue-layouts/bulk-operations/bulk-archive-modal.tsx b/web/components/issues/issue-layouts/bulk-operations/bulk-archive-modal.tsx index b29e0b669..fac62caf1 100644 --- a/web/components/issues/issue-layouts/bulk-operations/bulk-archive-modal.tsx +++ b/web/components/issues/issue-layouts/bulk-operations/bulk-archive-modal.tsx @@ -7,25 +7,25 @@ import { AlertModalCore, EModalPosition, EModalWidth } from "@/components/core"; // constants import { EIssuesStoreType } from "@/constants/issue"; // hooks -import { useBulkIssueOperations, useIssues } from "@/hooks/store"; +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, projectId, workspaceSlug } = props; + const { handleClose, isOpen, issueIds, onSubmit, projectId, workspaceSlug } = props; // states const [isArchiving, setIsDeleting] = useState(false); // store hooks const { issues: { archiveBulkIssues }, } = useIssues(EIssuesStoreType.PROJECT); - const { clearSelection } = useBulkIssueOperations(); const handleSubmit = async () => { setIsDeleting(true); @@ -37,7 +37,7 @@ export const BulkArchiveConfirmationModal: React.FC = observer((props) => title: "Success!", message: "Issues archived successfully.", }); - clearSelection(); + onSubmit?.(); handleClose(); }) .catch(() => diff --git a/web/components/issues/issue-layouts/bulk-operations/bulk-delete-modal.tsx b/web/components/issues/issue-layouts/bulk-operations/bulk-delete-modal.tsx index 5b65211eb..ab8fad452 100644 --- a/web/components/issues/issue-layouts/bulk-operations/bulk-delete-modal.tsx +++ b/web/components/issues/issue-layouts/bulk-operations/bulk-delete-modal.tsx @@ -7,25 +7,25 @@ import { AlertModalCore, EModalPosition, EModalWidth } from "@/components/core"; // constants import { EIssuesStoreType } from "@/constants/issue"; // hooks -import { useBulkIssueOperations, useIssues } from "@/hooks/store"; +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, projectId, workspaceSlug } = props; + const { handleClose, isOpen, issueIds, onSubmit, projectId, workspaceSlug } = props; // states const [isDeleting, setIsDeleting] = useState(false); // store hooks const { issues: { removeBulkIssues }, } = useIssues(EIssuesStoreType.PROJECT); - const { clearSelection } = useBulkIssueOperations(); const handleSubmit = async () => { setIsDeleting(true); @@ -37,7 +37,7 @@ export const BulkDeleteConfirmationModal: React.FC = observer((props) => title: "Success!", message: "Issues deleted successfully.", }); - clearSelection(); + onSubmit?.(); handleClose(); }) .catch(() => diff --git a/web/components/issues/issue-layouts/bulk-operations/index.ts b/web/components/issues/issue-layouts/bulk-operations/index.ts index 3d7b3791e..d226ac8a1 100644 --- a/web/components/issues/issue-layouts/bulk-operations/index.ts +++ b/web/components/issues/issue-layouts/bulk-operations/index.ts @@ -2,3 +2,5 @@ export * from "./bulk-archive-modal"; export * from "./bulk-delete-modal"; export * from "./properties"; export * from "./root"; +export * from "./select-group"; +export * from "./select"; diff --git a/web/components/issues/issue-layouts/bulk-operations/properties.tsx b/web/components/issues/issue-layouts/bulk-operations/properties.tsx index cc5f84352..594261f7b 100644 --- a/web/components/issues/issue-layouts/bulk-operations/properties.tsx +++ b/web/components/issues/issue-layouts/bulk-operations/properties.tsx @@ -8,23 +8,32 @@ import { DateDropdown, MemberDropdown, PriorityDropdown, StateDropdown } from "@ import { renderFormattedPayloadDate } from "@/helpers/date-time.helper"; // hooks import { useBulkIssueOperations } from "@/hooks/store"; +import { TSelectionHelper, TSelectionSnapshot } from "@/hooks/use-entity-selection"; -export const IssueBulkOperationsProperties: React.FC = () => { +type Props = { + selectionHelpers: TSelectionHelper; + snapshot: TSelectionSnapshot; +}; + +export const IssueBulkOperationsProperties: React.FC = (props) => { + const { snapshot } = props; // router const router = useRouter(); const { workspaceSlug, projectId } = router.query; // store hooks - const { bulkUpdateProperties, issueIds } = useBulkIssueOperations(); + const { bulkUpdateProperties } = useBulkIssueOperations(); const handleBulkOperation = (data: Partial) => { if (!workspaceSlug || !projectId) return; bulkUpdateProperties(workspaceSlug.toString(), projectId.toString(), { - issue_ids: issueIds, + issue_ids: snapshot.selectedEntityIds, properties: data, }); }; + const isUpdateDisabled = !snapshot.isSelectionActive; + return ( <> { onChange={(val) => handleBulkOperation({ state_id: val })} projectId={projectId?.toString() ?? ""} buttonVariant="border-with-text" + disabled={isUpdateDisabled} /> handleBulkOperation({ priority: val })} buttonVariant="border-with-text" + disabled={isUpdateDisabled} /> { buttonVariant="border-with-text" placeholder="Assignees" multiple + disabled={isUpdateDisabled} /> handleBulkOperation({ start_date: val ? renderFormattedPayloadDate(val) : null })} buttonVariant="border-with-text" placeholder="Start date" - icon={} + icon={} + disabled={isUpdateDisabled} /> handleBulkOperation({ target_date: val ? renderFormattedPayloadDate(val) : null })} buttonVariant="border-with-text" placeholder="Due date" - icon={} + icon={} + disabled={isUpdateDisabled} /> ); diff --git a/web/components/issues/issue-layouts/bulk-operations/root.tsx b/web/components/issues/issue-layouts/bulk-operations/root.tsx index 3ee40193c..b191de714 100644 --- a/web/components/issues/issue-layouts/bulk-operations/root.tsx +++ b/web/components/issues/issue-layouts/bulk-operations/root.tsx @@ -13,49 +13,70 @@ import { // helpers import { cn } from "@/helpers/common.helper"; // hooks -import { useBulkIssueOperations } from "@/hooks/store"; +import { TSelectionHelper, TSelectionSnapshot } from "@/hooks/use-entity-selection"; -export const IssueBulkOperationsRoot = observer(() => { +type Props = { + className?: string; + selectionHelpers: TSelectionHelper; + snapshot: TSelectionSnapshot; +}; + +export const IssueBulkOperationsRoot: React.FC = observer((props) => { + const { className, selectionHelpers, snapshot } = props; // states const [isBulkArchiveModalOpen, setIsBulkArchiveModalOpen] = useState(false); const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false); // router const router = useRouter(); const { workspaceSlug, projectId } = router.query; - // store hooks - const { issueIds } = useBulkIssueOperations(); + // serviced values + const { isSelectionActive, selectedEntityIds } = snapshot; + const { handleClearSelection } = selectionHelpers; return ( <> {workspaceSlug && projectId && ( - setIsBulkArchiveModalOpen(false)} - issueIds={issueIds} - projectId={projectId.toString()} - workspaceSlug={workspaceSlug.toString()} - /> + <> + setIsBulkArchiveModalOpen(false)} + issueIds={selectedEntityIds} + onSubmit={handleClearSelection} + projectId={projectId.toString()} + workspaceSlug={workspaceSlug.toString()} + /> + setIsBulkDeleteModalOpen(false)} + issueIds={selectedEntityIds} + onSubmit={handleClearSelection} + projectId={projectId.toString()} + workspaceSlug={workspaceSlug.toString()} + /> + )} - {workspaceSlug && projectId && ( - setIsBulkDeleteModalOpen(false)} - issueIds={issueIds} - projectId={projectId.toString()} - workspaceSlug={workspaceSlug.toString()} - /> - )} -
-
2 selected
+
+
+ +
+ {/* // TODO: add min width here */} + {selectedEntityIds.length}selected +
+
- +
diff --git a/web/components/issues/issue-layouts/bulk-operations/select-group.tsx b/web/components/issues/issue-layouts/bulk-operations/select-group.tsx new file mode 100644 index 000000000..c431825fd --- /dev/null +++ b/web/components/issues/issue-layouts/bulk-operations/select-group.tsx @@ -0,0 +1,16 @@ +import { TSelectionHelper, TSelectionSnapshot, useEntitySelection } from "@/hooks/use-entity-selection"; + +type Props = { + children: (helpers: TSelectionHelper, snapshot: TSelectionSnapshot) => React.ReactNode; + groups: string[]; +}; + +export const BulkOperationsSelectGroup: React.FC = (props) => { + const { children, groups } = props; + + const { helpers, snapshot } = useEntitySelection({ + groups, + }); + + return <>{children(helpers, snapshot)}; +}; diff --git a/web/components/issues/issue-layouts/bulk-operations/select.tsx b/web/components/issues/issue-layouts/bulk-operations/select.tsx new file mode 100644 index 000000000..e3b7917aa --- /dev/null +++ b/web/components/issues/issue-layouts/bulk-operations/select.tsx @@ -0,0 +1,34 @@ +// helpers +import { cn } from "@/helpers/common.helper"; +// hooks +import { TSelectionHelper } from "@/hooks/use-entity-selection"; + +type Props = { + className?: string; + groupId: string; + id: string; + selectionHelpers: TSelectionHelper; +}; + +export const BulkOperationsSelect: React.FC = (props) => { + const { className, groupId, id, selectionHelpers } = props; + // derived values + const isSelected = selectionHelpers.isEntitySelected(id); + + return ( + selectionHelpers.handleEntityClick(e, id, groupId)} + checked={isSelected} + data-entity-group-id={groupId} + data-entity-id={id} + /> + ); +}; diff --git a/web/components/issues/issue-layouts/list/block-root.tsx b/web/components/issues/issue-layouts/list/block-root.tsx index 287424ffe..8d054d064 100644 --- a/web/components/issues/issue-layouts/list/block-root.tsx +++ b/web/components/issues/issue-layouts/list/block-root.tsx @@ -7,10 +7,12 @@ import RenderIfVisible from "@/components/core/render-if-visible-HOC"; import { IssueBlock } from "@/components/issues/issue-layouts/list"; // hooks import { useIssueDetail } from "@/hooks/store"; +import { TSelectionHelper } from "@/hooks/use-entity-selection"; // types import { TRenderQuickActions } from "./list-view-types"; type Props = { + groupId: string; issueIds: string[]; issueId: string; issuesMap: TIssueMap; @@ -21,10 +23,12 @@ type Props = { nestingLevel: number; spacingLeft?: number; containerRef: MutableRefObject; + selectionHelpers: TSelectionHelper; }; export const IssueBlockRoot: FC = observer((props) => { const { + groupId, issueIds, issueId, issuesMap, @@ -35,6 +39,7 @@ export const IssueBlockRoot: FC = observer((props) => { nestingLevel, spacingLeft = 14, containerRef, + selectionHelpers, } = props; // states const [isExpanded, setExpanded] = useState(false); @@ -53,6 +58,7 @@ export const IssueBlockRoot: FC = observer((props) => { classNames="relative border-b border-b-custom-border-200 last:border-b-transparent" > = observer((props) => { setExpanded={setExpanded} nestingLevel={nestingLevel} spacingLeft={spacingLeft} + selectionHelpers={selectionHelpers} /> @@ -70,6 +77,7 @@ export const IssueBlockRoot: FC = observer((props) => { subIssues?.map((subIssueId) => ( = observer((props) => { nestingLevel={nestingLevel + 1} spacingLeft={spacingLeft + (displayProperties?.key ? 12 : 0)} containerRef={containerRef} + selectionHelpers={selectionHelpers} /> ))} diff --git a/web/components/issues/issue-layouts/list/block.tsx b/web/components/issues/issue-layouts/list/block.tsx index 90559ba31..19851010c 100644 --- a/web/components/issues/issue-layouts/list/block.tsx +++ b/web/components/issues/issue-layouts/list/block.tsx @@ -6,17 +6,19 @@ import { TIssue, IIssueDisplayProperties, TIssueMap } from "@plane/types"; // ui import { Spinner, Tooltip, ControlLink } from "@plane/ui"; // components +import { BulkOperationsSelect } from "@/components/issues"; import { IssueProperties } from "@/components/issues/issue-layouts/properties"; // helpers import { cn } from "@/helpers/common.helper"; // hooks -import { useAppRouter, useBulkIssueOperations, useIssueDetail, useProject } from "@/hooks/store"; -import { useEntitySelection } from "@/hooks/use-entity-selection"; +import { useAppRouter, useIssueDetail, useProject } from "@/hooks/store"; +import { TSelectionHelper } from "@/hooks/use-entity-selection"; import { usePlatformOS } from "@/hooks/use-platform-os"; // types import { TRenderQuickActions } from "./list-view-types"; interface IssueBlockProps { + groupId: string; issueId: string; issuesMap: TIssueMap; updateIssue: ((projectId: string, issueId: string, data: Partial) => Promise) | undefined; @@ -27,10 +29,12 @@ interface IssueBlockProps { spacingLeft?: number; isExpanded: boolean; setExpanded: Dispatch>; + selectionHelpers: TSelectionHelper; } export const IssueBlock: React.FC = observer((props: IssueBlockProps) => { const { + groupId, issuesMap, issueId, updateIssue, @@ -41,6 +45,7 @@ export const IssueBlock: React.FC = observer((props: IssueBlock spacingLeft = 14, isExpanded, setExpanded, + selectionHelpers, } = props; // refs const parentRef = useRef(null); @@ -61,13 +66,11 @@ export const IssueBlock: React.FC = observer((props: IssueBlock // const subIssues = subIssuesStore.subIssuesByIssueId(issueId); const { isMobile } = usePlatformOS(); - const { getIsIssueSelected } = useBulkIssueOperations(); - const { handleClick } = useEntitySelection(); if (!issue) return null; const canEditIssueProperties = canEditProperties(issue.project_id); const projectIdentifier = getProjectIdentifierById(issue.project_id); - const isIssueSelected = getIsIssueSelected(issueId); + const isIssueSelected = selectionHelpers.isEntitySelected(issue.id); // 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; @@ -98,7 +101,7 @@ export const IssueBlock: React.FC = observer((props: IssueBlock getIsIssuePeeked(issue.id) && peekIssue?.nestingLevel === nestingLevel, "last:border-b-transparent": !getIsIssuePeeked(issue.id), "hover:bg-custom-background-90": !isIssueSelected, - "bg-custom-primary-100/5": isIssueSelected, + "bg-custom-primary-100/5 hover:bg-custom-primary-100/10": isIssueSelected, } )} > @@ -108,17 +111,7 @@ export const IssueBlock: React.FC = observer((props: IssueBlock
{canEditIssueProperties && (
- handleClick(e, issueId)} - checked={isIssueSelected} - /> +
)}
diff --git a/web/components/issues/issue-layouts/list/blocks-list.tsx b/web/components/issues/issue-layouts/list/blocks-list.tsx index b8e5df1d8..81887ee58 100644 --- a/web/components/issues/issue-layouts/list/blocks-list.tsx +++ b/web/components/issues/issue-layouts/list/blocks-list.tsx @@ -2,10 +2,13 @@ import { FC, MutableRefObject } from "react"; // components import { TGroupedIssues, TIssue, IIssueDisplayProperties, TIssueMap, TUnGroupedIssues } from "@plane/types"; import { IssueBlockRoot } from "@/components/issues/issue-layouts/list"; +// hooks +import { TSelectionHelper } from "@/hooks/use-entity-selection"; // types import { TRenderQuickActions } from "./list-view-types"; interface Props { + groupId: string; issueIds: TGroupedIssues | TUnGroupedIssues | any; issuesMap: TIssueMap; canEditProperties: (projectId: string | undefined) => boolean; @@ -13,16 +16,28 @@ interface Props { quickActions: TRenderQuickActions; displayProperties: IIssueDisplayProperties | undefined; containerRef: MutableRefObject; + selectionHelpers: TSelectionHelper; } export const IssueBlocksList: FC = (props) => { - const { issueIds, issuesMap, updateIssue, quickActions, displayProperties, canEditProperties, containerRef } = props; + const { + groupId, + issueIds, + issuesMap, + updateIssue, + quickActions, + displayProperties, + canEditProperties, + containerRef, + selectionHelpers, + } = props; return (
{issueIds && issueIds.length > 0 ? ( issueIds.map((issueId: string) => ( = (props) => { nestingLevel={0} spacingLeft={0} containerRef={containerRef} + selectionHelpers={selectionHelpers} /> )) ) : ( diff --git a/web/components/issues/issue-layouts/list/default.tsx b/web/components/issues/issue-layouts/list/default.tsx index bcfd2230a..0dc1749e7 100644 --- a/web/components/issues/issue-layouts/list/default.tsx +++ b/web/components/issues/issue-layouts/list/default.tsx @@ -1,4 +1,5 @@ import { useRef } from "react"; +import { observer } from "mobx-react"; // types import { GroupByColumnTypes, @@ -9,7 +10,12 @@ import { TUnGroupedIssues, } from "@plane/types"; // components -import { IssueBlocksList, IssueBulkOperationsRoot, ListQuickAddIssueForm } from "@/components/issues"; +import { + BulkOperationsSelectGroup, + IssueBlocksList, + IssueBulkOperationsRoot, + ListQuickAddIssueForm, +} from "@/components/issues"; // constants import { EIssuesStoreType } from "@/constants/issue"; // hooks @@ -42,7 +48,7 @@ export interface IGroupByList { isCompletedCycle?: boolean; } -const GroupByList: React.FC = (props) => { +const GroupByList: React.FC = observer((props) => { const { issueIds, issuesMap, @@ -126,55 +132,67 @@ const GroupByList: React.FC = (props) => { return (
- {groups && - groups.map( - (_list) => - validateEmptyIssueGroups(is_list ? issueIds : issueIds?.[_list.id]) && ( -
-
- + {groups && ( + g.id)}> + {(helpers, snapshot) => ( + <> + {console.log("snapshot", snapshot.isSelectionActive)} + {groups.map( + (_list) => + validateEmptyIssueGroups(is_list ? issueIds : issueIds?.[_list.id]) && ( +
+
+ +
+ + {issueIds && ( + + )} + + {enableIssueQuickAdd && !disableIssueCreation && !isGroupByCreatedBy && !isCompletedCycle && ( +
+ +
+ )} +
+ ) + )} + {snapshot.isSelectionActive && ( +
+
- - {issueIds && ( - - )} - - {enableIssueQuickAdd && !disableIssueCreation && !isGroupByCreatedBy && !isCompletedCycle && ( -
- -
- )} -
- ) - )} -
- -
+ )} + + )} + + )}
); -}; +}); export interface IList { issueIds: TGroupedIssues | TUnGroupedIssues | any; diff --git a/web/hooks/use-entity-selection.ts b/web/hooks/use-entity-selection.ts index 8eebe821e..3a066353f 100644 --- a/web/hooks/use-entity-selection.ts +++ b/web/hooks/use-entity-selection.ts @@ -1,40 +1,245 @@ -import { useCallback, useEffect } from "react"; -// hooks -import { useBulkIssueOperations } from "@/hooks/store"; +import { useCallback, useEffect, useState } from "react"; -export const useEntitySelection = () => { - // store hooks - const { clearSelection, getIsIssueSelected, toggleIssueSelection } = useBulkIssueOperations(); +type Props = { + groups: string[]; +}; - const handleClick = useCallback( - (e: React.MouseEvent, issueId: string) => { - const isIssueSelected = getIsIssueSelected(issueId); - // const element = document.querySelector(`[data-element-id="${issueId}"]`); - if (e.shiftKey) { - console.log("shift key"); - if (isIssueSelected) return; +export type TSelectionSnapshot = { + isSelectionActive: boolean; + selectedEntityIds: string[]; +}; + +export type TSelectionHelper = { + handleClearSelection: () => void; + handleEntityClick: (event: React.MouseEvent, entityID: string, groupId: string) => void; + isEntitySelected: (entityID: string) => boolean; +}; + +const groupElements: Record> = {}; + +const getElementDetails = (element: Element) => ({ + entityID: element.getAttribute("data-entity-id") ?? "", + groupID: element.getAttribute("data-entity-group-id") ?? "", +}); + +export const useEntitySelection = (props: Props) => { + const { groups } = props; + // states + const [selectedEntities, setSelectedEntities] = useState<{ group: string; entity: string }[]>([]); + const [lastGroupID, setLastGroupID] = useState(null); + const [lastEntityID, setLastEntityID] = useState(null); + const [previousEntity, setPreviousEntity] = useState(null); + const [nextEntity, setNextEntity] = useState(null); + + /** + * @description clear all selection + */ + const handleClearSelection = useCallback(() => { + setSelectedEntities([]); + setLastGroupID(null); + setLastEntityID(null); + setPreviousEntity(null); + setNextEntity(null); + }, []); + + const handleEntitySelection = useCallback( + (entityID: string, groupID: string) => { + const index = selectedEntities.findIndex((en) => en.entity === entityID && en.group === groupID); + + const currentGroupElements = Array.from(groupElements[groupID]); + const currentGroupIndex = groups.findIndex((key) => key === groupID); + const currentEntityIndex = currentGroupElements.findIndex((el) => el.getAttribute("data-entity-id") === entityID); + + // group position + const isFirstGroup = currentGroupIndex === 0; + const isLastGroup = currentGroupIndex === groups.length - 1; + // entity position + const isFirstEntity = currentEntityIndex === 0; + const isLastEntity = currentEntityIndex === currentGroupElements.length - 1; + + if (!isLastEntity) { + setNextEntity(currentGroupElements[currentEntityIndex + 1]); + } else { + if (isLastGroup) setNextEntity(null); + else setNextEntity(groupElements[groups[currentGroupIndex + 1]][0] ?? null); + } + + if (!isFirstEntity) { + setPreviousEntity(currentGroupElements[currentEntityIndex - 1]); + } else { + if (isFirstGroup) setPreviousEntity(null); + else + setPreviousEntity( + groupElements[groups[currentGroupIndex - 1]][groupElements[groups[currentGroupIndex - 1]].length - 1] ?? + null + ); + } + + if (index === -1) { + setSelectedEntities((prev) => [...prev, { entity: entityID, group: groupID }]); + setLastEntityID(entityID); + setLastGroupID(groupID); + } else { + const newSelectedEntities = [...selectedEntities]; + newSelectedEntities.splice(index, 1); + setSelectedEntities(newSelectedEntities); + setLastEntityID(newSelectedEntities[newSelectedEntities.length - 1]?.entity ?? null); + setLastGroupID(newSelectedEntities[newSelectedEntities.length - 1]?.group ?? null); } - toggleIssueSelection(issueId); }, - [getIsIssueSelected, toggleIssueSelection] + [groups, selectedEntities] ); - useEffect(() => {}, []); + /** + * @description toggle entity selection + * @param {React.MouseEvent} event + * @param {string} entityID + * @param {string} groupID + */ + const handleEntityClick = useCallback( + (e: React.MouseEvent, entityID: string, groupID: string) => { + if (e.shiftKey && lastEntityID && lastGroupID) { + const currentGroupElements = Array.from(groupElements[groupID]); + const currentGroupIndex = groups.findIndex((key) => key === groupID); + const currentEntityIndex = currentGroupElements.findIndex( + (el) => el.getAttribute("data-entity-id") === entityID + ); + + const lastGroupElements = Array.from(groupElements[lastGroupID]); + const lastGroupIndex = groups.findIndex((key) => key === lastGroupID); + const lastEntityIndex = lastGroupElements.findIndex((el) => el.getAttribute("data-entity-id") === lastEntityID); + if (lastGroupIndex < currentGroupIndex) { + for (let i = lastGroupIndex; i <= currentGroupIndex; i++) { + const startIndex = i === lastGroupIndex ? lastEntityIndex + 1 : 0; + const endIndex = i === currentGroupIndex ? currentEntityIndex : groupElements[groups[i]].length - 1; + for (let j = startIndex; j <= endIndex; j++) { + const element = groupElements[groups[i]][j]; + const elementDetails = getElementDetails(element); + if (element) { + handleEntitySelection(elementDetails.entityID, elementDetails.groupID); + } + } + } + } else if (lastGroupIndex > currentGroupIndex) { + for (let i = currentGroupIndex; i <= lastGroupIndex; i++) { + const startIndex = i === currentGroupIndex ? currentEntityIndex : 0; + const endIndex = i === lastGroupIndex ? lastEntityIndex - 1 : groupElements[groups[i]].length - 1; + for (let j = startIndex; j <= endIndex; j++) { + const element = groupElements[groups[i]][j]; + const elementDetails = getElementDetails(element); + if (element) { + handleEntitySelection(elementDetails.entityID, elementDetails.groupID); + } + } + } + } else { + const startIndex = lastEntityIndex + 1; + const endIndex = currentEntityIndex; + for (let i = startIndex; i <= endIndex; i++) { + const element = groupElements[groups[lastGroupIndex]][i]; + const elementDetails = getElementDetails(element); + if (element) { + handleEntitySelection(elementDetails.entityID, elementDetails.groupID); + } + } + } + return; + } + + handleEntitySelection(entityID, groupID); + }, + [groups, handleEntitySelection, lastEntityID, lastGroupID] + ); + + /** + * @description check if entity is selected or not + * @param {string} entityID + * @returns {boolean} + */ + const isEntitySelected = useCallback( + (entityID: string) => !!selectedEntities.find((en) => en.entity === entityID), + [selectedEntities] + ); + + useEffect(() => { + const updateGroupElements = () => { + groups.forEach((group) => { + groupElements[group] = document.querySelectorAll(`[data-entity-group-id="${group}"]`); + }); + // console.log("groupElements", groupElements); + }; + + // Initial update + updateGroupElements(); + + // Create a MutationObserver to watch for changes in the DOM + const observer = new MutationObserver((mutationsList) => { + for (const mutation of mutationsList) { + if (mutation.type === "childList" || mutation.type === "attributes") { + updateGroupElements(); + break; + } + } + }); + + // Start observing the document body for changes + observer.observe(document.body, { childList: true, subtree: true, attributes: true }); + + return () => { + observer.disconnect(); + }; + }, [groups]); // clear selection on escape key press useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === "Escape") clearSelection(); + if (e.key === "Escape") handleClearSelection(); }; + window.addEventListener("keydown", handleKeyDown); + return () => { + window.removeEventListener("keydown", handleKeyDown); + }; + }, [handleClearSelection]); + + // select entities on shift + arrow up/down key press + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.shiftKey && e.key === "ArrowDown" && nextEntity) { + const elementDetails = getElementDetails(nextEntity); + handleEntitySelection(elementDetails.entityID, elementDetails.groupID); + } + if (e.shiftKey && e.key === "ArrowUp" && previousEntity) { + const elementDetails = getElementDetails(previousEntity); + handleEntitySelection(elementDetails.entityID, elementDetails.groupID); + } + }; window.addEventListener("keydown", handleKeyDown); return () => { window.removeEventListener("keydown", handleKeyDown); }; - }, [clearSelection]); + }, [handleEntitySelection, nextEntity, previousEntity]); + + /** + * @description snapshot of the current state of selection + */ + const snapshot: TSelectionSnapshot = { + isSelectionActive: selectedEntities.length > 0, + selectedEntityIds: selectedEntities.map((en) => en.entity), + }; + + /** + * @description helper functions for selection + */ + const helpers: TSelectionHelper = { + handleClearSelection, + handleEntityClick, + isEntitySelected, + }; return { - handleClick, + helpers, + snapshot, }; }; diff --git a/web/store/issue/bulk-operations.store.ts b/web/store/issue/bulk-operations.store.ts index f1fa963d3..63f2d6361 100644 --- a/web/store/issue/bulk-operations.store.ts +++ b/web/store/issue/bulk-operations.store.ts @@ -1,5 +1,5 @@ import set from "lodash/set"; -import { action, computed, makeObservable, observable, runInAction } from "mobx"; +import { action, makeObservable, runInAction } from "mobx"; // types import { TBulkOperationsPayload } from "@plane/types"; // services @@ -7,21 +7,11 @@ import { IssueService } from "@/services/issue"; import { IIssueRootStore } from "./root.store"; export type IIssueBulkOperationsStore = { - // observables - issueIds: string[]; - // computed - isSelectionActive: boolean; - // helper functions - getIsIssueSelected: (issueId: string) => boolean; // actions - toggleIssueSelection: (issueId: string) => void; - clearSelection: () => void; bulkUpdateProperties: (workspaceSlug: string, projectId: string, data: TBulkOperationsPayload) => void; }; export class IssueBulkOperationsStore implements IIssueBulkOperationsStore { - // observables - issueIds: string[] = []; // root store rootIssueStore: IIssueRootStore; // service @@ -29,13 +19,7 @@ export class IssueBulkOperationsStore implements IIssueBulkOperationsStore { constructor(_rootStore: IIssueRootStore) { makeObservable(this, { - // observable - issueIds: observable, - // computed - isSelectionActive: computed, // actions - toggleIssueSelection: action, - clearSelection: action, bulkUpdateProperties: action, }); @@ -43,34 +27,6 @@ export class IssueBulkOperationsStore implements IIssueBulkOperationsStore { this.issueService = new IssueService(); } - get isSelectionActive() { - return this.issueIds.length > 0; - } - - /** - * @description check if an issue is selected - * @param {string} issueId - * @returns {boolean} - */ - getIsIssueSelected = (issueId: string): boolean => this.issueIds.includes(issueId); - - /** - * @description select an issue by issue id - * @param {string} issueId - */ - toggleIssueSelection = (issueId: string) => { - const index = this.issueIds.indexOf(issueId); - if (index === -1) this.issueIds.push(issueId); - else this.issueIds.splice(index, 1); - }; - - /** - * @description clear all selected issues - */ - clearSelection = () => { - this.issueIds = []; - }; - /** * @description bulk update properties of selected issues * @param {TBulkOperationsPayload} data diff --git a/web/styles/globals.css b/web/styles/globals.css index 482f056c6..cbcce6840 100644 --- a/web/styles/globals.css +++ b/web/styles/globals.css @@ -674,3 +674,31 @@ div.web-view-spinner div.bar12 { .disable-autofill-style:-webkit-autofill:active { -webkit-background-clip: text; } + +input[type="checkbox"].minus-checkbox { + position: relative; + + &::before, + &::after { + content: ""; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + } + + &:checked::after { + height: 100%; + width: 100%; + z-index: 1; + background-color: rgba(var(--color-primary-100)); + border-radius: 2px; + } + + &::before { + content: "-"; + font-size: 1.1rem; + z-index: 2; + color: rgba(var(--color-text-100)); + } +}