mirror of
https://github.com/makeplane/plane.git
synced 2026-02-25 04:35:21 +01:00
[WIKI-564] fix: project nested page mutation bugs (#4116)
* fix: move page internally * fix: mutation issues fixed
This commit is contained in:
@@ -15,8 +15,6 @@ import { useProject } from "@/hooks/store/use-project";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
|
||||
// plane web hooks
|
||||
import { EPageStoreType } from "@/plane-web/hooks/store";
|
||||
|
||||
const ProjectPagesPage = observer(() => {
|
||||
// router
|
||||
|
||||
@@ -38,7 +38,7 @@ export const PageListBlockRoot: React.FC<TPageListBlock> = observer((props) => {
|
||||
const itemContentRef = useRef<HTMLDivElement>(null);
|
||||
const { workspaceSlug, projectId, pageId: currentPageIdParam } = useParams();
|
||||
// store hooks
|
||||
const { getPageById, isNestedPagesEnabled } = usePageStore(storeType);
|
||||
const { getPageById, isNestedPagesEnabled, movePageInternally } = usePageStore(storeType);
|
||||
const page = usePage({
|
||||
pageId,
|
||||
storeType,
|
||||
@@ -243,7 +243,8 @@ export const PageListBlockRoot: React.FC<TPageListBlock> = observer((props) => {
|
||||
updatePayload.access = targetAccess;
|
||||
}
|
||||
|
||||
droppedPageDetails.update(updatePayload);
|
||||
// Use the store method instead of direct update
|
||||
movePageInternally(droppedPageId, updatePayload);
|
||||
},
|
||||
canDrop: ({ source }) => {
|
||||
if (
|
||||
@@ -286,6 +287,7 @@ export const PageListBlockRoot: React.FC<TPageListBlock> = observer((props) => {
|
||||
isNestedPagesEnabled,
|
||||
workspaceSlug,
|
||||
sectionType,
|
||||
movePageInternally,
|
||||
]);
|
||||
|
||||
if (!page) return null;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
|
||||
import { observer } from "mobx-react";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
@@ -11,7 +12,7 @@ import {
|
||||
PROJECT_PAGE_TRACKER_EVENTS,
|
||||
} from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { EUserProjectRoles, type TPageNavigationTabs, TPage } from "@plane/types";
|
||||
import { EUserProjectRoles, type TPageNavigationTabs, TPage, TPageDragPayload } from "@plane/types";
|
||||
import { setToast, TOAST_TYPE } from "@plane/ui";
|
||||
// components
|
||||
import { DetailedEmptyState } from "@/components/empty-state/detailed-empty-state-root";
|
||||
@@ -40,6 +41,9 @@ export const ProjectPagesListRoot: React.FC<Props> = observer((props) => {
|
||||
const { pageType, workspaceSlug, projectId } = props;
|
||||
// states
|
||||
const [isCreatingPage, setIsCreatingPage] = useState(false);
|
||||
const [isRootDropping, setIsRootDropping] = useState(false);
|
||||
// refs
|
||||
const rootDropRef = useRef<HTMLDivElement>(null);
|
||||
// router
|
||||
const router = useRouter();
|
||||
|
||||
@@ -55,6 +59,9 @@ export const ProjectPagesListRoot: React.FC<Props> = observer((props) => {
|
||||
filteredArchivedPageIds,
|
||||
filteredPrivatePageIds,
|
||||
createPage,
|
||||
movePageInternally,
|
||||
getPageById,
|
||||
isNestedPagesEnabled,
|
||||
} = usePageStore(storeType);
|
||||
|
||||
// Debounce the search query to avoid excessive API calls
|
||||
@@ -138,6 +145,73 @@ export const ProjectPagesListRoot: React.FC<Props> = observer((props) => {
|
||||
EUserPermissionsLevel.PROJECT
|
||||
);
|
||||
|
||||
// Root level drop target
|
||||
useEffect(() => {
|
||||
const element = rootDropRef.current;
|
||||
if (!element) return;
|
||||
|
||||
return dropTargetForElements({
|
||||
element,
|
||||
onDragEnter: () => setIsRootDropping(true),
|
||||
onDragLeave: () => setIsRootDropping(false),
|
||||
onDrop: ({ location, source }) => {
|
||||
setIsRootDropping(false);
|
||||
|
||||
// Only handle drops that are ONLY on the root container (not on individual pages)
|
||||
if (location.current.dropTargets.length !== 1) return;
|
||||
|
||||
const { id: droppedPageId } = source.data as TPageDragPayload;
|
||||
const droppedPageDetails = getPageById(droppedPageId);
|
||||
if (!droppedPageDetails) return;
|
||||
|
||||
// Move to root level (no parent)
|
||||
const updatePayload: { parent_id: string | null; access?: EPageAccess } = {
|
||||
parent_id: null,
|
||||
};
|
||||
|
||||
// Update access based on current section
|
||||
let targetAccess: EPageAccess | undefined;
|
||||
if (pageType === "public") {
|
||||
targetAccess = EPageAccess.PUBLIC;
|
||||
} else if (pageType === "private") {
|
||||
targetAccess = EPageAccess.PRIVATE;
|
||||
}
|
||||
|
||||
if (targetAccess && droppedPageDetails.access !== targetAccess) {
|
||||
updatePayload.access = targetAccess;
|
||||
}
|
||||
|
||||
movePageInternally(droppedPageId, updatePayload);
|
||||
},
|
||||
canDrop: ({ source }) => {
|
||||
// Don't allow drops if user doesn't have permissions or in archived section
|
||||
if (!hasProjectMemberLevelPermissions || pageType === "archived") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { id: droppedPageId } = source.data as TPageDragPayload;
|
||||
const sourcePage = getPageById(droppedPageId);
|
||||
if (!sourcePage) return false;
|
||||
|
||||
return (
|
||||
sourcePage.canCurrentUserEditPage &&
|
||||
sourcePage.isContentEditable &&
|
||||
isNestedPagesEnabled(workspaceSlug) &&
|
||||
!sourcePage.archived_at &&
|
||||
// For shared pages, only the owner can move them
|
||||
(!sourcePage.is_shared || sourcePage.isCurrentUserOwner)
|
||||
);
|
||||
},
|
||||
});
|
||||
}, [
|
||||
hasProjectMemberLevelPermissions,
|
||||
pageType,
|
||||
getPageById,
|
||||
isNestedPagesEnabled,
|
||||
workspaceSlug,
|
||||
movePageInternally,
|
||||
]);
|
||||
|
||||
const generalPageResolvedPath = useResolvedAssetPath({
|
||||
basePath: "/empty-state/onboarding/pages",
|
||||
});
|
||||
@@ -239,7 +313,12 @@ export const ProjectPagesListRoot: React.FC<Props> = observer((props) => {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="size-full overflow-y-scroll vertical-scrollbar scrollbar-sm">
|
||||
<div
|
||||
ref={rootDropRef}
|
||||
className={`size-full overflow-y-scroll vertical-scrollbar scrollbar-sm ${
|
||||
isRootDropping ? "bg-custom-background-80" : ""
|
||||
}`}
|
||||
>
|
||||
{pageIds.map((pageId) => (
|
||||
<PageListBlockRoot
|
||||
key={pageId}
|
||||
|
||||
@@ -81,6 +81,7 @@ export interface IProjectPageStore {
|
||||
) => Promise<TPage | undefined>;
|
||||
createPage: (pageData: Partial<TPage>) => Promise<TPage | undefined>;
|
||||
removePage: (params: { pageId: string; shouldSync?: boolean }) => Promise<void>;
|
||||
movePageInternally: (pageId: string, updatePayload: Partial<TPage>) => Promise<void>;
|
||||
movePage: (params: {
|
||||
workspaceSlug: string;
|
||||
newProjectId: string;
|
||||
@@ -161,6 +162,7 @@ export class ProjectPageStore implements IProjectPageStore {
|
||||
fetchPageDetails: action,
|
||||
createPage: action,
|
||||
removePage: action,
|
||||
movePageInternally: action,
|
||||
movePage: action,
|
||||
updatePagesInStore: action,
|
||||
// page sharing actions
|
||||
@@ -818,6 +820,70 @@ export class ProjectPageStore implements IProjectPageStore {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description move a page internally within the project hierarchy
|
||||
* @param {string} pageId - The ID of the page to move
|
||||
* @param {Partial<TPage>} updatePayload - The update payload containing parent_id and other properties
|
||||
*/
|
||||
movePageInternally = async (pageId: string, updatePayload: Partial<TPage>) => {
|
||||
try {
|
||||
const pageInstance = this.getPageById(pageId);
|
||||
if (!pageInstance) return;
|
||||
|
||||
runInAction(() => {
|
||||
if (updatePayload.hasOwnProperty("parent_id") && updatePayload.parent_id !== pageInstance.parent_id) {
|
||||
this.updateParentSubPageCounts(pageInstance.parent_id ?? null, updatePayload.parent_id ?? null);
|
||||
}
|
||||
|
||||
// Apply all updates to the page instance
|
||||
Object.keys(updatePayload).forEach((key) => {
|
||||
const currentPageKey = key as keyof TPage;
|
||||
set(pageInstance, key, updatePayload[currentPageKey] || undefined);
|
||||
});
|
||||
|
||||
// Update the updated_at field locally to ensure reactions trigger
|
||||
pageInstance.updated_at = new Date();
|
||||
});
|
||||
|
||||
await pageInstance.update(updatePayload);
|
||||
} catch (error) {
|
||||
console.error("Unable to move page internally", error);
|
||||
runInAction(() => {
|
||||
this.error = {
|
||||
title: "Failed",
|
||||
description: "Failed to move page internally, Please try again later.",
|
||||
};
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Helper method to update sub_pages_count when moving pages between parents
|
||||
* @param {string | null} oldParentId - The current parent ID (can be null for root pages)
|
||||
* @param {string | null} newParentId - The new parent ID (can be null for root pages)
|
||||
* @private
|
||||
*/
|
||||
private updateParentSubPageCounts = (oldParentId: string | null, newParentId: string | null) => {
|
||||
// Decrement count for old parent (if it exists)
|
||||
if (oldParentId) {
|
||||
const oldParentPageInstance = this.getPageById(oldParentId);
|
||||
if (oldParentPageInstance) {
|
||||
const newCount = Math.max(0, (oldParentPageInstance.sub_pages_count ?? 1) - 1);
|
||||
oldParentPageInstance.mutateProperties({ sub_pages_count: newCount });
|
||||
}
|
||||
}
|
||||
|
||||
// Increment count for new parent (if it exists)
|
||||
if (newParentId) {
|
||||
const newParentPageInstance = this.getPageById(newParentId);
|
||||
if (newParentPageInstance) {
|
||||
const newCount = (newParentPageInstance.sub_pages_count ?? 0) + 1;
|
||||
newParentPageInstance.mutateProperties({ sub_pages_count: newCount });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description move a page to a new project
|
||||
* @param {string} workspaceSlug
|
||||
|
||||
@@ -60,6 +60,7 @@ export interface IWorkspacePageStore {
|
||||
) => Promise<TPage | undefined>;
|
||||
createPage: (pageData: Partial<TPage>) => Promise<TPage | undefined>;
|
||||
removePage: (params: { pageId: string; shouldSync?: boolean }) => Promise<void>;
|
||||
movePageInternally: (pageId: string, updatePayload: Partial<TPage>) => Promise<void>;
|
||||
getOrFetchPageInstance: ({ pageId }: { pageId: string }) => Promise<TWorkspacePage | undefined>;
|
||||
removePageInstance: (pageId: string) => void;
|
||||
updatePagesInStore: (pages: TPage[]) => void;
|
||||
@@ -723,6 +724,71 @@ export class WorkspacePageStore implements IWorkspacePageStore {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description move a page internally within the project hierarchy
|
||||
* @param {string} pageId - The ID of the page to move
|
||||
* @param {Partial<TPage>} updatePayload - The update payload containing parent_id and other properties
|
||||
*/
|
||||
movePageInternally = async (pageId: string, updatePayload: Partial<TPage>) => {
|
||||
try {
|
||||
const pageInstance = this.getPageById(pageId);
|
||||
if (!pageInstance) return;
|
||||
|
||||
runInAction(() => {
|
||||
// Handle parent_id changes and update sub_pages_count accordingly
|
||||
if (updatePayload.hasOwnProperty("parent_id") && updatePayload.parent_id !== pageInstance.parent_id) {
|
||||
this.updateParentSubPageCounts(pageInstance.parent_id ?? null, updatePayload.parent_id ?? null);
|
||||
}
|
||||
|
||||
// Apply all updates to the page instance
|
||||
Object.keys(updatePayload).forEach((key) => {
|
||||
const currentPageKey = key as keyof TPage;
|
||||
set(pageInstance, key, updatePayload[currentPageKey] || undefined);
|
||||
});
|
||||
|
||||
// Update the updated_at field locally to ensure reactions trigger
|
||||
pageInstance.updated_at = new Date();
|
||||
});
|
||||
|
||||
await pageInstance.update(updatePayload);
|
||||
} catch (error) {
|
||||
console.error("Unable to move page internally", error);
|
||||
runInAction(() => {
|
||||
this.error = {
|
||||
title: "Failed",
|
||||
description: "Failed to move page internally, Please try again later.",
|
||||
};
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Helper method to update sub_pages_count when moving pages between parents
|
||||
* @param {string | null} oldParentId - The current parent ID (can be null for root pages)
|
||||
* @param {string | null} newParentId - The new parent ID (can be null for root pages)
|
||||
* @private
|
||||
*/
|
||||
private updateParentSubPageCounts = (oldParentId: string | null, newParentId: string | null) => {
|
||||
// Decrement count for old parent (if it exists)
|
||||
if (oldParentId) {
|
||||
const oldParentPageInstance = this.getPageById(oldParentId);
|
||||
if (oldParentPageInstance) {
|
||||
const newCount = Math.max(0, (oldParentPageInstance.sub_pages_count ?? 1) - 1);
|
||||
oldParentPageInstance.mutateProperties({ sub_pages_count: newCount });
|
||||
}
|
||||
}
|
||||
|
||||
// Increment count for new parent (if it exists)
|
||||
if (newParentId) {
|
||||
const newParentPageInstance = this.getPageById(newParentId);
|
||||
if (newParentPageInstance) {
|
||||
const newCount = (newParentPageInstance.sub_pages_count ?? 0) + 1;
|
||||
newParentPageInstance.mutateProperties({ sub_pages_count: newCount });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
getOrFetchPageInstance = async ({ pageId }: { pageId: string }) => {
|
||||
const pageInstance = this.getPageById(pageId);
|
||||
if (pageInstance) {
|
||||
|
||||
@@ -49,6 +49,7 @@ export interface ITeamspacePageStore {
|
||||
// CRUD actions
|
||||
createPage: (data: Partial<TPage>) => Promise<TPage>;
|
||||
removePage: (params: { pageId: string; shouldSync?: boolean }) => Promise<void>;
|
||||
movePageInternally: (pageId: string, updatePayload: Partial<TPage>) => Promise<void>;
|
||||
getOrFetchPageInstance: ({ pageId }: { pageId: string }) => Promise<TTeamspacePage | undefined>;
|
||||
removePageInstance: (pageId: string) => void;
|
||||
// page sharing actions
|
||||
@@ -345,6 +346,65 @@ export class TeamspacePageStore implements ITeamspacePageStore {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description move a page internally within the project hierarchy
|
||||
* @param {string} pageId - The ID of the page to move
|
||||
* @param {Partial<TPage>} updatePayload - The update payload containing parent_id and other properties
|
||||
*/
|
||||
movePageInternally = async (pageId: string, updatePayload: Partial<TPage>) => {
|
||||
try {
|
||||
const pageInstance = this.getPageById(pageId);
|
||||
if (!pageInstance) return;
|
||||
|
||||
runInAction(() => {
|
||||
// Handle parent_id changes and update sub_pages_count accordingly
|
||||
if (updatePayload.hasOwnProperty("parent_id") && updatePayload.parent_id !== pageInstance.parent_id) {
|
||||
this.updateParentSubPageCounts(pageInstance.parent_id ?? null, updatePayload.parent_id ?? null);
|
||||
}
|
||||
|
||||
// Apply all updates to the page instance
|
||||
Object.keys(updatePayload).forEach((key) => {
|
||||
const currentPageKey = key as keyof TPage;
|
||||
set(pageInstance, key, updatePayload[currentPageKey] || undefined);
|
||||
});
|
||||
|
||||
// Update the updated_at field locally to ensure reactions trigger
|
||||
pageInstance.updated_at = new Date();
|
||||
});
|
||||
|
||||
await pageInstance.update(updatePayload);
|
||||
} catch (error) {
|
||||
console.error("Unable to move page internally", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Helper method to update sub_pages_count when moving pages between parents
|
||||
* @param {string | null} oldParentId - The current parent ID (can be null for root pages)
|
||||
* @param {string | null} newParentId - The new parent ID (can be null for root pages)
|
||||
* @private
|
||||
*/
|
||||
private updateParentSubPageCounts = (oldParentId: string | null, newParentId: string | null) => {
|
||||
// Decrement count for old parent (if it exists)
|
||||
if (oldParentId) {
|
||||
const oldParentPageInstance = this.getPageById(oldParentId);
|
||||
if (oldParentPageInstance) {
|
||||
const newCount = Math.max(0, (oldParentPageInstance.sub_pages_count ?? 1) - 1);
|
||||
oldParentPageInstance.mutateProperties({ sub_pages_count: newCount });
|
||||
}
|
||||
}
|
||||
|
||||
// Increment count for new parent (if it exists)
|
||||
if (newParentId) {
|
||||
const newParentPageInstance = this.getPageById(newParentId);
|
||||
if (newParentPageInstance) {
|
||||
const newCount = (newParentPageInstance.sub_pages_count ?? 0) + 1;
|
||||
newParentPageInstance.mutateProperties({ sub_pages_count: newCount });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
getOrFetchPageInstance = async ({ pageId }: { pageId: string }) => {
|
||||
const pageInstance = this.getPageById(pageId);
|
||||
if (pageInstance) {
|
||||
|
||||
Reference in New Issue
Block a user