[WIKI-564] fix: project nested page mutation bugs (#4116)

* fix: move page internally

* fix: mutation issues fixed
This commit is contained in:
M. Palanikannan
2025-09-04 18:13:36 +05:30
committed by GitHub
parent bae24cc7c8
commit 01a070e52d
6 changed files with 278 additions and 7 deletions

View File

@@ -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

View File

@@ -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;

View File

@@ -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}

View File

@@ -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

View File

@@ -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) {

View File

@@ -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) {