diff --git a/web/components/core/index.ts b/web/components/core/index.ts index 3f753e025..81649c648 100644 --- a/web/components/core/index.ts +++ b/web/components/core/index.ts @@ -1,5 +1,6 @@ export * from "./filters"; export * from "./modals"; +export * from "./multiple-select"; export * from "./sidebar"; export * from "./activity"; export * from "./favorite-star"; diff --git a/web/components/core/multiple-select/entity-select-action.tsx b/web/components/core/multiple-select/entity-select-action.tsx new file mode 100644 index 000000000..7b9ca9409 --- /dev/null +++ b/web/components/core/multiple-select/entity-select-action.tsx @@ -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) => { + const { className, disabled = false, groupId, id, selectionHelpers } = props; + // derived values + const isSelected = selectionHelpers.getIsEntitySelected(id); + + return ( + { + e.stopPropagation(); + selectionHelpers.handleEntityClick(e, id, groupId); + }} + checked={isSelected} + data-entity-group-id={groupId} + data-entity-id={id} + disabled={disabled} + readOnly + /> + ); +}; diff --git a/web/components/core/multiple-select/group-select-action.tsx b/web/components/core/multiple-select/group-select-action.tsx new file mode 100644 index 000000000..ae2532153 --- /dev/null +++ b/web/components/core/multiple-select/group-select-action.tsx @@ -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) => { + const { className, disabled = false, groupID, selectionHelpers } = props; + // derived values + const groupSelectionStatus = selectionHelpers.isGroupSelected(groupID); + + return ( + selectionHelpers.handleGroupClick(groupID)} + checked={groupSelectionStatus === "complete"} + indeterminate={groupSelectionStatus === "partial"} + disabled={disabled} + /> + ); +}; diff --git a/web/components/core/multiple-select/index.ts b/web/components/core/multiple-select/index.ts new file mode 100644 index 000000000..b2cdf13c3 --- /dev/null +++ b/web/components/core/multiple-select/index.ts @@ -0,0 +1,3 @@ +export * from "./entity-select-action"; +export * from "./group-select-action"; +export * from "./select-group"; diff --git a/web/components/core/multiple-select/select-group.tsx b/web/components/core/multiple-select/select-group.tsx new file mode 100644 index 000000000..6f47b0632 --- /dev/null +++ b/web/components/core/multiple-select/select-group.tsx @@ -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; + entities: Record; // { groupID: entityIds[] } +}; + +export const MultipleSelectGroup: React.FC = observer((props) => { + const { children, containerRef, entities } = props; + + const helpers = useMultipleSelect({ + containerRef, + entities, + }); + + return <>{children(helpers)}; +}); + +MultipleSelectGroup.displayName = "MultipleSelectGroup"; diff --git a/web/hooks/store/index.ts b/web/hooks/store/index.ts index bf139d69a..e9d4a638c 100644 --- a/web/hooks/store/index.ts +++ b/web/hooks/store/index.ts @@ -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"; diff --git a/web/hooks/store/use-multiple-select-store.ts b/web/hooks/store/use-multiple-select-store.ts new file mode 100644 index 000000000..1a599bfa7 --- /dev/null +++ b/web/hooks/store/use-multiple-select-store.ts @@ -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; +}; diff --git a/web/hooks/use-multiple-select.ts b/web/hooks/use-multiple-select.ts new file mode 100644 index 000000000..9dcc0e17c --- /dev/null +++ b/web/hooks/use-multiple-select.ts @@ -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; + entities: Record; // { 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; +}; diff --git a/web/store/multiple_select.store.ts b/web/store/multiple_select.store.ts new file mode 100644 index 000000000..14750f31a --- /dev/null +++ b/web/store/multiple_select.store.ts @@ -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; + }); + }; +} diff --git a/web/store/root.store.ts b/web/store/root.store.ts index 4e6a74815..83ed43e82 100644 --- a/web/store/root.store.ts +++ b/web/store/root.store.ts @@ -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(); } }