From 94f421f27df1ea743d61c7556c450503bd956566 Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com> Date: Fri, 27 Dec 2024 21:03:20 +0530 Subject: [PATCH 1/3] chore: add live server prettier config (#6287) --- live/.prettierignore | 6 ++++++ live/.prettierrc | 5 +++++ 2 files changed, 11 insertions(+) create mode 100644 live/.prettierignore create mode 100644 live/.prettierrc diff --git a/live/.prettierignore b/live/.prettierignore new file mode 100644 index 00000000000..8f6f9062d21 --- /dev/null +++ b/live/.prettierignore @@ -0,0 +1,6 @@ +.next +.turbo +out/ +dist/ +build/ +node_modules/ \ No newline at end of file diff --git a/live/.prettierrc b/live/.prettierrc new file mode 100644 index 00000000000..87d988f1b26 --- /dev/null +++ b/live/.prettierrc @@ -0,0 +1,5 @@ +{ + "printWidth": 120, + "tabWidth": 2, + "trailingComma": "es5" +} From 752a27a175a434086c0c125641e834dc7cc81095 Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com> Date: Tue, 31 Dec 2024 12:54:09 +0530 Subject: [PATCH 2/3] [PE-97] refactor: pages actions (#6234) * dev: support for edition specific options in pages * refactor: page quick actions * chore: add customizable page actions * fix: type errors * dev: hook to get page operations * refactor: remove unnecessary props * chore: add permisssions to duplicate page endpoint * chore: memoize arranged options * chore: use enum for page access * chore: add type assertion * fix: auth for access change and delete * fix: removing readonly editor * chore: add sync for page access cahnge * fix: sync state * fix: indexeddb sync loader added * fix: remove node error fixed * style: page title and checkbox * chore: removing the syncing logic * revert: is editable check removed in display message * fix: editable field optional * fix: editable removed as optional prop * fix: extra options import fix * fix: remove readonly stuff * fix: added toggle access * chore: add access change sync * fix: full width toggle * refactor: types and enums added * refactore: update store action * chore: changed the duplicate viewset * fix: remove the page binary * fix: duplicate page action * fix: merge conflicts --------- Co-authored-by: Palanikannan M Co-authored-by: NarayanBavisetti --- apiserver/plane/app/serializers/page.py | 4 + apiserver/plane/app/urls/page.py | 6 + apiserver/plane/app/views/__init__.py | 1 + apiserver/plane/app/views/page/base.py | 36 +++ .../document-collaborative-events.ts | 2 + .../ui/src/dropdowns/context-menu/item.tsx | 30 +-- .../ui/src/dropdowns/context-menu/root.tsx | 3 +- packages/ui/src/dropdowns/custom-menu.tsx | 4 +- .../pages/(detail)/[pageId]/page.tsx | 4 +- web/ce/components/pages/index.ts | 1 + web/ce/components/pages/modals/index.ts | 1 + .../pages/modals/move-page-modal.tsx | 10 + .../components/pages/dropdowns/actions.tsx | 195 ++++++++++++++++ web/core/components/pages/dropdowns/index.ts | 2 +- .../pages/dropdowns/quick-actions.tsx | 131 ----------- .../pages/editor/header/extra-options.tsx | 9 +- .../pages/editor/header/mobile-root.tsx | 14 +- .../pages/editor/header/options-dropdown.tsx | 220 ++++++------------ .../components/pages/editor/header/root.tsx | 13 +- .../components/pages/editor/page-root.tsx | 24 +- .../pages/list/block-item-action.tsx | 49 ++-- .../hooks/use-collaborative-page-actions.tsx | 23 +- web/core/hooks/use-page-filters.ts | 63 +++-- web/core/hooks/use-page-operations.ts | 197 ++++++++++++++++ .../services/page/project-page.service.ts | 8 + web/core/store/pages/base-page.ts | 66 ++++-- web/core/store/pages/project-page.store.ts | 15 +- web/core/store/pages/project-page.ts | 22 ++ 28 files changed, 735 insertions(+), 418 deletions(-) create mode 100644 web/ce/components/pages/modals/index.ts create mode 100644 web/ce/components/pages/modals/move-page-modal.tsx create mode 100644 web/core/components/pages/dropdowns/actions.tsx delete mode 100644 web/core/components/pages/dropdowns/quick-actions.tsx create mode 100644 web/core/hooks/use-page-operations.ts 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//projects//pages//duplicate/", + PageDuplicateEndpoint.as_view(), + name="page-duplicate", + ), ] diff --git a/apiserver/plane/app/views/__init__.py b/apiserver/plane/app/views/__init__.py index 56ea78b4130..46b94f240e6 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..9243a3f9fdf 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", "

"), }, ) @@ -553,3 +555,37 @@ 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 + ).first() + + # get all the project ids where page is present + project_ids = ProjectPage.objects.filter(page_id=page_id).values_list( + "project_id", flat=True + ) + + page.pk = None + page.name = f"{page.name} (Copy)" + page.description_binary = None + page.save() + + for project_id in project_ids: + ProjectPage.objects.create( + workspace_id=page.workspace_id, + project_id=project_id, + page_id=page.id, + created_by_id=page.created_by_id, + updated_by_id=page.updated_by_id, + ) + + page_transaction.delay( + {"description_html": page.description_html}, None, page.id + ) + page = Page.objects.get(pk=page.id) + serializer = PageDetailSerializer(page) + return Response(serializer.data, status=status.HTTP_201_CREATED) 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 = (props) => { onMouseEnter={handleActiveItem} disabled={item.disabled} > - {item.icon && } -
-
{item.title}
- {item.description && ( -

- {item.description} -

- )} -
+ {item.customContent ?? ( + <> + {item.icon && } +
+
{item.title}
+ {item.description && ( +

+ {item.description} +

+ )} +
+ + )} ); }; 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; 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 = (props) => { )} onClick={(e) => { close(); - onClick && onClick(e); + onClick?.(e); }} disabled={disabled} > diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx index 1aabb14181b..8d7c5135725 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx @@ -41,7 +41,7 @@ const PageDetailsPage = observer(() => { const { getWorkspaceBySlug } = useWorkspace(); // derived values const workspaceId = workspaceSlug ? (getWorkspaceBySlug(workspaceSlug.toString())?.id ?? "") : ""; - const { id, name, updateDescription } = page; + const { canCurrentUserAccessPage, id, name, updateDescription } = page; // entity search handler const fetchEntityCallback = useCallback( async (payload: TSearchEntityRequestPayload) => @@ -129,7 +129,7 @@ const PageDetailsPage = observer(() => { ); - if (pageDetailsError) + if (pageDetailsError || !canCurrentUserAccessPage) return (

Page not found

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..d1aeb7b5313 --- /dev/null +++ b/web/ce/components/pages/modals/move-page-modal.tsx @@ -0,0 +1,10 @@ +// store types +import { TPageInstance } from "@/store/pages/base-page"; + +export type TMovePageModalProps = { + isOpen: boolean; + onClose: () => void; + page: TPageInstance; +}; + +export const MovePageModal: React.FC = () => 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..496ee14c9cd --- /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 { EditorRefApi } from "@plane/editor"; +// plane ui +import { ArchiveIcon, ContextMenu, CustomMenu, TContextMenuItem } from "@plane/ui"; +// components +import { DeletePageModal } from "@/components/pages"; +// constants +import { EPageAccess } from "@/constants/page"; +// helpers +import { cn } from "@/helpers/common.helper"; +// hooks +import { usePageOperations } from "@/hooks/use-page-operations"; +// plane web components +import { MovePageModal } from "@/plane-web/components/pages"; +// store types +import { TPageInstance } from "@/store/pages/base-page"; + +export type TPageActions = + | "full-screen" + | "copy-markdown" + | "toggle-lock" + | "toggle-access" + | "open-in-new-tab" + | "copy-link" + | "make-a-copy" + | "archive-restore" + | "delete" + | "version-history" + | "export" + | "move"; + +type Props = { + editorRef?: EditorRefApi | null; + extraOptions?: (TContextMenuItem & { key: TPageActions })[]; + optionsOrder: TPageActions[]; + page: TPageInstance; + parentRef?: React.RefObject; +}; + +export const PageActions: React.FC = observer((props) => { + const { editorRef, extraOptions, optionsOrder, page, parentRef } = props; + // states + const [deletePageModal, setDeletePageModal] = useState(false); + const [movePageModal, setMovePageModal] = useState(false); + // page operations + const { pageOperations } = usePageOperations({ + editorRef, + page, + }); + // derived values + const { + access, + archived_at, + is_locked, + canCurrentUserArchivePage, + canCurrentUserChangeAccess, + canCurrentUserDeletePage, + canCurrentUserDuplicatePage, + canCurrentUserLockPage, + canCurrentUserMovePage, + } = page; + // menu items + const MENU_ITEMS: (TContextMenuItem & { key: TPageActions })[] = useMemo(() => { + const menuItems: (TContextMenuItem & { key: TPageActions })[] = [ + { + key: "toggle-lock", + action: pageOperations.toggleLock, + title: is_locked ? "Unlock" : "Lock", + icon: is_locked ? LockKeyholeOpen : LockKeyhole, + shouldRender: canCurrentUserLockPage, + }, + { + key: "toggle-access", + action: pageOperations.toggleAccess, + title: access === EPageAccess.PUBLIC ? "Make private" : "Make public", + icon: access === EPageAccess.PUBLIC ? Lock : Globe2, + shouldRender: canCurrentUserChangeAccess && !archived_at, + }, + { + key: "open-in-new-tab", + action: pageOperations.openInNewTab, + title: "Open in new tab", + icon: ExternalLink, + shouldRender: true, + }, + { + key: "copy-link", + action: pageOperations.copyLink, + title: "Copy link", + icon: Link, + shouldRender: true, + }, + { + key: "make-a-copy", + action: pageOperations.duplicate, + title: "Make a copy", + icon: Copy, + shouldRender: canCurrentUserDuplicatePage, + }, + { + key: "archive-restore", + action: pageOperations.toggleArchive, + title: archived_at ? "Restore" : "Archive", + icon: archived_at ? ArchiveRestoreIcon : ArchiveIcon, + shouldRender: canCurrentUserArchivePage, + }, + { + key: "delete", + action: () => setDeletePageModal(true), + title: "Delete", + icon: Trash2, + shouldRender: canCurrentUserDeletePage && !!archived_at, + }, + { + key: "move", + action: () => setMovePageModal(true), + title: "Move", + icon: FileOutput, + shouldRender: canCurrentUserMovePage, + }, + ]; + if (extraOptions) { + menuItems.push(...extraOptions); + } + return menuItems; + }, [ + access, + archived_at, + extraOptions, + is_locked, + canCurrentUserArchivePage, + canCurrentUserChangeAccess, + canCurrentUserDeletePage, + canCurrentUserDuplicatePage, + canCurrentUserLockPage, + canCurrentUserMovePage, + pageOperations, + ]); + // arrange options + const arrangedOptions = useMemo( + () => + optionsOrder + .map((key) => MENU_ITEMS.find((item) => item.key === key)) + .filter((item) => !!item) as (TContextMenuItem & { key: TPageActions })[], + [optionsOrder, MENU_ITEMS] + ); + + return ( + <> + setMovePageModal(false)} page={page} /> + setDeletePageModal(false)} page={page} /> + {parentRef && } + + {arrangedOptions.map((item) => { + if (item.shouldRender === false) return null; + return ( + { + e.preventDefault(); + e.stopPropagation(); + item.action?.(); + }} + className={cn("flex items-center gap-2", item.className)} + disabled={item.disabled} + > + {item.customContent ?? ( + <> + {item.icon && } + {item.title} + + )} + + ); + })} + + + ); +}); 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 71344fd48b6..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 { TPageInstance } from "@/store/pages/base-page"; - -type Props = { - page: TPageInstance; - pageLink: string; - parentRef: React.RefObject; -}; - -export const PageQuickActions: React.FC = observer((props) => { - const { page, pageLink, parentRef } = props; - // states - const [deletePageModal, setDeletePageModal] = useState(false); - // store hooks - const { - access, - archive, - archived_at, - makePublic, - makePrivate, - restore, - canCurrentUserArchivePage, - canCurrentUserChangeAccess, - canCurrentUserDeletePage, - } = page; - - const handleCopyText = () => - copyUrlToClipboard(pageLink).then(() => { - setToast({ - type: TOAST_TYPE.SUCCESS, - title: "Link Copied!", - message: "Page link copied to clipboard.", - }); - }); - - const handleOpenInNewTab = () => window.open(`/${pageLink}`, "_blank"); - - const MENU_ITEMS: TContextMenuItem[] = [ - { - key: "make-public-private", - action: async () => { - const changedPageType = access === 0 ? "private" : "public"; - - try { - if (access === 0) await makePrivate(); - else await makePublic(); - - setToast({ - type: TOAST_TYPE.SUCCESS, - title: "Success!", - message: `The page has been marked ${changedPageType} and moved to the ${changedPageType} section.`, - }); - } catch { - setToast({ - type: TOAST_TYPE.ERROR, - title: "Error!", - message: `The page couldn't be marked ${changedPageType}. Please try again.`, - }); - } - }, - title: access === 0 ? "Make private" : "Make public", - icon: access === 0 ? Lock : UsersRound, - shouldRender: canCurrentUserChangeAccess && !archived_at, - }, - { - key: "open-new-tab", - action: handleOpenInNewTab, - title: "Open in new tab", - icon: ExternalLink, - shouldRender: true, - }, - { - key: "copy-link", - action: handleCopyText, - title: "Copy link", - icon: Link, - shouldRender: true, - }, - { - key: "archive-restore", - action: archived_at ? restore : archive, - title: archived_at ? "Restore" : "Archive", - icon: archived_at ? ArchiveRestoreIcon : ArchiveIcon, - shouldRender: canCurrentUserArchivePage, - }, - { - key: "delete", - action: () => setDeletePageModal(true), - title: "Delete", - icon: Trash2, - shouldRender: canCurrentUserDeletePage && !!archived_at, - }, - ]; - - return ( - <> - setDeletePageModal(false)} page={page} /> - - - {MENU_ITEMS.map((item) => { - if (!item.shouldRender) return null; - return ( - { - e.preventDefault(); - e.stopPropagation(); - item.action(); - }} - className="flex items-center gap-2" - disabled={item.disabled} - > - {item.icon && } - {item.title} - - ); - })} - - - ); -}); diff --git a/web/core/components/pages/editor/header/extra-options.tsx b/web/core/components/pages/editor/header/extra-options.tsx index 5f7153c5105..069e02622b3 100644 --- a/web/core/components/pages/editor/header/extra-options.tsx +++ b/web/core/components/pages/editor/header/extra-options.tsx @@ -16,13 +16,12 @@ import useOnlineStatus from "@/hooks/use-online-status"; import { TPageInstance } from "@/store/pages/base-page"; type Props = { - editorRef: React.RefObject; - handleDuplicatePage: () => void; + editorRef: EditorRefApi; page: TPageInstance; }; export const PageExtraOptions: React.FC = observer((props) => { - const { editorRef, handleDuplicatePage, page } = props; + const { editorRef, page } = props; // derived values const { archived_at, @@ -84,8 +83,8 @@ export const PageExtraOptions: React.FC = observer((props) => { iconClassName="text-custom-text-100" /> )} - - + +
); }); diff --git a/web/core/components/pages/editor/header/mobile-root.tsx b/web/core/components/pages/editor/header/mobile-root.tsx index 901f2606984..6f8be2977d8 100644 --- a/web/core/components/pages/editor/header/mobile-root.tsx +++ b/web/core/components/pages/editor/header/mobile-root.tsx @@ -9,38 +9,34 @@ import { usePageFilters } from "@/hooks/use-page-filters"; import { TPageInstance } from "@/store/pages/base-page"; type Props = { - editorReady: boolean; - editorRef: React.RefObject; - handleDuplicatePage: () => void; + editorRef: EditorRefApi; page: TPageInstance; setSidePeekVisible: (sidePeekState: boolean) => void; sidePeekVisible: boolean; }; export const PageEditorMobileHeaderRoot: React.FC = observer((props) => { - const { editorReady, editorRef, handleDuplicatePage, page, setSidePeekVisible, sidePeekVisible } = props; + const { editorRef, page, setSidePeekVisible, sidePeekVisible } = props; // derived values const { isContentEditable } = page; // page filters const { isFullWidth } = usePageFilters(); - if (!editorRef.current) return null; - return ( <>
- +
- {editorReady && isContentEditable && editorRef.current && } + {isContentEditable && editorRef && }
); diff --git a/web/core/components/pages/editor/header/options-dropdown.tsx b/web/core/components/pages/editor/header/options-dropdown.tsx index 557e5381a5d..32359cb3b4f 100644 --- a/web/core/components/pages/editor/header/options-dropdown.tsx +++ b/web/core/components/pages/editor/header/options-dropdown.tsx @@ -1,160 +1,93 @@ "use client"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { observer } from "mobx-react"; -import { useParams, useRouter } from "next/navigation"; -import { - ArchiveRestoreIcon, - ArrowUpToLine, - Clipboard, - Copy, - History, - Link, - Lock, - LockOpen, - LucideIcon, -} from "lucide-react"; +import { useRouter } from "next/navigation"; +import { ArrowUpToLine, Clipboard, History } from "lucide-react"; // document editor import { EditorRefApi } from "@plane/editor"; // ui -import { ArchiveIcon, CustomMenu, type ISvgIcons, TOAST_TYPE, ToggleSwitch, setToast } from "@plane/ui"; +import { TContextMenuItem, TOAST_TYPE, ToggleSwitch, setToast } from "@plane/ui"; // components -import { ExportPageModal } from "@/components/pages"; +import { ExportPageModal, PageActions, TPageActions } from "@/components/pages"; // helpers -import { copyTextToClipboard, copyUrlToClipboard } from "@/helpers/string.helper"; +import { copyTextToClipboard } from "@/helpers/string.helper"; // hooks -import { useCollaborativePageActions } from "@/hooks/use-collaborative-page-actions"; import { usePageFilters } from "@/hooks/use-page-filters"; -import { useParseEditorContent } from "@/hooks/use-parse-editor-content"; import { useQueryParams } from "@/hooks/use-query-params"; // store import { TPageInstance } from "@/store/pages/base-page"; type Props = { editorRef: EditorRefApi | null; - handleDuplicatePage: () => void; page: TPageInstance; }; export const PageOptionsDropdown: React.FC = observer((props) => { - const { editorRef, handleDuplicatePage, page } = props; + const { editorRef, page } = props; + // states + const [isExportModalOpen, setIsExportModalOpen] = useState(false); // router const router = useRouter(); // store values - const { - name, - archived_at, - is_locked, - id, - canCurrentUserArchivePage, - canCurrentUserDuplicatePage, - canCurrentUserLockPage, - } = page; - // states - const [isExportModalOpen, setIsExportModalOpen] = useState(false); - // store hooks - const { workspaceSlug, projectId } = useParams(); + const { name } = page; // page filters const { isFullWidth, handleFullWidth } = usePageFilters(); // update query params const { updateQueryParams } = useQueryParams(); - // collaborative actions - const { executeCollaborativeAction } = useCollaborativePageActions(editorRef, page); - // parse editor content - const { replaceCustomComponentsFromMarkdownContent } = useParseEditorContent(); - // menu items list - const MENU_ITEMS: { - key: string; - action: () => void; - label: string; - icon: LucideIcon | React.FC; - shouldRender: boolean; - }[] = [ - { - key: "copy-markdown", - action: () => { - if (!editorRef) return; - const markdownContent = editorRef.getMarkDown(); - const parsedMarkdownContent = replaceCustomComponentsFromMarkdownContent({ - markdownContent, - }); - copyTextToClipboard(parsedMarkdownContent).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 + {}} /> + + ), + 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 ( <> @@ -164,24 +97,23 @@ export const PageOptionsDropdown: React.FC = observer((props) => { onClose={() => setIsExportModalOpen(false)} pageTitle={name ?? ""} /> - - handleFullWidth(!isFullWidth)} - > - Full width - {}} /> - - {MENU_ITEMS.map((item) => { - if (!item.shouldRender) return null; - return ( - - - {item.label} - - ); - })} - + ); }); diff --git a/web/core/components/pages/editor/header/root.tsx b/web/core/components/pages/editor/header/root.tsx index 0a5827774ec..996f501bb3f 100644 --- a/web/core/components/pages/editor/header/root.tsx +++ b/web/core/components/pages/editor/header/root.tsx @@ -13,20 +13,21 @@ import { TPageInstance } from "@/store/pages/base-page"; type Props = { editorReady: boolean; editorRef: React.RefObject; - handleDuplicatePage: () => void; page: TPageInstance; setSidePeekVisible: (sidePeekState: boolean) => void; sidePeekVisible: boolean; }; export const PageEditorHeaderRoot: React.FC = observer((props) => { - const { editorReady, editorRef, setSidePeekVisible, sidePeekVisible, handleDuplicatePage, page } = props; + const { editorReady, editorRef, page, setSidePeekVisible, sidePeekVisible } = props; // derived values const { isContentEditable } = page; // page filters const { isFullWidth } = usePageFilters(); + // derived values + const resolvedEditorRef = editorRef.current; - if (!editorRef.current) return null; + if (!resolvedEditorRef) return null; return ( <> @@ -49,13 +50,11 @@ export const PageEditorHeaderRoot: React.FC = observer((props) => { )} {editorReady && isContentEditable && editorRef.current && } - +
{ // search params const searchParams = useSearchParams(); // derived values - const { access, description_html, name, isContentEditable } = page; + const { isContentEditable } = page; // page fallback usePageFallback({ editorRef, @@ -66,25 +64,6 @@ export const PageRoot = observer((props: TPageRootProps) => { // update query params const { updateQueryParams } = useQueryParams(); - const handleDuplicatePage = async () => { - const formData: Partial = { - name: "Copy of " + name, - description_html: editorRef.current?.getDocument().html ?? description_html ?? "

", - access, - }; - - await handlers - .create(formData) - .then((res) => router.push(handlers.getRedirectionLink(res?.id ?? ""))) - .catch(() => - setToast({ - type: TOAST_TYPE.ERROR, - title: "Error!", - message: "Page could not be duplicated. Please try again later.", - }) - ); - }; - const version = searchParams.get("version"); useEffect(() => { if (!version) { @@ -124,7 +103,6 @@ export const PageRoot = observer((props: TPageRootProps) => { setSidePeekVisible(state)} sidePeekVisible={sidePeekVisible} diff --git a/web/core/components/pages/list/block-item-action.tsx b/web/core/components/pages/list/block-item-action.tsx index 9f315053b31..15f4154b2c8 100644 --- a/web/core/components/pages/list/block-item-action.tsx +++ b/web/core/components/pages/list/block-item-action.tsx @@ -4,14 +4,15 @@ import React, { FC } from "react"; import { observer } from "mobx-react"; import { Earth, Info, Lock, Minus } from "lucide-react"; // ui -import { Avatar, FavoriteStar, TOAST_TYPE, Tooltip, setToast } from "@plane/ui"; +import { Avatar, FavoriteStar, Tooltip } from "@plane/ui"; // components -import { PageQuickActions } from "@/components/pages/dropdowns"; +import { PageActions } from "@/components/pages"; // helpers import { renderFormattedDate } from "@/helpers/date-time.helper"; import { getFileURL } from "@/helpers/file.helper"; // hooks import { useMember } from "@/hooks/store"; +import { usePageOperations } from "@/hooks/use-page-operations"; import { TPageInstance } from "@/store/pages/base-page"; type Props = { @@ -23,6 +24,10 @@ export const BlockItemAction: FC = observer((props) => { const { page, parentRef } = props; // store hooks const { getUserDetails } = useMember(); + // page operations + const { pageOperations } = usePageOperations({ + page, + }); // derived values const { access, @@ -30,33 +35,9 @@ export const BlockItemAction: FC = observer((props) => { is_favorite, owned_by, canCurrentUserFavoritePage, - addToFavorites, - removePageFromFavorites, - getRedirectionLink, } = page; const ownerDetails = owned_by ? getUserDetails(owned_by) : undefined; - // handlers - const handleFavorites = () => { - if (is_favorite) { - removePageFromFavorites().then(() => - setToast({ - type: TOAST_TYPE.SUCCESS, - title: "Success!", - message: "Page removed from favorites.", - }) - ); - } else { - addToFavorites().then(() => - setToast({ - type: TOAST_TYPE.SUCCESS, - title: "Success!", - message: "Page added to favorites.", - }) - ); - } - }; - return ( <> {/* page details */} @@ -86,14 +67,26 @@ export const BlockItemAction: FC = observer((props) => { onClick={(e) => { e.preventDefault(); e.stopPropagation(); - handleFavorites(); + pageOperations.toggleFavorite(); }} selected={is_favorite} /> )} {/* quick actions dropdown */} - + ); }); diff --git a/web/core/hooks/use-collaborative-page-actions.tsx b/web/core/hooks/use-collaborative-page-actions.tsx index b9dfe9a5f86..5ef1bfec9f1 100644 --- a/web/core/hooks/use-collaborative-page-actions.tsx +++ b/web/core/hooks/use-collaborative-page-actions.tsx @@ -1,6 +1,5 @@ import { useState, useEffect, useCallback, useMemo } from "react"; -// plane editor -import { EditorReadOnlyRefApi, EditorRefApi, TDocumentEventsServer } from "@plane/editor"; +import { EditorRefApi, TDocumentEventsServer } from "@plane/editor"; import { DocumentCollaborativeEvents, TDocumentEventsClient, getServerEventName } from "@plane/editor/lib"; // plane ui import { TOAST_TYPE, setToast } from "@plane/ui"; @@ -17,10 +16,13 @@ type CollaborativeActionEvent = | { type: "sendMessageToServer"; message: TDocumentEventsServer } | { type: "receivedMessageFromServer"; message: TDocumentEventsClient }; -export const useCollaborativePageActions = ( - editorRef: EditorRefApi | EditorReadOnlyRefApi | null, - page: TPageInstance -) => { +type Props = { + editorRef?: EditorRefApi | null; + page: TPageInstance; +}; + +export const useCollaborativePageActions = (props: Props) => { + const { editorRef, page } = props; // currentUserAction local state to track if the current action is being processed, a // local action is basically the action performed by the current user to avoid double operations const [currentActionBeingProcessed, setCurrentActionBeingProcessed] = useState(null); @@ -43,6 +45,14 @@ export const useCollaborativePageActions = ( execute: (shouldSync) => page.restore(shouldSync), errorMessage: "Page could not be restored. Please try again later.", }, + [DocumentCollaborativeEvents["make-public"].client]: { + execute: (shouldSync) => page.makePublic(shouldSync), + errorMessage: "Page could not be made public. Please try again later.", + }, + [DocumentCollaborativeEvents["make-private"].client]: { + execute: (shouldSync) => page.makePrivate(shouldSync), + errorMessage: "Page could not be made private. Please try again later.", + }, }), [page] ); @@ -75,7 +85,6 @@ export const useCollaborativePageActions = ( useEffect(() => { const realTimeStatelessMessageListener = editorRef?.listenToRealTimeUpdate(); - const handleStatelessMessage = (message: { payload: TDocumentEventsClient }) => { if (currentActionBeingProcessed === message.payload) { setCurrentActionBeingProcessed(null); diff --git a/web/core/hooks/use-page-filters.ts b/web/core/hooks/use-page-filters.ts index 309cb5f462e..e7698a93405 100644 --- a/web/core/hooks/use-page-filters.ts +++ b/web/core/hooks/use-page-filters.ts @@ -1,3 +1,4 @@ +import { useCallback, useMemo } from "react"; // plane editor import { TEditorFontSize, TEditorFontStyle } from "@plane/editor"; // hooks @@ -22,39 +23,61 @@ export const usePageFilters = () => { DEFAULT_PERSONALIZATION_VALUES ); // stored values - const isFullWidth = !!pagesConfig?.full_width; - const fontSize = pagesConfig?.font_size ?? DEFAULT_PERSONALIZATION_VALUES.font_size; - const fontStyle = pagesConfig?.font_style ?? DEFAULT_PERSONALIZATION_VALUES.font_style; + const isFullWidth = useMemo(() => !!pagesConfig?.full_width, [pagesConfig?.full_width]); + const fontSize = useMemo( + () => pagesConfig?.font_size ?? DEFAULT_PERSONALIZATION_VALUES.font_size, + [pagesConfig?.font_size] + ); + const fontStyle = useMemo( + () => pagesConfig?.font_style ?? DEFAULT_PERSONALIZATION_VALUES.font_style, + [pagesConfig?.font_style] + ); // update action - const handleUpdateConfig = (payload: Partial) => - setPagesConfig({ - ...(pagesConfig ?? DEFAULT_PERSONALIZATION_VALUES), - ...payload, - }); + const handleUpdateConfig = useCallback( + (payload: Partial) => { + setPagesConfig({ + ...(pagesConfig ?? DEFAULT_PERSONALIZATION_VALUES), + ...payload, + }); + }, + [pagesConfig, setPagesConfig] + ); /** * @description action to update full_width value * @param {boolean} value */ - const handleFullWidth = (value: boolean) => - handleUpdateConfig({ - full_width: value, - }); + const handleFullWidth = useCallback( + (value: boolean) => { + handleUpdateConfig({ + full_width: value, + }); + }, + [handleUpdateConfig] + ); /** * @description action to update font_size value * @param {TEditorFontSize} value */ - const handleFontSize = (value: TEditorFontSize) => - handleUpdateConfig({ - font_size: value, - }); + const handleFontSize = useCallback( + (value: TEditorFontSize) => { + handleUpdateConfig({ + font_size: value, + }); + }, + [handleUpdateConfig] + ); /** * @description action to update font_size value * @param {TEditorFontSize} value */ - const handleFontStyle = (value: TEditorFontStyle) => - handleUpdateConfig({ - font_style: value, - }); + const handleFontStyle = useCallback( + (value: TEditorFontStyle) => { + handleUpdateConfig({ + font_style: value, + }); + }, + [handleUpdateConfig] + ); return { fontSize, diff --git a/web/core/hooks/use-page-operations.ts b/web/core/hooks/use-page-operations.ts new file mode 100644 index 00000000000..efe973ad7d2 --- /dev/null +++ b/web/core/hooks/use-page-operations.ts @@ -0,0 +1,197 @@ +import { useMemo } from "react"; +import { useParams } from "next/navigation"; +// plane editor +import { EditorRefApi } from "@plane/editor"; +// plane types +import { EPageAccess } from "@plane/types/src/enums"; +// plane ui +import { setToast, TOAST_TYPE } from "@plane/ui"; +// helpers +import { copyUrlToClipboard } from "@/helpers/string.helper"; +// hooks +import { useCollaborativePageActions } from "@/hooks/use-collaborative-page-actions"; +// store types +import { TPageInstance } from "@/store/pages/base-page"; + +export type TPageOperations = { + toggleLock: () => void; + toggleAccess: () => void; + toggleFavorite: () => void; + openInNewTab: () => void; + copyLink: () => void; + duplicate: () => void; + toggleArchive: () => void; +}; + +type Props = { + editorRef?: EditorRefApi | null; + page: TPageInstance; +}; + +export const usePageOperations = ( + props: Props +): { + pageOperations: TPageOperations; +} => { + const { page } = props; + // params + const { workspaceSlug, projectId } = useParams(); + // derived values + const { access, addToFavorites, archived_at, duplicate, id, is_favorite, is_locked, removePageFromFavorites } = page; + // collaborative actions + const { executeCollaborativeAction } = useCollaborativePageActions(props); + // page operations + const pageOperations: TPageOperations = useMemo(() => { + const pageLink = projectId ? `${workspaceSlug}/projects/${projectId}/pages/${id}` : `${workspaceSlug}/pages/${id}`; + + return { + copyLink: () => { + copyUrlToClipboard(pageLink).then(() => { + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Link Copied!", + message: "Page link copied to clipboard.", + }); + }); + }, + duplicate: async () => { + try { + await duplicate(); + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Success!", + message: "Page duplicated successfully.", + }); + } catch { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: "Page could not be duplicated. Please try again later.", + }); + } + }, + move: async () => {}, + openInNewTab: () => window.open(`/${pageLink}`, "_blank"), + toggleAccess: async () => { + const changedPageType = access === EPageAccess.PUBLIC ? "private" : "public"; + try { + if (access === EPageAccess.PUBLIC) + await executeCollaborativeAction({ type: "sendMessageToServer", message: "make-private" }); + else await executeCollaborativeAction({ type: "sendMessageToServer", message: "make-public" }); + + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Success!", + message: `The page has been marked ${changedPageType} and moved to the ${changedPageType} section.`, + }); + } catch { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: `The page couldn't be marked ${changedPageType}. Please try again.`, + }); + } + }, + toggleArchive: async () => { + if (archived_at) { + try { + await executeCollaborativeAction({ type: "sendMessageToServer", message: "unarchive" }); + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Success!", + message: "Page restored successfully.", + }); + } catch { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: "Page could not be restored. Please try again later.", + }); + } + } else { + try { + await executeCollaborativeAction({ type: "sendMessageToServer", message: "archive" }); + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Success!", + message: "Page archived successfully.", + }); + } catch { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: "Page could not be archived. Please try again later.", + }); + } + } + }, + toggleFavorite: () => { + if (is_favorite) { + removePageFromFavorites().then(() => + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Success!", + message: "Page removed from favorites.", + }) + ); + } else { + addToFavorites().then(() => + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Success!", + message: "Page added to favorites.", + }) + ); + } + }, + toggleLock: async () => { + if (is_locked) { + try { + await executeCollaborativeAction({ type: "sendMessageToServer", message: "unlock" }); + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Success!", + message: "Page unlocked successfully.", + }); + } catch { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: "Page could not be unlocked. Please try again later.", + }); + } + } else { + try { + await executeCollaborativeAction({ type: "sendMessageToServer", message: "lock" }); + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Success!", + message: "Page locked successfully.", + }); + } catch { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: "Page could not be locked. Please try again later.", + }); + } + } + }, + }; + }, [ + access, + addToFavorites, + archived_at, + duplicate, + executeCollaborativeAction, + id, + is_favorite, + is_locked, + projectId, + removePageFromFavorites, + workspaceSlug, + ]); + return { + pageOperations, + }; +}; diff --git a/web/core/services/page/project-page.service.ts b/web/core/services/page/project-page.service.ts index 18ef2ed2f20..748c1345d21 100644 --- a/web/core/services/page/project-page.service.ts +++ b/web/core/services/page/project-page.service.ts @@ -163,4 +163,12 @@ export class ProjectPageService extends APIService { throw error; }); } + + async duplicate(workspaceSlug: string, projectId: string, pageId: string): Promise { + 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/base-page.ts b/web/core/store/pages/base-page.ts index 93c0aecc027..e7338ff3c5f 100644 --- a/web/core/store/pages/base-page.ts +++ b/web/core/store/pages/base-page.ts @@ -21,8 +21,8 @@ export type TBasePage = TPage & { update: (pageData: Partial) => Promise | undefined>; updateTitle: (title: string) => void; updateDescription: (document: TDocumentPayload) => Promise; - makePublic: () => Promise; - makePrivate: () => Promise; + makePublic: (shouldSync?: boolean) => Promise; + makePrivate: (shouldSync?: boolean) => Promise; lock: (shouldSync?: boolean) => Promise; unlock: (shouldSync?: boolean) => Promise; archive: (shouldSync?: boolean) => Promise; @@ -30,9 +30,11 @@ export type TBasePage = TPage & { updatePageLogo: (logo_props: TLogoProps) => Promise; addToFavorites: () => Promise; removePageFromFavorites: () => Promise; + duplicate: () => Promise; }; export type TBasePagePermissions = { + canCurrentUserAccessPage: boolean; canCurrentUserEditPage: boolean; canCurrentUserDuplicatePage: boolean; canCurrentUserLockPage: boolean; @@ -40,6 +42,7 @@ export type TBasePagePermissions = { canCurrentUserArchivePage: boolean; canCurrentUserDeletePage: boolean; canCurrentUserFavoritePage: boolean; + canCurrentUserMovePage: boolean; isContentEditable: boolean; }; @@ -53,6 +56,7 @@ export type TBasePageServices = { archived_at: string; }>; restore: () => Promise; + duplicate: () => Promise; }; export type TPageInstance = TBasePage & @@ -161,6 +165,7 @@ export class BasePage implements TBasePage { updatePageLogo: action, addToFavorites: action, removePageFromFavorites: action, + duplicate: action, }); this.rootStore = store; @@ -295,38 +300,46 @@ export class BasePage implements TBasePage { /** * @description make the page public */ - makePublic = async () => { + makePublic = async (shouldSync: boolean = true) => { const pageAccess = this.access; - runInAction(() => (this.access = EPageAccess.PUBLIC)); + runInAction(() => { + this.access = EPageAccess.PUBLIC; + }); - try { - await this.services.updateAccess({ - access: EPageAccess.PUBLIC, - }); - } catch (error) { - runInAction(() => { - this.access = pageAccess; - }); - throw error; + if (shouldSync) { + try { + await this.services.updateAccess({ + access: EPageAccess.PUBLIC, + }); + } catch (error) { + runInAction(() => { + this.access = pageAccess; + }); + throw error; + } } }; /** * @description make the page private */ - makePrivate = async () => { + makePrivate = async (shouldSync: boolean = true) => { const pageAccess = this.access; - runInAction(() => (this.access = EPageAccess.PRIVATE)); + runInAction(() => { + this.access = EPageAccess.PRIVATE; + }); - try { - await this.services.updateAccess({ - access: EPageAccess.PRIVATE, - }); - } catch (error) { - runInAction(() => { - this.access = pageAccess; - }); - throw error; + if (shouldSync) { + try { + await this.services.updateAccess({ + access: EPageAccess.PRIVATE, + }); + } catch (error) { + runInAction(() => { + this.access = pageAccess; + }); + throw error; + } } }; @@ -468,4 +481,9 @@ export class BasePage implements TBasePage { throw error; }); }; + + /** + * @description duplicate the page + */ + duplicate = async () => await this.services.duplicate(); } diff --git a/web/core/store/pages/project-page.store.ts b/web/core/store/pages/project-page.store.ts index 230fb89e868..b5199249c87 100644 --- a/web/core/store/pages/project-page.store.ts +++ b/web/core/store/pages/project-page.store.ts @@ -20,6 +20,8 @@ type TLoader = "init-loader" | "mutation-loader" | undefined; type TError = { title: string; description: string }; +export const ROLE_PERMISSIONS_TO_CREATE_PAGE = [EUserPermissions.ADMIN, EUserPermissions.MEMBER]; + export interface IProjectPageStore { // observables loader: TLoader; @@ -44,6 +46,7 @@ export interface IProjectPageStore { getPageById: (workspaceSlug: string, projectId: string, pageId: string) => Promise; createPage: (pageData: Partial) => Promise; removePage: (pageId: string) => Promise; + movePage: (workspaceSlug: string, projectId: string, pageId: string, newProjectId: string) => Promise; } export class ProjectPageStore implements IProjectPageStore { @@ -78,6 +81,7 @@ export class ProjectPageStore implements IProjectPageStore { getPageById: action, createPage: action, removePage: action, + movePage: action, }); this.rootStore = store; // service @@ -109,7 +113,7 @@ export class ProjectPageStore implements IProjectPageStore { workspaceSlug?.toString() || "", projectId?.toString() || "" ); - return !!currentUserProjectRole && currentUserProjectRole >= EUserPermissions.MEMBER; + return !!currentUserProjectRole && ROLE_PERMISSIONS_TO_CREATE_PAGE.includes(currentUserProjectRole); } /** @@ -294,4 +298,13 @@ export class ProjectPageStore implements IProjectPageStore { throw error; } }; + + /** + * @description move a page to a new project + * @param {string} workspaceSlug + * @param {string} projectId + * @param {string} pageId + * @param {string} newProjectId + */ + movePage = async (workspaceSlug: string, projectId: string, pageId: string, newProjectId: string) => {}; } diff --git a/web/core/store/pages/project-page.ts b/web/core/store/pages/project-page.ts index b03e391e184..a7201687d0f 100644 --- a/web/core/store/pages/project-page.ts +++ b/web/core/store/pages/project-page.ts @@ -50,9 +50,14 @@ export class ProjectPage extends BasePage implements TProjectPage { if (!workspaceSlug || !projectId || !page.id) throw new Error("Missing required fields."); await projectPageService.restore(workspaceSlug, projectId, page.id); }, + duplicate: async () => { + if (!workspaceSlug || !projectId || !page.id) throw new Error("Missing required fields."); + return await projectPageService.duplicate(workspaceSlug, projectId, page.id); + }, }); makeObservable(this, { // computed + canCurrentUserAccessPage: computed, canCurrentUserEditPage: computed, canCurrentUserDuplicatePage: computed, canCurrentUserLockPage: computed, @@ -60,6 +65,7 @@ export class ProjectPage extends BasePage implements TProjectPage { canCurrentUserArchivePage: computed, canCurrentUserDeletePage: computed, canCurrentUserFavoritePage: computed, + canCurrentUserMovePage: computed, isContentEditable: computed, }); } @@ -81,6 +87,14 @@ export class ProjectPage extends BasePage implements TProjectPage { return highestRole; }); + /** + * @description returns true if the current logged in user can access the page + */ + get canCurrentUserAccessPage() { + const isPagePublic = this.access === EPageAccess.PUBLIC; + return isPagePublic || this.isCurrentUserOwner; + } + /** * @description returns true if the current logged in user can edit the page */ @@ -141,6 +155,14 @@ export class ProjectPage extends BasePage implements TProjectPage { return !!highestRole && highestRole >= EUserPermissions.MEMBER; } + /** + * @description returns true if the current logged in user can move the page + */ + get canCurrentUserMovePage() { + const highestRole = this.getHighestRoleAcrossProjects(); + return this.isCurrentUserOwner || highestRole === EUserPermissions.ADMIN; + } + /** * @description returns true if the page can be edited */ From 8833e4e23b48a163c2a06fba9385159fe2331a4c Mon Sep 17 00:00:00 2001 From: Akash Verma Date: Tue, 31 Dec 2024 13:57:26 +0530 Subject: [PATCH 3/3] Integrates LiteLLM for Unified Access to Multiple LLM Models (#5925) * adds litellm gateway * Fixes repeating code * Fixes error exposing * Fixes error for None text * handles logging exception * Adds multiple providers support * handling edge cases * adds new envs to instance store * strategy pattern for llm config --------- Co-authored-by: akash5100 --- apiserver/plane/app/views/external/base.py | 220 ++++++++++++------ .../management/commands/configure_instance.py | 23 +- apiserver/requirements/base.txt | 2 +- 3 files changed, 171 insertions(+), 74 deletions(-) diff --git a/apiserver/plane/app/views/external/base.py b/apiserver/plane/app/views/external/base.py index 1dfbc421a00..ae5c47f1455 100644 --- a/apiserver/plane/app/views/external/base.py +++ b/apiserver/plane/app/views/external/base.py @@ -1,71 +1,169 @@ -# Python imports -import requests +# Python import import os +from typing import List, Dict, Tuple + +# Third party import +import litellm +import requests -# Third party imports -from openai import OpenAI -from rest_framework.response import Response from rest_framework import status +from rest_framework.response import Response -# Django imports +# Module import +from plane.app.permissions import ROLE, allow_permission +from plane.app.serializers import (ProjectLiteSerializer, + WorkspaceLiteSerializer) +from plane.db.models import Project, Workspace +from plane.license.utils.instance_value import get_configuration_value +from plane.utils.exception_logger import log_exception -# Module imports from ..base import BaseAPIView -from plane.app.permissions import allow_permission, ROLE -from plane.db.models import Workspace, Project -from plane.app.serializers import ProjectLiteSerializer, WorkspaceLiteSerializer -from plane.license.utils.instance_value import get_configuration_value +class LLMProvider: + """Base class for LLM provider configurations""" + name: str = "" + models: List[str] = [] + default_model: str = "" + + @classmethod + def get_config(cls) -> Dict[str, str | List[str]]: + return { + "name": cls.name, + "models": cls.models, + "default_model": cls.default_model, + } + +class OpenAIProvider(LLMProvider): + name = "OpenAI" + models = ["gpt-3.5-turbo", "gpt-4o-mini", "gpt-4o", "o1-mini", "o1-preview"] + default_model = "gpt-4o-mini" + +class AnthropicProvider(LLMProvider): + name = "Anthropic" + models = [ + "claude-3-5-sonnet-20240620", + "claude-3-haiku-20240307", + "claude-3-opus-20240229", + "claude-3-sonnet-20240229", + "claude-2.1", + "claude-2", + "claude-instant-1.2", + "claude-instant-1" + ] + default_model = "claude-3-sonnet-20240229" + +class GeminiProvider(LLMProvider): + name = "Gemini" + models = ["gemini-pro", "gemini-1.5-pro-latest", "gemini-pro-vision"] + default_model = "gemini-pro" + +SUPPORTED_PROVIDERS = { + "openai": OpenAIProvider, + "anthropic": AnthropicProvider, + "gemini": GeminiProvider, +} + +def get_llm_config() -> Tuple[str | None, str | None, str | None]: + """ + Helper to get LLM configuration values, returns: + - api_key, model, provider + """ + api_key, provider_key, model = get_configuration_value([ + { + "key": "LLM_API_KEY", + "default": os.environ.get("LLM_API_KEY", None), + }, + { + "key": "LLM_PROVIDER", + "default": os.environ.get("LLM_PROVIDER", "openai"), + }, + { + "key": "LLM_MODEL", + "default": os.environ.get("LLM_MODEL", None), + }, + ]) + + provider = SUPPORTED_PROVIDERS.get(provider_key.lower()) + if not provider: + log_exception(ValueError(f"Unsupported provider: {provider_key}")) + return None, None, None + + if not api_key: + log_exception(ValueError(f"Missing API key for provider: {provider.name}")) + return None, None, None + + # If no model specified, use provider's default + if not model: + model = provider.default_model + + # Validate model is supported by provider + if model not in provider.models: + log_exception(ValueError( + f"Model {model} not supported by {provider.name}. " + f"Supported models: {', '.join(provider.models)}" + )) + return None, None, None + + return api_key, model, provider_key + + +def get_llm_response(task, prompt, api_key: str, model: str, provider: str) -> Tuple[str | None, str | None]: + """Helper to get LLM completion response""" + final_text = task + "\n" + prompt + try: + # For Gemini, prepend provider name to model + if provider.lower() == "gemini": + model = f"gemini/{model}" + + response = litellm.completion( + model=model, + messages=[{"role": "user", "content": final_text}], + api_key=api_key, + ) + text = response.choices[0].message.content.strip() + return text, None + except Exception as e: + log_exception(e) + error_type = e.__class__.__name__ + if error_type == "AuthenticationError": + return None, f"Invalid API key for {provider}" + elif error_type == "RateLimitError": + return None, f"Rate limit exceeded for {provider}" + else: + return None, f"Error occurred while generating response from {provider}" + class GPTIntegrationEndpoint(BaseAPIView): @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) def post(self, request, slug, project_id): - OPENAI_API_KEY, GPT_ENGINE = get_configuration_value( - [ - { - "key": "OPENAI_API_KEY", - "default": os.environ.get("OPENAI_API_KEY", None), - }, - { - "key": "GPT_ENGINE", - "default": os.environ.get("GPT_ENGINE", "gpt-3.5-turbo"), - }, - ] - ) + api_key, model, provider = get_llm_config() - # Get the configuration value - # Check the keys - if not OPENAI_API_KEY or not GPT_ENGINE: + if not api_key or not model or not provider: return Response( - {"error": "OpenAI API key and engine is required"}, + {"error": "LLM provider API key and model are required"}, status=status.HTTP_400_BAD_REQUEST, ) - prompt = request.data.get("prompt", False) task = request.data.get("task", False) - if not task: return Response( {"error": "Task is required"}, status=status.HTTP_400_BAD_REQUEST ) - final_text = task + "\n" + prompt - - client = OpenAI(api_key=OPENAI_API_KEY) - - response = client.chat.completions.create( - model=GPT_ENGINE, messages=[{"role": "user", "content": final_text}] - ) + text, error = get_llm_response(task, request.data.get("prompt", False), api_key, model, provider) + if not text and error: + return Response( + {"error": "An internal error has occurred."}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) workspace = Workspace.objects.get(slug=slug) project = Project.objects.get(pk=project_id) - text = response.choices[0].message.content.strip() - text_html = text.replace("\n", "
") return Response( { "response": text, - "response_html": text_html, + "response_html": text.replace("\n", "
"), "project_detail": ProjectLiteSerializer(project).data, "workspace_detail": WorkspaceLiteSerializer(workspace).data, }, @@ -76,47 +174,33 @@ def post(self, request, slug, project_id): class WorkspaceGPTIntegrationEndpoint(BaseAPIView): @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") def post(self, request, slug): - OPENAI_API_KEY, GPT_ENGINE = get_configuration_value( - [ - { - "key": "OPENAI_API_KEY", - "default": os.environ.get("OPENAI_API_KEY", None), - }, - { - "key": "GPT_ENGINE", - "default": os.environ.get("GPT_ENGINE", "gpt-3.5-turbo"), - }, - ] - ) - - # Get the configuration value - # Check the keys - if not OPENAI_API_KEY or not GPT_ENGINE: + api_key, model, provider = get_llm_config() + + if not api_key or not model or not provider: return Response( - {"error": "OpenAI API key and engine is required"}, + {"error": "LLM provider API key and model are required"}, status=status.HTTP_400_BAD_REQUEST, ) - prompt = request.data.get("prompt", False) task = request.data.get("task", False) - if not task: return Response( {"error": "Task is required"}, status=status.HTTP_400_BAD_REQUEST ) - final_text = task + "\n" + prompt - - client = OpenAI(api_key=OPENAI_API_KEY) - - response = client.chat.completions.create( - model=GPT_ENGINE, messages=[{"role": "user", "content": final_text}] - ) + text, error = get_llm_response(task, request.data.get("prompt", False), api_key, model, provider) + if not text and error: + return Response( + {"error": "An internal error has occurred."}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) - text = response.choices[0].message.content.strip() - text_html = text.replace("\n", "
") return Response( - {"response": text, "response_html": text_html}, status=status.HTTP_200_OK + { + "response": text, + "response_html": text.replace("\n", "
"), + }, + status=status.HTTP_200_OK, ) diff --git a/apiserver/plane/license/management/commands/configure_instance.py b/apiserver/plane/license/management/commands/configure_instance.py index 548c9c77ed0..8458df5df6d 100644 --- a/apiserver/plane/license/management/commands/configure_instance.py +++ b/apiserver/plane/license/management/commands/configure_instance.py @@ -132,20 +132,33 @@ def handle(self, *args, **options): "is_encrypted": False, }, { - "key": "OPENAI_API_KEY", - "value": os.environ.get("OPENAI_API_KEY"), - "category": "OPENAI", + "key": "LLM_API_KEY", + "value": os.environ.get("LLM_API_KEY"), + "category": "AI", "is_encrypted": True, }, { - "key": "GPT_ENGINE", + "key": "LLM_PROVIDER", + "value": os.environ.get("LLM_PROVIDER", "openai"), + "category": "AI", + "is_encrypted": False, + }, + { + "key": "LLM_MODEL", + "value": os.environ.get("LLM_MODEL", "gpt-4o-mini"), + "category": "AI", + "is_encrypted": False, + }, + # Deprecated, use LLM_MODEL + { + "key": "GPT_ENGINE", "value": os.environ.get("GPT_ENGINE", "gpt-3.5-turbo"), "category": "SMTP", "is_encrypted": False, }, { "key": "UNSPLASH_ACCESS_KEY", - "value": os.environ.get("UNSPLASH_ACESS_KEY", ""), + "value": os.environ.get("UNSPLASH_ACCESS_KEY", ""), "category": "UNSPLASH", "is_encrypted": True, }, diff --git a/apiserver/requirements/base.txt b/apiserver/requirements/base.txt index 40e90aedfc4..f7eb46a4a4f 100644 --- a/apiserver/requirements/base.txt +++ b/apiserver/requirements/base.txt @@ -37,7 +37,7 @@ uvicorn==0.29.0 # sockets channels==4.1.0 # ai -openai==1.25.0 +litellm==1.51.0 # slack slack-sdk==3.27.1 # apm