[PE-90] feat: move page across projects (#1711)

* feat: move page across projects

* chore: removed the page label deletion

* chore: add authorization to move page

* chore: move page modal added

* chore: add move page permissions

* fix: pk validation

* chore: permission change for move page

* chore: add move page flag

* chore: sync with ce

---------

Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>
This commit is contained in:
Bavisetti Narayan
2025-01-07 16:31:26 +05:30
committed by GitHub
parent dc74915541
commit 365192454d
11 changed files with 279 additions and 10 deletions

View File

@@ -0,0 +1,30 @@
from plane.db.models import ProjectMember, UserFavorite
# Third Party imports
from celery import shared_task
@shared_task
def move_page(page_id, old_project_id, new_project_id):
# Get all the members for the new project
new_project_members_list = ProjectMember.objects.filter(
project_id=new_project_id,
).values_list("member_id", flat=True)
# Delete favorites for the members who are not part of the new project
UserFavorite.objects.filter(
entity_type="page",
entity_identifier=page_id,
project_id=old_project_id,
).exclude(user_id__in=new_project_members_list).delete()
# Update the project id fo the members who are part of the project
UserFavorite.objects.filter(
entity_type="page",
entity_identifier=page_id,
project_id=old_project_id,
user_id__in=new_project_members_list,
).update(project_id=new_project_id)
return

View File

@@ -8,6 +8,7 @@ from plane.ee.views import (
WorkspacePageVersionEndpoint,
WorkspacePageFavoriteEndpoint,
WorkspacePageDuplicateEndpoint,
MovePageEndpoint,
)
@@ -76,4 +77,9 @@ urlpatterns = [
WorkspacePageFavoriteEndpoint.as_view(),
name="page-favorites",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:pk>/move/",
MovePageEndpoint.as_view(),
name="move-page",
),
]

View File

@@ -34,6 +34,7 @@ from plane.ee.views.app.page import (
WorkspacePageVersionEndpoint,
WorkspacePageFavoriteEndpoint,
WorkspacePageDuplicateEndpoint,
MovePageEndpoint,
)
from plane.ee.views.app.views import (
IssueViewEEViewSet,
@@ -74,14 +75,28 @@ from plane.ee.views.space.intake import (
)
# workspace connection views
from plane.ee.views.app.workspace.credential import WorkspaceCredentialView, VerifyWorkspaceCredentialView
from plane.ee.views.app.workspace.connection import WorkspaceConnectionView, WorkspaceUserConnectionView
from plane.ee.views.app.workspace.credential import (
WorkspaceCredentialView,
VerifyWorkspaceCredentialView,
)
from plane.ee.views.app.workspace.connection import (
WorkspaceConnectionView,
WorkspaceUserConnectionView,
)
from plane.ee.views.app.workspace.entity_connection import WorkspaceEntityConnectionView
from plane.ee.views.api.workspace.credential import WorkspaceCredentialAPIView, VerifyWorkspaceCredentialAPIView
from plane.ee.views.api.workspace.connection import WorkspaceConnectionAPIView, WorkspaceUserConnectionAPIView
from plane.ee.views.api.workspace.entity_connection import WorkspaceEntityConnectionAPIView
from plane.ee.views.api.workspace.credential import (
WorkspaceCredentialAPIView,
VerifyWorkspaceCredentialAPIView,
)
from plane.ee.views.api.workspace.connection import (
WorkspaceConnectionAPIView,
WorkspaceUserConnectionAPIView,
)
from plane.ee.views.api.workspace.entity_connection import (
WorkspaceEntityConnectionAPIView,
)
# jobs views
from plane.ee.views.app.job.base import ImportJobView

View File

@@ -6,3 +6,4 @@ from .workspace import (
WorkspacePageFavoriteEndpoint,
WorkspacePageDuplicateEndpoint,
)
from .base import MovePageEndpoint

View File

@@ -0,0 +1,49 @@
from plane.ee.views.base import BaseAPIView
from plane.db.models import FileAsset, ProjectPage, Page, ProjectMember
from plane.app.permissions import allow_permission, ROLE
from plane.ee.bgtasks.move_page import move_page
from plane.app.permissions import allow_permission, ROLE
# Third party imports
from rest_framework import status
from rest_framework.response import Response
class MovePageEndpoint(BaseAPIView):
@allow_permission(allowed_roles=[ROLE.ADMIN], creator=True, model=Page)
def post(self, request, slug, project_id, pk):
new_project_id = request.data.get("new_project_id")
if not new_project_id:
return Response(
{"error": "new_project_id is required"},
status=status.HTTP_400_BAD_REQUEST,
)
# check if the user is admin or member of the project
if ProjectMember.objects.filter(
project_id=new_project_id,
member_id=request.user.id,
workspace__slug=slug,
role__lte=15
).exists():
return Response(
{"error": "You do not have permission to move the page"},
status=status.HTTP_403_FORBIDDEN,
)
# Update the project id for the project pages
ProjectPage.objects.filter(
page_id=pk,
).update(project_id=new_project_id)
# Update the project id for the file assets
FileAsset.objects.filter(
page_id=pk,
project_id=project_id,
).update(project_id=new_project_id)
# Background job to handle favorites
move_page.delay(pk, project_id, new_project_id)
return Response(status=status.HTTP_204_NO_CONTENT)

View File

@@ -1,5 +1,5 @@
// types
import { TDocumentPayload, TPage, TPageEmbedType } from "@plane/types";
import { TDocumentPayload, TPage } from "@plane/types";
// helpers
import { API_BASE_URL } from "@/helpers/common.helper";
// services

View File

@@ -36,7 +36,7 @@ export const IssueEmbedCard: React.FC<Props> = observer((props) => {
} = useIssueDetail();
// derived values
const projectRole = workspaceProjectsPermissions?.[workspaceSlug][projectId];
const projectRole = workspaceProjectsPermissions?.[workspaceSlug]?.[projectId];
const issueDetails = getIssueById(issueId);
// auth

View File

@@ -1,4 +1,5 @@
export * from "./create-page-modal";
export * from "./delete-page-modal";
export * from "./move-page-modal";
export * from "./publish-page-modal";
export * from "./move-page-modal";

View File

@@ -1 +1,168 @@
export * from "ce/components/pages/modals/move-page-modal";
import React, { useRef, useState } from "react";
import { observer } from "mobx-react";
import { useParams, useRouter } from "next/navigation";
import { Check, Search } from "lucide-react";
import { Combobox } from "@headlessui/react";
// plane ui
import { Button, EModalPosition, EModalWidth, ModalCore, setToast, TOAST_TYPE } from "@plane/ui";
// component types
import { TMovePageModalProps } from "@/ce/components/pages";
// components
import { Logo } from "@/components/common";
import { EmptyState } from "@/components/empty-state";
// constants
import { EmptyStateType } from "@/constants/empty-state";
// helpers
import { cn } from "@/helpers/common.helper";
// hooks
import { useProject, useProjectPages } from "@/hooks/store";
// store
import { ROLE_PERMISSIONS_TO_CREATE_PAGE } from "@/store/pages/project-page.store";
export const MovePageModal: React.FC<TMovePageModalProps> = observer((props) => {
const { isOpen, onClose, page } = props;
// states
const [searchTerm, setSearchTerm] = useState("");
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null);
const [isMoving, setIsMoving] = useState(false);
// refs
const moveButtonRef = useRef<HTMLButtonElement>(null);
// router
const { workspaceSlug, projectId } = useParams();
const router = useRouter();
// store hooks
const { currentProjectDetails, getProjectById, joinedProjectIds } = useProject();
const { movePage } = useProjectPages();
// derived values
const { id } = page;
const transferrableProjectIds = joinedProjectIds.filter((id) => {
const projectDetails = getProjectById(id);
const isCurrentProject = projectDetails?.id === currentProjectDetails?.id;
const canCurrentUserMovePage =
!!projectDetails?.member_role && ROLE_PERMISSIONS_TO_CREATE_PAGE.includes(projectDetails?.member_role);
return !isCurrentProject && canCurrentUserMovePage;
});
const filteredProjectIds = transferrableProjectIds.filter((id) => {
const projectDetails = getProjectById(id);
const projectQuery = `${projectDetails?.identifier} ${projectDetails?.name}`.toLowerCase();
return projectQuery.includes(searchTerm.toLowerCase());
});
const handleClose = () => {
onClose();
setTimeout(() => {
setSearchTerm("");
setSelectedProjectId(null);
}, 300);
};
const handleMovePage = async (newProjectId: string) => {
if (!workspaceSlug || !projectId || !id) return;
await movePage(workspaceSlug.toString(), projectId.toString(), id, newProjectId)
.then(() => {
handleClose();
router.push(`/${workspaceSlug}/projects/${newProjectId}/pages/${id}`);
})
.catch(() =>
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Page could not be moved. Please try again later.",
})
);
};
const handleMove = async () => {
if (!selectedProjectId) return;
setIsMoving(true);
await handleMovePage(selectedProjectId);
setIsMoving(false);
};
return (
<ModalCore isOpen={isOpen} width={EModalWidth.LG} position={EModalPosition.TOP} handleClose={handleClose}>
<Combobox
as="div"
value={selectedProjectId}
onChange={(val: string) => {
setSelectedProjectId(val);
setSearchTerm("");
moveButtonRef.current?.focus();
}}
>
<div className="flex items-center gap-2 px-4">
<Search className="flex-shrink-0 size-4 text-custom-text-400" aria-hidden="true" />
<Combobox.Input
className="h-12 w-full border-0 bg-transparent text-sm text-custom-text-100 outline-none placeholder:text-custom-text-400 focus:ring-0"
placeholder="Type to search..."
displayValue={() => ""}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<Combobox.Options static className="vertical-scrollbar scrollbar-md max-h-80 scroll-py-2 overflow-y-auto">
{filteredProjectIds.length === 0 ? (
<div className="flex flex-col items-center justify-center px-3 py-8 text-center">
<EmptyState type={EmptyStateType.PROJECTS_EMPTY_SEARCH} layout="screen-simple" />
</div>
) : (
<ul
className={cn("text-custom-text-100", {
"px-2": filteredProjectIds.length > 0,
})}
>
{filteredProjectIds.map((projectId) => {
const projectDetails = getProjectById(projectId);
const isProjectSelected = selectedProjectId === projectDetails?.id;
if (!projectDetails) return null;
return (
<Combobox.Option
key={projectDetails.id}
value={projectDetails.id}
className={({ active }) =>
cn(
"flex items-center justify-between gap-2 truncate w-full cursor-pointer select-none rounded-md p-2 text-custom-text-200 transition-colors",
{
"bg-custom-background-80": active,
"text-custom-text-100": isProjectSelected,
}
)
}
>
<div className="flex items-center gap-2 truncate">
<span className="flex-shrink-0 size-4 grid place-items-center">
{isProjectSelected ? (
<Check className="size-4 text-custom-text-100" />
) : (
<Logo logo={projectDetails.logo_props} size={16} />
)}
</span>
<span className="flex-shrink-0 text-[10px]">{projectDetails.identifier}</span>
<p className="text-sm truncate">{projectDetails.name}</p>
</div>
</Combobox.Option>
);
})}
</ul>
)}
</Combobox.Options>
</Combobox>
<div className="flex items-center justify-end gap-2 p-3">
<Button variant="neutral-primary" size="sm" onClick={handleClose}>
Cancel
</Button>
<Button
ref={moveButtonRef}
variant="primary"
size="sm"
onClick={handleMove}
loading={isMoving}
disabled={!selectedProjectId}
>
{isMoving ? "Moving" : "Move"}
</Button>
</div>
</ModalCore>
);
});

View File

@@ -1 +1 @@
export * from "ce/constants/page";
export const ENABLE_MOVE_PAGE = true;

View File

@@ -14162,4 +14162,4 @@ zod@^3.23.8, zod@^3.24.1:
zxcvbn@^4.4.2:
version "4.4.2"
resolved "https://registry.yarnpkg.com/zxcvbn/-/zxcvbn-4.4.2.tgz#28ec17cf09743edcab056ddd8b1b06262cc73c30"
integrity sha512-Bq0B+ixT/DMyG8kgX2xWcI5jUvCwqrMxSFam7m0lAf78nf04hv6lNCsyLYdyYTrCVMqNDY/206K7eExYCeSyUQ==
integrity sha512-Bq0B+ixT/DMyG8kgX2xWcI5jUvCwqrMxSFam7m0lAf78nf04hv6lNCsyLYdyYTrCVMqNDY/206K7eExYCeSyUQ==