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
|
// 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(() =>
|
||||||
|
@ -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(() =>
|
||||||
|
@ -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";
|
||||||
|
@ -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}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
@ -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
|
<>
|
||||||
isOpen={isBulkArchiveModalOpen}
|
<BulkArchiveConfirmationModal
|
||||||
handleClose={() => setIsBulkArchiveModalOpen(false)}
|
isOpen={isBulkArchiveModalOpen}
|
||||||
issueIds={issueIds}
|
handleClose={() => setIsBulkArchiveModalOpen(false)}
|
||||||
projectId={projectId.toString()}
|
issueIds={selectedEntityIds}
|
||||||
workspaceSlug={workspaceSlug.toString()}
|
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 && (
|
<div
|
||||||
<BulkDeleteConfirmationModal
|
className={cn(
|
||||||
isOpen={isBulkDeleteModalOpen}
|
"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",
|
||||||
handleClose={() => setIsBulkDeleteModalOpen(false)}
|
className
|
||||||
issueIds={issueIds}
|
)}
|
||||||
projectId={projectId.toString()}
|
>
|
||||||
workspaceSlug={workspaceSlug.toString()}
|
<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">
|
||||||
<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">
|
{/* // TODO: add min width here */}
|
||||||
<div className="h-7 pr-3 text-sm flex items-center">2 selected</div>
|
<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>
|
||||||
</>
|
</>
|
||||||
|
@ -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";
|
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}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
|
@ -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">
|
||||||
|
@ -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}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
|
@ -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,55 +132,67 @@ 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)}>
|
||||||
(_list) =>
|
{(helpers, snapshot) => (
|
||||||
validateEmptyIssueGroups(is_list ? issueIds : issueIds?.[_list.id]) && (
|
<>
|
||||||
<div key={_list.id} className={`flex flex-shrink-0 flex-col`}>
|
{console.log("snapshot", snapshot.isSelectionActive)}
|
||||||
<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">
|
{groups.map(
|
||||||
<HeaderGroupByCard
|
(_list) =>
|
||||||
icon={_list.icon}
|
validateEmptyIssueGroups(is_list ? issueIds : issueIds?.[_list.id]) && (
|
||||||
title={_list.name || ""}
|
<div key={_list.id} className={`flex flex-shrink-0 flex-col`}>
|
||||||
count={is_list ? issueIds?.length || 0 : issueIds?.[_list.id]?.length || 0}
|
<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">
|
||||||
issuePayload={_list.payload}
|
<HeaderGroupByCard
|
||||||
disableIssueCreation={disableIssueCreation || isGroupByCreatedBy || isCompletedCycle}
|
icon={_list.icon}
|
||||||
storeType={storeType}
|
title={_list.name || ""}
|
||||||
addIssuesToView={addIssuesToView}
|
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>
|
</div>
|
||||||
|
)}
|
||||||
{issueIds && (
|
</>
|
||||||
<IssueBlocksList
|
)}
|
||||||
issueIds={is_list ? issueIds || 0 : issueIds?.[_list.id] || 0}
|
</BulkOperationsSelectGroup>
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
||||||
export interface IList {
|
export interface IList {
|
||||||
issueIds: TGroupedIssues | TUnGroupedIssues | any;
|
issueIds: TGroupedIssues | TUnGroupedIssues | any;
|
||||||
|
@ -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,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
@ -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
|
||||||
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user