[PE-97] refactor: pages actions (#6234)

* dev: support for edition specific options in pages

* refactor: page quick actions

* chore: add customizable page actions

* fix: type errors

* dev: hook to get page operations

* refactor: remove unnecessary props

* chore: add permisssions to duplicate page endpoint

* chore: memoize arranged options

* chore: use enum for page access

* chore: add type assertion

* fix: auth for access change and delete

* fix: removing readonly editor

* chore: add sync for page access cahnge

* fix: sync state

* fix: indexeddb sync loader added

* fix: remove node error fixed

* style: page title and checkbox

* chore: removing the syncing logic

* revert: is editable check removed in display message

* fix: editable field optional

* fix: editable removed as optional prop

* fix: extra options import fix

* fix: remove readonly stuff

* fix: added toggle access

* chore: add access change sync

* fix: full width toggle

* refactor: types and enums added

* refactore: update store action

* chore: changed the duplicate viewset

* fix: remove the page binary

* fix: duplicate page action

* fix: merge conflicts

---------

Co-authored-by: Palanikannan M <akashmalinimurugu@gmail.com>
Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
This commit is contained in:
Aaryan Khandelwal 2024-12-31 12:54:09 +05:30 committed by GitHub
parent 94f421f27d
commit 752a27a175
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 735 additions and 418 deletions

View file

@ -54,6 +54,8 @@ class PageSerializer(BaseSerializer):
labels = validated_data.pop("labels", None)
project_id = self.context["project_id"]
owned_by_id = self.context["owned_by_id"]
description = self.context["description"]
description_binary = self.context["description_binary"]
description_html = self.context["description_html"]
# Get the workspace id from the project
@ -62,6 +64,8 @@ class PageSerializer(BaseSerializer):
# Create the page
page = Page.objects.create(
**validated_data,
description=description,
description_binary=description_binary,
description_html=description_html,
owned_by_id=owned_by_id,
workspace_id=project.workspace_id,

View file

@ -8,6 +8,7 @@ from plane.app.views import (
SubPagesEndpoint,
PagesDescriptionViewSet,
PageVersionEndpoint,
PageDuplicateEndpoint,
)
@ -78,4 +79,9 @@ urlpatterns = [
PageVersionEndpoint.as_view(),
name="page-versions",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/duplicate/",
PageDuplicateEndpoint.as_view(),
name="page-duplicate",
),
]

View file

@ -155,6 +155,7 @@ from .page.base import (
PageLogEndpoint,
SubPagesEndpoint,
PagesDescriptionViewSet,
PageDuplicateEndpoint,
)
from .page.version import PageVersionEndpoint

View file

@ -121,6 +121,8 @@ class PageViewSet(BaseViewSet):
context={
"project_id": project_id,
"owned_by_id": request.user.id,
"description": request.data.get("description", {}),
"description_binary": request.data.get("description_binary", None),
"description_html": request.data.get("description_html", "<p></p>"),
},
)
@ -553,3 +555,37 @@ class PagesDescriptionViewSet(BaseViewSet):
return Response({"message": "Updated successfully"})
else:
return Response({"error": "No binary data provided"})
class PageDuplicateEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def post(self, request, slug, project_id, page_id):
page = Page.objects.filter(
pk=page_id, workspace__slug=slug, projects__id=project_id
).first()
# get all the project ids where page is present
project_ids = ProjectPage.objects.filter(page_id=page_id).values_list(
"project_id", flat=True
)
page.pk = None
page.name = f"{page.name} (Copy)"
page.description_binary = None
page.save()
for project_id in project_ids:
ProjectPage.objects.create(
workspace_id=page.workspace_id,
project_id=project_id,
page_id=page.id,
created_by_id=page.created_by_id,
updated_by_id=page.updated_by_id,
)
page_transaction.delay(
{"description_html": page.description_html}, None, page.id
)
page = Page.objects.get(pk=page.id)
serializer = PageDetailSerializer(page)
return Response(serializer.data, status=status.HTTP_201_CREATED)

View file

@ -3,4 +3,6 @@ export const DocumentCollaborativeEvents = {
unlock: { client: "unlocked", server: "unlock" },
archive: { client: "archived", server: "archive" },
unarchive: { client: "unarchived", server: "unarchive" },
"make-public": { client: "made-public", server: "make-public" },
"make-private": { client: "made-private", server: "make-private" },
} as const;

View file

@ -36,6 +36,8 @@ export const ContextMenuItem: React.FC<ContextMenuItemProps> = (props) => {
onMouseEnter={handleActiveItem}
disabled={item.disabled}
>
{item.customContent ?? (
<>
{item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />}
<div>
<h5>{item.title}</h5>
@ -49,6 +51,8 @@ export const ContextMenuItem: React.FC<ContextMenuItemProps> = (props) => {
</p>
)}
</div>
</>
)}
</button>
);
};

View file

@ -11,7 +11,8 @@ import { usePlatformOS } from "../../hooks/use-platform-os";
export type TContextMenuItem = {
key: string;
title: string;
customContent?: React.ReactNode;
title?: string;
description?: string;
icon?: React.FC<any>;
action: () => void;

View file

@ -54,7 +54,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
if (referenceElement) referenceElement.focus();
};
const closeDropdown = () => {
isOpen && onMenuClose && onMenuClose();
if (isOpen) onMenuClose?.();
setIsOpen(false);
};
@ -216,7 +216,7 @@ const MenuItem: React.FC<ICustomMenuItemProps> = (props) => {
)}
onClick={(e) => {
close();
onClick && onClick(e);
onClick?.(e);
}}
disabled={disabled}
>

View file

@ -41,7 +41,7 @@ const PageDetailsPage = observer(() => {
const { getWorkspaceBySlug } = useWorkspace();
// derived values
const workspaceId = workspaceSlug ? (getWorkspaceBySlug(workspaceSlug.toString())?.id ?? "") : "";
const { id, name, updateDescription } = page;
const { canCurrentUserAccessPage, id, name, updateDescription } = page;
// entity search handler
const fetchEntityCallback = useCallback(
async (payload: TSearchEntityRequestPayload) =>
@ -129,7 +129,7 @@ const PageDetailsPage = observer(() => {
</div>
);
if (pageDetailsError)
if (pageDetailsError || !canCurrentUserAccessPage)
return (
<div className="h-full w-full flex flex-col items-center justify-center">
<h3 className="text-lg font-semibold text-center">Page not found</h3>

View file

@ -1,2 +1,3 @@
export * from "./editor";
export * from "./modals";
export * from "./extra-actions";

View file

@ -0,0 +1 @@
export * from "./move-page-modal";

View file

@ -0,0 +1,10 @@
// store types
import { TPageInstance } from "@/store/pages/base-page";
export type TMovePageModalProps = {
isOpen: boolean;
onClose: () => void;
page: TPageInstance;
};
export const MovePageModal: React.FC<TMovePageModalProps> = () => null;

View file

@ -0,0 +1,195 @@
"use client";
import { useMemo, useState } from "react";
import { observer } from "mobx-react";
import {
ArchiveRestoreIcon,
Copy,
ExternalLink,
FileOutput,
Globe2,
Link,
Lock,
LockKeyhole,
LockKeyholeOpen,
Trash2,
} from "lucide-react";
// plane editor
import { EditorRefApi } from "@plane/editor";
// plane ui
import { ArchiveIcon, ContextMenu, CustomMenu, TContextMenuItem } from "@plane/ui";
// components
import { DeletePageModal } from "@/components/pages";
// constants
import { EPageAccess } from "@/constants/page";
// helpers
import { cn } from "@/helpers/common.helper";
// hooks
import { usePageOperations } from "@/hooks/use-page-operations";
// plane web components
import { MovePageModal } from "@/plane-web/components/pages";
// store types
import { TPageInstance } from "@/store/pages/base-page";
export type TPageActions =
| "full-screen"
| "copy-markdown"
| "toggle-lock"
| "toggle-access"
| "open-in-new-tab"
| "copy-link"
| "make-a-copy"
| "archive-restore"
| "delete"
| "version-history"
| "export"
| "move";
type Props = {
editorRef?: EditorRefApi | null;
extraOptions?: (TContextMenuItem & { key: TPageActions })[];
optionsOrder: TPageActions[];
page: TPageInstance;
parentRef?: React.RefObject<HTMLElement>;
};
export const PageActions: React.FC<Props> = observer((props) => {
const { editorRef, extraOptions, optionsOrder, page, parentRef } = props;
// states
const [deletePageModal, setDeletePageModal] = useState(false);
const [movePageModal, setMovePageModal] = useState(false);
// page operations
const { pageOperations } = usePageOperations({
editorRef,
page,
});
// derived values
const {
access,
archived_at,
is_locked,
canCurrentUserArchivePage,
canCurrentUserChangeAccess,
canCurrentUserDeletePage,
canCurrentUserDuplicatePage,
canCurrentUserLockPage,
canCurrentUserMovePage,
} = page;
// menu items
const MENU_ITEMS: (TContextMenuItem & { key: TPageActions })[] = useMemo(() => {
const menuItems: (TContextMenuItem & { key: TPageActions })[] = [
{
key: "toggle-lock",
action: pageOperations.toggleLock,
title: is_locked ? "Unlock" : "Lock",
icon: is_locked ? LockKeyholeOpen : LockKeyhole,
shouldRender: canCurrentUserLockPage,
},
{
key: "toggle-access",
action: pageOperations.toggleAccess,
title: access === EPageAccess.PUBLIC ? "Make private" : "Make public",
icon: access === EPageAccess.PUBLIC ? Lock : Globe2,
shouldRender: canCurrentUserChangeAccess && !archived_at,
},
{
key: "open-in-new-tab",
action: pageOperations.openInNewTab,
title: "Open in new tab",
icon: ExternalLink,
shouldRender: true,
},
{
key: "copy-link",
action: pageOperations.copyLink,
title: "Copy link",
icon: Link,
shouldRender: true,
},
{
key: "make-a-copy",
action: pageOperations.duplicate,
title: "Make a copy",
icon: Copy,
shouldRender: canCurrentUserDuplicatePage,
},
{
key: "archive-restore",
action: pageOperations.toggleArchive,
title: archived_at ? "Restore" : "Archive",
icon: archived_at ? ArchiveRestoreIcon : ArchiveIcon,
shouldRender: canCurrentUserArchivePage,
},
{
key: "delete",
action: () => setDeletePageModal(true),
title: "Delete",
icon: Trash2,
shouldRender: canCurrentUserDeletePage && !!archived_at,
},
{
key: "move",
action: () => setMovePageModal(true),
title: "Move",
icon: FileOutput,
shouldRender: canCurrentUserMovePage,
},
];
if (extraOptions) {
menuItems.push(...extraOptions);
}
return menuItems;
}, [
access,
archived_at,
extraOptions,
is_locked,
canCurrentUserArchivePage,
canCurrentUserChangeAccess,
canCurrentUserDeletePage,
canCurrentUserDuplicatePage,
canCurrentUserLockPage,
canCurrentUserMovePage,
pageOperations,
]);
// arrange options
const arrangedOptions = useMemo(
() =>
optionsOrder
.map((key) => MENU_ITEMS.find((item) => item.key === key))
.filter((item) => !!item) as (TContextMenuItem & { key: TPageActions })[],
[optionsOrder, MENU_ITEMS]
);
return (
<>
<MovePageModal isOpen={movePageModal} onClose={() => setMovePageModal(false)} page={page} />
<DeletePageModal isOpen={deletePageModal} onClose={() => setDeletePageModal(false)} page={page} />
{parentRef && <ContextMenu parentRef={parentRef} items={arrangedOptions} />}
<CustomMenu placement="bottom-end" optionsClassName="max-h-[90vh]" ellipsis closeOnSelect>
{arrangedOptions.map((item) => {
if (item.shouldRender === false) return null;
return (
<CustomMenu.MenuItem
key={item.key}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
item.action?.();
}}
className={cn("flex items-center gap-2", item.className)}
disabled={item.disabled}
>
{item.customContent ?? (
<>
{item.icon && <item.icon className="size-3" />}
{item.title}
</>
)}
</CustomMenu.MenuItem>
);
})}
</CustomMenu>
</>
);
});

View file

@ -1,2 +1,2 @@
export * from "./actions";
export * from "./edit-information-popover";
export * from "./quick-actions";

View file

@ -1,131 +0,0 @@
"use client";
import { useState } from "react";
import { observer } from "mobx-react";
import { ArchiveRestoreIcon, ExternalLink, Link, Lock, Trash2, UsersRound } from "lucide-react";
import { ArchiveIcon, ContextMenu, CustomMenu, TContextMenuItem, TOAST_TYPE, setToast } from "@plane/ui";
// components
import { DeletePageModal } from "@/components/pages";
// helpers
import { copyUrlToClipboard } from "@/helpers/string.helper";
// store
import { TPageInstance } from "@/store/pages/base-page";
type Props = {
page: TPageInstance;
pageLink: string;
parentRef: React.RefObject<HTMLElement>;
};
export const PageQuickActions: React.FC<Props> = observer((props) => {
const { page, pageLink, parentRef } = props;
// states
const [deletePageModal, setDeletePageModal] = useState(false);
// store hooks
const {
access,
archive,
archived_at,
makePublic,
makePrivate,
restore,
canCurrentUserArchivePage,
canCurrentUserChangeAccess,
canCurrentUserDeletePage,
} = page;
const handleCopyText = () =>
copyUrlToClipboard(pageLink).then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Link Copied!",
message: "Page link copied to clipboard.",
});
});
const handleOpenInNewTab = () => window.open(`/${pageLink}`, "_blank");
const MENU_ITEMS: TContextMenuItem[] = [
{
key: "make-public-private",
action: async () => {
const changedPageType = access === 0 ? "private" : "public";
try {
if (access === 0) await makePrivate();
else await makePublic();
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: `The page has been marked ${changedPageType} and moved to the ${changedPageType} section.`,
});
} catch {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: `The page couldn't be marked ${changedPageType}. Please try again.`,
});
}
},
title: access === 0 ? "Make private" : "Make public",
icon: access === 0 ? Lock : UsersRound,
shouldRender: canCurrentUserChangeAccess && !archived_at,
},
{
key: "open-new-tab",
action: handleOpenInNewTab,
title: "Open in new tab",
icon: ExternalLink,
shouldRender: true,
},
{
key: "copy-link",
action: handleCopyText,
title: "Copy link",
icon: Link,
shouldRender: true,
},
{
key: "archive-restore",
action: archived_at ? restore : archive,
title: archived_at ? "Restore" : "Archive",
icon: archived_at ? ArchiveRestoreIcon : ArchiveIcon,
shouldRender: canCurrentUserArchivePage,
},
{
key: "delete",
action: () => setDeletePageModal(true),
title: "Delete",
icon: Trash2,
shouldRender: canCurrentUserDeletePage && !!archived_at,
},
];
return (
<>
<DeletePageModal isOpen={deletePageModal} onClose={() => setDeletePageModal(false)} page={page} />
<ContextMenu parentRef={parentRef} items={MENU_ITEMS} />
<CustomMenu placement="bottom-end" ellipsis closeOnSelect>
{MENU_ITEMS.map((item) => {
if (!item.shouldRender) return null;
return (
<CustomMenu.MenuItem
key={item.key}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
item.action();
}}
className="flex items-center gap-2"
disabled={item.disabled}
>
{item.icon && <item.icon className="h-3 w-3" />}
{item.title}
</CustomMenu.MenuItem>
);
})}
</CustomMenu>
</>
);
});

View file

@ -16,13 +16,12 @@ import useOnlineStatus from "@/hooks/use-online-status";
import { TPageInstance } from "@/store/pages/base-page";
type Props = {
editorRef: React.RefObject<EditorRefApi>;
handleDuplicatePage: () => void;
editorRef: EditorRefApi;
page: TPageInstance;
};
export const PageExtraOptions: React.FC<Props> = observer((props) => {
const { editorRef, handleDuplicatePage, page } = props;
const { editorRef, page } = props;
// derived values
const {
archived_at,
@ -84,8 +83,8 @@ export const PageExtraOptions: React.FC<Props> = observer((props) => {
iconClassName="text-custom-text-100"
/>
)}
<PageInfoPopover editorRef={editorRef?.current} />
<PageOptionsDropdown editorRef={editorRef?.current} handleDuplicatePage={handleDuplicatePage} page={page} />
<PageInfoPopover editorRef={editorRef} />
<PageOptionsDropdown editorRef={editorRef} page={page} />
</div>
);
});

View file

@ -9,38 +9,34 @@ import { usePageFilters } from "@/hooks/use-page-filters";
import { TPageInstance } from "@/store/pages/base-page";
type Props = {
editorReady: boolean;
editorRef: React.RefObject<EditorRefApi>;
handleDuplicatePage: () => void;
editorRef: EditorRefApi;
page: TPageInstance;
setSidePeekVisible: (sidePeekState: boolean) => void;
sidePeekVisible: boolean;
};
export const PageEditorMobileHeaderRoot: React.FC<Props> = observer((props) => {
const { editorReady, editorRef, handleDuplicatePage, page, setSidePeekVisible, sidePeekVisible } = props;
const { editorRef, page, setSidePeekVisible, sidePeekVisible } = props;
// derived values
const { isContentEditable } = page;
// page filters
const { isFullWidth } = usePageFilters();
if (!editorRef.current) return null;
return (
<>
<Header variant={EHeaderVariant.SECONDARY}>
<div className="flex-shrink-0 my-auto">
<PageSummaryPopover
editorRef={editorRef.current}
editorRef={editorRef}
isFullWidth={isFullWidth}
sidePeekVisible={sidePeekVisible}
setSidePeekVisible={setSidePeekVisible}
/>
</div>
<PageExtraOptions editorRef={editorRef} handleDuplicatePage={handleDuplicatePage} page={page} />
<PageExtraOptions editorRef={editorRef} page={page} />
</Header>
<Header variant={EHeaderVariant.TERNARY}>
{editorReady && isContentEditable && editorRef.current && <PageToolbar editorRef={editorRef?.current} />}
{isContentEditable && editorRef && <PageToolbar editorRef={editorRef} />}
</Header>
</>
);

View file

@ -1,85 +1,59 @@
"use client";
import { useState } from "react";
import { useMemo, useState } from "react";
import { observer } from "mobx-react";
import { useParams, useRouter } from "next/navigation";
import {
ArchiveRestoreIcon,
ArrowUpToLine,
Clipboard,
Copy,
History,
Link,
Lock,
LockOpen,
LucideIcon,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { ArrowUpToLine, Clipboard, History } from "lucide-react";
// document editor
import { EditorRefApi } from "@plane/editor";
// ui
import { ArchiveIcon, CustomMenu, type ISvgIcons, TOAST_TYPE, ToggleSwitch, setToast } from "@plane/ui";
import { TContextMenuItem, TOAST_TYPE, ToggleSwitch, setToast } from "@plane/ui";
// components
import { ExportPageModal } from "@/components/pages";
import { ExportPageModal, PageActions, TPageActions } from "@/components/pages";
// helpers
import { copyTextToClipboard, copyUrlToClipboard } from "@/helpers/string.helper";
import { copyTextToClipboard } from "@/helpers/string.helper";
// hooks
import { useCollaborativePageActions } from "@/hooks/use-collaborative-page-actions";
import { usePageFilters } from "@/hooks/use-page-filters";
import { useParseEditorContent } from "@/hooks/use-parse-editor-content";
import { useQueryParams } from "@/hooks/use-query-params";
// store
import { TPageInstance } from "@/store/pages/base-page";
type Props = {
editorRef: EditorRefApi | null;
handleDuplicatePage: () => void;
page: TPageInstance;
};
export const PageOptionsDropdown: React.FC<Props> = observer((props) => {
const { editorRef, handleDuplicatePage, page } = props;
const { editorRef, page } = props;
// states
const [isExportModalOpen, setIsExportModalOpen] = useState(false);
// router
const router = useRouter();
// store values
const {
name,
archived_at,
is_locked,
id,
canCurrentUserArchivePage,
canCurrentUserDuplicatePage,
canCurrentUserLockPage,
} = page;
// states
const [isExportModalOpen, setIsExportModalOpen] = useState(false);
// store hooks
const { workspaceSlug, projectId } = useParams();
const { name } = page;
// page filters
const { isFullWidth, handleFullWidth } = usePageFilters();
// update query params
const { updateQueryParams } = useQueryParams();
// collaborative actions
const { executeCollaborativeAction } = useCollaborativePageActions(editorRef, page);
// parse editor content
const { replaceCustomComponentsFromMarkdownContent } = useParseEditorContent();
// menu items list
const MENU_ITEMS: {
key: string;
action: () => void;
label: string;
icon: LucideIcon | React.FC<ISvgIcons>;
shouldRender: boolean;
}[] = [
const EXTRA_MENU_OPTIONS: (TContextMenuItem & { key: TPageActions })[] = useMemo(
() => [
{
key: "full-screen",
action: () => handleFullWidth(!isFullWidth),
customContent: (
<>
Full width
<ToggleSwitch value={isFullWidth} onChange={() => {}} />
</>
),
className: "flex items-center justify-between gap-2",
},
{
key: "copy-markdown",
action: () => {
if (!editorRef) return;
const markdownContent = editorRef.getMarkDown();
const parsedMarkdownContent = replaceCustomComponentsFromMarkdownContent({
markdownContent,
});
copyTextToClipboard(parsedMarkdownContent).then(() =>
copyTextToClipboard(editorRef.getMarkDown()).then(() =>
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
@ -87,53 +61,10 @@ export const PageOptionsDropdown: React.FC<Props> = observer((props) => {
})
);
},
label: "Copy markdown",
title: "Copy markdown",
icon: Clipboard,
shouldRender: true,
},
{
key: "copy-page-link",
action: () => {
const pageLink = projectId
? `${workspaceSlug?.toString()}/projects/${projectId?.toString()}/pages/${id}`
: `${workspaceSlug?.toString()}/pages/${id}`;
copyUrlToClipboard(pageLink).then(() =>
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Page link copied to clipboard.",
})
);
},
label: "Copy page link",
icon: Link,
shouldRender: true,
},
{
key: "make-a-copy",
action: handleDuplicatePage,
label: "Make a copy",
icon: Copy,
shouldRender: canCurrentUserDuplicatePage,
},
{
key: "lock-unlock-page",
action: is_locked
? () => executeCollaborativeAction({ type: "sendMessageToServer", message: "unlock" })
: () => executeCollaborativeAction({ type: "sendMessageToServer", message: "lock" }),
label: is_locked ? "Unlock page" : "Lock page",
icon: is_locked ? LockOpen : Lock,
shouldRender: canCurrentUserLockPage,
},
{
key: "archive-restore-page",
action: archived_at
? () => executeCollaborativeAction({ type: "sendMessageToServer", message: "unarchive" })
: () => executeCollaborativeAction({ type: "sendMessageToServer", message: "archive" }),
label: archived_at ? "Restore page" : "Archive page",
icon: archived_at ? ArchiveRestoreIcon : ArchiveIcon,
shouldRender: canCurrentUserArchivePage,
},
{
key: "version-history",
action: () => {
@ -143,18 +74,20 @@ export const PageOptionsDropdown: React.FC<Props> = observer((props) => {
});
router.push(updatedRoute);
},
label: "Version history",
title: "Version history",
icon: History,
shouldRender: true,
},
{
key: "export",
action: () => setIsExportModalOpen(true),
label: "Export",
title: "Export",
icon: ArrowUpToLine,
shouldRender: true,
},
];
],
[editorRef, handleFullWidth, isFullWidth, router, updateQueryParams]
);
return (
<>
@ -164,24 +97,23 @@ export const PageOptionsDropdown: React.FC<Props> = observer((props) => {
onClose={() => setIsExportModalOpen(false)}
pageTitle={name ?? ""}
/>
<CustomMenu maxHeight="lg" placement="bottom-start" verticalEllipsis closeOnSelect>
<CustomMenu.MenuItem
className="hidden md:flex w-full items-center justify-between gap-2"
onClick={() => handleFullWidth(!isFullWidth)}
>
Full width
<ToggleSwitch value={isFullWidth} onChange={() => {}} />
</CustomMenu.MenuItem>
{MENU_ITEMS.map((item) => {
if (!item.shouldRender) return null;
return (
<CustomMenu.MenuItem key={item.key} onClick={item.action} className="flex items-center gap-2">
<item.icon className="h-3 w-3" />
{item.label}
</CustomMenu.MenuItem>
);
})}
</CustomMenu>
<PageActions
editorRef={editorRef}
extraOptions={EXTRA_MENU_OPTIONS}
optionsOrder={[
"full-screen",
"copy-markdown",
"copy-link",
"toggle-lock",
"toggle-access",
"make-a-copy",
"archive-restore",
"delete",
"version-history",
"export",
]}
page={page}
/>
</>
);
});

View file

@ -13,20 +13,21 @@ import { TPageInstance } from "@/store/pages/base-page";
type Props = {
editorReady: boolean;
editorRef: React.RefObject<EditorRefApi>;
handleDuplicatePage: () => void;
page: TPageInstance;
setSidePeekVisible: (sidePeekState: boolean) => void;
sidePeekVisible: boolean;
};
export const PageEditorHeaderRoot: React.FC<Props> = observer((props) => {
const { editorReady, editorRef, setSidePeekVisible, sidePeekVisible, handleDuplicatePage, page } = props;
const { editorReady, editorRef, page, setSidePeekVisible, sidePeekVisible } = props;
// derived values
const { isContentEditable } = page;
// page filters
const { isFullWidth } = usePageFilters();
// derived values
const resolvedEditorRef = editorRef.current;
if (!editorRef.current) return null;
if (!resolvedEditorRef) return null;
return (
<>
@ -49,13 +50,11 @@ export const PageEditorHeaderRoot: React.FC<Props> = observer((props) => {
)}
{editorReady && isContentEditable && editorRef.current && <PageToolbar editorRef={editorRef?.current} />}
</Header.LeftItem>
<PageExtraOptions editorRef={editorRef} handleDuplicatePage={handleDuplicatePage} page={page} />
<PageExtraOptions editorRef={resolvedEditorRef} page={page} />
</Header>
<div className="md:hidden">
<PageEditorMobileHeaderRoot
editorRef={editorRef}
editorReady={editorReady}
handleDuplicatePage={handleDuplicatePage}
editorRef={resolvedEditorRef}
page={page}
sidePeekVisible={sidePeekVisible}
setSidePeekVisible={setSidePeekVisible}

View file

@ -5,8 +5,6 @@ import { useSearchParams } from "next/navigation";
import { EditorRefApi } from "@plane/editor";
// types
import { TDocumentPayload, TPage, TPageVersion } from "@plane/types";
// ui
import { setToast, TOAST_TYPE } from "@plane/ui";
// components
import {
PageEditorHeaderRoot,
@ -55,7 +53,7 @@ export const PageRoot = observer((props: TPageRootProps) => {
// search params
const searchParams = useSearchParams();
// derived values
const { access, description_html, name, isContentEditable } = page;
const { isContentEditable } = page;
// page fallback
usePageFallback({
editorRef,
@ -66,25 +64,6 @@ export const PageRoot = observer((props: TPageRootProps) => {
// update query params
const { updateQueryParams } = useQueryParams();
const handleDuplicatePage = async () => {
const formData: Partial<TPage> = {
name: "Copy of " + name,
description_html: editorRef.current?.getDocument().html ?? description_html ?? "<p></p>",
access,
};
await handlers
.create(formData)
.then((res) => router.push(handlers.getRedirectionLink(res?.id ?? "")))
.catch(() =>
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Page could not be duplicated. Please try again later.",
})
);
};
const version = searchParams.get("version");
useEffect(() => {
if (!version) {
@ -124,7 +103,6 @@ export const PageRoot = observer((props: TPageRootProps) => {
<PageEditorHeaderRoot
editorReady={editorReady}
editorRef={editorRef}
handleDuplicatePage={handleDuplicatePage}
page={page}
setSidePeekVisible={(state) => setSidePeekVisible(state)}
sidePeekVisible={sidePeekVisible}

View file

@ -4,14 +4,15 @@ import React, { FC } from "react";
import { observer } from "mobx-react";
import { Earth, Info, Lock, Minus } from "lucide-react";
// ui
import { Avatar, FavoriteStar, TOAST_TYPE, Tooltip, setToast } from "@plane/ui";
import { Avatar, FavoriteStar, Tooltip } from "@plane/ui";
// components
import { PageQuickActions } from "@/components/pages/dropdowns";
import { PageActions } from "@/components/pages";
// helpers
import { renderFormattedDate } from "@/helpers/date-time.helper";
import { getFileURL } from "@/helpers/file.helper";
// hooks
import { useMember } from "@/hooks/store";
import { usePageOperations } from "@/hooks/use-page-operations";
import { TPageInstance } from "@/store/pages/base-page";
type Props = {
@ -23,6 +24,10 @@ export const BlockItemAction: FC<Props> = observer((props) => {
const { page, parentRef } = props;
// store hooks
const { getUserDetails } = useMember();
// page operations
const { pageOperations } = usePageOperations({
page,
});
// derived values
const {
access,
@ -30,33 +35,9 @@ export const BlockItemAction: FC<Props> = observer((props) => {
is_favorite,
owned_by,
canCurrentUserFavoritePage,
addToFavorites,
removePageFromFavorites,
getRedirectionLink,
} = page;
const ownerDetails = owned_by ? getUserDetails(owned_by) : undefined;
// handlers
const handleFavorites = () => {
if (is_favorite) {
removePageFromFavorites().then(() =>
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Page removed from favorites.",
})
);
} else {
addToFavorites().then(() =>
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Page added to favorites.",
})
);
}
};
return (
<>
{/* page details */}
@ -86,14 +67,26 @@ export const BlockItemAction: FC<Props> = observer((props) => {
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleFavorites();
pageOperations.toggleFavorite();
}}
selected={is_favorite}
/>
)}
{/* quick actions dropdown */}
<PageQuickActions parentRef={parentRef} page={page} pageLink={getRedirectionLink()} />
<PageActions
optionsOrder={[
"toggle-lock",
"toggle-access",
"open-in-new-tab",
"copy-link",
"make-a-copy",
"archive-restore",
"delete",
]}
page={page}
parentRef={parentRef}
/>
</>
);
});

View file

@ -1,6 +1,5 @@
import { useState, useEffect, useCallback, useMemo } from "react";
// plane editor
import { EditorReadOnlyRefApi, EditorRefApi, TDocumentEventsServer } from "@plane/editor";
import { EditorRefApi, TDocumentEventsServer } from "@plane/editor";
import { DocumentCollaborativeEvents, TDocumentEventsClient, getServerEventName } from "@plane/editor/lib";
// plane ui
import { TOAST_TYPE, setToast } from "@plane/ui";
@ -17,10 +16,13 @@ type CollaborativeActionEvent =
| { type: "sendMessageToServer"; message: TDocumentEventsServer }
| { type: "receivedMessageFromServer"; message: TDocumentEventsClient };
export const useCollaborativePageActions = (
editorRef: EditorRefApi | EditorReadOnlyRefApi | null,
page: TPageInstance
) => {
type Props = {
editorRef?: EditorRefApi | null;
page: TPageInstance;
};
export const useCollaborativePageActions = (props: Props) => {
const { editorRef, page } = props;
// currentUserAction local state to track if the current action is being processed, a
// local action is basically the action performed by the current user to avoid double operations
const [currentActionBeingProcessed, setCurrentActionBeingProcessed] = useState<TDocumentEventsClient | null>(null);
@ -43,6 +45,14 @@ export const useCollaborativePageActions = (
execute: (shouldSync) => page.restore(shouldSync),
errorMessage: "Page could not be restored. Please try again later.",
},
[DocumentCollaborativeEvents["make-public"].client]: {
execute: (shouldSync) => page.makePublic(shouldSync),
errorMessage: "Page could not be made public. Please try again later.",
},
[DocumentCollaborativeEvents["make-private"].client]: {
execute: (shouldSync) => page.makePrivate(shouldSync),
errorMessage: "Page could not be made private. Please try again later.",
},
}),
[page]
);
@ -75,7 +85,6 @@ export const useCollaborativePageActions = (
useEffect(() => {
const realTimeStatelessMessageListener = editorRef?.listenToRealTimeUpdate();
const handleStatelessMessage = (message: { payload: TDocumentEventsClient }) => {
if (currentActionBeingProcessed === message.payload) {
setCurrentActionBeingProcessed(null);

View file

@ -1,3 +1,4 @@
import { useCallback, useMemo } from "react";
// plane editor
import { TEditorFontSize, TEditorFontStyle } from "@plane/editor";
// hooks
@ -22,39 +23,61 @@ export const usePageFilters = () => {
DEFAULT_PERSONALIZATION_VALUES
);
// stored values
const isFullWidth = !!pagesConfig?.full_width;
const fontSize = pagesConfig?.font_size ?? DEFAULT_PERSONALIZATION_VALUES.font_size;
const fontStyle = pagesConfig?.font_style ?? DEFAULT_PERSONALIZATION_VALUES.font_style;
const isFullWidth = useMemo(() => !!pagesConfig?.full_width, [pagesConfig?.full_width]);
const fontSize = useMemo(
() => pagesConfig?.font_size ?? DEFAULT_PERSONALIZATION_VALUES.font_size,
[pagesConfig?.font_size]
);
const fontStyle = useMemo(
() => pagesConfig?.font_style ?? DEFAULT_PERSONALIZATION_VALUES.font_style,
[pagesConfig?.font_style]
);
// update action
const handleUpdateConfig = (payload: Partial<TPagesPersonalizationConfig>) =>
const handleUpdateConfig = useCallback(
(payload: Partial<TPagesPersonalizationConfig>) => {
setPagesConfig({
...(pagesConfig ?? DEFAULT_PERSONALIZATION_VALUES),
...payload,
});
},
[pagesConfig, setPagesConfig]
);
/**
* @description action to update full_width value
* @param {boolean} value
*/
const handleFullWidth = (value: boolean) =>
const handleFullWidth = useCallback(
(value: boolean) => {
handleUpdateConfig({
full_width: value,
});
},
[handleUpdateConfig]
);
/**
* @description action to update font_size value
* @param {TEditorFontSize} value
*/
const handleFontSize = (value: TEditorFontSize) =>
const handleFontSize = useCallback(
(value: TEditorFontSize) => {
handleUpdateConfig({
font_size: value,
});
},
[handleUpdateConfig]
);
/**
* @description action to update font_size value
* @param {TEditorFontSize} value
*/
const handleFontStyle = (value: TEditorFontStyle) =>
const handleFontStyle = useCallback(
(value: TEditorFontStyle) => {
handleUpdateConfig({
font_style: value,
});
},
[handleUpdateConfig]
);
return {
fontSize,

View file

@ -0,0 +1,197 @@
import { useMemo } from "react";
import { useParams } from "next/navigation";
// plane editor
import { EditorRefApi } from "@plane/editor";
// plane types
import { EPageAccess } from "@plane/types/src/enums";
// plane ui
import { setToast, TOAST_TYPE } from "@plane/ui";
// helpers
import { copyUrlToClipboard } from "@/helpers/string.helper";
// hooks
import { useCollaborativePageActions } from "@/hooks/use-collaborative-page-actions";
// store types
import { TPageInstance } from "@/store/pages/base-page";
export type TPageOperations = {
toggleLock: () => void;
toggleAccess: () => void;
toggleFavorite: () => void;
openInNewTab: () => void;
copyLink: () => void;
duplicate: () => void;
toggleArchive: () => void;
};
type Props = {
editorRef?: EditorRefApi | null;
page: TPageInstance;
};
export const usePageOperations = (
props: Props
): {
pageOperations: TPageOperations;
} => {
const { page } = props;
// params
const { workspaceSlug, projectId } = useParams();
// derived values
const { access, addToFavorites, archived_at, duplicate, id, is_favorite, is_locked, removePageFromFavorites } = page;
// collaborative actions
const { executeCollaborativeAction } = useCollaborativePageActions(props);
// page operations
const pageOperations: TPageOperations = useMemo(() => {
const pageLink = projectId ? `${workspaceSlug}/projects/${projectId}/pages/${id}` : `${workspaceSlug}/pages/${id}`;
return {
copyLink: () => {
copyUrlToClipboard(pageLink).then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Link Copied!",
message: "Page link copied to clipboard.",
});
});
},
duplicate: async () => {
try {
await duplicate();
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Page duplicated successfully.",
});
} catch {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Page could not be duplicated. Please try again later.",
});
}
},
move: async () => {},
openInNewTab: () => window.open(`/${pageLink}`, "_blank"),
toggleAccess: async () => {
const changedPageType = access === EPageAccess.PUBLIC ? "private" : "public";
try {
if (access === EPageAccess.PUBLIC)
await executeCollaborativeAction({ type: "sendMessageToServer", message: "make-private" });
else await executeCollaborativeAction({ type: "sendMessageToServer", message: "make-public" });
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: `The page has been marked ${changedPageType} and moved to the ${changedPageType} section.`,
});
} catch {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: `The page couldn't be marked ${changedPageType}. Please try again.`,
});
}
},
toggleArchive: async () => {
if (archived_at) {
try {
await executeCollaborativeAction({ type: "sendMessageToServer", message: "unarchive" });
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Page restored successfully.",
});
} catch {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Page could not be restored. Please try again later.",
});
}
} else {
try {
await executeCollaborativeAction({ type: "sendMessageToServer", message: "archive" });
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Page archived successfully.",
});
} catch {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Page could not be archived. Please try again later.",
});
}
}
},
toggleFavorite: () => {
if (is_favorite) {
removePageFromFavorites().then(() =>
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Page removed from favorites.",
})
);
} else {
addToFavorites().then(() =>
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Page added to favorites.",
})
);
}
},
toggleLock: async () => {
if (is_locked) {
try {
await executeCollaborativeAction({ type: "sendMessageToServer", message: "unlock" });
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Page unlocked successfully.",
});
} catch {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Page could not be unlocked. Please try again later.",
});
}
} else {
try {
await executeCollaborativeAction({ type: "sendMessageToServer", message: "lock" });
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Page locked successfully.",
});
} catch {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Page could not be locked. Please try again later.",
});
}
}
},
};
}, [
access,
addToFavorites,
archived_at,
duplicate,
executeCollaborativeAction,
id,
is_favorite,
is_locked,
projectId,
removePageFromFavorites,
workspaceSlug,
]);
return {
pageOperations,
};
};

View file

@ -163,4 +163,12 @@ export class ProjectPageService extends APIService {
throw error;
});
}
async duplicate(workspaceSlug: string, projectId: string, pageId: string): Promise<TPage> {
return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/duplicate/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
}

View file

@ -21,8 +21,8 @@ export type TBasePage = TPage & {
update: (pageData: Partial<TPage>) => Promise<Partial<TPage> | undefined>;
updateTitle: (title: string) => void;
updateDescription: (document: TDocumentPayload) => Promise<void>;
makePublic: () => Promise<void>;
makePrivate: () => Promise<void>;
makePublic: (shouldSync?: boolean) => Promise<void>;
makePrivate: (shouldSync?: boolean) => Promise<void>;
lock: (shouldSync?: boolean) => Promise<void>;
unlock: (shouldSync?: boolean) => Promise<void>;
archive: (shouldSync?: boolean) => Promise<void>;
@ -30,9 +30,11 @@ export type TBasePage = TPage & {
updatePageLogo: (logo_props: TLogoProps) => Promise<void>;
addToFavorites: () => Promise<void>;
removePageFromFavorites: () => Promise<void>;
duplicate: () => Promise<TPage | undefined>;
};
export type TBasePagePermissions = {
canCurrentUserAccessPage: boolean;
canCurrentUserEditPage: boolean;
canCurrentUserDuplicatePage: boolean;
canCurrentUserLockPage: boolean;
@ -40,6 +42,7 @@ export type TBasePagePermissions = {
canCurrentUserArchivePage: boolean;
canCurrentUserDeletePage: boolean;
canCurrentUserFavoritePage: boolean;
canCurrentUserMovePage: boolean;
isContentEditable: boolean;
};
@ -53,6 +56,7 @@ export type TBasePageServices = {
archived_at: string;
}>;
restore: () => Promise<void>;
duplicate: () => Promise<TPage>;
};
export type TPageInstance = TBasePage &
@ -161,6 +165,7 @@ export class BasePage implements TBasePage {
updatePageLogo: action,
addToFavorites: action,
removePageFromFavorites: action,
duplicate: action,
});
this.rootStore = store;
@ -295,10 +300,13 @@ export class BasePage implements TBasePage {
/**
* @description make the page public
*/
makePublic = async () => {
makePublic = async (shouldSync: boolean = true) => {
const pageAccess = this.access;
runInAction(() => (this.access = EPageAccess.PUBLIC));
runInAction(() => {
this.access = EPageAccess.PUBLIC;
});
if (shouldSync) {
try {
await this.services.updateAccess({
access: EPageAccess.PUBLIC,
@ -309,15 +317,19 @@ export class BasePage implements TBasePage {
});
throw error;
}
}
};
/**
* @description make the page private
*/
makePrivate = async () => {
makePrivate = async (shouldSync: boolean = true) => {
const pageAccess = this.access;
runInAction(() => (this.access = EPageAccess.PRIVATE));
runInAction(() => {
this.access = EPageAccess.PRIVATE;
});
if (shouldSync) {
try {
await this.services.updateAccess({
access: EPageAccess.PRIVATE,
@ -328,6 +340,7 @@ export class BasePage implements TBasePage {
});
throw error;
}
}
};
/**
@ -468,4 +481,9 @@ export class BasePage implements TBasePage {
throw error;
});
};
/**
* @description duplicate the page
*/
duplicate = async () => await this.services.duplicate();
}

View file

@ -20,6 +20,8 @@ type TLoader = "init-loader" | "mutation-loader" | undefined;
type TError = { title: string; description: string };
export const ROLE_PERMISSIONS_TO_CREATE_PAGE = [EUserPermissions.ADMIN, EUserPermissions.MEMBER];
export interface IProjectPageStore {
// observables
loader: TLoader;
@ -44,6 +46,7 @@ export interface IProjectPageStore {
getPageById: (workspaceSlug: string, projectId: string, pageId: string) => Promise<TPage | undefined>;
createPage: (pageData: Partial<TPage>) => Promise<TPage | undefined>;
removePage: (pageId: string) => Promise<void>;
movePage: (workspaceSlug: string, projectId: string, pageId: string, newProjectId: string) => Promise<void>;
}
export class ProjectPageStore implements IProjectPageStore {
@ -78,6 +81,7 @@ export class ProjectPageStore implements IProjectPageStore {
getPageById: action,
createPage: action,
removePage: action,
movePage: action,
});
this.rootStore = store;
// service
@ -109,7 +113,7 @@ export class ProjectPageStore implements IProjectPageStore {
workspaceSlug?.toString() || "",
projectId?.toString() || ""
);
return !!currentUserProjectRole && currentUserProjectRole >= EUserPermissions.MEMBER;
return !!currentUserProjectRole && ROLE_PERMISSIONS_TO_CREATE_PAGE.includes(currentUserProjectRole);
}
/**
@ -294,4 +298,13 @@ export class ProjectPageStore implements IProjectPageStore {
throw error;
}
};
/**
* @description move a page to a new project
* @param {string} workspaceSlug
* @param {string} projectId
* @param {string} pageId
* @param {string} newProjectId
*/
movePage = async (workspaceSlug: string, projectId: string, pageId: string, newProjectId: string) => {};
}

View file

@ -50,9 +50,14 @@ export class ProjectPage extends BasePage implements TProjectPage {
if (!workspaceSlug || !projectId || !page.id) throw new Error("Missing required fields.");
await projectPageService.restore(workspaceSlug, projectId, page.id);
},
duplicate: async () => {
if (!workspaceSlug || !projectId || !page.id) throw new Error("Missing required fields.");
return await projectPageService.duplicate(workspaceSlug, projectId, page.id);
},
});
makeObservable(this, {
// computed
canCurrentUserAccessPage: computed,
canCurrentUserEditPage: computed,
canCurrentUserDuplicatePage: computed,
canCurrentUserLockPage: computed,
@ -60,6 +65,7 @@ export class ProjectPage extends BasePage implements TProjectPage {
canCurrentUserArchivePage: computed,
canCurrentUserDeletePage: computed,
canCurrentUserFavoritePage: computed,
canCurrentUserMovePage: computed,
isContentEditable: computed,
});
}
@ -81,6 +87,14 @@ export class ProjectPage extends BasePage implements TProjectPage {
return highestRole;
});
/**
* @description returns true if the current logged in user can access the page
*/
get canCurrentUserAccessPage() {
const isPagePublic = this.access === EPageAccess.PUBLIC;
return isPagePublic || this.isCurrentUserOwner;
}
/**
* @description returns true if the current logged in user can edit the page
*/
@ -141,6 +155,14 @@ export class ProjectPage extends BasePage implements TProjectPage {
return !!highestRole && highestRole >= EUserPermissions.MEMBER;
}
/**
* @description returns true if the current logged in user can move the page
*/
get canCurrentUserMovePage() {
const highestRole = this.getHighestRoleAcrossProjects();
return this.isCurrentUserOwner || highestRole === EUserPermissions.ADMIN;
}
/**
* @description returns true if the page can be edited
*/