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 // constants
import { EIssuesStoreType } from "@/constants/issue"; import { EIssuesStoreType } from "@/constants/issue";
// hooks // hooks
import { useBulkIssueOperations, useIssues } from "@/hooks/store"; import { useIssues } from "@/hooks/store";
type Props = { type Props = {
handleClose: () => void; handleClose: () => void;
isOpen: boolean; isOpen: boolean;
issueIds: string[]; issueIds: string[];
onSubmit?: () => void;
projectId: string; projectId: string;
workspaceSlug: string; workspaceSlug: string;
}; };
export const BulkArchiveConfirmationModal: React.FC<Props> = observer((props) => { export const BulkArchiveConfirmationModal: React.FC<Props> = observer((props) => {
const { handleClose, isOpen, issueIds, projectId, workspaceSlug } = props; const { handleClose, isOpen, issueIds, onSubmit, projectId, workspaceSlug } = props;
// states // states
const [isArchiving, setIsDeleting] = useState(false); const [isArchiving, setIsDeleting] = useState(false);
// store hooks // store hooks
const { const {
issues: { archiveBulkIssues }, issues: { archiveBulkIssues },
} = useIssues(EIssuesStoreType.PROJECT); } = useIssues(EIssuesStoreType.PROJECT);
const { clearSelection } = useBulkIssueOperations();
const handleSubmit = async () => { const handleSubmit = async () => {
setIsDeleting(true); setIsDeleting(true);
@ -37,7 +37,7 @@ export const BulkArchiveConfirmationModal: React.FC<Props> = observer((props) =>
title: "Success!", title: "Success!",
message: "Issues archived successfully.", message: "Issues archived successfully.",
}); });
clearSelection(); onSubmit?.();
handleClose(); handleClose();
}) })
.catch(() => .catch(() =>

View File

@ -7,25 +7,25 @@ import { AlertModalCore, EModalPosition, EModalWidth } from "@/components/core";
// constants // constants
import { EIssuesStoreType } from "@/constants/issue"; import { EIssuesStoreType } from "@/constants/issue";
// hooks // hooks
import { useBulkIssueOperations, useIssues } from "@/hooks/store"; import { useIssues } from "@/hooks/store";
type Props = { type Props = {
handleClose: () => void; handleClose: () => void;
isOpen: boolean; isOpen: boolean;
issueIds: string[]; issueIds: string[];
onSubmit?: () => void;
projectId: string; projectId: string;
workspaceSlug: string; workspaceSlug: string;
}; };
export const BulkDeleteConfirmationModal: React.FC<Props> = observer((props) => { export const BulkDeleteConfirmationModal: React.FC<Props> = observer((props) => {
const { handleClose, isOpen, issueIds, projectId, workspaceSlug } = props; const { handleClose, isOpen, issueIds, onSubmit, projectId, workspaceSlug } = props;
// states // states
const [isDeleting, setIsDeleting] = useState(false); const [isDeleting, setIsDeleting] = useState(false);
// store hooks // store hooks
const { const {
issues: { removeBulkIssues }, issues: { removeBulkIssues },
} = useIssues(EIssuesStoreType.PROJECT); } = useIssues(EIssuesStoreType.PROJECT);
const { clearSelection } = useBulkIssueOperations();
const handleSubmit = async () => { const handleSubmit = async () => {
setIsDeleting(true); setIsDeleting(true);
@ -37,7 +37,7 @@ export const BulkDeleteConfirmationModal: React.FC<Props> = observer((props) =>
title: "Success!", title: "Success!",
message: "Issues deleted successfully.", message: "Issues deleted successfully.",
}); });
clearSelection(); onSubmit?.();
handleClose(); handleClose();
}) })
.catch(() => .catch(() =>

View File

@ -2,3 +2,5 @@ export * from "./bulk-archive-modal";
export * from "./bulk-delete-modal"; export * from "./bulk-delete-modal";
export * from "./properties"; export * from "./properties";
export * from "./root"; 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"; import { renderFormattedPayloadDate } from "@/helpers/date-time.helper";
// hooks // hooks
import { useBulkIssueOperations } from "@/hooks/store"; 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 // router
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId } = router.query; const { workspaceSlug, projectId } = router.query;
// store hooks // store hooks
const { bulkUpdateProperties, issueIds } = useBulkIssueOperations(); const { bulkUpdateProperties } = useBulkIssueOperations();
const handleBulkOperation = (data: Partial<TBulkIssueProperties>) => { const handleBulkOperation = (data: Partial<TBulkIssueProperties>) => {
if (!workspaceSlug || !projectId) return; if (!workspaceSlug || !projectId) return;
bulkUpdateProperties(workspaceSlug.toString(), projectId.toString(), { bulkUpdateProperties(workspaceSlug.toString(), projectId.toString(), {
issue_ids: issueIds, issue_ids: snapshot.selectedEntityIds,
properties: data, properties: data,
}); });
}; };
const isUpdateDisabled = !snapshot.isSelectionActive;
return ( return (
<> <>
<StateDropdown <StateDropdown
@ -32,11 +41,13 @@ export const IssueBulkOperationsProperties: React.FC = () => {
onChange={(val) => handleBulkOperation({ state_id: val })} onChange={(val) => handleBulkOperation({ state_id: val })}
projectId={projectId?.toString() ?? ""} projectId={projectId?.toString() ?? ""}
buttonVariant="border-with-text" buttonVariant="border-with-text"
disabled={isUpdateDisabled}
/> />
<PriorityDropdown <PriorityDropdown
value="urgent" value="urgent"
onChange={(val) => handleBulkOperation({ priority: val })} onChange={(val) => handleBulkOperation({ priority: val })}
buttonVariant="border-with-text" buttonVariant="border-with-text"
disabled={isUpdateDisabled}
/> />
<MemberDropdown <MemberDropdown
value={[]} value={[]}
@ -44,20 +55,23 @@ export const IssueBulkOperationsProperties: React.FC = () => {
buttonVariant="border-with-text" buttonVariant="border-with-text"
placeholder="Assignees" placeholder="Assignees"
multiple multiple
disabled={isUpdateDisabled}
/> />
<DateDropdown <DateDropdown
value={null} value={null}
onChange={(val) => handleBulkOperation({ start_date: val ? renderFormattedPayloadDate(val) : null })} onChange={(val) => handleBulkOperation({ start_date: val ? renderFormattedPayloadDate(val) : null })}
buttonVariant="border-with-text" buttonVariant="border-with-text"
placeholder="Start date" placeholder="Start date"
icon={<CalendarClock className="h-3 w-3 flex-shrink-0" />} icon={<CalendarClock className="size-3 flex-shrink-0" />}
disabled={isUpdateDisabled}
/> />
<DateDropdown <DateDropdown
value={null} value={null}
onChange={(val) => handleBulkOperation({ target_date: val ? renderFormattedPayloadDate(val) : null })} onChange={(val) => handleBulkOperation({ target_date: val ? renderFormattedPayloadDate(val) : null })}
buttonVariant="border-with-text" buttonVariant="border-with-text"
placeholder="Due date" 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 // helpers
import { cn } from "@/helpers/common.helper"; import { cn } from "@/helpers/common.helper";
// hooks // 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 // states
const [isBulkArchiveModalOpen, setIsBulkArchiveModalOpen] = useState(false); const [isBulkArchiveModalOpen, setIsBulkArchiveModalOpen] = useState(false);
const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false); const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
// router // router
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId } = router.query; const { workspaceSlug, projectId } = router.query;
// store hooks // serviced values
const { issueIds } = useBulkIssueOperations(); const { isSelectionActive, selectedEntityIds } = snapshot;
const { handleClearSelection } = selectionHelpers;
return ( return (
<> <>
{workspaceSlug && projectId && ( {workspaceSlug && projectId && (
<>
<BulkArchiveConfirmationModal <BulkArchiveConfirmationModal
isOpen={isBulkArchiveModalOpen} isOpen={isBulkArchiveModalOpen}
handleClose={() => setIsBulkArchiveModalOpen(false)} handleClose={() => setIsBulkArchiveModalOpen(false)}
issueIds={issueIds} issueIds={selectedEntityIds}
onSubmit={handleClearSelection}
projectId={projectId.toString()} projectId={projectId.toString()}
workspaceSlug={workspaceSlug.toString()} workspaceSlug={workspaceSlug.toString()}
/> />
)}
{workspaceSlug && projectId && (
<BulkDeleteConfirmationModal <BulkDeleteConfirmationModal
isOpen={isBulkDeleteModalOpen} isOpen={isBulkDeleteModalOpen}
handleClose={() => setIsBulkDeleteModalOpen(false)} handleClose={() => setIsBulkDeleteModalOpen(false)}
issueIds={issueIds} issueIds={selectedEntityIds}
onSubmit={handleClearSelection}
projectId={projectId.toString()} projectId={projectId.toString()}
workspaceSlug={workspaceSlug.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
<div className="h-7 pr-3 text-sm flex items-center">2 selected</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"> <div className="h-7 px-3 flex items-center">
<Tooltip tooltipContent="Archive"> <Tooltip tooltipContent="Archive">
<button <button
type="button" type="button"
className={cn("outline-none grid place-items-center", { 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={() => { onClick={() => {
if (issueIds.length > 0) setIsBulkArchiveModalOpen(true); if (isSelectionActive) setIsBulkArchiveModalOpen(true);
}} }}
> >
<ArchiveIcon className="size-4" /> <ArchiveIcon className="size-4" />
@ -67,10 +88,10 @@ export const IssueBulkOperationsRoot = observer(() => {
<button <button
type="button" type="button"
className={cn("outline-none grid place-items-center", { 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={() => { onClick={() => {
if (issueIds.length > 0) setIsBulkDeleteModalOpen(true); if (isSelectionActive) setIsBulkDeleteModalOpen(true);
}} }}
> >
<Trash2 className="size-4" /> <Trash2 className="size-4" />
@ -78,7 +99,7 @@ export const IssueBulkOperationsRoot = observer(() => {
</Tooltip> </Tooltip>
</div> </div>
<div className="h-7 pl-3 flex items-center gap-3"> <div className="h-7 pl-3 flex items-center gap-3">
<IssueBulkOperationsProperties /> <IssueBulkOperationsProperties selectionHelpers={selectionHelpers} snapshot={snapshot} />
</div> </div>
</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"; import { IssueBlock } from "@/components/issues/issue-layouts/list";
// hooks // hooks
import { useIssueDetail } from "@/hooks/store"; import { useIssueDetail } from "@/hooks/store";
import { TSelectionHelper } from "@/hooks/use-entity-selection";
// types // types
import { TRenderQuickActions } from "./list-view-types"; import { TRenderQuickActions } from "./list-view-types";
type Props = { type Props = {
groupId: string;
issueIds: string[]; issueIds: string[];
issueId: string; issueId: string;
issuesMap: TIssueMap; issuesMap: TIssueMap;
@ -21,10 +23,12 @@ type Props = {
nestingLevel: number; nestingLevel: number;
spacingLeft?: number; spacingLeft?: number;
containerRef: MutableRefObject<HTMLDivElement | null>; containerRef: MutableRefObject<HTMLDivElement | null>;
selectionHelpers: TSelectionHelper;
}; };
export const IssueBlockRoot: FC<Props> = observer((props) => { export const IssueBlockRoot: FC<Props> = observer((props) => {
const { const {
groupId,
issueIds, issueIds,
issueId, issueId,
issuesMap, issuesMap,
@ -35,6 +39,7 @@ export const IssueBlockRoot: FC<Props> = observer((props) => {
nestingLevel, nestingLevel,
spacingLeft = 14, spacingLeft = 14,
containerRef, containerRef,
selectionHelpers,
} = props; } = props;
// states // states
const [isExpanded, setExpanded] = useState(false); 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" classNames="relative border-b border-b-custom-border-200 last:border-b-transparent"
> >
<IssueBlock <IssueBlock
groupId={groupId}
issueId={issueId} issueId={issueId}
issuesMap={issuesMap} issuesMap={issuesMap}
updateIssue={updateIssue} updateIssue={updateIssue}
@ -63,6 +69,7 @@ export const IssueBlockRoot: FC<Props> = observer((props) => {
setExpanded={setExpanded} setExpanded={setExpanded}
nestingLevel={nestingLevel} nestingLevel={nestingLevel}
spacingLeft={spacingLeft} spacingLeft={spacingLeft}
selectionHelpers={selectionHelpers}
/> />
</RenderIfVisible> </RenderIfVisible>
@ -70,6 +77,7 @@ export const IssueBlockRoot: FC<Props> = observer((props) => {
subIssues?.map((subIssueId) => ( subIssues?.map((subIssueId) => (
<IssueBlockRoot <IssueBlockRoot
key={`${subIssueId}`} key={`${subIssueId}`}
groupId={groupId}
issueIds={issueIds} issueIds={issueIds}
issueId={subIssueId} issueId={subIssueId}
issuesMap={issuesMap} issuesMap={issuesMap}
@ -80,6 +88,7 @@ export const IssueBlockRoot: FC<Props> = observer((props) => {
nestingLevel={nestingLevel + 1} nestingLevel={nestingLevel + 1}
spacingLeft={spacingLeft + (displayProperties?.key ? 12 : 0)} spacingLeft={spacingLeft + (displayProperties?.key ? 12 : 0)}
containerRef={containerRef} containerRef={containerRef}
selectionHelpers={selectionHelpers}
/> />
))} ))}
</> </>

View File

@ -6,17 +6,19 @@ import { TIssue, IIssueDisplayProperties, TIssueMap } from "@plane/types";
// ui // ui
import { Spinner, Tooltip, ControlLink } from "@plane/ui"; import { Spinner, Tooltip, ControlLink } from "@plane/ui";
// components // components
import { BulkOperationsSelect } from "@/components/issues";
import { IssueProperties } from "@/components/issues/issue-layouts/properties"; import { IssueProperties } from "@/components/issues/issue-layouts/properties";
// helpers // helpers
import { cn } from "@/helpers/common.helper"; import { cn } from "@/helpers/common.helper";
// hooks // hooks
import { useAppRouter, useBulkIssueOperations, useIssueDetail, useProject } from "@/hooks/store"; import { useAppRouter, useIssueDetail, useProject } from "@/hooks/store";
import { useEntitySelection } from "@/hooks/use-entity-selection"; import { TSelectionHelper } from "@/hooks/use-entity-selection";
import { usePlatformOS } from "@/hooks/use-platform-os"; import { usePlatformOS } from "@/hooks/use-platform-os";
// types // types
import { TRenderQuickActions } from "./list-view-types"; import { TRenderQuickActions } from "./list-view-types";
interface IssueBlockProps { interface IssueBlockProps {
groupId: string;
issueId: string; issueId: string;
issuesMap: TIssueMap; issuesMap: TIssueMap;
updateIssue: ((projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined; updateIssue: ((projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined;
@ -27,10 +29,12 @@ interface IssueBlockProps {
spacingLeft?: number; spacingLeft?: number;
isExpanded: boolean; isExpanded: boolean;
setExpanded: Dispatch<SetStateAction<boolean>>; setExpanded: Dispatch<SetStateAction<boolean>>;
selectionHelpers: TSelectionHelper;
} }
export const IssueBlock: React.FC<IssueBlockProps> = observer((props: IssueBlockProps) => { export const IssueBlock: React.FC<IssueBlockProps> = observer((props: IssueBlockProps) => {
const { const {
groupId,
issuesMap, issuesMap,
issueId, issueId,
updateIssue, updateIssue,
@ -41,6 +45,7 @@ export const IssueBlock: React.FC<IssueBlockProps> = observer((props: IssueBlock
spacingLeft = 14, spacingLeft = 14,
isExpanded, isExpanded,
setExpanded, setExpanded,
selectionHelpers,
} = props; } = props;
// refs // refs
const parentRef = useRef(null); const parentRef = useRef(null);
@ -61,13 +66,11 @@ export const IssueBlock: React.FC<IssueBlockProps> = observer((props: IssueBlock
// const subIssues = subIssuesStore.subIssuesByIssueId(issueId); // const subIssues = subIssuesStore.subIssuesByIssueId(issueId);
const { isMobile } = usePlatformOS(); const { isMobile } = usePlatformOS();
const { getIsIssueSelected } = useBulkIssueOperations();
const { handleClick } = useEntitySelection();
if (!issue) return null; if (!issue) return null;
const canEditIssueProperties = canEditProperties(issue.project_id); const canEditIssueProperties = canEditProperties(issue.project_id);
const projectIdentifier = getProjectIdentifierById(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 // if sub issues have been fetched for the issue, use that for count or use issue's sub_issues_count
// const subIssuesCount = subIssues ? subIssues.length : issue.sub_issues_count; // const 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, getIsIssuePeeked(issue.id) && peekIssue?.nestingLevel === nestingLevel,
"last:border-b-transparent": !getIsIssuePeeked(issue.id), "last:border-b-transparent": !getIsIssuePeeked(issue.id),
"hover:bg-custom-background-90": !isIssueSelected, "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"> <div className="flex items-center group">
{canEditIssueProperties && ( {canEditIssueProperties && (
<div className="flex-shrink-0 grid place-items-center w-3.5 pl-1.5"> <div className="flex-shrink-0 grid place-items-center w-3.5 pl-1.5">
<input <BulkOperationsSelect groupId={groupId} id={issue.id} selectionHelpers={selectionHelpers} />
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}
/>
</div> </div>
)} )}
<div className="flex h-4 w-4 items-center justify-center"> <div className="flex h-4 w-4 items-center justify-center">

View File

@ -2,10 +2,13 @@ import { FC, MutableRefObject } from "react";
// components // components
import { TGroupedIssues, TIssue, IIssueDisplayProperties, TIssueMap, TUnGroupedIssues } from "@plane/types"; import { TGroupedIssues, TIssue, IIssueDisplayProperties, TIssueMap, TUnGroupedIssues } from "@plane/types";
import { IssueBlockRoot } from "@/components/issues/issue-layouts/list"; import { IssueBlockRoot } from "@/components/issues/issue-layouts/list";
// hooks
import { TSelectionHelper } from "@/hooks/use-entity-selection";
// types // types
import { TRenderQuickActions } from "./list-view-types"; import { TRenderQuickActions } from "./list-view-types";
interface Props { interface Props {
groupId: string;
issueIds: TGroupedIssues | TUnGroupedIssues | any; issueIds: TGroupedIssues | TUnGroupedIssues | any;
issuesMap: TIssueMap; issuesMap: TIssueMap;
canEditProperties: (projectId: string | undefined) => boolean; canEditProperties: (projectId: string | undefined) => boolean;
@ -13,16 +16,28 @@ interface Props {
quickActions: TRenderQuickActions; quickActions: TRenderQuickActions;
displayProperties: IIssueDisplayProperties | undefined; displayProperties: IIssueDisplayProperties | undefined;
containerRef: MutableRefObject<HTMLDivElement | null>; containerRef: MutableRefObject<HTMLDivElement | null>;
selectionHelpers: TSelectionHelper;
} }
export const IssueBlocksList: FC<Props> = (props) => { 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 ( return (
<div className="relative h-full w-full"> <div className="relative h-full w-full">
{issueIds && issueIds.length > 0 ? ( {issueIds && issueIds.length > 0 ? (
issueIds.map((issueId: string) => ( issueIds.map((issueId: string) => (
<IssueBlockRoot <IssueBlockRoot
groupId={groupId}
key={`${issueId}`} key={`${issueId}`}
issueIds={issueIds} issueIds={issueIds}
issueId={issueId} issueId={issueId}
@ -34,6 +49,7 @@ export const IssueBlocksList: FC<Props> = (props) => {
nestingLevel={0} nestingLevel={0}
spacingLeft={0} spacingLeft={0}
containerRef={containerRef} containerRef={containerRef}
selectionHelpers={selectionHelpers}
/> />
)) ))
) : ( ) : (

View File

@ -1,4 +1,5 @@
import { useRef } from "react"; import { useRef } from "react";
import { observer } from "mobx-react";
// types // types
import { import {
GroupByColumnTypes, GroupByColumnTypes,
@ -9,7 +10,12 @@ import {
TUnGroupedIssues, TUnGroupedIssues,
} from "@plane/types"; } from "@plane/types";
// components // components
import { IssueBlocksList, IssueBulkOperationsRoot, ListQuickAddIssueForm } from "@/components/issues"; import {
BulkOperationsSelectGroup,
IssueBlocksList,
IssueBulkOperationsRoot,
ListQuickAddIssueForm,
} from "@/components/issues";
// constants // constants
import { EIssuesStoreType } from "@/constants/issue"; import { EIssuesStoreType } from "@/constants/issue";
// hooks // hooks
@ -42,7 +48,7 @@ export interface IGroupByList {
isCompletedCycle?: boolean; isCompletedCycle?: boolean;
} }
const GroupByList: React.FC<IGroupByList> = (props) => { const GroupByList: React.FC<IGroupByList> = observer((props) => {
const { const {
issueIds, issueIds,
issuesMap, issuesMap,
@ -126,10 +132,14 @@ const GroupByList: React.FC<IGroupByList> = (props) => {
return ( return (
<div <div
ref={containerRef} 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 && (
groups.map( <BulkOperationsSelectGroup groups={groups.map((g) => g.id)}>
{(helpers, snapshot) => (
<>
{console.log("snapshot", snapshot.isSelectionActive)}
{groups.map(
(_list) => (_list) =>
validateEmptyIssueGroups(is_list ? issueIds : issueIds?.[_list.id]) && ( validateEmptyIssueGroups(is_list ? issueIds : issueIds?.[_list.id]) && (
<div key={_list.id} className={`flex flex-shrink-0 flex-col`}> <div key={_list.id} className={`flex flex-shrink-0 flex-col`}>
@ -147,6 +157,7 @@ const GroupByList: React.FC<IGroupByList> = (props) => {
{issueIds && ( {issueIds && (
<IssueBlocksList <IssueBlocksList
groupId={_list.id}
issueIds={is_list ? issueIds || 0 : issueIds?.[_list.id] || 0} issueIds={is_list ? issueIds || 0 : issueIds?.[_list.id] || 0}
issuesMap={issuesMap} issuesMap={issuesMap}
updateIssue={updateIssue} updateIssue={updateIssue}
@ -154,6 +165,7 @@ const GroupByList: React.FC<IGroupByList> = (props) => {
displayProperties={displayProperties} displayProperties={displayProperties}
canEditProperties={canEditProperties} canEditProperties={canEditProperties}
containerRef={containerRef} containerRef={containerRef}
selectionHelpers={helpers}
/> />
)} )}
@ -169,12 +181,18 @@ const GroupByList: React.FC<IGroupByList> = (props) => {
</div> </div>
) )
)} )}
<div className="sticky bottom-0 left-0 z-10 h-14"> {snapshot.isSelectionActive && (
<IssueBulkOperationsRoot /> <div className="sticky bottom-0 left-0 z-[2] h-14">
<IssueBulkOperationsRoot selectionHelpers={helpers} snapshot={snapshot} />
</div> </div>
)}
</>
)}
</BulkOperationsSelectGroup>
)}
</div> </div>
); );
}; });
export interface IList { export interface IList {
issueIds: TGroupedIssues | TUnGroupedIssues | any; issueIds: TGroupedIssues | TUnGroupedIssues | any;

View File

@ -1,40 +1,245 @@
import { useCallback, useEffect } from "react"; import { useCallback, useEffect, useState } from "react";
// hooks
import { useBulkIssueOperations } from "@/hooks/store";
export const useEntitySelection = () => { type Props = {
// store hooks groups: string[];
const { clearSelection, getIsIssueSelected, toggleIssueSelection } = useBulkIssueOperations(); };
const handleClick = useCallback( export type TSelectionSnapshot = {
(e: React.MouseEvent, issueId: string) => { isSelectionActive: boolean;
const isIssueSelected = getIsIssueSelected(issueId); selectedEntityIds: string[];
// const element = document.querySelector(`[data-element-id="${issueId}"]`); };
if (e.shiftKey) {
console.log("shift key"); export type TSelectionHelper = {
if (isIssueSelected) return; 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 // clear selection on escape key press
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { 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); window.addEventListener("keydown", handleKeyDown);
return () => { return () => {
window.removeEventListener("keydown", handleKeyDown); 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 { return {
handleClick, helpers,
snapshot,
}; };
}; };

View File

@ -1,5 +1,5 @@
import set from "lodash/set"; import set from "lodash/set";
import { action, computed, makeObservable, observable, runInAction } from "mobx"; import { action, makeObservable, runInAction } from "mobx";
// types // types
import { TBulkOperationsPayload } from "@plane/types"; import { TBulkOperationsPayload } from "@plane/types";
// services // services
@ -7,21 +7,11 @@ import { IssueService } from "@/services/issue";
import { IIssueRootStore } from "./root.store"; import { IIssueRootStore } from "./root.store";
export type IIssueBulkOperationsStore = { export type IIssueBulkOperationsStore = {
// observables
issueIds: string[];
// computed
isSelectionActive: boolean;
// helper functions
getIsIssueSelected: (issueId: string) => boolean;
// actions // actions
toggleIssueSelection: (issueId: string) => void;
clearSelection: () => void;
bulkUpdateProperties: (workspaceSlug: string, projectId: string, data: TBulkOperationsPayload) => void; bulkUpdateProperties: (workspaceSlug: string, projectId: string, data: TBulkOperationsPayload) => void;
}; };
export class IssueBulkOperationsStore implements IIssueBulkOperationsStore { export class IssueBulkOperationsStore implements IIssueBulkOperationsStore {
// observables
issueIds: string[] = [];
// root store // root store
rootIssueStore: IIssueRootStore; rootIssueStore: IIssueRootStore;
// service // service
@ -29,13 +19,7 @@ export class IssueBulkOperationsStore implements IIssueBulkOperationsStore {
constructor(_rootStore: IIssueRootStore) { constructor(_rootStore: IIssueRootStore) {
makeObservable(this, { makeObservable(this, {
// observable
issueIds: observable,
// computed
isSelectionActive: computed,
// actions // actions
toggleIssueSelection: action,
clearSelection: action,
bulkUpdateProperties: action, bulkUpdateProperties: action,
}); });
@ -43,34 +27,6 @@ export class IssueBulkOperationsStore implements IIssueBulkOperationsStore {
this.issueService = new IssueService(); 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 * @description bulk update properties of selected issues
* @param {TBulkOperationsPayload} data * @param {TBulkOperationsPayload} data

View File

@ -674,3 +674,31 @@ div.web-view-spinner div.bar12 {
.disable-autofill-style:-webkit-autofill:active { .disable-autofill-style:-webkit-autofill:active {
-webkit-background-clip: text; -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));
}
}