chore: create hoc for multi-select groups

This commit is contained in:
Aaryan Khandelwal 2024-05-21 14:24:13 +05:30
parent 53b9f60b39
commit 836e186634
14 changed files with 481 additions and 169 deletions

View File

@ -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<Props> = 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<Props> = observer((props) =>
title: "Success!",
message: "Issues archived successfully.",
});
clearSelection();
onSubmit?.();
handleClose();
})
.catch(() =>

View File

@ -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<Props> = 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<Props> = observer((props) =>
title: "Success!",
message: "Issues deleted successfully.",
});
clearSelection();
onSubmit?.();
handleClose();
})
.catch(() =>

View File

@ -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";

View File

@ -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> = (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<TBulkIssueProperties>) => {
if (!workspaceSlug || !projectId) return;
bulkUpdateProperties(workspaceSlug.toString(), projectId.toString(), {
issue_ids: issueIds,
issue_ids: snapshot.selectedEntityIds,
properties: data,
});
};
const isUpdateDisabled = !snapshot.isSelectionActive;
return (
<>
<StateDropdown
@ -32,11 +41,13 @@ export const IssueBulkOperationsProperties: React.FC = () => {
onChange={(val) => handleBulkOperation({ state_id: val })}
projectId={projectId?.toString() ?? ""}
buttonVariant="border-with-text"
disabled={isUpdateDisabled}
/>
<PriorityDropdown
value="urgent"
onChange={(val) => handleBulkOperation({ priority: val })}
buttonVariant="border-with-text"
disabled={isUpdateDisabled}
/>
<MemberDropdown
value={[]}
@ -44,20 +55,23 @@ export const IssueBulkOperationsProperties: React.FC = () => {
buttonVariant="border-with-text"
placeholder="Assignees"
multiple
disabled={isUpdateDisabled}
/>
<DateDropdown
value={null}
onChange={(val) => handleBulkOperation({ start_date: val ? renderFormattedPayloadDate(val) : null })}
buttonVariant="border-with-text"
placeholder="Start date"
icon={<CalendarClock className="h-3 w-3 flex-shrink-0" />}
icon={<CalendarClock className="size-3 flex-shrink-0" />}
disabled={isUpdateDisabled}
/>
<DateDropdown
value={null}
onChange={(val) => handleBulkOperation({ target_date: val ? renderFormattedPayloadDate(val) : null })}
buttonVariant="border-with-text"
placeholder="Due date"
icon={<CalendarCheck2 className="h-3 w-3 flex-shrink-0" />}
icon={<CalendarCheck2 className="size-3 flex-shrink-0" />}
disabled={isUpdateDisabled}
/>
</>
);

View File

@ -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<Props> = 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 && (
<BulkArchiveConfirmationModal
isOpen={isBulkArchiveModalOpen}
handleClose={() => setIsBulkArchiveModalOpen(false)}
issueIds={issueIds}
projectId={projectId.toString()}
workspaceSlug={workspaceSlug.toString()}
/>
<>
<BulkArchiveConfirmationModal
isOpen={isBulkArchiveModalOpen}
handleClose={() => setIsBulkArchiveModalOpen(false)}
issueIds={selectedEntityIds}
onSubmit={handleClearSelection}
projectId={projectId.toString()}
workspaceSlug={workspaceSlug.toString()}
/>
<BulkDeleteConfirmationModal
isOpen={isBulkDeleteModalOpen}
handleClose={() => setIsBulkDeleteModalOpen(false)}
issueIds={selectedEntityIds}
onSubmit={handleClearSelection}
projectId={projectId.toString()}
workspaceSlug={workspaceSlug.toString()}
/>
</>
)}
{workspaceSlug && projectId && (
<BulkDeleteConfirmationModal
isOpen={isBulkDeleteModalOpen}
handleClose={() => setIsBulkDeleteModalOpen(false)}
issueIds={issueIds}
projectId={projectId.toString()}
workspaceSlug={workspaceSlug.toString()}
/>
)}
<div className="h-full w-full bg-custom-background-100 border-t border-custom-border-200 py-4 px-3.5 flex items-center divide-x-[0.5px] divide-custom-border-200 text-custom-text-300">
<div className="h-7 pr-3 text-sm flex items-center">2 selected</div>
<div
className={cn(
"size-full bg-custom-background-100 border-t border-custom-border-200 py-4 px-3.5 flex items-center divide-x-[0.5px] divide-custom-border-200 text-custom-text-300",
className
)}
>
<div className="h-7 pr-3 text-sm flex items-center gap-2">
<input type="checkbox" className="minus-checkbox" checked />
<div className="flex items-center gap-1">
{/* // TODO: add min width here */}
<span className="flex-shrink-0">{selectedEntityIds.length}</span>selected
</div>
</div>
<div className="h-7 px-3 flex items-center">
<Tooltip tooltipContent="Archive">
<button
type="button"
className={cn("outline-none grid place-items-center", {
"cursor-not-allowed text-custom-text-400": issueIds.length === 0,
"cursor-not-allowed text-custom-text-400": !isSelectionActive,
})}
onClick={() => {
if (issueIds.length > 0) setIsBulkArchiveModalOpen(true);
if (isSelectionActive) setIsBulkArchiveModalOpen(true);
}}
>
<ArchiveIcon className="size-4" />
@ -67,10 +88,10 @@ export const IssueBulkOperationsRoot = observer(() => {
<button
type="button"
className={cn("outline-none grid place-items-center", {
"cursor-not-allowed text-custom-text-400": issueIds.length === 0,
"cursor-not-allowed text-custom-text-400": !isSelectionActive,
})}
onClick={() => {
if (issueIds.length > 0) setIsBulkDeleteModalOpen(true);
if (isSelectionActive) setIsBulkDeleteModalOpen(true);
}}
>
<Trash2 className="size-4" />
@ -78,7 +99,7 @@ export const IssueBulkOperationsRoot = observer(() => {
</Tooltip>
</div>
<div className="h-7 pl-3 flex items-center gap-3">
<IssueBulkOperationsProperties />
<IssueBulkOperationsProperties selectionHelpers={selectionHelpers} snapshot={snapshot} />
</div>
</div>
</>

View File

@ -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> = (props) => {
const { children, groups } = props;
const { helpers, snapshot } = useEntitySelection({
groups,
});
return <>{children(helpers, snapshot)}</>;
};

View File

@ -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> = (props) => {
const { className, groupId, id, selectionHelpers } = props;
// derived values
const isSelected = selectionHelpers.isEntitySelected(id);
return (
<input
type="checkbox"
className={cn(
"opacity-0 pointer-events-none group-hover/list-block:opacity-100 group-hover/list-block:pointer-events-auto cursor-pointer transition-opacity outline-none",
{
"opacity-100 pointer-events-auto": isSelected,
},
className
)}
onClick={(e) => selectionHelpers.handleEntityClick(e, id, groupId)}
checked={isSelected}
data-entity-group-id={groupId}
data-entity-id={id}
/>
);
};

View File

@ -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<HTMLDivElement | null>;
selectionHelpers: TSelectionHelper;
};
export const IssueBlockRoot: FC<Props> = observer((props) => {
const {
groupId,
issueIds,
issueId,
issuesMap,
@ -35,6 +39,7 @@ export const IssueBlockRoot: FC<Props> = observer((props) => {
nestingLevel,
spacingLeft = 14,
containerRef,
selectionHelpers,
} = props;
// states
const [isExpanded, setExpanded] = useState(false);
@ -53,6 +58,7 @@ export const IssueBlockRoot: FC<Props> = observer((props) => {
classNames="relative border-b border-b-custom-border-200 last:border-b-transparent"
>
<IssueBlock
groupId={groupId}
issueId={issueId}
issuesMap={issuesMap}
updateIssue={updateIssue}
@ -63,6 +69,7 @@ export const IssueBlockRoot: FC<Props> = observer((props) => {
setExpanded={setExpanded}
nestingLevel={nestingLevel}
spacingLeft={spacingLeft}
selectionHelpers={selectionHelpers}
/>
</RenderIfVisible>
@ -70,6 +77,7 @@ export const IssueBlockRoot: FC<Props> = observer((props) => {
subIssues?.map((subIssueId) => (
<IssueBlockRoot
key={`${subIssueId}`}
groupId={groupId}
issueIds={issueIds}
issueId={subIssueId}
issuesMap={issuesMap}
@ -80,6 +88,7 @@ export const IssueBlockRoot: FC<Props> = observer((props) => {
nestingLevel={nestingLevel + 1}
spacingLeft={spacingLeft + (displayProperties?.key ? 12 : 0)}
containerRef={containerRef}
selectionHelpers={selectionHelpers}
/>
))}
</>

View File

@ -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<TIssue>) => Promise<void>) | undefined;
@ -27,10 +29,12 @@ interface IssueBlockProps {
spacingLeft?: number;
isExpanded: boolean;
setExpanded: Dispatch<SetStateAction<boolean>>;
selectionHelpers: TSelectionHelper;
}
export const IssueBlock: React.FC<IssueBlockProps> = observer((props: IssueBlockProps) => {
const {
groupId,
issuesMap,
issueId,
updateIssue,
@ -41,6 +45,7 @@ export const IssueBlock: React.FC<IssueBlockProps> = observer((props: IssueBlock
spacingLeft = 14,
isExpanded,
setExpanded,
selectionHelpers,
} = props;
// refs
const parentRef = useRef(null);
@ -61,13 +66,11 @@ export const IssueBlock: React.FC<IssueBlockProps> = 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<IssueBlockProps> = 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<IssueBlockProps> = observer((props: IssueBlock
<div className="flex items-center group">
{canEditIssueProperties && (
<div className="flex-shrink-0 grid place-items-center w-3.5 pl-1.5">
<input
type="checkbox"
className={cn(
"opacity-0 pointer-events-none group-hover/list-block:opacity-100 group-hover/list-block:pointer-events-auto cursor-pointer transition-opacity outline-none",
{
"opacity-100 pointer-events-auto": isIssueSelected,
}
)}
onClick={(e) => handleClick(e, issueId)}
checked={isIssueSelected}
/>
<BulkOperationsSelect groupId={groupId} id={issue.id} selectionHelpers={selectionHelpers} />
</div>
)}
<div className="flex h-4 w-4 items-center justify-center">

View File

@ -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<HTMLDivElement | null>;
selectionHelpers: TSelectionHelper;
}
export const IssueBlocksList: FC<Props> = (props) => {
const { issueIds, issuesMap, updateIssue, quickActions, displayProperties, canEditProperties, containerRef } = props;
const {
groupId,
issueIds,
issuesMap,
updateIssue,
quickActions,
displayProperties,
canEditProperties,
containerRef,
selectionHelpers,
} = props;
return (
<div className="relative h-full w-full">
{issueIds && issueIds.length > 0 ? (
issueIds.map((issueId: string) => (
<IssueBlockRoot
groupId={groupId}
key={`${issueId}`}
issueIds={issueIds}
issueId={issueId}
@ -34,6 +49,7 @@ export const IssueBlocksList: FC<Props> = (props) => {
nestingLevel={0}
spacingLeft={0}
containerRef={containerRef}
selectionHelpers={selectionHelpers}
/>
))
) : (

View File

@ -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<IGroupByList> = (props) => {
const GroupByList: React.FC<IGroupByList> = observer((props) => {
const {
issueIds,
issuesMap,
@ -126,55 +132,67 @@ const GroupByList: React.FC<IGroupByList> = (props) => {
return (
<div
ref={containerRef}
className="vertical-scrollbar scrollbar-lg relative h-full w-full overflow-auto vertical-scrollbar-margin-top-md"
className="vertical-scrollbar scrollbar-lg relative size-full overflow-auto vertical-scrollbar-margin-top-md"
>
{groups &&
groups.map(
(_list) =>
validateEmptyIssueGroups(is_list ? issueIds : issueIds?.[_list.id]) && (
<div key={_list.id} className={`flex flex-shrink-0 flex-col`}>
<div className="sticky top-0 z-[2] w-full flex-shrink-0 border-b border-custom-border-200 bg-custom-background-90 px-3 pl-5 py-1">
<HeaderGroupByCard
icon={_list.icon}
title={_list.name || ""}
count={is_list ? issueIds?.length || 0 : issueIds?.[_list.id]?.length || 0}
issuePayload={_list.payload}
disableIssueCreation={disableIssueCreation || isGroupByCreatedBy || isCompletedCycle}
storeType={storeType}
addIssuesToView={addIssuesToView}
/>
{groups && (
<BulkOperationsSelectGroup groups={groups.map((g) => g.id)}>
{(helpers, snapshot) => (
<>
{console.log("snapshot", snapshot.isSelectionActive)}
{groups.map(
(_list) =>
validateEmptyIssueGroups(is_list ? issueIds : issueIds?.[_list.id]) && (
<div key={_list.id} className={`flex flex-shrink-0 flex-col`}>
<div className="sticky top-0 z-[2] w-full flex-shrink-0 border-b border-custom-border-200 bg-custom-background-90 px-3 pl-5 py-1">
<HeaderGroupByCard
icon={_list.icon}
title={_list.name || ""}
count={is_list ? issueIds?.length || 0 : issueIds?.[_list.id]?.length || 0}
issuePayload={_list.payload}
disableIssueCreation={disableIssueCreation || isGroupByCreatedBy || isCompletedCycle}
storeType={storeType}
addIssuesToView={addIssuesToView}
/>
</div>
{issueIds && (
<IssueBlocksList
groupId={_list.id}
issueIds={is_list ? issueIds || 0 : issueIds?.[_list.id] || 0}
issuesMap={issuesMap}
updateIssue={updateIssue}
quickActions={quickActions}
displayProperties={displayProperties}
canEditProperties={canEditProperties}
containerRef={containerRef}
selectionHelpers={helpers}
/>
)}
{enableIssueQuickAdd && !disableIssueCreation && !isGroupByCreatedBy && !isCompletedCycle && (
<div className="sticky bottom-0 z-[1] w-full flex-shrink-0">
<ListQuickAddIssueForm
prePopulatedData={prePopulateQuickAddData(group_by, _list.id)}
quickAddCallback={quickAddCallback}
viewId={viewId}
/>
</div>
)}
</div>
)
)}
{snapshot.isSelectionActive && (
<div className="sticky bottom-0 left-0 z-[2] h-14">
<IssueBulkOperationsRoot selectionHelpers={helpers} snapshot={snapshot} />
</div>
{issueIds && (
<IssueBlocksList
issueIds={is_list ? issueIds || 0 : issueIds?.[_list.id] || 0}
issuesMap={issuesMap}
updateIssue={updateIssue}
quickActions={quickActions}
displayProperties={displayProperties}
canEditProperties={canEditProperties}
containerRef={containerRef}
/>
)}
{enableIssueQuickAdd && !disableIssueCreation && !isGroupByCreatedBy && !isCompletedCycle && (
<div className="sticky bottom-0 z-[1] w-full flex-shrink-0">
<ListQuickAddIssueForm
prePopulatedData={prePopulateQuickAddData(group_by, _list.id)}
quickAddCallback={quickAddCallback}
viewId={viewId}
/>
</div>
)}
</div>
)
)}
<div className="sticky bottom-0 left-0 z-10 h-14">
<IssueBulkOperationsRoot />
</div>
)}
</>
)}
</BulkOperationsSelectGroup>
)}
</div>
);
};
});
export interface IList {
issueIds: TGroupedIssues | TUnGroupedIssues | any;

View File

@ -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<string, NodeListOf<Element>> = {};
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<string | null>(null);
const [lastEntityID, setLastEntityID] = useState<string | null>(null);
const [previousEntity, setPreviousEntity] = useState<Element | null>(null);
const [nextEntity, setNextEntity] = useState<Element | null>(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,
};
};

View File

@ -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

View File

@ -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));
}
}