From 01a070e52d6633e949219fa3eb4906ff5ff83030 Mon Sep 17 00:00:00 2001 From: "M. Palanikannan" <73993394+Palanikannan1437@users.noreply.github.com> Date: Thu, 4 Sep 2025 18:13:36 +0530 Subject: [PATCH] [WIKI-564] fix: project nested page mutation bugs (#4116) * fix: move page internally * fix: mutation issues fixed --- .../[projectId]/pages/(list)/page.tsx | 2 - .../core/components/pages/list/block-root.tsx | 6 +- apps/web/core/components/pages/list/root.tsx | 85 ++++++++++++++++++- .../core/store/pages/project-page.store.ts | 66 ++++++++++++++ .../ee/store/pages/workspace-page.store.ts | 66 ++++++++++++++ .../teamspace/pages/teamspace-page.store.ts | 60 +++++++++++++ 6 files changed, 278 insertions(+), 7 deletions(-) diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/page.tsx index e2b14030d8..4a5a468c7b 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/page.tsx @@ -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 diff --git a/apps/web/core/components/pages/list/block-root.tsx b/apps/web/core/components/pages/list/block-root.tsx index 9587ca4c68..b92ac24f59 100644 --- a/apps/web/core/components/pages/list/block-root.tsx +++ b/apps/web/core/components/pages/list/block-root.tsx @@ -38,7 +38,7 @@ export const PageListBlockRoot: React.FC = observer((props) => { const itemContentRef = useRef(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 = 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 = observer((props) => { isNestedPagesEnabled, workspaceSlug, sectionType, + movePageInternally, ]); if (!page) return null; diff --git a/apps/web/core/components/pages/list/root.tsx b/apps/web/core/components/pages/list/root.tsx index 9d6c7f3ccb..3a3ff8ba58 100644 --- a/apps/web/core/components/pages/list/root.tsx +++ b/apps/web/core/components/pages/list/root.tsx @@ -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 = observer((props) => { const { pageType, workspaceSlug, projectId } = props; // states const [isCreatingPage, setIsCreatingPage] = useState(false); + const [isRootDropping, setIsRootDropping] = useState(false); + // refs + const rootDropRef = useRef(null); // router const router = useRouter(); @@ -55,6 +59,9 @@ export const ProjectPagesListRoot: React.FC = 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 = 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 = observer((props) => { ); return ( -
+
{pageIds.map((pageId) => ( Promise; createPage: (pageData: Partial) => Promise; removePage: (params: { pageId: string; shouldSync?: boolean }) => Promise; + movePageInternally: (pageId: string, updatePayload: Partial) => Promise; 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} updatePayload - The update payload containing parent_id and other properties + */ + movePageInternally = async (pageId: string, updatePayload: Partial) => { + 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 diff --git a/apps/web/ee/store/pages/workspace-page.store.ts b/apps/web/ee/store/pages/workspace-page.store.ts index bcda3bba5e..94b347d724 100644 --- a/apps/web/ee/store/pages/workspace-page.store.ts +++ b/apps/web/ee/store/pages/workspace-page.store.ts @@ -60,6 +60,7 @@ export interface IWorkspacePageStore { ) => Promise; createPage: (pageData: Partial) => Promise; removePage: (params: { pageId: string; shouldSync?: boolean }) => Promise; + movePageInternally: (pageId: string, updatePayload: Partial) => Promise; getOrFetchPageInstance: ({ pageId }: { pageId: string }) => Promise; 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} updatePayload - The update payload containing parent_id and other properties + */ + movePageInternally = async (pageId: string, updatePayload: Partial) => { + 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) { diff --git a/apps/web/ee/store/teamspace/pages/teamspace-page.store.ts b/apps/web/ee/store/teamspace/pages/teamspace-page.store.ts index 9c06d05aaa..bbbe32b180 100644 --- a/apps/web/ee/store/teamspace/pages/teamspace-page.store.ts +++ b/apps/web/ee/store/teamspace/pages/teamspace-page.store.ts @@ -49,6 +49,7 @@ export interface ITeamspacePageStore { // CRUD actions createPage: (data: Partial) => Promise; removePage: (params: { pageId: string; shouldSync?: boolean }) => Promise; + movePageInternally: (pageId: string, updatePayload: Partial) => Promise; getOrFetchPageInstance: ({ pageId }: { pageId: string }) => Promise; 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} updatePayload - The update payload containing parent_id and other properties + */ + movePageInternally = async (pageId: string, updatePayload: Partial) => { + 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) {