mirror of
https://github.com/makeplane/plane
synced 2024-06-14 14:31:34 +00:00
chore: create hoc for multi-select groups
This commit is contained in:
parent
53b9f60b39
commit
836e186634
@ -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(() =>
|
||||
|
@ -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(() =>
|
||||
|
@ -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";
|
||||
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
@ -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}
|
||||
issueIds={selectedEntityIds}
|
||||
onSubmit={handleClearSelection}
|
||||
projectId={projectId.toString()}
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
/>
|
||||
)}
|
||||
{workspaceSlug && projectId && (
|
||||
<BulkDeleteConfirmationModal
|
||||
isOpen={isBulkDeleteModalOpen}
|
||||
handleClose={() => setIsBulkDeleteModalOpen(false)}
|
||||
issueIds={issueIds}
|
||||
issueIds={selectedEntityIds}
|
||||
onSubmit={handleClearSelection}
|
||||
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>
|
||||
</>
|
||||
|
@ -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)}</>;
|
||||
};
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
@ -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}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
|
@ -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">
|
||||
|
@ -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}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
|
@ -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,10 +132,14 @@ 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(
|
||||
{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`}>
|
||||
@ -147,6 +157,7 @@ const GroupByList: React.FC<IGroupByList> = (props) => {
|
||||
|
||||
{issueIds && (
|
||||
<IssueBlocksList
|
||||
groupId={_list.id}
|
||||
issueIds={is_list ? issueIds || 0 : issueIds?.[_list.id] || 0}
|
||||
issuesMap={issuesMap}
|
||||
updateIssue={updateIssue}
|
||||
@ -154,6 +165,7 @@ const GroupByList: React.FC<IGroupByList> = (props) => {
|
||||
displayProperties={displayProperties}
|
||||
canEditProperties={canEditProperties}
|
||||
containerRef={containerRef}
|
||||
selectionHelpers={helpers}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -169,12 +181,18 @@ const GroupByList: React.FC<IGroupByList> = (props) => {
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
<div className="sticky bottom-0 left-0 z-10 h-14">
|
||||
<IssueBulkOperationsRoot />
|
||||
{snapshot.isSelectionActive && (
|
||||
<div className="sticky bottom-0 left-0 z-[2] h-14">
|
||||
<IssueBulkOperationsRoot selectionHelpers={helpers} snapshot={snapshot} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</BulkOperationsSelectGroup>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
export interface IList {
|
||||
issueIds: TGroupedIssues | TUnGroupedIssues | any;
|
||||
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
@ -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
|
||||
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user