chore: add customizable page actions

This commit is contained in:
Aaryan Khandelwal
2024-11-20 13:03:27 +05:30
parent 9ceb91c207
commit bb00042bff
13 changed files with 584 additions and 222 deletions

View File

@@ -36,9 +36,7 @@ export const ContextMenuItem: React.FC<ContextMenuItemProps> = (props) => {
onMouseEnter={handleActiveItem}
disabled={item.disabled}
>
{item.customContent ? (
item.customContent
) : (
{item.customContent ?? (
<>
{item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />}
<div>

View File

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

View File

@@ -13,9 +13,7 @@ import { BreadcrumbLink, Logo } from "@/components/common";
// constants
import { EPageAccess } from "@/constants/page";
// hooks
import { useEventTracker, useProject, useProjectPages, useUserPermissions } from "@/hooks/store";
// plane web hooks
import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions";
import { useEventTracker, useProject, useProjectPages } from "@/hooks/store";
export const PagesListHeader = observer(() => {
// states
@@ -25,17 +23,10 @@ export const PagesListHeader = observer(() => {
const { workspaceSlug } = useParams();
const searchParams = useSearchParams();
const pageType = searchParams.get("type");
// store hooks
const { allowPermissions } = useUserPermissions();
const { currentProjectDetails, loader } = useProject();
const { createPage } = useProjectPages();
const { canCurrentUserCreatePage, createPage } = useProjectPages();
const { setTrackElement } = useEventTracker();
// auth
const canUserCreatePage = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST],
EUserPermissionsLevel.PROJECT
);
// handle page create
const handleCreatePage = async () => {
setIsCreatingPage(true);
@@ -88,7 +79,7 @@ export const PagesListHeader = observer(() => {
</Breadcrumbs>
</div>
</Header.LeftItem>
{canUserCreatePage ? (
{canCurrentUserCreatePage ? (
<Header.RightItem>
<Button variant="primary" size="sm" onClick={handleCreatePage} loading={isCreatingPage}>
{isCreatingPage ? "Adding" : "Add page"}

View File

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

View File

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

View File

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

View File

@@ -1 +0,0 @@
export type TPageExtraActions = never;

View File

@@ -1,12 +1,12 @@
"use client";
import { useState } from "react";
import { useMemo, useState } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import {
ArchiveRestoreIcon,
Copy,
ExternalLink,
FileOutput,
Globe2,
Link,
Lock,
@@ -15,22 +15,21 @@ import {
Trash2,
} from "lucide-react";
// plane ui
import { ArchiveIcon, ContextMenu, CustomMenu, TContextMenuItem, TOAST_TYPE, setToast } from "@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";
import { copyUrlToClipboard } from "@/helpers/string.helper";
// hooks
import { useProjectPages } from "@/hooks/store";
// plane web types
import { TPageExtraActions } from "@/plane-web/types/page";
// plane web components
import { MovePageModal } from "@/plane-web/components/pages";
export type TPageActions =
| "full-screen"
| "copy-markdown"
| "toggle-lock"
| "toggle-privacy"
| "toggle-access"
| "open-in-new-tab"
| "copy-link"
| "make-a-copy"
@@ -38,148 +37,107 @@ export type TPageActions =
| "delete"
| "version-history"
| "export"
| TPageExtraActions;
| "move";
export type TPageOperations = {
toggleLock: () => void;
toggleAccess: () => void;
openInNewTab: () => void;
copyLink: () => void;
duplicate: () => void;
toggleArchive: () => void;
};
export type TPageConfig = {
canArchive: boolean;
canDelete: boolean;
canDuplicate: boolean;
canLock: boolean;
canMove: boolean;
canToggleAccess: boolean;
isArchived: boolean;
isLocked: boolean;
pageAccess: EPageAccess;
};
type Props = {
extraOptions?: TContextMenuItem[];
extraOptions?: (TContextMenuItem & { key: TPageActions })[];
optionsOrder: TPageActions[];
pageConfig: TPageConfig;
pageId: string;
pageOperations: TPageOperations;
parentRef?: React.RefObject<HTMLElement>;
};
export const PageActions: React.FC<Props> = observer((props) => {
const { extraOptions, optionsOrder, pageId, parentRef } = props;
const { extraOptions, optionsOrder, pageConfig, pageId, pageOperations, parentRef } = props;
// states
const [deletePageModal, setDeletePageModal] = useState(false);
// params
const { workspaceSlug, projectId } = useParams();
// store hooks
const { pageById } = useProjectPages();
const page = pageById(pageId);
if (!page) return null;
const {
access,
archived_at,
archive,
is_locked,
restore,
lock,
unlock,
makePublic,
makePrivate,
duplicate,
canCurrentUserArchivePage,
canCurrentUserDuplicatePage,
canCurrentUserChangeAccess,
canCurrentUserDeletePage,
canCurrentUserLockPage,
} = page;
// derived values
const pageLink = projectId
? `${workspaceSlug}/projects/${projectId}/pages/${pageId}`
: `${workspaceSlug}/pages/${pageId}`;
const [movePageModal, setMovePageModal] = useState(false);
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 handleLockPage = async () =>
await lock().catch(() =>
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Page could not be locked. Please try again later.",
})
);
const handleUnlockPage = async () =>
await unlock().catch(() =>
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Page could not be unlocked. Please try again later.",
})
);
const MENU_ITEMS: (TContextMenuItem & { key: TPageActions })[] = [
{
key: "toggle-lock",
action: is_locked ? handleUnlockPage : handleLockPage,
title: is_locked ? "Unlock page" : "Lock page",
icon: is_locked ? LockKeyholeOpen : LockKeyhole,
shouldRender: canCurrentUserLockPage,
},
{
key: "toggle-privacy",
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.`,
});
}
const MENU_ITEMS: (TContextMenuItem & { key: TPageActions })[] = useMemo(
() => [
{
key: "toggle-lock",
action: pageOperations.toggleLock,
title: pageConfig.isLocked ? "Unlock page" : "Lock page",
icon: pageConfig.isLocked ? LockKeyholeOpen : LockKeyhole,
shouldRender: pageConfig.canLock,
},
title: access === 0 ? "Make private" : "Make public",
icon: access === 0 ? Lock : Globe2,
shouldRender: canCurrentUserChangeAccess && !archived_at,
},
{
key: "open-in-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: "make-a-copy",
action: duplicate,
title: "Make a copy",
icon: Copy,
shouldRender: canCurrentUserDuplicatePage,
},
{
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,
},
];
{
key: "toggle-access",
action: pageOperations.toggleAccess,
title: pageConfig.pageAccess === 0 ? "Make private" : "Make public",
icon: pageConfig.pageAccess === 0 ? Lock : Globe2,
shouldRender: pageConfig.canToggleAccess,
},
{
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: pageConfig.canDuplicate,
},
{
key: "archive-restore",
action: pageOperations.toggleArchive,
title: pageConfig.isArchived ? "Restore" : "Archive",
icon: pageConfig.isArchived ? ArchiveRestoreIcon : ArchiveIcon,
shouldRender: pageConfig.canArchive,
},
{
key: "delete",
action: () => setDeletePageModal(true),
title: "Delete",
icon: Trash2,
shouldRender: pageConfig.canDelete,
},
{
key: "move",
action: () => setMovePageModal(true),
title: "Move",
icon: FileOutput,
shouldRender: pageConfig.canMove,
},
],
[pageConfig, pageOperations]
);
if (extraOptions) {
// @ts-expect-error type mismatch, not necessary to fix
MENU_ITEMS.push(...extraOptions);
}
// arrange options
@@ -189,7 +147,8 @@ export const PageActions: React.FC<Props> = observer((props) => {
return (
<>
<DeletePageModal isOpen={deletePageModal} onClose={() => setDeletePageModal(false)} pageId={page.id ?? ""} />
<MovePageModal isOpen={movePageModal} onClose={() => setMovePageModal(false)} pageId={pageId} />
<DeletePageModal isOpen={deletePageModal} onClose={() => setDeletePageModal(false)} pageId={pageId} />
{parentRef && <ContextMenu parentRef={parentRef} items={arrangedOptions} />}
<CustomMenu placement="bottom-end" optionsClassName="max-h-[90vh]" ellipsis closeOnSelect>
{arrangedOptions.map((item) => {
@@ -200,14 +159,12 @@ export const PageActions: React.FC<Props> = observer((props) => {
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
item.action();
item.action?.();
}}
className={cn("flex items-center gap-2", item.className)}
disabled={item.disabled}
>
{item.customContent ? (
item.customContent
) : (
{item.customContent ?? (
<>
{item.icon && <item.icon className="size-3" />}
{item.title}

View File

@@ -1,17 +1,17 @@
"use client";
import { useState } from "react";
import { useMemo, useState } from "react";
import { observer } from "mobx-react";
import { useRouter } from "next/navigation";
import { ArrowUpToLine, Clipboard, History } from "lucide-react";
import { useParams, useRouter } from "next/navigation";
// document editor
import { EditorReadOnlyRefApi, EditorRefApi } from "@plane/editor";
// ui
import { TContextMenuItem, TOAST_TYPE, setToast, ToggleSwitch } from "@plane/ui";
// components
import { ExportPageModal, PageActions, TPageActions } from "@/components/pages";
import { ExportPageModal, PageActions, TPageActions, TPageConfig, TPageOperations } from "@/components/pages";
// helpers
import { copyTextToClipboard } from "@/helpers/string.helper";
import { copyTextToClipboard, copyUrlToClipboard } from "@/helpers/string.helper";
// hooks
import { usePageFilters } from "@/hooks/use-page-filters";
import { useQueryParams } from "@/hooks/use-query-params";
@@ -27,8 +27,28 @@ export const PageOptionsDropdown: React.FC<Props> = observer((props) => {
const { editorRef, page } = props;
// router
const router = useRouter();
const { workspaceSlug, projectId } = useParams();
// store values
const { name } = page;
const {
access,
archive,
archived_at,
canCurrentUserArchivePage,
canCurrentUserChangeAccess,
canCurrentUserDeletePage,
canCurrentUserDuplicatePage,
canCurrentUserLockPage,
canCurrentUserMovePage,
duplicate,
lock,
id,
is_locked,
makePrivate,
makePublic,
name,
restore,
unlock,
} = page;
// states
const [isExportModalOpen, setIsExportModalOpen] = useState(false);
// page filters
@@ -36,55 +56,211 @@ export const PageOptionsDropdown: React.FC<Props> = observer((props) => {
// update query params
const { updateQueryParams } = useQueryParams();
// menu items list
const EXTRA_MENU_OPTIONS: (TContextMenuItem & { key: TPageActions })[] = [
{
key: "full-screen",
action: () => handleFullWidth(!isFullWidth),
customContent: (
<>
Full width
<ToggleSwitch value={isFullWidth} onChange={() => {}} />
</>
),
className: "flex items-center justify-between gap-2",
},
{
key: "copy-markdown",
action: () => {
if (!editorRef) return;
copyTextToClipboard(editorRef.getMarkDown()).then(() =>
const EXTRA_MENU_OPTIONS: (TContextMenuItem & { key: TPageActions })[] = useMemo(
() => [
{
key: "full-screen",
action: () => handleFullWidth(!isFullWidth),
customContent: (
<>
Full width
<ToggleSwitch value={isFullWidth} onChange={() => {}} />
</>
),
className: "flex items-center justify-between gap-2",
},
{
key: "copy-markdown",
action: () => {
if (!editorRef) return;
copyTextToClipboard(editorRef.getMarkDown()).then(() =>
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Markdown copied to clipboard.",
})
);
},
title: "Copy markdown",
icon: Clipboard,
shouldRender: true,
},
{
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,
},
{
key: "export",
action: () => setIsExportModalOpen(true),
title: "Export",
icon: ArrowUpToLine,
shouldRender: true,
},
],
[editorRef, isFullWidth, router, updateQueryParams]
);
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: "Markdown copied to clipboard.",
})
);
message: "Page duplicated successfully.",
});
} catch {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Page could not be duplicated. Please try again later.",
});
}
},
title: "Copy markdown",
icon: Clipboard,
shouldRender: true,
},
{
key: "version-history",
action: () => {
// add query param, version=current to the route
const updatedRoute = updateQueryParams({
paramsToAdd: { version: "current" },
});
router.push(updatedRoute);
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.`,
});
}
},
title: "Version history",
icon: History,
shouldRender: true,
},
{
key: "export",
action: () => setIsExportModalOpen(true),
title: "Export",
icon: ArrowUpToLine,
shouldRender: true,
},
];
toggleArchive: async () => {
if (archived_at) {
try {
await restore();
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 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.",
});
}
}
},
toggleLock: async () => {
if (is_locked) {
try {
await 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 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,
archive,
archived_at,
duplicate,
id,
is_locked,
lock,
makePrivate,
makePublic,
projectId,
restore,
unlock,
workspaceSlug,
]);
const pageConfig: TPageConfig = useMemo(() => {
return {
canArchive: canCurrentUserArchivePage,
canLock: canCurrentUserLockPage,
canMove: canCurrentUserMovePage,
canToggleAccess: canCurrentUserChangeAccess && !archived_at,
canDelete: canCurrentUserDeletePage && !!archived_at,
canDuplicate: canCurrentUserDuplicatePage,
isArchived: !!archived_at,
isLocked: is_locked,
pageAccess: access ?? 0,
};
}, [
archived_at,
canCurrentUserArchivePage,
canCurrentUserChangeAccess,
canCurrentUserDeletePage,
canCurrentUserDuplicatePage,
canCurrentUserLockPage,
]);
return (
<>
@@ -101,12 +277,15 @@ export const PageOptionsDropdown: React.FC<Props> = observer((props) => {
"copy-markdown",
"copy-link",
"make-a-copy",
"move",
"archive-restore",
"delete",
"version-history",
"export",
]}
pageConfig={pageConfig}
pageId={page.id ?? ""}
pageOperations={pageOperations}
/>
</>
);

View File

@@ -1,15 +1,17 @@
"use client";
import React, { FC } from "react";
import React, { FC, useMemo } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { Earth, Info, Lock, Minus } from "lucide-react";
// ui
import { Avatar, FavoriteStar, TOAST_TYPE, Tooltip, setToast } from "@plane/ui";
// components
import { PageActions } from "@/components/pages";
import { PageActions, TPageConfig, TPageOperations } from "@/components/pages";
// helpers
import { renderFormattedDate } from "@/helpers/date-time.helper";
import { getFileURL } from "@/helpers/file.helper";
import { copyUrlToClipboard } from "@/helpers/string.helper";
// hooks
import { useMember, usePage } from "@/hooks/store";
@@ -20,21 +22,195 @@ type Props = {
export const BlockItemAction: FC<Props> = observer((props) => {
const { pageId, parentRef } = props;
// params
const { workspaceSlug, projectId } = useParams();
// store hooks
const page = usePage(pageId);
const { getUserDetails } = useMember();
// derived values
const {
access,
created_at,
is_favorite,
owned_by,
canCurrentUserFavoritePage,
addToFavorites,
archive,
archived_at,
canCurrentUserChangeAccess,
canCurrentUserArchivePage,
canCurrentUserDeletePage,
canCurrentUserDuplicatePage,
canCurrentUserFavoritePage,
canCurrentUserLockPage,
canCurrentUserMovePage,
created_at,
duplicate,
id,
is_favorite,
is_locked,
lock,
makePrivate,
makePublic,
owned_by,
removePageFromFavorites,
restore,
unlock,
} = page;
const ownerDetails = owned_by ? getUserDetails(owned_by) : undefined;
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 restore();
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 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.",
});
}
}
},
toggleLock: async () => {
if (is_locked) {
try {
await 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 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,
archive,
archived_at,
duplicate,
id,
is_locked,
lock,
makePrivate,
makePublic,
projectId,
restore,
unlock,
workspaceSlug,
]);
const pageConfig: TPageConfig = useMemo(
() => ({
canArchive: canCurrentUserArchivePage,
canLock: canCurrentUserLockPage,
canMove: canCurrentUserMovePage,
canToggleAccess: canCurrentUserChangeAccess && !archived_at,
canDelete: canCurrentUserDeletePage && !!archived_at,
canDuplicate: canCurrentUserDuplicatePage,
isArchived: !!archived_at,
isLocked: is_locked,
pageAccess: access ?? 0,
}),
[
access,
archived_at,
canCurrentUserMovePage,
canCurrentUserArchivePage,
canCurrentUserChangeAccess,
canCurrentUserDeletePage,
canCurrentUserDuplicatePage,
canCurrentUserLockPage,
is_locked,
]
);
// handlers
const handleFavorites = () => {
if (is_favorite) {
@@ -95,15 +271,17 @@ export const BlockItemAction: FC<Props> = observer((props) => {
<PageActions
optionsOrder={[
"toggle-lock",
"toggle-privacy",
"toggle-access",
"open-in-new-tab",
"copy-link",
"make-a-copy",
"archive-restore",
"delete",
]}
parentRef={parentRef}
pageConfig={pageConfig}
pageId={pageId}
pageOperations={pageOperations}
parentRef={parentRef}
/>
</>
);

View File

@@ -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}
/>

View File

@@ -25,6 +25,7 @@ export interface IPage extends TPage {
canCurrentUserArchivePage: boolean;
canCurrentUserDeletePage: boolean;
canCurrentUserFavoritePage: boolean;
canCurrentUserMovePage: boolean;
isContentEditable: boolean;
// helpers
oldName: string;
@@ -136,6 +137,7 @@ export class Page implements IPage {
canCurrentUserArchivePage: computed,
canCurrentUserDeletePage: computed,
canCurrentUserFavoritePage: computed,
canCurrentUserMovePage: computed,
isContentEditable: computed,
// actions
update: action,
@@ -291,6 +293,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
*/

View File

@@ -1,11 +1,13 @@
import set from "lodash/set";
import unset from "lodash/unset";
import { makeObservable, observable, runInAction, action, reaction } from "mobx";
import { makeObservable, observable, runInAction, action, reaction, computed } from "mobx";
import { computedFn } from "mobx-utils";
// types
import { TPage, TPageFilters, TPageNavigationTabs } from "@plane/types";
// helpers
import { filterPagesByPageType, getPageName, orderPages, shouldFilterPage } from "@/helpers/page.helper";
// plane web constants
import { EUserPermissions } from "@/plane-web/constants/user-permissions";
// services
import { ProjectPageService } from "@/services/page";
// store
@@ -16,6 +18,12 @@ type TLoader = "init-loader" | "mutation-loader" | undefined;
type TError = { title: string; description: string };
export const ROLE_PERMISSIONS_TO_CREATE_PAGE = [
EUserPermissions.ADMIN,
EUserPermissions.MEMBER,
EUserPermissions.GUEST,
];
export interface IProjectPageStore {
// observables
loader: TLoader;
@@ -24,6 +32,7 @@ export interface IProjectPageStore {
filters: TPageFilters;
// computed
isAnyPageAvailable: boolean;
canCurrentUserCreatePage: boolean;
// helper actions
getCurrentProjectPageIds: (pageType: TPageNavigationTabs) => string[] | undefined;
getCurrentProjectFilteredPageIds: (pageType: TPageNavigationTabs) => string[] | undefined;
@@ -39,6 +48,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 {
@@ -62,6 +72,9 @@ export class ProjectPageStore implements IProjectPageStore {
data: observable,
error: observable,
filters: observable,
// computed
isAnyPageAvailable: computed,
canCurrentUserCreatePage: computed,
// helper actions
updateFilters: action,
clearAllFilters: action,
@@ -70,6 +83,7 @@ export class ProjectPageStore implements IProjectPageStore {
getPageById: action,
createPage: action,
removePage: action,
movePage: action,
});
this.rootStore = store;
// service
@@ -92,6 +106,18 @@ export class ProjectPageStore implements IProjectPageStore {
return Object.keys(this.data).length > 0;
}
/**
* @description check if the current user can create a page
*/
get canCurrentUserCreatePage() {
const { workspaceSlug, projectId } = this.store.router;
const currentUserProjectRole = this.store.user.permission.projectPermissionsByWorkspaceSlugAndProjectId(
workspaceSlug?.toString() || "",
projectId?.toString() || ""
);
return !!currentUserProjectRole && ROLE_PERMISSIONS_TO_CREATE_PAGE.includes(currentUserProjectRole);
}
/**
* @description get the current project page ids based on the pageType
* @param {TPageNavigationTabs} pageType
@@ -274,4 +300,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) => {};
}