[WEB-1501] dev: multiple select core components (#4667)

* dev: multiple select core components

* chore: added export statement
This commit is contained in:
Aaryan Khandelwal 2024-05-31 17:37:24 +05:30 committed by GitHub
parent c8c86a33f8
commit 98ebe88c86
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 691 additions and 0 deletions

View File

@ -1,5 +1,6 @@
export * from "./filters";
export * from "./modals";
export * from "./multiple-select";
export * from "./sidebar";
export * from "./activity";
export * from "./favorite-star";

View 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
/>
);
};

View 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}
/>
);
};

View File

@ -0,0 +1,3 @@
export * from "./entity-select-action";
export * from "./group-select-action";
export * from "./select-group";

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

View File

@ -9,6 +9,7 @@ export * from "./use-label";
export * from "./use-member";
export * from "./use-mention";
export * from "./use-module";
export * from "./use-multiple-select-store";
export * from "./pages/use-project-page";
export * from "./pages/use-page";

View 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;
};

View 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;
};

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

View File

@ -14,6 +14,7 @@ import { ILabelStore, LabelStore } from "./label.store";
import { IMemberRootStore, MemberRootStore } from "./member";
import { IModuleStore, ModulesStore } from "./module.store";
import { IModuleFilterStore, ModuleFilterStore } from "./module_filter.store";
import { IMultipleSelectStore, MultipleSelectStore } from "./multiple_select.store";
import { IProjectPageStore, ProjectPageStore } from "./pages/project-page.store";
import { IProjectRootStore, ProjectRootStore } from "./project";
import { IProjectViewStore, ProjectViewStore } from "./project-view.store";
@ -48,6 +49,7 @@ export class RootStore {
instance: IInstanceStore;
user: IUserStore;
projectInbox: IProjectInboxStore;
multipleSelect: IMultipleSelectStore;
constructor() {
this.router = new RouterStore();
@ -70,6 +72,7 @@ export class RootStore {
this.theme = new ThemeStore(this);
this.eventTracker = new EventTrackerStore(this);
this.instance = new InstanceStore();
this.multipleSelect = new MultipleSelectStore();
// inbox
this.projectInbox = new ProjectInboxStore(this);
this.projectPages = new ProjectPageStore(this);
@ -101,5 +104,6 @@ export class RootStore {
this.user = new UserStore(this);
this.projectInbox = new ProjectInboxStore(this);
this.projectPages = new ProjectPageStore(this);
this.multipleSelect = new MultipleSelectStore();
}
}