diff --git a/apiserver/plane/app/serializers/page.py b/apiserver/plane/app/serializers/page.py index b69221081f4..1fd2f4d3c84 100644 --- a/apiserver/plane/app/serializers/page.py +++ b/apiserver/plane/app/serializers/page.py @@ -54,6 +54,8 @@ def create(self, validated_data): 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 @@ def create(self, validated_data): # 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, diff --git a/apiserver/plane/app/urls/page.py b/apiserver/plane/app/urls/page.py index b49f1d4a28d..f7eb7e42429 100644 --- a/apiserver/plane/app/urls/page.py +++ b/apiserver/plane/app/urls/page.py @@ -8,6 +8,7 @@ SubPagesEndpoint, PagesDescriptionViewSet, PageVersionEndpoint, + PageDuplicateEndpoint, ) @@ -78,4 +79,9 @@ 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", + ), ] diff --git a/apiserver/plane/app/views/__init__.py b/apiserver/plane/app/views/__init__.py index 581a1065d96..7c1e801f9d0 100644 --- a/apiserver/plane/app/views/__init__.py +++ b/apiserver/plane/app/views/__init__.py @@ -155,6 +155,7 @@ PageLogEndpoint, SubPagesEndpoint, PagesDescriptionViewSet, + PageDuplicateEndpoint, ) from .page.version import PageVersionEndpoint diff --git a/apiserver/plane/app/views/page/base.py b/apiserver/plane/app/views/page/base.py index 46ce81ce179..8f22845a227 100644 --- a/apiserver/plane/app/views/page/base.py +++ b/apiserver/plane/app/views/page/base.py @@ -121,6 +121,8 @@ def create(self, request, slug, project_id): 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,33 @@ def partial_update(self, request, slug, project_id, pk): 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 + ).values() + new_page_data = list(page)[0] + new_page_data.name = f"{new_page_data.name} (Copy)" + + serializer = PageSerializer( + data=new_page_data, + context={ + "project_id": project_id, + "owned_by_id": request.user.id, + "description": new_page_data.description, + "description_binary": new_page_data.description_binary, + "description_html": new_page_data.description_html, + }, + ) + + if serializer.is_valid(): + serializer.save() + # capture the page transaction + page_transaction.delay(request.data, None, serializer.data["id"]) + page = Page.objects.get(pk=serializer.data["id"]) + serializer = PageDetailSerializer(page) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) diff --git a/packages/editor/src/core/constants/document-collaborative-events.ts b/packages/editor/src/core/constants/document-collaborative-events.ts index 5e79efc7a71..72e8b1dbded 100644 --- a/packages/editor/src/core/constants/document-collaborative-events.ts +++ b/packages/editor/src/core/constants/document-collaborative-events.ts @@ -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; diff --git a/packages/ui/src/dropdowns/context-menu/item.tsx b/packages/ui/src/dropdowns/context-menu/item.tsx index 99ef790e3f6..83124392082 100644 --- a/packages/ui/src/dropdowns/context-menu/item.tsx +++ b/packages/ui/src/dropdowns/context-menu/item.tsx @@ -36,19 +36,23 @@ export const ContextMenuItem: React.FC<ContextMenuItemProps> = (props) => { onMouseEnter={handleActiveItem} disabled={item.disabled} > - {item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />} - <div> - <h5>{item.title}</h5> - {item.description && ( - <p - className={cn("text-custom-text-300 whitespace-pre-line", { - "text-custom-text-400": item.disabled, - })} - > - {item.description} - </p> - )} - </div> + {item.customContent ?? ( + <> + {item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />} + <div> + <h5>{item.title}</h5> + {item.description && ( + <p + className={cn("text-custom-text-300 whitespace-pre-line", { + "text-custom-text-400": item.disabled, + })} + > + {item.description} + </p> + )} + </div> + </> + )} </button> ); }; diff --git a/packages/ui/src/dropdowns/context-menu/root.tsx b/packages/ui/src/dropdowns/context-menu/root.tsx index f251696d212..e4265f1007b 100644 --- a/packages/ui/src/dropdowns/context-menu/root.tsx +++ b/packages/ui/src/dropdowns/context-menu/root.tsx @@ -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; diff --git a/packages/ui/src/dropdowns/custom-menu.tsx b/packages/ui/src/dropdowns/custom-menu.tsx index 39f01d1ed27..f21da438196 100644 --- a/packages/ui/src/dropdowns/custom-menu.tsx +++ b/packages/ui/src/dropdowns/custom-menu.tsx @@ -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} > diff --git a/web/ce/components/pages/index.ts b/web/ce/components/pages/index.ts index 6f3d30c9a95..c4131c5f95a 100644 --- a/web/ce/components/pages/index.ts +++ b/web/ce/components/pages/index.ts @@ -1,2 +1,3 @@ export * from "./editor"; +export * from "./modals"; export * from "./extra-actions"; diff --git a/web/ce/components/pages/modals/index.ts b/web/ce/components/pages/modals/index.ts new file mode 100644 index 00000000000..da78df1c843 --- /dev/null +++ b/web/ce/components/pages/modals/index.ts @@ -0,0 +1 @@ +export * from "./move-page-modal"; diff --git a/web/ce/components/pages/modals/move-page-modal.tsx b/web/ce/components/pages/modals/move-page-modal.tsx new file mode 100644 index 00000000000..3fac0f6a042 --- /dev/null +++ b/web/ce/components/pages/modals/move-page-modal.tsx @@ -0,0 +1,10 @@ +// store types +import { IPage } from "@/store/pages/page"; + +export type TMovePageModalProps = { + isOpen: boolean; + onClose: () => void; + page: IPage; +}; + +export const MovePageModal: React.FC<TMovePageModalProps> = () => null; diff --git a/web/core/components/pages/dropdowns/actions.tsx b/web/core/components/pages/dropdowns/actions.tsx new file mode 100644 index 00000000000..f979d7f43bd --- /dev/null +++ b/web/core/components/pages/dropdowns/actions.tsx @@ -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 { EditorReadOnlyRefApi, 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 { IPage } from "@/store/pages/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 | EditorReadOnlyRefApi | null; + extraOptions?: (TContextMenuItem & { key: TPageActions })[]; + optionsOrder: TPageActions[]; + page: IPage; + 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( + () => [ + { + 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, + }, + ], + [ + access, + archived_at, + is_locked, + canCurrentUserArchivePage, + canCurrentUserChangeAccess, + canCurrentUserDeletePage, + canCurrentUserDuplicatePage, + canCurrentUserLockPage, + canCurrentUserMovePage, + pageOperations, + ] + ); + if (extraOptions) { + MENU_ITEMS.push(...extraOptions); + } + // 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)} pageId={page.id ?? ""} /> + {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> + </> + ); +}); diff --git a/web/core/components/pages/dropdowns/index.ts b/web/core/components/pages/dropdowns/index.ts index 16d9c337209..74ebad1d675 100644 --- a/web/core/components/pages/dropdowns/index.ts +++ b/web/core/components/pages/dropdowns/index.ts @@ -1,2 +1,2 @@ +export * from "./actions"; export * from "./edit-information-popover"; -export * from "./quick-actions"; diff --git a/web/core/components/pages/dropdowns/quick-actions.tsx b/web/core/components/pages/dropdowns/quick-actions.tsx deleted file mode 100644 index 6bed6be2c65..00000000000 --- a/web/core/components/pages/dropdowns/quick-actions.tsx +++ /dev/null @@ -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 { IPage } from "@/store/pages/page"; - -type Props = { - page: IPage; - 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 (err) { - 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)} pageId={page.id ?? ""} /> - <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> - </> - ); -}); diff --git a/web/core/components/pages/editor/header/extra-options.tsx b/web/core/components/pages/editor/header/extra-options.tsx index f21959cd93d..bfc6d35eaf7 100644 --- a/web/core/components/pages/editor/header/extra-options.tsx +++ b/web/core/components/pages/editor/header/extra-options.tsx @@ -16,14 +16,12 @@ import useOnlineStatus from "@/hooks/use-online-status"; import { IPage } from "@/store/pages/page"; type Props = { - editorRef: React.RefObject<EditorRefApi>; - handleDuplicatePage: () => void; + editorRef: EditorRefApi | EditorReadOnlyRefApi | null; page: IPage; - readOnlyEditorRef: React.RefObject<EditorReadOnlyRefApi>; }; export const PageExtraOptions: React.FC<Props> = observer((props) => { - const { editorRef, handleDuplicatePage, page, readOnlyEditorRef } = props; + const { editorRef, page } = props; // derived values const { archived_at, @@ -85,12 +83,8 @@ export const PageExtraOptions: React.FC<Props> = observer((props) => { iconClassName="text-custom-text-100" /> )} - <PageInfoPopover editorRef={isContentEditable ? editorRef.current : readOnlyEditorRef.current} /> - <PageOptionsDropdown - editorRef={isContentEditable ? editorRef.current : readOnlyEditorRef.current} - handleDuplicatePage={handleDuplicatePage} - page={page} - /> + <PageInfoPopover editorRef={editorRef} /> + <PageOptionsDropdown editorRef={editorRef} page={page} /> </div> ); }); diff --git a/web/core/components/pages/editor/header/mobile-root.tsx b/web/core/components/pages/editor/header/mobile-root.tsx index ac831796cbe..003237d915a 100644 --- a/web/core/components/pages/editor/header/mobile-root.tsx +++ b/web/core/components/pages/editor/header/mobile-root.tsx @@ -1,5 +1,5 @@ import { observer } from "mobx-react"; -import { EditorReadOnlyRefApi, EditorRefApi, IMarking } from "@plane/editor"; +import { EditorReadOnlyRefApi, EditorRefApi } from "@plane/editor"; // components import { Header, EHeaderVariant } from "@plane/ui"; import { PageExtraOptions, PageSummaryPopover, PageToolbar } from "@/components/pages"; @@ -9,56 +9,34 @@ import { usePageFilters } from "@/hooks/use-page-filters"; import { IPage } from "@/store/pages/page"; type Props = { - editorReady: boolean; - editorRef: React.RefObject<EditorRefApi>; - handleDuplicatePage: () => void; + editorRef: EditorRefApi | EditorReadOnlyRefApi | null; page: IPage; - readOnlyEditorReady: boolean; - readOnlyEditorRef: React.RefObject<EditorReadOnlyRefApi>; setSidePeekVisible: (sidePeekState: boolean) => void; sidePeekVisible: boolean; }; export const PageEditorMobileHeaderRoot: React.FC<Props> = observer((props) => { - const { - editorReady, - editorRef, - handleDuplicatePage, - page, - readOnlyEditorReady, - readOnlyEditorRef, - setSidePeekVisible, - sidePeekVisible, - } = props; + const { editorRef, page, setSidePeekVisible, sidePeekVisible } = props; // derived values const { isContentEditable } = page; // page filters const { isFullWidth } = usePageFilters(); - if (!editorRef.current && !readOnlyEditorRef.current) return null; - return ( <> <Header variant={EHeaderVariant.SECONDARY}> <div className="flex-shrink-0 my-auto"> <PageSummaryPopover - editorRef={isContentEditable ? editorRef.current : readOnlyEditorRef.current} + editorRef={editorRef} isFullWidth={isFullWidth} sidePeekVisible={sidePeekVisible} setSidePeekVisible={setSidePeekVisible} /> </div> - <PageExtraOptions - editorRef={editorRef} - handleDuplicatePage={handleDuplicatePage} - page={page} - readOnlyEditorRef={readOnlyEditorRef} - /> + <PageExtraOptions editorRef={editorRef} page={page} /> </Header> <Header variant={EHeaderVariant.TERNARY}> - {(editorReady || readOnlyEditorReady) && isContentEditable && editorRef.current && ( - <PageToolbar editorRef={editorRef?.current} /> - )} + {isContentEditable && editorRef && <PageToolbar editorRef={editorRef as EditorRefApi} />} </Header> </> ); diff --git a/web/core/components/pages/editor/header/options-dropdown.tsx b/web/core/components/pages/editor/header/options-dropdown.tsx index ff0987a9dc2..3793eee9d28 100644 --- a/web/core/components/pages/editor/header/options-dropdown.tsx +++ b/web/core/components/pages/editor/header/options-dropdown.tsx @@ -1,29 +1,18 @@ "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 { EditorReadOnlyRefApi, 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 { useQueryParams } from "@/hooks/use-query-params"; // store @@ -31,123 +20,74 @@ import { IPage } from "@/store/pages/page"; type Props = { editorRef: EditorRefApi | EditorReadOnlyRefApi | null; - handleDuplicatePage: () => void; page: IPage; }; 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); - // menu items list - const MENU_ITEMS: { - key: string; - action: () => void; - label: string; - icon: LucideIcon | React.FC<ISvgIcons>; - shouldRender: boolean; - }[] = [ - { - key: "copy-markdown", - action: () => { - if (!editorRef) return; - copyTextToClipboard(editorRef.getMarkDown()).then(() => - setToast({ - type: TOAST_TYPE.SUCCESS, - title: "Success!", - message: "Markdown copied to clipboard.", - }) - ); + 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", }, - label: "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.", - }) - ); + { + key: "copy-markdown", + action: () => { + if (!editorRef) return; + copyTextToClipboard(editorRef.getMarkDown()).then(() => + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Success!", + message: "Markdown copied to clipboard.", + }) + ); + }, + title: "Copy markdown", + icon: Clipboard, + shouldRender: true, }, - 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: () => { - // add query param, version=current to the route - const updatedRoute = updateQueryParams({ - paramsToAdd: { version: "current" }, - }); - router.push(updatedRoute); + { + key: "version-history", + action: () => { + // add query param, version=current to the route + const updatedRoute = updateQueryParams({ + paramsToAdd: { version: "current" }, + }); + router.push(updatedRoute); + }, + title: "Version history", + icon: History, + shouldRender: true, }, - label: "Version history", - icon: History, - shouldRender: true, - }, - { - key: "export", - action: () => setIsExportModalOpen(true), - label: "Export", - icon: ArrowUpToLine, - shouldRender: true, - }, - ]; + { + key: "export", + action: () => setIsExportModalOpen(true), + title: "Export", + icon: ArrowUpToLine, + shouldRender: true, + }, + ], + [editorRef, handleFullWidth, isFullWidth, router, updateQueryParams] + ); return ( <> @@ -157,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", + "make-a-copy", + "move", + "archive-restore", + "delete", + "version-history", + "export", + ]} + page={page} + /> </> ); }); diff --git a/web/core/components/pages/editor/header/root.tsx b/web/core/components/pages/editor/header/root.tsx index 9640f4e43b6..c0dd1d3fd2f 100644 --- a/web/core/components/pages/editor/header/root.tsx +++ b/web/core/components/pages/editor/header/root.tsx @@ -13,7 +13,6 @@ import { IPage } from "@/store/pages/page"; type Props = { editorReady: boolean; editorRef: React.RefObject<EditorRefApi>; - handleDuplicatePage: () => void; page: IPage; readOnlyEditorReady: boolean; readOnlyEditorRef: React.RefObject<EditorReadOnlyRefApi>; @@ -22,22 +21,16 @@ type Props = { }; export const PageEditorHeaderRoot: React.FC<Props> = observer((props) => { - const { - editorReady, - editorRef, - handleDuplicatePage, - page, - readOnlyEditorReady, - readOnlyEditorRef, - setSidePeekVisible, - sidePeekVisible, - } = props; + const { editorReady, editorRef, page, readOnlyEditorReady, readOnlyEditorRef, setSidePeekVisible, sidePeekVisible } = + props; // derived values const { isContentEditable } = page; // page filters const { isFullWidth } = usePageFilters(); + // derived values + const resolvedEditorRef = isContentEditable ? editorRef.current : readOnlyEditorRef.current; - if (!editorRef.current && !readOnlyEditorRef.current) return null; + if (!resolvedEditorRef) return null; return ( <> @@ -62,20 +55,11 @@ export const PageEditorHeaderRoot: React.FC<Props> = observer((props) => { <PageToolbar editorRef={editorRef?.current} /> )} </Header.LeftItem> - <PageExtraOptions - editorRef={editorRef} - handleDuplicatePage={handleDuplicatePage} - page={page} - readOnlyEditorRef={readOnlyEditorRef} - /> + <PageExtraOptions editorRef={resolvedEditorRef} page={page} /> </Header> <div className="md:hidden"> <PageEditorMobileHeaderRoot - editorRef={editorRef} - readOnlyEditorRef={readOnlyEditorRef} - editorReady={editorReady} - readOnlyEditorReady={readOnlyEditorReady} - handleDuplicatePage={handleDuplicatePage} + editorRef={resolvedEditorRef} page={page} sidePeekVisible={sidePeekVisible} setSidePeekVisible={setSidePeekVisible} diff --git a/web/core/components/pages/editor/page-root.tsx b/web/core/components/pages/editor/page-root.tsx index ff1f3519e93..6cc353a709a 100644 --- a/web/core/components/pages/editor/page-root.tsx +++ b/web/core/components/pages/editor/page-root.tsx @@ -3,14 +3,9 @@ import { observer } from "mobx-react"; import { useSearchParams } from "next/navigation"; // editor import { EditorReadOnlyRefApi, EditorRefApi } from "@plane/editor"; -// types -import { TPage } from "@plane/types"; -// ui -import { setToast, TOAST_TYPE } from "@plane/ui"; // components import { PageEditorHeaderRoot, PageEditorBody, PageVersionsOverlay, PagesVersionEditor } from "@/components/pages"; // hooks -import { useProjectPages } from "@/hooks/store"; import { useAppRouter } from "@/hooks/use-app-router"; import { usePageFallback } from "@/hooks/use-page-fallback"; import { useQueryParams } from "@/hooks/use-query-params"; @@ -42,10 +37,8 @@ export const PageRoot = observer((props: TPageRootProps) => { const router = useAppRouter(); // search params const searchParams = useSearchParams(); - // store hooks - const { createPage } = useProjectPages(); // derived values - const { access, description_html, name, isContentEditable, updateDescription } = page; + const { isContentEditable, updateDescription } = page; // page fallback usePageFallback({ editorRef, @@ -59,26 +52,6 @@ export const PageRoot = observer((props: TPageRootProps) => { // update query params const { updateQueryParams } = useQueryParams(); - const handleCreatePage = async (payload: Partial<TPage>) => await createPage(payload); - - const handleDuplicatePage = async () => { - const formData: Partial<TPage> = { - name: "Copy of " + name, - description_html: editorRef.current?.getDocument().html ?? description_html ?? "<p></p>", - access, - }; - - await handleCreatePage(formData) - .then((res) => router.push(`/${workspaceSlug}/projects/${projectId}/pages/${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) { @@ -135,7 +108,6 @@ export const PageRoot = observer((props: TPageRootProps) => { <PageEditorHeaderRoot editorReady={editorReady} editorRef={editorRef} - handleDuplicatePage={handleDuplicatePage} page={page} readOnlyEditorReady={readOnlyEditorReady} readOnlyEditorRef={readOnlyEditorRef} diff --git a/web/core/components/pages/list/block-item-action.tsx b/web/core/components/pages/list/block-item-action.tsx index 740b44c814b..38d1b4e8370 100644 --- a/web/core/components/pages/list/block-item-action.tsx +++ b/web/core/components/pages/list/block-item-action.tsx @@ -4,60 +4,34 @@ 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, usePage } from "@/hooks/store"; +import { usePageOperations } from "@/hooks/use-page-operations"; type Props = { - workspaceSlug: string; - projectId: string; pageId: string; parentRef: React.RefObject<HTMLElement>; }; export const BlockItemAction: FC<Props> = observer((props) => { - const { workspaceSlug, projectId, pageId, parentRef } = props; + const { pageId, parentRef } = props; // store hooks const page = usePage(pageId); const { getUserDetails } = useMember(); + // page operations + const { pageOperations } = usePageOperations({ + page, + }); // derived values - const { - access, - created_at, - is_favorite, - owned_by, - canCurrentUserFavoritePage, - addToFavorites, - removePageFromFavorites, - } = page; + const { access, created_at, is_favorite, owned_by, canCurrentUserFavoritePage } = 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 */} @@ -87,17 +61,25 @@ 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} + <PageActions + optionsOrder={[ + "toggle-lock", + "toggle-access", + "open-in-new-tab", + "copy-link", + "make-a-copy", + "archive-restore", + "delete", + ]} page={page} - pageLink={`${workspaceSlug}/projects/${projectId}/pages/${pageId}`} + parentRef={parentRef} /> </> ); diff --git a/web/core/components/pages/list/block.tsx b/web/core/components/pages/list/block.tsx index abb373a6495..dbe1e3486d3 100644 --- a/web/core/components/pages/list/block.tsx +++ b/web/core/components/pages/list/block.tsx @@ -40,9 +40,7 @@ export const PageListBlock: FC<TPageListBlock> = observer((props) => { } title={getPageName(name)} itemLink={`/${workspaceSlug}/projects/${projectId}/pages/${pageId}`} - actionableItems={ - <BlockItemAction workspaceSlug={workspaceSlug} projectId={projectId} pageId={pageId} parentRef={parentRef} /> - } + actionableItems={<BlockItemAction pageId={pageId} parentRef={parentRef} />} isMobile={isMobile} parentRef={parentRef} /> diff --git a/web/core/hooks/use-collaborative-page-actions.tsx b/web/core/hooks/use-collaborative-page-actions.tsx index 6ec9f799050..cd89607d644 100644 --- a/web/core/hooks/use-collaborative-page-actions.tsx +++ b/web/core/hooks/use-collaborative-page-actions.tsx @@ -14,7 +14,13 @@ type CollaborativeActionEvent = | { type: "sendMessageToServer"; message: TDocumentEventsServer } | { type: "receivedMessageFromServer"; message: TDocumentEventsClient }; -export const useCollaborativePageActions = (editorRef: EditorRefApi | EditorReadOnlyRefApi | null, page: IPage) => { +type Props = { + editorRef?: EditorRefApi | EditorReadOnlyRefApi | null; + page: IPage; +}; + +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); @@ -37,6 +43,14 @@ export const useCollaborativePageActions = (editorRef: EditorRefApi | EditorRead 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] ); @@ -64,6 +78,7 @@ export const useCollaborativePageActions = (editorRef: EditorRefApi | EditorRead ); useEffect(() => { + if (!editorRef) return; if (currentActionBeingProcessed) { const serverEventName = getServerEventName(currentActionBeingProcessed); if (serverEventName) { @@ -73,9 +88,12 @@ export const useCollaborativePageActions = (editorRef: EditorRefApi | EditorRead }, [currentActionBeingProcessed, editorRef]); useEffect(() => { - const realTimeStatelessMessageListener = editorRef?.listenToRealTimeUpdate(); + if (!editorRef) return; + const realTimeStatelessMessageListener = editorRef?.listenToRealTimeUpdate(); + console.log(realTimeStatelessMessageListener); const handleStatelessMessage = (message: { payload: TDocumentEventsClient }) => { + console.log("aaa", message); if (currentActionBeingProcessed === message.payload) { setCurrentActionBeingProcessed(null); return; diff --git a/web/core/hooks/use-page-operations.ts b/web/core/hooks/use-page-operations.ts new file mode 100644 index 00000000000..7990252a6b5 --- /dev/null +++ b/web/core/hooks/use-page-operations.ts @@ -0,0 +1,207 @@ +import { useMemo } from "react"; +import { useParams } from "next/navigation"; +// plane editor +import { EditorReadOnlyRefApi, EditorRefApi } from "@plane/editor"; +// 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 { IPage } from "@/store/pages/page"; + +export type TPageOperations = { + toggleLock: () => void; + toggleAccess: () => void; + toggleFavorite: () => void; + openInNewTab: () => void; + copyLink: () => void; + duplicate: () => void; + toggleArchive: () => void; +}; + +type Props = { + editorRef?: EditorRefApi | EditorReadOnlyRefApi | null; + page: IPage; +}; + +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, + makePrivate, + makePublic, + 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 === 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.`, + }); + } + }, + 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, + makePrivate, + makePublic, + projectId, + removePageFromFavorites, + workspaceSlug, + ]); + return { + pageOperations, + }; +}; diff --git a/web/core/services/page/project-page.service.ts b/web/core/services/page/project-page.service.ts index 00d9401a69a..b0583742eda 100644 --- a/web/core/services/page/project-page.service.ts +++ b/web/core/services/page/project-page.service.ts @@ -158,4 +158,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; + }); + } } diff --git a/web/core/store/pages/page.ts b/web/core/store/pages/page.ts index d609cab6498..383e6d8ef19 100644 --- a/web/core/store/pages/page.ts +++ b/web/core/store/pages/page.ts @@ -23,6 +23,7 @@ export interface IPage extends TPage { canCurrentUserArchivePage: boolean; canCurrentUserDeletePage: boolean; canCurrentUserFavoritePage: boolean; + canCurrentUserMovePage: boolean; isContentEditable: boolean; // helpers oldName: string; @@ -32,8 +33,8 @@ export interface IPage extends TPage { update: (pageData: Partial<TPage>) => Promise<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>; @@ -41,6 +42,7 @@ export interface IPage extends TPage { updatePageLogo: (logo_props: TLogoProps) => Promise<void>; addToFavorites: () => Promise<void>; removePageFromFavorites: () => Promise<void>; + duplicate: () => Promise<void>; } export class Page implements IPage { @@ -133,6 +135,7 @@ export class Page implements IPage { canCurrentUserArchivePage: computed, canCurrentUserDeletePage: computed, canCurrentUserFavoritePage: computed, + canCurrentUserMovePage: computed, isContentEditable: computed, // actions update: action, @@ -147,6 +150,7 @@ export class Page implements IPage { updatePageLogo: action, addToFavorites: action, removePageFromFavorites: action, + duplicate: action, }); this.pageService = new ProjectPageService(); @@ -296,6 +300,19 @@ export class Page implements IPage { return !!currentUserProjectRole && currentUserProjectRole >= EUserPermissions.MEMBER; } + /** + * @description returns true if the current logged in user can move the page + */ + get canCurrentUserMovePage() { + const { workspaceSlug, projectId } = this.store.router; + + const currentUserProjectRole = this.store.user.permission.projectPermissionsByWorkspaceSlugAndProjectId( + workspaceSlug?.toString() || "", + projectId?.toString() || "" + ); + return this.isCurrentUserOwner || currentUserProjectRole === EUserPermissions.ADMIN; + } + /** * @description returns true if the page can be edited */ @@ -398,44 +415,48 @@ export class Page implements IPage { /** * @description make the page public */ - makePublic = async () => { + makePublic = async (shouldSync: boolean = true) => { const { workspaceSlug, projectId } = this.store.router; if (!workspaceSlug || !projectId || !this.id) return undefined; const pageAccess = this.access; runInAction(() => (this.access = EPageAccess.PUBLIC)); - try { - await this.pageService.updateAccess(workspaceSlug, projectId, this.id, { - access: EPageAccess.PUBLIC, - }); - } catch (error) { - runInAction(() => { - this.access = pageAccess; - }); - throw error; + if (shouldSync) { + try { + await this.pageService.updateAccess(workspaceSlug, projectId, this.id, { + access: EPageAccess.PUBLIC, + }); + } catch (error) { + runInAction(() => { + this.access = pageAccess; + }); + throw error; + } } }; /** * @description make the page private */ - makePrivate = async () => { + makePrivate = async (shouldSync: boolean = true) => { const { workspaceSlug, projectId } = this.store.router; if (!workspaceSlug || !projectId || !this.id) return undefined; const pageAccess = this.access; runInAction(() => (this.access = EPageAccess.PRIVATE)); - try { - await this.pageService.updateAccess(workspaceSlug, projectId, this.id, { - access: EPageAccess.PRIVATE, - }); - } catch (error) { - runInAction(() => { - this.access = pageAccess; - }); - throw error; + if (shouldSync) { + try { + await this.pageService.updateAccess(workspaceSlug, projectId, this.id, { + access: EPageAccess.PRIVATE, + }); + } catch (error) { + runInAction(() => { + this.access = pageAccess; + }); + throw error; + } } }; @@ -588,4 +609,13 @@ export class Page implements IPage { throw error; }); }; + + /** + * @description duplicate the page + */ + duplicate = async () => { + const { workspaceSlug, projectId } = this.store.router; + if (!workspaceSlug || !projectId || !this.id) return undefined; + await this.pageService.duplicate(workspaceSlug, projectId, this.id); + }; } diff --git a/web/core/store/pages/project-page.store.ts b/web/core/store/pages/project-page.store.ts index 7cb8f0014cc..9b10caa1374 100644 --- a/web/core/store/pages/project-page.store.ts +++ b/web/core/store/pages/project-page.store.ts @@ -18,6 +18,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; @@ -42,6 +44,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 { @@ -76,6 +79,7 @@ export class ProjectPageStore implements IProjectPageStore { getPageById: action, createPage: action, removePage: action, + movePage: action, }); this.rootStore = store; // service @@ -107,7 +111,7 @@ export class ProjectPageStore implements IProjectPageStore { workspaceSlug?.toString() || "", projectId?.toString() || "" ); - return !!currentUserProjectRole && currentUserProjectRole >= EUserPermissions.MEMBER; + return !!currentUserProjectRole && ROLE_PERMISSIONS_TO_CREATE_PAGE.includes(currentUserProjectRole); } /** @@ -292,4 +296,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) => {}; }