forked from github/plane
[WEB-1501] dev: multiple select core components (#4667)
* dev: multiple select core components * chore: added export statement
This commit is contained in:
parent
c8c86a33f8
commit
98ebe88c86
@ -1,5 +1,6 @@
|
|||||||
export * from "./filters";
|
export * from "./filters";
|
||||||
export * from "./modals";
|
export * from "./modals";
|
||||||
|
export * from "./multiple-select";
|
||||||
export * from "./sidebar";
|
export * from "./sidebar";
|
||||||
export * from "./activity";
|
export * from "./activity";
|
||||||
export * from "./favorite-star";
|
export * from "./favorite-star";
|
||||||
|
36
web/components/core/multiple-select/entity-select-action.tsx
Normal file
36
web/components/core/multiple-select/entity-select-action.tsx
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
// ui
|
||||||
|
import { Checkbox } from "@plane/ui";
|
||||||
|
// helpers
|
||||||
|
import { cn } from "@/helpers/common.helper";
|
||||||
|
// hooks
|
||||||
|
import { TSelectionHelper } from "@/hooks/use-multiple-select";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
className?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
groupId: string;
|
||||||
|
id: string;
|
||||||
|
selectionHelpers: TSelectionHelper;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MultipleSelectEntityAction: React.FC<Props> = (props) => {
|
||||||
|
const { className, disabled = false, groupId, id, selectionHelpers } = props;
|
||||||
|
// derived values
|
||||||
|
const isSelected = selectionHelpers.getIsEntitySelected(id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Checkbox
|
||||||
|
className={cn("!outline-none size-3.5", className)}
|
||||||
|
iconClassName="size-3"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
selectionHelpers.handleEntityClick(e, id, groupId);
|
||||||
|
}}
|
||||||
|
checked={isSelected}
|
||||||
|
data-entity-group-id={groupId}
|
||||||
|
data-entity-id={id}
|
||||||
|
disabled={disabled}
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
30
web/components/core/multiple-select/group-select-action.tsx
Normal file
30
web/components/core/multiple-select/group-select-action.tsx
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
// ui
|
||||||
|
import { Checkbox } from "@plane/ui";
|
||||||
|
// helpers
|
||||||
|
import { cn } from "@/helpers/common.helper";
|
||||||
|
// hooks
|
||||||
|
import { TSelectionHelper } from "@/hooks/use-multiple-select";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
className?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
groupID: string;
|
||||||
|
selectionHelpers: TSelectionHelper;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MultipleSelectGroupAction: React.FC<Props> = (props) => {
|
||||||
|
const { className, disabled = false, groupID, selectionHelpers } = props;
|
||||||
|
// derived values
|
||||||
|
const groupSelectionStatus = selectionHelpers.isGroupSelected(groupID);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Checkbox
|
||||||
|
className={cn("size-3.5 !outline-none", className)}
|
||||||
|
iconClassName="size-3"
|
||||||
|
onClick={() => selectionHelpers.handleGroupClick(groupID)}
|
||||||
|
checked={groupSelectionStatus === "complete"}
|
||||||
|
indeterminate={groupSelectionStatus === "partial"}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
3
web/components/core/multiple-select/index.ts
Normal file
3
web/components/core/multiple-select/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export * from "./entity-select-action";
|
||||||
|
export * from "./group-select-action";
|
||||||
|
export * from "./select-group";
|
22
web/components/core/multiple-select/select-group.tsx
Normal file
22
web/components/core/multiple-select/select-group.tsx
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { observer } from "mobx-react";
|
||||||
|
// hooks
|
||||||
|
import { TSelectionHelper, useMultipleSelect } from "@/hooks/use-multiple-select";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
children: (helpers: TSelectionHelper) => React.ReactNode;
|
||||||
|
containerRef: React.MutableRefObject<HTMLElement | null>;
|
||||||
|
entities: Record<string, string[]>; // { groupID: entityIds[] }
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MultipleSelectGroup: React.FC<Props> = observer((props) => {
|
||||||
|
const { children, containerRef, entities } = props;
|
||||||
|
|
||||||
|
const helpers = useMultipleSelect({
|
||||||
|
containerRef,
|
||||||
|
entities,
|
||||||
|
});
|
||||||
|
|
||||||
|
return <>{children(helpers)}</>;
|
||||||
|
});
|
||||||
|
|
||||||
|
MultipleSelectGroup.displayName = "MultipleSelectGroup";
|
@ -9,6 +9,7 @@ export * from "./use-label";
|
|||||||
export * from "./use-member";
|
export * from "./use-member";
|
||||||
export * from "./use-mention";
|
export * from "./use-mention";
|
||||||
export * from "./use-module";
|
export * from "./use-module";
|
||||||
|
export * from "./use-multiple-select-store";
|
||||||
|
|
||||||
export * from "./pages/use-project-page";
|
export * from "./pages/use-project-page";
|
||||||
export * from "./pages/use-page";
|
export * from "./pages/use-page";
|
||||||
|
9
web/hooks/store/use-multiple-select-store.ts
Normal file
9
web/hooks/store/use-multiple-select-store.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { useContext } from "react";
|
||||||
|
// store
|
||||||
|
import { StoreContext } from "@/lib/store-context";
|
||||||
|
|
||||||
|
export const useMultipleSelectStore = () => {
|
||||||
|
const context = useContext(StoreContext);
|
||||||
|
if (context === undefined) throw new Error("useMultipleSelectStore must be used within StoreProvider");
|
||||||
|
return context.multipleSelect;
|
||||||
|
};
|
365
web/hooks/use-multiple-select.ts
Normal file
365
web/hooks/use-multiple-select.ts
Normal file
@ -0,0 +1,365 @@
|
|||||||
|
import { useCallback, useEffect, useMemo } from "react";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
// hooks
|
||||||
|
import { useMultipleSelectStore } from "@/hooks/store";
|
||||||
|
|
||||||
|
export type TEntityDetails = {
|
||||||
|
entityID: string;
|
||||||
|
groupID: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
containerRef: React.MutableRefObject<HTMLElement | null>;
|
||||||
|
entities: Record<string, string[]>; // { groupID: entityIds[] }
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TSelectionSnapshot = {
|
||||||
|
isSelectionActive: boolean;
|
||||||
|
selectedEntityIds: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TSelectionHelper = {
|
||||||
|
handleClearSelection: () => void;
|
||||||
|
handleEntityClick: (event: React.MouseEvent, entityID: string, groupId: string) => void;
|
||||||
|
getIsEntitySelected: (entityID: string) => boolean;
|
||||||
|
getIsEntityActive: (entityID: string) => boolean;
|
||||||
|
handleGroupClick: (groupID: string) => void;
|
||||||
|
isGroupSelected: (groupID: string) => "empty" | "partial" | "complete";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useMultipleSelect = (props: Props) => {
|
||||||
|
const { containerRef, entities } = props;
|
||||||
|
// router
|
||||||
|
const router = useRouter();
|
||||||
|
// store hooks
|
||||||
|
const {
|
||||||
|
updateSelectedEntityDetails,
|
||||||
|
bulkUpdateSelectedEntityDetails,
|
||||||
|
getActiveEntityDetails,
|
||||||
|
updateActiveEntityDetails,
|
||||||
|
getPreviousActiveEntity,
|
||||||
|
updatePreviousActiveEntity,
|
||||||
|
getNextActiveEntity,
|
||||||
|
updateNextActiveEntity,
|
||||||
|
getLastSelectedEntityDetails,
|
||||||
|
clearSelection,
|
||||||
|
getIsEntitySelected,
|
||||||
|
getIsEntityActive,
|
||||||
|
} = useMultipleSelectStore();
|
||||||
|
|
||||||
|
const groups = useMemo(() => Object.keys(entities), [entities]);
|
||||||
|
|
||||||
|
const entitiesList: TEntityDetails[] = useMemo(
|
||||||
|
() =>
|
||||||
|
groups
|
||||||
|
.map((groupID) =>
|
||||||
|
entities[groupID].map((entityID) => ({
|
||||||
|
entityID,
|
||||||
|
groupID,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
.flat(1),
|
||||||
|
[entities, groups]
|
||||||
|
);
|
||||||
|
|
||||||
|
const getPreviousAndNextEntities = useCallback(
|
||||||
|
(entityID: string) => {
|
||||||
|
const currentEntityIndex = entitiesList.findIndex((entity) => entity?.entityID === entityID);
|
||||||
|
|
||||||
|
// entity position
|
||||||
|
const isFirstEntity = currentEntityIndex === 0;
|
||||||
|
const isLastEntity = currentEntityIndex === entitiesList.length - 1;
|
||||||
|
|
||||||
|
let previousEntity: TEntityDetails | null = null;
|
||||||
|
let nextEntity: TEntityDetails | null = null;
|
||||||
|
|
||||||
|
if (isLastEntity) {
|
||||||
|
nextEntity = null;
|
||||||
|
} else {
|
||||||
|
nextEntity = entitiesList[currentEntityIndex + 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isFirstEntity) {
|
||||||
|
previousEntity = null;
|
||||||
|
} else {
|
||||||
|
previousEntity = entitiesList[currentEntityIndex - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
previousEntity,
|
||||||
|
nextEntity,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[entitiesList]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleActiveEntityChange = useCallback(
|
||||||
|
(entityDetails: TEntityDetails | null, shouldScroll: boolean = true) => {
|
||||||
|
if (!entityDetails) {
|
||||||
|
updateActiveEntityDetails(null);
|
||||||
|
updatePreviousActiveEntity(null);
|
||||||
|
updateNextActiveEntity(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateActiveEntityDetails(entityDetails);
|
||||||
|
|
||||||
|
// scroll to get the active element in view
|
||||||
|
const activeElement = document.querySelector(
|
||||||
|
`[data-entity-id="${entityDetails.entityID}"][data-entity-group-id="${entityDetails.groupID}"]`
|
||||||
|
);
|
||||||
|
if (activeElement && containerRef.current && shouldScroll) {
|
||||||
|
const SCROLL_OFFSET = 200;
|
||||||
|
const containerRect = containerRef.current.getBoundingClientRect();
|
||||||
|
const elementRect = activeElement.getBoundingClientRect();
|
||||||
|
|
||||||
|
const isInView =
|
||||||
|
elementRect.top >= containerRect.top + SCROLL_OFFSET &&
|
||||||
|
elementRect.bottom <= containerRect.bottom - SCROLL_OFFSET;
|
||||||
|
|
||||||
|
if (!isInView) {
|
||||||
|
containerRef.current.scrollBy({
|
||||||
|
top: elementRect.top < containerRect.top + SCROLL_OFFSET ? -50 : 50,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { previousEntity: previousActiveEntity, nextEntity: nextActiveEntity } = getPreviousAndNextEntities(
|
||||||
|
entityDetails.entityID
|
||||||
|
);
|
||||||
|
updatePreviousActiveEntity(previousActiveEntity);
|
||||||
|
updateNextActiveEntity(nextActiveEntity);
|
||||||
|
},
|
||||||
|
[
|
||||||
|
containerRef,
|
||||||
|
getPreviousAndNextEntities,
|
||||||
|
updateActiveEntityDetails,
|
||||||
|
updateNextActiveEntity,
|
||||||
|
updatePreviousActiveEntity,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleEntitySelection = useCallback(
|
||||||
|
(
|
||||||
|
entityDetails: TEntityDetails | TEntityDetails[],
|
||||||
|
shouldScroll: boolean = true,
|
||||||
|
forceAction: "force-add" | "force-remove" | null = null
|
||||||
|
) => {
|
||||||
|
if (Array.isArray(entityDetails)) {
|
||||||
|
bulkUpdateSelectedEntityDetails(entityDetails, forceAction === "force-add" ? "add" : "remove");
|
||||||
|
if (forceAction === "force-add" && entityDetails.length > 0) {
|
||||||
|
handleActiveEntityChange(entityDetails[entityDetails.length - 1], shouldScroll);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (forceAction) {
|
||||||
|
if (forceAction === "force-add") {
|
||||||
|
console.log("force adding");
|
||||||
|
updateSelectedEntityDetails(entityDetails, "add");
|
||||||
|
handleActiveEntityChange(entityDetails, shouldScroll);
|
||||||
|
}
|
||||||
|
if (forceAction === "force-remove") {
|
||||||
|
updateSelectedEntityDetails(entityDetails, "remove");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSelected = getIsEntitySelected(entityDetails.entityID);
|
||||||
|
if (isSelected) {
|
||||||
|
updateSelectedEntityDetails(entityDetails, "remove");
|
||||||
|
handleActiveEntityChange(entityDetails, shouldScroll);
|
||||||
|
} else {
|
||||||
|
updateSelectedEntityDetails(entityDetails, "add");
|
||||||
|
handleActiveEntityChange(entityDetails, shouldScroll);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[bulkUpdateSelectedEntityDetails, getIsEntitySelected, handleActiveEntityChange, updateSelectedEntityDetails]
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description toggle entity selection
|
||||||
|
* @param {React.MouseEvent} event
|
||||||
|
* @param {string} entityID
|
||||||
|
* @param {string} groupID
|
||||||
|
*/
|
||||||
|
const handleEntityClick = useCallback(
|
||||||
|
(e: React.MouseEvent, entityID: string, groupID: string) => {
|
||||||
|
const lastSelectedEntityDetails = getLastSelectedEntityDetails();
|
||||||
|
if (e.shiftKey && lastSelectedEntityDetails) {
|
||||||
|
const currentEntityIndex = entitiesList.findIndex((entity) => entity?.entityID === entityID);
|
||||||
|
|
||||||
|
const lastEntityIndex = entitiesList.findIndex(
|
||||||
|
(entity) => entity?.entityID === lastSelectedEntityDetails.entityID
|
||||||
|
);
|
||||||
|
if (lastEntityIndex < currentEntityIndex) {
|
||||||
|
for (let i = lastEntityIndex + 1; i <= currentEntityIndex; i++) {
|
||||||
|
const entityDetails = entitiesList[i];
|
||||||
|
if (entityDetails) {
|
||||||
|
handleEntitySelection(entityDetails, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (lastEntityIndex > currentEntityIndex) {
|
||||||
|
for (let i = currentEntityIndex; i <= lastEntityIndex - 1; i++) {
|
||||||
|
const entityDetails = entitiesList[i];
|
||||||
|
if (entityDetails) {
|
||||||
|
handleEntitySelection(entityDetails, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const startIndex = lastEntityIndex + 1;
|
||||||
|
const endIndex = currentEntityIndex;
|
||||||
|
for (let i = startIndex; i <= endIndex; i++) {
|
||||||
|
const entityDetails = entitiesList[i];
|
||||||
|
if (entityDetails) {
|
||||||
|
handleEntitySelection(entityDetails, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleEntitySelection({ entityID, groupID }, false);
|
||||||
|
},
|
||||||
|
[entitiesList, handleEntitySelection, getLastSelectedEntityDetails]
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description check if any entity of the group is selected
|
||||||
|
* @param {string} groupID
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
const isGroupSelected = useCallback(
|
||||||
|
(groupID: string) => {
|
||||||
|
const groupEntities = entitiesList.filter((entity) => entity.groupID === groupID);
|
||||||
|
const totalSelected = groupEntities.filter((entity) => getIsEntitySelected(entity.entityID ?? "")).length;
|
||||||
|
if (totalSelected === 0) return "empty";
|
||||||
|
if (totalSelected === groupEntities.length) return "complete";
|
||||||
|
return "partial";
|
||||||
|
},
|
||||||
|
[entitiesList, getIsEntitySelected]
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description toggle group selection
|
||||||
|
* @param {string} groupID
|
||||||
|
*/
|
||||||
|
const handleGroupClick = useCallback(
|
||||||
|
(groupID: string) => {
|
||||||
|
const groupEntities = entitiesList.filter((entity) => entity.groupID === groupID);
|
||||||
|
const groupSelectionStatus = isGroupSelected(groupID);
|
||||||
|
// groupEntities.map((entity) => {
|
||||||
|
// console.log("group click");
|
||||||
|
// handleEntitySelection(entity, false, groupSelectionStatus === "empty" ? "force-add" : "force-remove");
|
||||||
|
// });
|
||||||
|
handleEntitySelection(groupEntities, false, groupSelectionStatus === "empty" ? "force-add" : "force-remove");
|
||||||
|
},
|
||||||
|
[entitiesList, handleEntitySelection, isGroupSelected]
|
||||||
|
);
|
||||||
|
|
||||||
|
// clear selection on escape key press
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") clearSelection();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [clearSelection]);
|
||||||
|
|
||||||
|
// select entities on shift + arrow up/down key press
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (!e.shiftKey) return;
|
||||||
|
|
||||||
|
const activeEntityDetails = getActiveEntityDetails();
|
||||||
|
const nextActiveEntity = getNextActiveEntity();
|
||||||
|
const previousActiveEntity = getPreviousActiveEntity();
|
||||||
|
|
||||||
|
if (e.key === "ArrowDown" && activeEntityDetails) {
|
||||||
|
if (!nextActiveEntity) return;
|
||||||
|
handleEntitySelection(nextActiveEntity);
|
||||||
|
}
|
||||||
|
if (e.key === "ArrowUp" && activeEntityDetails) {
|
||||||
|
if (!previousActiveEntity) return;
|
||||||
|
handleEntitySelection(previousActiveEntity);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
getActiveEntityDetails,
|
||||||
|
handleEntitySelection,
|
||||||
|
getLastSelectedEntityDetails,
|
||||||
|
getNextActiveEntity,
|
||||||
|
getPreviousActiveEntity,
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.shiftKey) return;
|
||||||
|
const activeEntityDetails = getActiveEntityDetails();
|
||||||
|
// set active entity id to the first entity
|
||||||
|
if (["ArrowUp", "ArrowDown"].includes(e.key) && !activeEntityDetails) {
|
||||||
|
const firstElementDetails = entitiesList[0];
|
||||||
|
if (!firstElementDetails) return;
|
||||||
|
handleActiveEntityChange(firstElementDetails);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === "ArrowDown" && activeEntityDetails) {
|
||||||
|
if (!activeEntityDetails) return;
|
||||||
|
const { nextEntity: nextActiveEntity } = getPreviousAndNextEntities(activeEntityDetails.entityID);
|
||||||
|
if (nextActiveEntity) {
|
||||||
|
handleActiveEntityChange(nextActiveEntity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === "ArrowUp" && activeEntityDetails) {
|
||||||
|
if (!activeEntityDetails) return;
|
||||||
|
const { previousEntity: previousActiveEntity } = getPreviousAndNextEntities(activeEntityDetails.entityID);
|
||||||
|
if (previousActiveEntity) {
|
||||||
|
handleActiveEntityChange(previousActiveEntity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [getActiveEntityDetails, entitiesList, groups, getPreviousAndNextEntities, handleActiveEntityChange]);
|
||||||
|
|
||||||
|
// clear selection on route change
|
||||||
|
useEffect(() => {
|
||||||
|
const handleRouteChange = () => clearSelection();
|
||||||
|
|
||||||
|
router.events.on("routeChangeComplete", handleRouteChange);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
router.events.off("routeChangeComplete", handleRouteChange);
|
||||||
|
};
|
||||||
|
}, [clearSelection, router.events]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description helper functions for selection
|
||||||
|
*/
|
||||||
|
const helpers: TSelectionHelper = useMemo(
|
||||||
|
() => ({
|
||||||
|
handleClearSelection: clearSelection,
|
||||||
|
handleEntityClick,
|
||||||
|
getIsEntitySelected,
|
||||||
|
getIsEntityActive,
|
||||||
|
handleGroupClick,
|
||||||
|
isGroupSelected,
|
||||||
|
}),
|
||||||
|
[clearSelection, getIsEntityActive, getIsEntitySelected, handleEntityClick, handleGroupClick, isGroupSelected]
|
||||||
|
);
|
||||||
|
|
||||||
|
return helpers;
|
||||||
|
};
|
220
web/store/multiple_select.store.ts
Normal file
220
web/store/multiple_select.store.ts
Normal file
@ -0,0 +1,220 @@
|
|||||||
|
import differenceWith from "lodash/differenceWith";
|
||||||
|
import isEqual from "lodash/isEqual";
|
||||||
|
import remove from "lodash/remove";
|
||||||
|
import { action, computed, makeObservable, observable, runInAction } from "mobx";
|
||||||
|
import { computedFn } from "mobx-utils";
|
||||||
|
// hooks
|
||||||
|
import { TEntityDetails } from "@/hooks/use-multiple-select";
|
||||||
|
// services
|
||||||
|
import { IssueService } from "@/services/issue";
|
||||||
|
|
||||||
|
export type IMultipleSelectStore = {
|
||||||
|
// computed functions
|
||||||
|
isSelectionActive: boolean;
|
||||||
|
selectedEntityIds: string[];
|
||||||
|
// helper actions
|
||||||
|
getIsEntitySelected: (entityID: string) => boolean;
|
||||||
|
getIsEntityActive: (entityID: string) => boolean;
|
||||||
|
getLastSelectedEntityDetails: () => TEntityDetails | null;
|
||||||
|
getPreviousActiveEntity: () => TEntityDetails | null;
|
||||||
|
getNextActiveEntity: () => TEntityDetails | null;
|
||||||
|
getActiveEntityDetails: () => TEntityDetails | null;
|
||||||
|
// entity actions
|
||||||
|
updateSelectedEntityDetails: (entityDetails: TEntityDetails, action: "add" | "remove") => void;
|
||||||
|
bulkUpdateSelectedEntityDetails: (entitiesList: TEntityDetails[], action: "add" | "remove") => void;
|
||||||
|
updateLastSelectedEntityDetails: (entityDetails: TEntityDetails | null) => void;
|
||||||
|
updatePreviousActiveEntity: (entityDetails: TEntityDetails | null) => void;
|
||||||
|
updateNextActiveEntity: (entityDetails: TEntityDetails | null) => void;
|
||||||
|
updateActiveEntityDetails: (entityDetails: TEntityDetails | null) => void;
|
||||||
|
clearSelection: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description the MultipleSelectStore manages multiple selection states by keeping track of the selected entities and providing a bunch of helper functions and actions to maintain the selected states
|
||||||
|
* @description use the useMultipleSelectStore custom hook to access the observables
|
||||||
|
* @description use the useMultipleSelect custom hook for added functionality on top of the store, including-
|
||||||
|
* 1. Keyboard and mouse interaction
|
||||||
|
* 2. Clear state on route change
|
||||||
|
*/
|
||||||
|
export class MultipleSelectStore implements IMultipleSelectStore {
|
||||||
|
// observables
|
||||||
|
selectedEntityDetails: TEntityDetails[] = [];
|
||||||
|
lastSelectedEntityDetails: TEntityDetails | null = null;
|
||||||
|
previousActiveEntity: TEntityDetails | null = null;
|
||||||
|
nextActiveEntity: TEntityDetails | null = null;
|
||||||
|
activeEntityDetails: TEntityDetails | null = null;
|
||||||
|
// service
|
||||||
|
issueService;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
makeObservable(this, {
|
||||||
|
// observables
|
||||||
|
selectedEntityDetails: observable,
|
||||||
|
lastSelectedEntityDetails: observable,
|
||||||
|
previousActiveEntity: observable,
|
||||||
|
nextActiveEntity: observable,
|
||||||
|
activeEntityDetails: observable,
|
||||||
|
// computed functions
|
||||||
|
isSelectionActive: computed,
|
||||||
|
selectedEntityIds: computed,
|
||||||
|
// actions
|
||||||
|
updateSelectedEntityDetails: action,
|
||||||
|
bulkUpdateSelectedEntityDetails: action,
|
||||||
|
updateLastSelectedEntityDetails: action,
|
||||||
|
updatePreviousActiveEntity: action,
|
||||||
|
updateNextActiveEntity: action,
|
||||||
|
updateActiveEntityDetails: action,
|
||||||
|
clearSelection: action,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.issueService = new IssueService();
|
||||||
|
}
|
||||||
|
|
||||||
|
get isSelectionActive() {
|
||||||
|
return this.selectedEntityDetails.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
get selectedEntityIds() {
|
||||||
|
return this.selectedEntityDetails.map((en) => en.entityID);
|
||||||
|
}
|
||||||
|
|
||||||
|
// helper actions
|
||||||
|
/**
|
||||||
|
* @description returns if the entity is selected or not
|
||||||
|
* @param {string} entityID
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
getIsEntitySelected = computedFn((entityID: string): boolean =>
|
||||||
|
this.selectedEntityDetails.some((en) => en.entityID === entityID)
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description returns if the entity is active or not
|
||||||
|
* @param {string} entityID
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
getIsEntityActive = computedFn((entityID: string): boolean => this.activeEntityDetails?.entityID === entityID);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description get the last selected entity details
|
||||||
|
* @returns {TEntityDetails}
|
||||||
|
*/
|
||||||
|
getLastSelectedEntityDetails = computedFn(() => this.lastSelectedEntityDetails);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description get the details of the entity preceding the active entity
|
||||||
|
* @returns {TEntityDetails}
|
||||||
|
*/
|
||||||
|
getPreviousActiveEntity = computedFn(() => this.previousActiveEntity);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description get the details of the entity succeeding the active entity
|
||||||
|
* @returns {TEntityDetails}
|
||||||
|
*/
|
||||||
|
getNextActiveEntity = computedFn(() => this.nextActiveEntity);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description get the active entity details
|
||||||
|
* @returns {TEntityDetails}
|
||||||
|
*/
|
||||||
|
getActiveEntityDetails = computedFn(() => this.activeEntityDetails);
|
||||||
|
|
||||||
|
// entity actions
|
||||||
|
/**
|
||||||
|
* @description add or remove entities
|
||||||
|
* @param {TEntityDetails} entityDetails
|
||||||
|
* @param {"add" | "remove"} action
|
||||||
|
*/
|
||||||
|
updateSelectedEntityDetails = (entityDetails: TEntityDetails, action: "add" | "remove") => {
|
||||||
|
if (action === "add") {
|
||||||
|
runInAction(() => {
|
||||||
|
if (this.getIsEntitySelected(entityDetails.entityID)) {
|
||||||
|
remove(this.selectedEntityDetails, (en) => en.entityID === entityDetails.entityID);
|
||||||
|
}
|
||||||
|
this.selectedEntityDetails.push(entityDetails);
|
||||||
|
this.updateLastSelectedEntityDetails(entityDetails);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
let currentSelection = [...this.selectedEntityDetails];
|
||||||
|
currentSelection = currentSelection.filter((en) => en.entityID !== entityDetails.entityID);
|
||||||
|
runInAction(() => {
|
||||||
|
remove(this.selectedEntityDetails, (en) => en.entityID === entityDetails.entityID);
|
||||||
|
this.updateLastSelectedEntityDetails(currentSelection[currentSelection.length - 1] ?? null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description add or remove multiple entities
|
||||||
|
* @param {TEntityDetails[]} entitiesList
|
||||||
|
* @param {"add" | "remove"} action
|
||||||
|
*/
|
||||||
|
bulkUpdateSelectedEntityDetails = (entitiesList: TEntityDetails[], action: "add" | "remove") => {
|
||||||
|
if (action === "add") {
|
||||||
|
runInAction(() => {
|
||||||
|
let newEntities: TEntityDetails[] = [];
|
||||||
|
newEntities = differenceWith(this.selectedEntityDetails, entitiesList, isEqual);
|
||||||
|
newEntities = newEntities.concat(entitiesList);
|
||||||
|
this.selectedEntityDetails = newEntities;
|
||||||
|
if (entitiesList.length > 0) this.updateLastSelectedEntityDetails(entitiesList[entitiesList.length - 1]);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
runInAction(() => {
|
||||||
|
this.selectedEntityDetails = differenceWith(this.selectedEntityDetails, entitiesList, isEqual);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description update last selected entity
|
||||||
|
* @param {TEntityDetails} entityDetails
|
||||||
|
*/
|
||||||
|
updateLastSelectedEntityDetails = (entityDetails: TEntityDetails | null) => {
|
||||||
|
runInAction(() => {
|
||||||
|
this.lastSelectedEntityDetails = entityDetails;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description update previous active entity
|
||||||
|
* @param {TEntityDetails} entityDetails
|
||||||
|
*/
|
||||||
|
updatePreviousActiveEntity = (entityDetails: TEntityDetails | null) => {
|
||||||
|
runInAction(() => {
|
||||||
|
this.previousActiveEntity = entityDetails;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description update next active entity
|
||||||
|
* @param {TEntityDetails} entityDetails
|
||||||
|
*/
|
||||||
|
updateNextActiveEntity = (entityDetails: TEntityDetails | null) => {
|
||||||
|
runInAction(() => {
|
||||||
|
this.nextActiveEntity = entityDetails;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description update active entity
|
||||||
|
* @param {TEntityDetails} entityDetails
|
||||||
|
*/
|
||||||
|
updateActiveEntityDetails = (entityDetails: TEntityDetails | null) => {
|
||||||
|
runInAction(() => {
|
||||||
|
this.activeEntityDetails = entityDetails;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description clear selection and reset all the observables
|
||||||
|
*/
|
||||||
|
clearSelection = () => {
|
||||||
|
runInAction(() => {
|
||||||
|
this.selectedEntityDetails = [];
|
||||||
|
this.lastSelectedEntityDetails = null;
|
||||||
|
this.previousActiveEntity = null;
|
||||||
|
this.nextActiveEntity = null;
|
||||||
|
this.activeEntityDetails = null;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
@ -14,6 +14,7 @@ import { ILabelStore, LabelStore } from "./label.store";
|
|||||||
import { IMemberRootStore, MemberRootStore } from "./member";
|
import { IMemberRootStore, MemberRootStore } from "./member";
|
||||||
import { IModuleStore, ModulesStore } from "./module.store";
|
import { IModuleStore, ModulesStore } from "./module.store";
|
||||||
import { IModuleFilterStore, ModuleFilterStore } from "./module_filter.store";
|
import { IModuleFilterStore, ModuleFilterStore } from "./module_filter.store";
|
||||||
|
import { IMultipleSelectStore, MultipleSelectStore } from "./multiple_select.store";
|
||||||
import { IProjectPageStore, ProjectPageStore } from "./pages/project-page.store";
|
import { IProjectPageStore, ProjectPageStore } from "./pages/project-page.store";
|
||||||
import { IProjectRootStore, ProjectRootStore } from "./project";
|
import { IProjectRootStore, ProjectRootStore } from "./project";
|
||||||
import { IProjectViewStore, ProjectViewStore } from "./project-view.store";
|
import { IProjectViewStore, ProjectViewStore } from "./project-view.store";
|
||||||
@ -48,6 +49,7 @@ export class RootStore {
|
|||||||
instance: IInstanceStore;
|
instance: IInstanceStore;
|
||||||
user: IUserStore;
|
user: IUserStore;
|
||||||
projectInbox: IProjectInboxStore;
|
projectInbox: IProjectInboxStore;
|
||||||
|
multipleSelect: IMultipleSelectStore;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.router = new RouterStore();
|
this.router = new RouterStore();
|
||||||
@ -70,6 +72,7 @@ export class RootStore {
|
|||||||
this.theme = new ThemeStore(this);
|
this.theme = new ThemeStore(this);
|
||||||
this.eventTracker = new EventTrackerStore(this);
|
this.eventTracker = new EventTrackerStore(this);
|
||||||
this.instance = new InstanceStore();
|
this.instance = new InstanceStore();
|
||||||
|
this.multipleSelect = new MultipleSelectStore();
|
||||||
// inbox
|
// inbox
|
||||||
this.projectInbox = new ProjectInboxStore(this);
|
this.projectInbox = new ProjectInboxStore(this);
|
||||||
this.projectPages = new ProjectPageStore(this);
|
this.projectPages = new ProjectPageStore(this);
|
||||||
@ -101,5 +104,6 @@ export class RootStore {
|
|||||||
this.user = new UserStore(this);
|
this.user = new UserStore(this);
|
||||||
this.projectInbox = new ProjectInboxStore(this);
|
this.projectInbox = new ProjectInboxStore(this);
|
||||||
this.projectPages = new ProjectPageStore(this);
|
this.projectPages = new ProjectPageStore(this);
|
||||||
|
this.multipleSelect = new MultipleSelectStore();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user