[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:
parent
94f421f27d
commit
752a27a175
28 changed files with 735 additions and 418 deletions
|
|
@ -54,6 +54,8 @@ class PageSerializer(BaseSerializer):
|
||||||
labels = validated_data.pop("labels", None)
|
labels = validated_data.pop("labels", None)
|
||||||
project_id = self.context["project_id"]
|
project_id = self.context["project_id"]
|
||||||
owned_by_id = self.context["owned_by_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"]
|
description_html = self.context["description_html"]
|
||||||
|
|
||||||
# Get the workspace id from the project
|
# Get the workspace id from the project
|
||||||
|
|
@ -62,6 +64,8 @@ class PageSerializer(BaseSerializer):
|
||||||
# Create the page
|
# Create the page
|
||||||
page = Page.objects.create(
|
page = Page.objects.create(
|
||||||
**validated_data,
|
**validated_data,
|
||||||
|
description=description,
|
||||||
|
description_binary=description_binary,
|
||||||
description_html=description_html,
|
description_html=description_html,
|
||||||
owned_by_id=owned_by_id,
|
owned_by_id=owned_by_id,
|
||||||
workspace_id=project.workspace_id,
|
workspace_id=project.workspace_id,
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ from plane.app.views import (
|
||||||
SubPagesEndpoint,
|
SubPagesEndpoint,
|
||||||
PagesDescriptionViewSet,
|
PagesDescriptionViewSet,
|
||||||
PageVersionEndpoint,
|
PageVersionEndpoint,
|
||||||
|
PageDuplicateEndpoint,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -78,4 +79,9 @@ urlpatterns = [
|
||||||
PageVersionEndpoint.as_view(),
|
PageVersionEndpoint.as_view(),
|
||||||
name="page-versions",
|
name="page-versions",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/duplicate/",
|
||||||
|
PageDuplicateEndpoint.as_view(),
|
||||||
|
name="page-duplicate",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -155,6 +155,7 @@ from .page.base import (
|
||||||
PageLogEndpoint,
|
PageLogEndpoint,
|
||||||
SubPagesEndpoint,
|
SubPagesEndpoint,
|
||||||
PagesDescriptionViewSet,
|
PagesDescriptionViewSet,
|
||||||
|
PageDuplicateEndpoint,
|
||||||
)
|
)
|
||||||
from .page.version import PageVersionEndpoint
|
from .page.version import PageVersionEndpoint
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -121,6 +121,8 @@ class PageViewSet(BaseViewSet):
|
||||||
context={
|
context={
|
||||||
"project_id": project_id,
|
"project_id": project_id,
|
||||||
"owned_by_id": request.user.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>"),
|
"description_html": request.data.get("description_html", "<p></p>"),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
@ -553,3 +555,37 @@ class PagesDescriptionViewSet(BaseViewSet):
|
||||||
return Response({"message": "Updated successfully"})
|
return Response({"message": "Updated successfully"})
|
||||||
else:
|
else:
|
||||||
return Response({"error": "No binary data provided"})
|
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)
|
||||||
|
|
|
||||||
|
|
@ -3,4 +3,6 @@ export const DocumentCollaborativeEvents = {
|
||||||
unlock: { client: "unlocked", server: "unlock" },
|
unlock: { client: "unlocked", server: "unlock" },
|
||||||
archive: { client: "archived", server: "archive" },
|
archive: { client: "archived", server: "archive" },
|
||||||
unarchive: { client: "unarchived", server: "unarchive" },
|
unarchive: { client: "unarchived", server: "unarchive" },
|
||||||
|
"make-public": { client: "made-public", server: "make-public" },
|
||||||
|
"make-private": { client: "made-private", server: "make-private" },
|
||||||
} as const;
|
} as const;
|
||||||
|
|
|
||||||
|
|
@ -36,19 +36,23 @@ export const ContextMenuItem: React.FC<ContextMenuItemProps> = (props) => {
|
||||||
onMouseEnter={handleActiveItem}
|
onMouseEnter={handleActiveItem}
|
||||||
disabled={item.disabled}
|
disabled={item.disabled}
|
||||||
>
|
>
|
||||||
{item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />}
|
{item.customContent ?? (
|
||||||
<div>
|
<>
|
||||||
<h5>{item.title}</h5>
|
{item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />}
|
||||||
{item.description && (
|
<div>
|
||||||
<p
|
<h5>{item.title}</h5>
|
||||||
className={cn("text-custom-text-300 whitespace-pre-line", {
|
{item.description && (
|
||||||
"text-custom-text-400": item.disabled,
|
<p
|
||||||
})}
|
className={cn("text-custom-text-300 whitespace-pre-line", {
|
||||||
>
|
"text-custom-text-400": item.disabled,
|
||||||
{item.description}
|
})}
|
||||||
</p>
|
>
|
||||||
)}
|
{item.description}
|
||||||
</div>
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,8 @@ import { usePlatformOS } from "../../hooks/use-platform-os";
|
||||||
|
|
||||||
export type TContextMenuItem = {
|
export type TContextMenuItem = {
|
||||||
key: string;
|
key: string;
|
||||||
title: string;
|
customContent?: React.ReactNode;
|
||||||
|
title?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
icon?: React.FC<any>;
|
icon?: React.FC<any>;
|
||||||
action: () => void;
|
action: () => void;
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
|
||||||
if (referenceElement) referenceElement.focus();
|
if (referenceElement) referenceElement.focus();
|
||||||
};
|
};
|
||||||
const closeDropdown = () => {
|
const closeDropdown = () => {
|
||||||
isOpen && onMenuClose && onMenuClose();
|
if (isOpen) onMenuClose?.();
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -216,7 +216,7 @@ const MenuItem: React.FC<ICustomMenuItemProps> = (props) => {
|
||||||
)}
|
)}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
close();
|
close();
|
||||||
onClick && onClick(e);
|
onClick?.(e);
|
||||||
}}
|
}}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ const PageDetailsPage = observer(() => {
|
||||||
const { getWorkspaceBySlug } = useWorkspace();
|
const { getWorkspaceBySlug } = useWorkspace();
|
||||||
// derived values
|
// derived values
|
||||||
const workspaceId = workspaceSlug ? (getWorkspaceBySlug(workspaceSlug.toString())?.id ?? "") : "";
|
const workspaceId = workspaceSlug ? (getWorkspaceBySlug(workspaceSlug.toString())?.id ?? "") : "";
|
||||||
const { id, name, updateDescription } = page;
|
const { canCurrentUserAccessPage, id, name, updateDescription } = page;
|
||||||
// entity search handler
|
// entity search handler
|
||||||
const fetchEntityCallback = useCallback(
|
const fetchEntityCallback = useCallback(
|
||||||
async (payload: TSearchEntityRequestPayload) =>
|
async (payload: TSearchEntityRequestPayload) =>
|
||||||
|
|
@ -129,7 +129,7 @@ const PageDetailsPage = observer(() => {
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (pageDetailsError)
|
if (pageDetailsError || !canCurrentUserAccessPage)
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full flex flex-col items-center justify-center">
|
<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>
|
<h3 className="text-lg font-semibold text-center">Page not found</h3>
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,3 @@
|
||||||
export * from "./editor";
|
export * from "./editor";
|
||||||
|
export * from "./modals";
|
||||||
export * from "./extra-actions";
|
export * from "./extra-actions";
|
||||||
|
|
|
||||||
1
web/ce/components/pages/modals/index.ts
Normal file
1
web/ce/components/pages/modals/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export * from "./move-page-modal";
|
||||||
10
web/ce/components/pages/modals/move-page-modal.tsx
Normal file
10
web/ce/components/pages/modals/move-page-modal.tsx
Normal 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;
|
||||||
195
web/core/components/pages/dropdowns/actions.tsx
Normal file
195
web/core/components/pages/dropdowns/actions.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
@ -1,2 +1,2 @@
|
||||||
|
export * from "./actions";
|
||||||
export * from "./edit-information-popover";
|
export * from "./edit-information-popover";
|
||||||
export * from "./quick-actions";
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
@ -16,13 +16,12 @@ import useOnlineStatus from "@/hooks/use-online-status";
|
||||||
import { TPageInstance } from "@/store/pages/base-page";
|
import { TPageInstance } from "@/store/pages/base-page";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
editorRef: React.RefObject<EditorRefApi>;
|
editorRef: EditorRefApi;
|
||||||
handleDuplicatePage: () => void;
|
|
||||||
page: TPageInstance;
|
page: TPageInstance;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PageExtraOptions: React.FC<Props> = observer((props) => {
|
export const PageExtraOptions: React.FC<Props> = observer((props) => {
|
||||||
const { editorRef, handleDuplicatePage, page } = props;
|
const { editorRef, page } = props;
|
||||||
// derived values
|
// derived values
|
||||||
const {
|
const {
|
||||||
archived_at,
|
archived_at,
|
||||||
|
|
@ -84,8 +83,8 @@ export const PageExtraOptions: React.FC<Props> = observer((props) => {
|
||||||
iconClassName="text-custom-text-100"
|
iconClassName="text-custom-text-100"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<PageInfoPopover editorRef={editorRef?.current} />
|
<PageInfoPopover editorRef={editorRef} />
|
||||||
<PageOptionsDropdown editorRef={editorRef?.current} handleDuplicatePage={handleDuplicatePage} page={page} />
|
<PageOptionsDropdown editorRef={editorRef} page={page} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -9,38 +9,34 @@ import { usePageFilters } from "@/hooks/use-page-filters";
|
||||||
import { TPageInstance } from "@/store/pages/base-page";
|
import { TPageInstance } from "@/store/pages/base-page";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
editorReady: boolean;
|
editorRef: EditorRefApi;
|
||||||
editorRef: React.RefObject<EditorRefApi>;
|
|
||||||
handleDuplicatePage: () => void;
|
|
||||||
page: TPageInstance;
|
page: TPageInstance;
|
||||||
setSidePeekVisible: (sidePeekState: boolean) => void;
|
setSidePeekVisible: (sidePeekState: boolean) => void;
|
||||||
sidePeekVisible: boolean;
|
sidePeekVisible: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PageEditorMobileHeaderRoot: React.FC<Props> = observer((props) => {
|
export const PageEditorMobileHeaderRoot: React.FC<Props> = observer((props) => {
|
||||||
const { editorReady, editorRef, handleDuplicatePage, page, setSidePeekVisible, sidePeekVisible } = props;
|
const { editorRef, page, setSidePeekVisible, sidePeekVisible } = props;
|
||||||
// derived values
|
// derived values
|
||||||
const { isContentEditable } = page;
|
const { isContentEditable } = page;
|
||||||
// page filters
|
// page filters
|
||||||
const { isFullWidth } = usePageFilters();
|
const { isFullWidth } = usePageFilters();
|
||||||
|
|
||||||
if (!editorRef.current) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header variant={EHeaderVariant.SECONDARY}>
|
<Header variant={EHeaderVariant.SECONDARY}>
|
||||||
<div className="flex-shrink-0 my-auto">
|
<div className="flex-shrink-0 my-auto">
|
||||||
<PageSummaryPopover
|
<PageSummaryPopover
|
||||||
editorRef={editorRef.current}
|
editorRef={editorRef}
|
||||||
isFullWidth={isFullWidth}
|
isFullWidth={isFullWidth}
|
||||||
sidePeekVisible={sidePeekVisible}
|
sidePeekVisible={sidePeekVisible}
|
||||||
setSidePeekVisible={setSidePeekVisible}
|
setSidePeekVisible={setSidePeekVisible}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<PageExtraOptions editorRef={editorRef} handleDuplicatePage={handleDuplicatePage} page={page} />
|
<PageExtraOptions editorRef={editorRef} page={page} />
|
||||||
</Header>
|
</Header>
|
||||||
<Header variant={EHeaderVariant.TERNARY}>
|
<Header variant={EHeaderVariant.TERNARY}>
|
||||||
{editorReady && isContentEditable && editorRef.current && <PageToolbar editorRef={editorRef?.current} />}
|
{isContentEditable && editorRef && <PageToolbar editorRef={editorRef} />}
|
||||||
</Header>
|
</Header>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,160 +1,93 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import {
|
import { ArrowUpToLine, Clipboard, History } from "lucide-react";
|
||||||
ArchiveRestoreIcon,
|
|
||||||
ArrowUpToLine,
|
|
||||||
Clipboard,
|
|
||||||
Copy,
|
|
||||||
History,
|
|
||||||
Link,
|
|
||||||
Lock,
|
|
||||||
LockOpen,
|
|
||||||
LucideIcon,
|
|
||||||
} from "lucide-react";
|
|
||||||
// document editor
|
// document editor
|
||||||
import { EditorRefApi } from "@plane/editor";
|
import { EditorRefApi } from "@plane/editor";
|
||||||
// ui
|
// ui
|
||||||
import { ArchiveIcon, CustomMenu, type ISvgIcons, TOAST_TYPE, ToggleSwitch, setToast } from "@plane/ui";
|
import { TContextMenuItem, TOAST_TYPE, ToggleSwitch, setToast } from "@plane/ui";
|
||||||
// components
|
// components
|
||||||
import { ExportPageModal } from "@/components/pages";
|
import { ExportPageModal, PageActions, TPageActions } from "@/components/pages";
|
||||||
// helpers
|
// helpers
|
||||||
import { copyTextToClipboard, copyUrlToClipboard } from "@/helpers/string.helper";
|
import { copyTextToClipboard } from "@/helpers/string.helper";
|
||||||
// hooks
|
// hooks
|
||||||
import { useCollaborativePageActions } from "@/hooks/use-collaborative-page-actions";
|
|
||||||
import { usePageFilters } from "@/hooks/use-page-filters";
|
import { usePageFilters } from "@/hooks/use-page-filters";
|
||||||
import { useParseEditorContent } from "@/hooks/use-parse-editor-content";
|
|
||||||
import { useQueryParams } from "@/hooks/use-query-params";
|
import { useQueryParams } from "@/hooks/use-query-params";
|
||||||
// store
|
// store
|
||||||
import { TPageInstance } from "@/store/pages/base-page";
|
import { TPageInstance } from "@/store/pages/base-page";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
editorRef: EditorRefApi | null;
|
editorRef: EditorRefApi | null;
|
||||||
handleDuplicatePage: () => void;
|
|
||||||
page: TPageInstance;
|
page: TPageInstance;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PageOptionsDropdown: React.FC<Props> = observer((props) => {
|
export const PageOptionsDropdown: React.FC<Props> = observer((props) => {
|
||||||
const { editorRef, handleDuplicatePage, page } = props;
|
const { editorRef, page } = props;
|
||||||
|
// states
|
||||||
|
const [isExportModalOpen, setIsExportModalOpen] = useState(false);
|
||||||
// router
|
// router
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
// store values
|
// store values
|
||||||
const {
|
const { name } = page;
|
||||||
name,
|
|
||||||
archived_at,
|
|
||||||
is_locked,
|
|
||||||
id,
|
|
||||||
canCurrentUserArchivePage,
|
|
||||||
canCurrentUserDuplicatePage,
|
|
||||||
canCurrentUserLockPage,
|
|
||||||
} = page;
|
|
||||||
// states
|
|
||||||
const [isExportModalOpen, setIsExportModalOpen] = useState(false);
|
|
||||||
// store hooks
|
|
||||||
const { workspaceSlug, projectId } = useParams();
|
|
||||||
// page filters
|
// page filters
|
||||||
const { isFullWidth, handleFullWidth } = usePageFilters();
|
const { isFullWidth, handleFullWidth } = usePageFilters();
|
||||||
// update query params
|
// update query params
|
||||||
const { updateQueryParams } = useQueryParams();
|
const { updateQueryParams } = useQueryParams();
|
||||||
// collaborative actions
|
|
||||||
const { executeCollaborativeAction } = useCollaborativePageActions(editorRef, page);
|
|
||||||
// parse editor content
|
|
||||||
const { replaceCustomComponentsFromMarkdownContent } = useParseEditorContent();
|
|
||||||
|
|
||||||
// menu items list
|
// menu items list
|
||||||
const MENU_ITEMS: {
|
const EXTRA_MENU_OPTIONS: (TContextMenuItem & { key: TPageActions })[] = useMemo(
|
||||||
key: string;
|
() => [
|
||||||
action: () => void;
|
{
|
||||||
label: string;
|
key: "full-screen",
|
||||||
icon: LucideIcon | React.FC<ISvgIcons>;
|
action: () => handleFullWidth(!isFullWidth),
|
||||||
shouldRender: boolean;
|
customContent: (
|
||||||
}[] = [
|
<>
|
||||||
{
|
Full width
|
||||||
key: "copy-markdown",
|
<ToggleSwitch value={isFullWidth} onChange={() => {}} />
|
||||||
action: () => {
|
</>
|
||||||
if (!editorRef) return;
|
),
|
||||||
const markdownContent = editorRef.getMarkDown();
|
className: "flex items-center justify-between gap-2",
|
||||||
const parsedMarkdownContent = replaceCustomComponentsFromMarkdownContent({
|
|
||||||
markdownContent,
|
|
||||||
});
|
|
||||||
copyTextToClipboard(parsedMarkdownContent).then(() =>
|
|
||||||
setToast({
|
|
||||||
type: TOAST_TYPE.SUCCESS,
|
|
||||||
title: "Success!",
|
|
||||||
message: "Markdown copied to clipboard.",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
label: "Copy markdown",
|
{
|
||||||
icon: Clipboard,
|
key: "copy-markdown",
|
||||||
shouldRender: true,
|
action: () => {
|
||||||
},
|
if (!editorRef) return;
|
||||||
{
|
copyTextToClipboard(editorRef.getMarkDown()).then(() =>
|
||||||
key: "copy-page-link",
|
setToast({
|
||||||
action: () => {
|
type: TOAST_TYPE.SUCCESS,
|
||||||
const pageLink = projectId
|
title: "Success!",
|
||||||
? `${workspaceSlug?.toString()}/projects/${projectId?.toString()}/pages/${id}`
|
message: "Markdown copied to clipboard.",
|
||||||
: `${workspaceSlug?.toString()}/pages/${id}`;
|
})
|
||||||
copyUrlToClipboard(pageLink).then(() =>
|
);
|
||||||
setToast({
|
},
|
||||||
type: TOAST_TYPE.SUCCESS,
|
title: "Copy markdown",
|
||||||
title: "Success!",
|
icon: Clipboard,
|
||||||
message: "Page link copied to clipboard.",
|
shouldRender: true,
|
||||||
})
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
label: "Copy page link",
|
{
|
||||||
icon: Link,
|
key: "version-history",
|
||||||
shouldRender: true,
|
action: () => {
|
||||||
},
|
// add query param, version=current to the route
|
||||||
{
|
const updatedRoute = updateQueryParams({
|
||||||
key: "make-a-copy",
|
paramsToAdd: { version: "current" },
|
||||||
action: handleDuplicatePage,
|
});
|
||||||
label: "Make a copy",
|
router.push(updatedRoute);
|
||||||
icon: Copy,
|
},
|
||||||
shouldRender: canCurrentUserDuplicatePage,
|
title: "Version history",
|
||||||
},
|
icon: History,
|
||||||
{
|
shouldRender: true,
|
||||||
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: () => {
|
|
||||||
// add query param, version=current to the route
|
|
||||||
const updatedRoute = updateQueryParams({
|
|
||||||
paramsToAdd: { version: "current" },
|
|
||||||
});
|
|
||||||
router.push(updatedRoute);
|
|
||||||
},
|
},
|
||||||
label: "Version history",
|
{
|
||||||
icon: History,
|
key: "export",
|
||||||
shouldRender: true,
|
action: () => setIsExportModalOpen(true),
|
||||||
},
|
title: "Export",
|
||||||
{
|
icon: ArrowUpToLine,
|
||||||
key: "export",
|
shouldRender: true,
|
||||||
action: () => setIsExportModalOpen(true),
|
},
|
||||||
label: "Export",
|
],
|
||||||
icon: ArrowUpToLine,
|
[editorRef, handleFullWidth, isFullWidth, router, updateQueryParams]
|
||||||
shouldRender: true,
|
);
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -164,24 +97,23 @@ export const PageOptionsDropdown: React.FC<Props> = observer((props) => {
|
||||||
onClose={() => setIsExportModalOpen(false)}
|
onClose={() => setIsExportModalOpen(false)}
|
||||||
pageTitle={name ?? ""}
|
pageTitle={name ?? ""}
|
||||||
/>
|
/>
|
||||||
<CustomMenu maxHeight="lg" placement="bottom-start" verticalEllipsis closeOnSelect>
|
<PageActions
|
||||||
<CustomMenu.MenuItem
|
editorRef={editorRef}
|
||||||
className="hidden md:flex w-full items-center justify-between gap-2"
|
extraOptions={EXTRA_MENU_OPTIONS}
|
||||||
onClick={() => handleFullWidth(!isFullWidth)}
|
optionsOrder={[
|
||||||
>
|
"full-screen",
|
||||||
Full width
|
"copy-markdown",
|
||||||
<ToggleSwitch value={isFullWidth} onChange={() => {}} />
|
"copy-link",
|
||||||
</CustomMenu.MenuItem>
|
"toggle-lock",
|
||||||
{MENU_ITEMS.map((item) => {
|
"toggle-access",
|
||||||
if (!item.shouldRender) return null;
|
"make-a-copy",
|
||||||
return (
|
"archive-restore",
|
||||||
<CustomMenu.MenuItem key={item.key} onClick={item.action} className="flex items-center gap-2">
|
"delete",
|
||||||
<item.icon className="h-3 w-3" />
|
"version-history",
|
||||||
{item.label}
|
"export",
|
||||||
</CustomMenu.MenuItem>
|
]}
|
||||||
);
|
page={page}
|
||||||
})}
|
/>
|
||||||
</CustomMenu>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -13,20 +13,21 @@ import { TPageInstance } from "@/store/pages/base-page";
|
||||||
type Props = {
|
type Props = {
|
||||||
editorReady: boolean;
|
editorReady: boolean;
|
||||||
editorRef: React.RefObject<EditorRefApi>;
|
editorRef: React.RefObject<EditorRefApi>;
|
||||||
handleDuplicatePage: () => void;
|
|
||||||
page: TPageInstance;
|
page: TPageInstance;
|
||||||
setSidePeekVisible: (sidePeekState: boolean) => void;
|
setSidePeekVisible: (sidePeekState: boolean) => void;
|
||||||
sidePeekVisible: boolean;
|
sidePeekVisible: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PageEditorHeaderRoot: React.FC<Props> = observer((props) => {
|
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
|
// derived values
|
||||||
const { isContentEditable } = page;
|
const { isContentEditable } = page;
|
||||||
// page filters
|
// page filters
|
||||||
const { isFullWidth } = usePageFilters();
|
const { isFullWidth } = usePageFilters();
|
||||||
|
// derived values
|
||||||
|
const resolvedEditorRef = editorRef.current;
|
||||||
|
|
||||||
if (!editorRef.current) return null;
|
if (!resolvedEditorRef) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -49,13 +50,11 @@ export const PageEditorHeaderRoot: React.FC<Props> = observer((props) => {
|
||||||
)}
|
)}
|
||||||
{editorReady && isContentEditable && editorRef.current && <PageToolbar editorRef={editorRef?.current} />}
|
{editorReady && isContentEditable && editorRef.current && <PageToolbar editorRef={editorRef?.current} />}
|
||||||
</Header.LeftItem>
|
</Header.LeftItem>
|
||||||
<PageExtraOptions editorRef={editorRef} handleDuplicatePage={handleDuplicatePage} page={page} />
|
<PageExtraOptions editorRef={resolvedEditorRef} page={page} />
|
||||||
</Header>
|
</Header>
|
||||||
<div className="md:hidden">
|
<div className="md:hidden">
|
||||||
<PageEditorMobileHeaderRoot
|
<PageEditorMobileHeaderRoot
|
||||||
editorRef={editorRef}
|
editorRef={resolvedEditorRef}
|
||||||
editorReady={editorReady}
|
|
||||||
handleDuplicatePage={handleDuplicatePage}
|
|
||||||
page={page}
|
page={page}
|
||||||
sidePeekVisible={sidePeekVisible}
|
sidePeekVisible={sidePeekVisible}
|
||||||
setSidePeekVisible={setSidePeekVisible}
|
setSidePeekVisible={setSidePeekVisible}
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,6 @@ import { useSearchParams } from "next/navigation";
|
||||||
import { EditorRefApi } from "@plane/editor";
|
import { EditorRefApi } from "@plane/editor";
|
||||||
// types
|
// types
|
||||||
import { TDocumentPayload, TPage, TPageVersion } from "@plane/types";
|
import { TDocumentPayload, TPage, TPageVersion } from "@plane/types";
|
||||||
// ui
|
|
||||||
import { setToast, TOAST_TYPE } from "@plane/ui";
|
|
||||||
// components
|
// components
|
||||||
import {
|
import {
|
||||||
PageEditorHeaderRoot,
|
PageEditorHeaderRoot,
|
||||||
|
|
@ -55,7 +53,7 @@ export const PageRoot = observer((props: TPageRootProps) => {
|
||||||
// search params
|
// search params
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
// derived values
|
// derived values
|
||||||
const { access, description_html, name, isContentEditable } = page;
|
const { isContentEditable } = page;
|
||||||
// page fallback
|
// page fallback
|
||||||
usePageFallback({
|
usePageFallback({
|
||||||
editorRef,
|
editorRef,
|
||||||
|
|
@ -66,25 +64,6 @@ export const PageRoot = observer((props: TPageRootProps) => {
|
||||||
// update query params
|
// update query params
|
||||||
const { updateQueryParams } = useQueryParams();
|
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");
|
const version = searchParams.get("version");
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!version) {
|
if (!version) {
|
||||||
|
|
@ -124,7 +103,6 @@ export const PageRoot = observer((props: TPageRootProps) => {
|
||||||
<PageEditorHeaderRoot
|
<PageEditorHeaderRoot
|
||||||
editorReady={editorReady}
|
editorReady={editorReady}
|
||||||
editorRef={editorRef}
|
editorRef={editorRef}
|
||||||
handleDuplicatePage={handleDuplicatePage}
|
|
||||||
page={page}
|
page={page}
|
||||||
setSidePeekVisible={(state) => setSidePeekVisible(state)}
|
setSidePeekVisible={(state) => setSidePeekVisible(state)}
|
||||||
sidePeekVisible={sidePeekVisible}
|
sidePeekVisible={sidePeekVisible}
|
||||||
|
|
|
||||||
|
|
@ -4,14 +4,15 @@ import React, { FC } from "react";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import { Earth, Info, Lock, Minus } from "lucide-react";
|
import { Earth, Info, Lock, Minus } from "lucide-react";
|
||||||
// ui
|
// ui
|
||||||
import { Avatar, FavoriteStar, TOAST_TYPE, Tooltip, setToast } from "@plane/ui";
|
import { Avatar, FavoriteStar, Tooltip } from "@plane/ui";
|
||||||
// components
|
// components
|
||||||
import { PageQuickActions } from "@/components/pages/dropdowns";
|
import { PageActions } from "@/components/pages";
|
||||||
// helpers
|
// helpers
|
||||||
import { renderFormattedDate } from "@/helpers/date-time.helper";
|
import { renderFormattedDate } from "@/helpers/date-time.helper";
|
||||||
import { getFileURL } from "@/helpers/file.helper";
|
import { getFileURL } from "@/helpers/file.helper";
|
||||||
// hooks
|
// hooks
|
||||||
import { useMember } from "@/hooks/store";
|
import { useMember } from "@/hooks/store";
|
||||||
|
import { usePageOperations } from "@/hooks/use-page-operations";
|
||||||
import { TPageInstance } from "@/store/pages/base-page";
|
import { TPageInstance } from "@/store/pages/base-page";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
|
@ -23,6 +24,10 @@ export const BlockItemAction: FC<Props> = observer((props) => {
|
||||||
const { page, parentRef } = props;
|
const { page, parentRef } = props;
|
||||||
// store hooks
|
// store hooks
|
||||||
const { getUserDetails } = useMember();
|
const { getUserDetails } = useMember();
|
||||||
|
// page operations
|
||||||
|
const { pageOperations } = usePageOperations({
|
||||||
|
page,
|
||||||
|
});
|
||||||
// derived values
|
// derived values
|
||||||
const {
|
const {
|
||||||
access,
|
access,
|
||||||
|
|
@ -30,33 +35,9 @@ export const BlockItemAction: FC<Props> = observer((props) => {
|
||||||
is_favorite,
|
is_favorite,
|
||||||
owned_by,
|
owned_by,
|
||||||
canCurrentUserFavoritePage,
|
canCurrentUserFavoritePage,
|
||||||
addToFavorites,
|
|
||||||
removePageFromFavorites,
|
|
||||||
getRedirectionLink,
|
|
||||||
} = page;
|
} = page;
|
||||||
const ownerDetails = owned_by ? getUserDetails(owned_by) : undefined;
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* page details */}
|
{/* page details */}
|
||||||
|
|
@ -86,14 +67,26 @@ export const BlockItemAction: FC<Props> = observer((props) => {
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
handleFavorites();
|
pageOperations.toggleFavorite();
|
||||||
}}
|
}}
|
||||||
selected={is_favorite}
|
selected={is_favorite}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* quick actions dropdown */}
|
{/* 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}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||||
// plane editor
|
import { EditorRefApi, TDocumentEventsServer } from "@plane/editor";
|
||||||
import { EditorReadOnlyRefApi, EditorRefApi, TDocumentEventsServer } from "@plane/editor";
|
|
||||||
import { DocumentCollaborativeEvents, TDocumentEventsClient, getServerEventName } from "@plane/editor/lib";
|
import { DocumentCollaborativeEvents, TDocumentEventsClient, getServerEventName } from "@plane/editor/lib";
|
||||||
// plane ui
|
// plane ui
|
||||||
import { TOAST_TYPE, setToast } from "@plane/ui";
|
import { TOAST_TYPE, setToast } from "@plane/ui";
|
||||||
|
|
@ -17,10 +16,13 @@ type CollaborativeActionEvent =
|
||||||
| { type: "sendMessageToServer"; message: TDocumentEventsServer }
|
| { type: "sendMessageToServer"; message: TDocumentEventsServer }
|
||||||
| { type: "receivedMessageFromServer"; message: TDocumentEventsClient };
|
| { type: "receivedMessageFromServer"; message: TDocumentEventsClient };
|
||||||
|
|
||||||
export const useCollaborativePageActions = (
|
type Props = {
|
||||||
editorRef: EditorRefApi | EditorReadOnlyRefApi | null,
|
editorRef?: EditorRefApi | null;
|
||||||
page: TPageInstance
|
page: TPageInstance;
|
||||||
) => {
|
};
|
||||||
|
|
||||||
|
export const useCollaborativePageActions = (props: Props) => {
|
||||||
|
const { editorRef, page } = props;
|
||||||
// currentUserAction local state to track if the current action is being processed, a
|
// 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
|
// local action is basically the action performed by the current user to avoid double operations
|
||||||
const [currentActionBeingProcessed, setCurrentActionBeingProcessed] = useState<TDocumentEventsClient | null>(null);
|
const [currentActionBeingProcessed, setCurrentActionBeingProcessed] = useState<TDocumentEventsClient | null>(null);
|
||||||
|
|
@ -43,6 +45,14 @@ export const useCollaborativePageActions = (
|
||||||
execute: (shouldSync) => page.restore(shouldSync),
|
execute: (shouldSync) => page.restore(shouldSync),
|
||||||
errorMessage: "Page could not be restored. Please try again later.",
|
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]
|
[page]
|
||||||
);
|
);
|
||||||
|
|
@ -75,7 +85,6 @@ export const useCollaborativePageActions = (
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const realTimeStatelessMessageListener = editorRef?.listenToRealTimeUpdate();
|
const realTimeStatelessMessageListener = editorRef?.listenToRealTimeUpdate();
|
||||||
|
|
||||||
const handleStatelessMessage = (message: { payload: TDocumentEventsClient }) => {
|
const handleStatelessMessage = (message: { payload: TDocumentEventsClient }) => {
|
||||||
if (currentActionBeingProcessed === message.payload) {
|
if (currentActionBeingProcessed === message.payload) {
|
||||||
setCurrentActionBeingProcessed(null);
|
setCurrentActionBeingProcessed(null);
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { useCallback, useMemo } from "react";
|
||||||
// plane editor
|
// plane editor
|
||||||
import { TEditorFontSize, TEditorFontStyle } from "@plane/editor";
|
import { TEditorFontSize, TEditorFontStyle } from "@plane/editor";
|
||||||
// hooks
|
// hooks
|
||||||
|
|
@ -22,39 +23,61 @@ export const usePageFilters = () => {
|
||||||
DEFAULT_PERSONALIZATION_VALUES
|
DEFAULT_PERSONALIZATION_VALUES
|
||||||
);
|
);
|
||||||
// stored values
|
// stored values
|
||||||
const isFullWidth = !!pagesConfig?.full_width;
|
const isFullWidth = useMemo(() => !!pagesConfig?.full_width, [pagesConfig?.full_width]);
|
||||||
const fontSize = pagesConfig?.font_size ?? DEFAULT_PERSONALIZATION_VALUES.font_size;
|
const fontSize = useMemo(
|
||||||
const fontStyle = pagesConfig?.font_style ?? DEFAULT_PERSONALIZATION_VALUES.font_style;
|
() => 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
|
// update action
|
||||||
const handleUpdateConfig = (payload: Partial<TPagesPersonalizationConfig>) =>
|
const handleUpdateConfig = useCallback(
|
||||||
setPagesConfig({
|
(payload: Partial<TPagesPersonalizationConfig>) => {
|
||||||
...(pagesConfig ?? DEFAULT_PERSONALIZATION_VALUES),
|
setPagesConfig({
|
||||||
...payload,
|
...(pagesConfig ?? DEFAULT_PERSONALIZATION_VALUES),
|
||||||
});
|
...payload,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[pagesConfig, setPagesConfig]
|
||||||
|
);
|
||||||
/**
|
/**
|
||||||
* @description action to update full_width value
|
* @description action to update full_width value
|
||||||
* @param {boolean} value
|
* @param {boolean} value
|
||||||
*/
|
*/
|
||||||
const handleFullWidth = (value: boolean) =>
|
const handleFullWidth = useCallback(
|
||||||
handleUpdateConfig({
|
(value: boolean) => {
|
||||||
full_width: value,
|
handleUpdateConfig({
|
||||||
});
|
full_width: value,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[handleUpdateConfig]
|
||||||
|
);
|
||||||
/**
|
/**
|
||||||
* @description action to update font_size value
|
* @description action to update font_size value
|
||||||
* @param {TEditorFontSize} value
|
* @param {TEditorFontSize} value
|
||||||
*/
|
*/
|
||||||
const handleFontSize = (value: TEditorFontSize) =>
|
const handleFontSize = useCallback(
|
||||||
handleUpdateConfig({
|
(value: TEditorFontSize) => {
|
||||||
font_size: value,
|
handleUpdateConfig({
|
||||||
});
|
font_size: value,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[handleUpdateConfig]
|
||||||
|
);
|
||||||
/**
|
/**
|
||||||
* @description action to update font_size value
|
* @description action to update font_size value
|
||||||
* @param {TEditorFontSize} value
|
* @param {TEditorFontSize} value
|
||||||
*/
|
*/
|
||||||
const handleFontStyle = (value: TEditorFontStyle) =>
|
const handleFontStyle = useCallback(
|
||||||
handleUpdateConfig({
|
(value: TEditorFontStyle) => {
|
||||||
font_style: value,
|
handleUpdateConfig({
|
||||||
});
|
font_style: value,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[handleUpdateConfig]
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
fontSize,
|
fontSize,
|
||||||
|
|
|
||||||
197
web/core/hooks/use-page-operations.ts
Normal file
197
web/core/hooks/use-page-operations.ts
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -163,4 +163,12 @@ export class ProjectPageService extends APIService {
|
||||||
throw error;
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,8 +21,8 @@ export type TBasePage = TPage & {
|
||||||
update: (pageData: Partial<TPage>) => Promise<Partial<TPage> | undefined>;
|
update: (pageData: Partial<TPage>) => Promise<Partial<TPage> | undefined>;
|
||||||
updateTitle: (title: string) => void;
|
updateTitle: (title: string) => void;
|
||||||
updateDescription: (document: TDocumentPayload) => Promise<void>;
|
updateDescription: (document: TDocumentPayload) => Promise<void>;
|
||||||
makePublic: () => Promise<void>;
|
makePublic: (shouldSync?: boolean) => Promise<void>;
|
||||||
makePrivate: () => Promise<void>;
|
makePrivate: (shouldSync?: boolean) => Promise<void>;
|
||||||
lock: (shouldSync?: boolean) => Promise<void>;
|
lock: (shouldSync?: boolean) => Promise<void>;
|
||||||
unlock: (shouldSync?: boolean) => Promise<void>;
|
unlock: (shouldSync?: boolean) => Promise<void>;
|
||||||
archive: (shouldSync?: boolean) => Promise<void>;
|
archive: (shouldSync?: boolean) => Promise<void>;
|
||||||
|
|
@ -30,9 +30,11 @@ export type TBasePage = TPage & {
|
||||||
updatePageLogo: (logo_props: TLogoProps) => Promise<void>;
|
updatePageLogo: (logo_props: TLogoProps) => Promise<void>;
|
||||||
addToFavorites: () => Promise<void>;
|
addToFavorites: () => Promise<void>;
|
||||||
removePageFromFavorites: () => Promise<void>;
|
removePageFromFavorites: () => Promise<void>;
|
||||||
|
duplicate: () => Promise<TPage | undefined>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TBasePagePermissions = {
|
export type TBasePagePermissions = {
|
||||||
|
canCurrentUserAccessPage: boolean;
|
||||||
canCurrentUserEditPage: boolean;
|
canCurrentUserEditPage: boolean;
|
||||||
canCurrentUserDuplicatePage: boolean;
|
canCurrentUserDuplicatePage: boolean;
|
||||||
canCurrentUserLockPage: boolean;
|
canCurrentUserLockPage: boolean;
|
||||||
|
|
@ -40,6 +42,7 @@ export type TBasePagePermissions = {
|
||||||
canCurrentUserArchivePage: boolean;
|
canCurrentUserArchivePage: boolean;
|
||||||
canCurrentUserDeletePage: boolean;
|
canCurrentUserDeletePage: boolean;
|
||||||
canCurrentUserFavoritePage: boolean;
|
canCurrentUserFavoritePage: boolean;
|
||||||
|
canCurrentUserMovePage: boolean;
|
||||||
isContentEditable: boolean;
|
isContentEditable: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -53,6 +56,7 @@ export type TBasePageServices = {
|
||||||
archived_at: string;
|
archived_at: string;
|
||||||
}>;
|
}>;
|
||||||
restore: () => Promise<void>;
|
restore: () => Promise<void>;
|
||||||
|
duplicate: () => Promise<TPage>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TPageInstance = TBasePage &
|
export type TPageInstance = TBasePage &
|
||||||
|
|
@ -161,6 +165,7 @@ export class BasePage implements TBasePage {
|
||||||
updatePageLogo: action,
|
updatePageLogo: action,
|
||||||
addToFavorites: action,
|
addToFavorites: action,
|
||||||
removePageFromFavorites: action,
|
removePageFromFavorites: action,
|
||||||
|
duplicate: action,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.rootStore = store;
|
this.rootStore = store;
|
||||||
|
|
@ -295,38 +300,46 @@ export class BasePage implements TBasePage {
|
||||||
/**
|
/**
|
||||||
* @description make the page public
|
* @description make the page public
|
||||||
*/
|
*/
|
||||||
makePublic = async () => {
|
makePublic = async (shouldSync: boolean = true) => {
|
||||||
const pageAccess = this.access;
|
const pageAccess = this.access;
|
||||||
runInAction(() => (this.access = EPageAccess.PUBLIC));
|
runInAction(() => {
|
||||||
|
this.access = EPageAccess.PUBLIC;
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
if (shouldSync) {
|
||||||
await this.services.updateAccess({
|
try {
|
||||||
access: EPageAccess.PUBLIC,
|
await this.services.updateAccess({
|
||||||
});
|
access: EPageAccess.PUBLIC,
|
||||||
} catch (error) {
|
});
|
||||||
runInAction(() => {
|
} catch (error) {
|
||||||
this.access = pageAccess;
|
runInAction(() => {
|
||||||
});
|
this.access = pageAccess;
|
||||||
throw error;
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description make the page private
|
* @description make the page private
|
||||||
*/
|
*/
|
||||||
makePrivate = async () => {
|
makePrivate = async (shouldSync: boolean = true) => {
|
||||||
const pageAccess = this.access;
|
const pageAccess = this.access;
|
||||||
runInAction(() => (this.access = EPageAccess.PRIVATE));
|
runInAction(() => {
|
||||||
|
this.access = EPageAccess.PRIVATE;
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
if (shouldSync) {
|
||||||
await this.services.updateAccess({
|
try {
|
||||||
access: EPageAccess.PRIVATE,
|
await this.services.updateAccess({
|
||||||
});
|
access: EPageAccess.PRIVATE,
|
||||||
} catch (error) {
|
});
|
||||||
runInAction(() => {
|
} catch (error) {
|
||||||
this.access = pageAccess;
|
runInAction(() => {
|
||||||
});
|
this.access = pageAccess;
|
||||||
throw error;
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -468,4 +481,9 @@ export class BasePage implements TBasePage {
|
||||||
throw error;
|
throw error;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description duplicate the page
|
||||||
|
*/
|
||||||
|
duplicate = async () => await this.services.duplicate();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,8 @@ type TLoader = "init-loader" | "mutation-loader" | undefined;
|
||||||
|
|
||||||
type TError = { title: string; description: string };
|
type TError = { title: string; description: string };
|
||||||
|
|
||||||
|
export const ROLE_PERMISSIONS_TO_CREATE_PAGE = [EUserPermissions.ADMIN, EUserPermissions.MEMBER];
|
||||||
|
|
||||||
export interface IProjectPageStore {
|
export interface IProjectPageStore {
|
||||||
// observables
|
// observables
|
||||||
loader: TLoader;
|
loader: TLoader;
|
||||||
|
|
@ -44,6 +46,7 @@ export interface IProjectPageStore {
|
||||||
getPageById: (workspaceSlug: string, projectId: string, pageId: string) => Promise<TPage | undefined>;
|
getPageById: (workspaceSlug: string, projectId: string, pageId: string) => Promise<TPage | undefined>;
|
||||||
createPage: (pageData: Partial<TPage>) => Promise<TPage | undefined>;
|
createPage: (pageData: Partial<TPage>) => Promise<TPage | undefined>;
|
||||||
removePage: (pageId: string) => Promise<void>;
|
removePage: (pageId: string) => Promise<void>;
|
||||||
|
movePage: (workspaceSlug: string, projectId: string, pageId: string, newProjectId: string) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ProjectPageStore implements IProjectPageStore {
|
export class ProjectPageStore implements IProjectPageStore {
|
||||||
|
|
@ -78,6 +81,7 @@ export class ProjectPageStore implements IProjectPageStore {
|
||||||
getPageById: action,
|
getPageById: action,
|
||||||
createPage: action,
|
createPage: action,
|
||||||
removePage: action,
|
removePage: action,
|
||||||
|
movePage: action,
|
||||||
});
|
});
|
||||||
this.rootStore = store;
|
this.rootStore = store;
|
||||||
// service
|
// service
|
||||||
|
|
@ -109,7 +113,7 @@ export class ProjectPageStore implements IProjectPageStore {
|
||||||
workspaceSlug?.toString() || "",
|
workspaceSlug?.toString() || "",
|
||||||
projectId?.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;
|
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) => {};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -50,9 +50,14 @@ export class ProjectPage extends BasePage implements TProjectPage {
|
||||||
if (!workspaceSlug || !projectId || !page.id) throw new Error("Missing required fields.");
|
if (!workspaceSlug || !projectId || !page.id) throw new Error("Missing required fields.");
|
||||||
await projectPageService.restore(workspaceSlug, projectId, page.id);
|
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, {
|
makeObservable(this, {
|
||||||
// computed
|
// computed
|
||||||
|
canCurrentUserAccessPage: computed,
|
||||||
canCurrentUserEditPage: computed,
|
canCurrentUserEditPage: computed,
|
||||||
canCurrentUserDuplicatePage: computed,
|
canCurrentUserDuplicatePage: computed,
|
||||||
canCurrentUserLockPage: computed,
|
canCurrentUserLockPage: computed,
|
||||||
|
|
@ -60,6 +65,7 @@ export class ProjectPage extends BasePage implements TProjectPage {
|
||||||
canCurrentUserArchivePage: computed,
|
canCurrentUserArchivePage: computed,
|
||||||
canCurrentUserDeletePage: computed,
|
canCurrentUserDeletePage: computed,
|
||||||
canCurrentUserFavoritePage: computed,
|
canCurrentUserFavoritePage: computed,
|
||||||
|
canCurrentUserMovePage: computed,
|
||||||
isContentEditable: computed,
|
isContentEditable: computed,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -81,6 +87,14 @@ export class ProjectPage extends BasePage implements TProjectPage {
|
||||||
return highestRole;
|
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
|
* @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;
|
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
|
* @description returns true if the page can be edited
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue