mirror of
https://github.com/makeplane/plane.git
synced 2026-07-12 21:40:18 +02:00
chore: add customizable page actions
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from "./editor";
|
||||
export * from "./modals";
|
||||
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 { IPage } from "@/store/pages/page";
|
||||
|
||||
export type TMovePageModalProps = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
page: IPage;
|
||||
};
|
||||
|
||||
export const MovePageModal: React.FC<TMovePageModalProps> = () => null;
|
||||
@@ -1 +0,0 @@
|
||||
export type TPageExtraActions = never;
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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) => {};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user