mirror of
https://github.com/makeplane/plane
synced 2025-08-07 19:59:33 +00:00
[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:
committed by
GitHub
parent
dc74915541
commit
365192454d
30
apiserver/plane/ee/bgtasks/move_page.py
Normal file
30
apiserver/plane/ee/bgtasks/move_page.py
Normal 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
|
||||
@@ -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",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -6,3 +6,4 @@ from .workspace import (
|
||||
WorkspacePageFavoriteEndpoint,
|
||||
WorkspacePageDuplicateEndpoint,
|
||||
)
|
||||
from .base import MovePageEndpoint
|
||||
|
||||
49
apiserver/plane/ee/views/app/page/base.py
Normal file
49
apiserver/plane/ee/views/app/page/base.py
Normal 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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1 +1 @@
|
||||
export * from "ce/constants/page";
|
||||
export const ENABLE_MOVE_PAGE = true;
|
||||
|
||||
@@ -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==
|
||||
Reference in New Issue
Block a user