From 7f5e8660d1a9ba68c37f71f7ff30266bd099fb5b Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com> Date: Tue, 27 Aug 2024 20:09:45 +0530 Subject: [PATCH] feat: workspace pages version history (#963) --- apiserver/plane/ee/serializers/__init__.py | 7 +- apiserver/plane/ee/serializers/app/page.py | 52 +++++++- apiserver/plane/ee/urls/app/page.py | 11 ++ apiserver/plane/ee/views/__init__.py | 1 + apiserver/plane/ee/views/app/page/__init__.py | 1 + .../plane/ee/views/app/page/workspace.py | 30 ++++- web/ee/components/pages/editor/page-root.tsx | 94 +++++++++++---- web/ee/components/pages/index.ts | 1 + web/ee/components/pages/version/editor.tsx | 111 ++++++++++++++++++ web/ee/components/pages/version/index.ts | 1 + .../hooks/use-workspace-page-description.ts | 14 +++ web/ee/services/page/index.ts | 1 + .../page/workspace-page-version.service.ts | 28 +++++ 13 files changed, 326 insertions(+), 26 deletions(-) create mode 100644 web/ee/components/pages/version/editor.tsx create mode 100644 web/ee/components/pages/version/index.ts create mode 100644 web/ee/services/page/workspace-page-version.service.ts diff --git a/apiserver/plane/ee/serializers/__init__.py b/apiserver/plane/ee/serializers/__init__.py index 25089833f2..d73e6e3b85 100644 --- a/apiserver/plane/ee/serializers/__init__.py +++ b/apiserver/plane/ee/serializers/__init__.py @@ -6,7 +6,12 @@ from plane.app.serializers import ( from .app.issue import IssueLiteSerializer from .app.active_cycle import WorkspaceActiveCycleSerializer -from .app.page import WorkspacePageSerializer, WorkspacePageDetailSerializer +from .app.page import ( + WorkspacePageSerializer, + WorkspacePageDetailSerializer, + WorkspacePageVersionSerializer, + WorkspacePageVersionDetailSerializer, +) from .app.issue_property import ( IssueTypeSerializer, IssuePropertySerializer, diff --git a/apiserver/plane/ee/serializers/app/page.py b/apiserver/plane/ee/serializers/app/page.py index 1903bb9dcb..4eaa0e253c 100644 --- a/apiserver/plane/ee/serializers/app/page.py +++ b/apiserver/plane/ee/serializers/app/page.py @@ -3,7 +3,14 @@ from rest_framework import serializers # Module imports from plane.app.serializers.base import BaseSerializer -from plane.db.models import Project, Page, Label, ProjectPage, PageLabel +from plane.db.models import ( + Project, + Page, + Label, + ProjectPage, + PageLabel, + PageVersion, +) class WorkspacePageSerializer(BaseSerializer): @@ -157,3 +164,46 @@ class WorkspacePageDetailSerializer(BaseSerializer): "description_html", "anchor", ] + + +class WorkspacePageVersionSerializer(BaseSerializer): + class Meta: + model = PageVersion + fields = [ + "id", + "workspace", + "page", + "last_saved_at", + "owned_by", + "created_at", + "updated_at", + "created_by", + "updated_by", + ] + read_only_fields = [ + "workspace", + "page", + ] + + +class WorkspacePageVersionDetailSerializer(BaseSerializer): + class Meta: + model = PageVersion + fields = [ + "id", + "workspace", + "page", + "last_saved_at", + "description_html", + "description_json", + "description_binary", + "owned_by", + "created_at", + "updated_at", + "created_by", + "updated_by", + ] + read_only_fields = [ + "workspace", + "page", + ] diff --git a/apiserver/plane/ee/urls/app/page.py b/apiserver/plane/ee/urls/app/page.py index 7d1eeab786..e2c62fd0ae 100644 --- a/apiserver/plane/ee/urls/app/page.py +++ b/apiserver/plane/ee/urls/app/page.py @@ -5,6 +5,7 @@ from plane.ee.views import ( WorkspacePagePublishEndpoint, WorkspacePageViewSet, WorkspacePagesDescriptionViewSet, + WorkspacePageVersionEndpoint, ) @@ -79,4 +80,14 @@ urlpatterns = [ ), name="page-description", ), + path( + "workspaces//pages//versions/", + WorkspacePageVersionEndpoint.as_view(), + name="workspace-page-version", + ), + path( + "workspaces//pages//versions//", + WorkspacePageVersionEndpoint.as_view(), + name="workspace-page-version", + ), ] diff --git a/apiserver/plane/ee/views/__init__.py b/apiserver/plane/ee/views/__init__.py index b238714b02..9ddfedd594 100644 --- a/apiserver/plane/ee/views/__init__.py +++ b/apiserver/plane/ee/views/__init__.py @@ -17,6 +17,7 @@ from plane.ee.views.app.page import ( WorkspacePagePublishEndpoint, WorkspacePageViewSet, WorkspacePagesDescriptionViewSet, + WorkspacePageVersionEndpoint, ) from plane.ee.views.app.views import ( IssueViewEEViewSet, diff --git a/apiserver/plane/ee/views/app/page/__init__.py b/apiserver/plane/ee/views/app/page/__init__.py index 81236f0b7a..537163d8d6 100644 --- a/apiserver/plane/ee/views/app/page/__init__.py +++ b/apiserver/plane/ee/views/app/page/__init__.py @@ -2,4 +2,5 @@ from .publish import ProjectPagePublishEndpoint, WorkspacePagePublishEndpoint from .workspace import ( WorkspacePageViewSet, WorkspacePagesDescriptionViewSet, + WorkspacePageVersionEndpoint, ) diff --git a/apiserver/plane/ee/views/app/page/workspace.py b/apiserver/plane/ee/views/app/page/workspace.py index 4af844efa3..a942fe2259 100644 --- a/apiserver/plane/ee/views/app/page/workspace.py +++ b/apiserver/plane/ee/views/app/page/workspace.py @@ -19,6 +19,8 @@ from plane.app.permissions import WorkspaceEntityPermission from plane.ee.serializers import ( WorkspacePageSerializer, WorkspacePageDetailSerializer, + WorkspacePageVersionSerializer, + WorkspacePageVersionDetailSerializer, ) from plane.db.models import ( Page, @@ -26,9 +28,10 @@ from plane.db.models import ( ProjectMember, Workspace, DeployBoard, + PageVersion, ) -from plane.ee.views.base import BaseViewSet +from plane.ee.views.base import BaseViewSet, BaseAPIView from plane.bgtasks.page_version_task import page_version from plane.bgtasks.page_transaction_task import page_transaction from plane.payment.flags.flag_decorator import check_feature_flag @@ -371,6 +374,7 @@ class WorkspacePagesDescriptionViewSet(BaseViewSet): ) return response + @check_feature_flag(FeatureFlag.WORKSPACE_PAGES) def partial_update(self, request, slug, pk): page = Page.objects.get(pk=pk, workspace__slug=slug) @@ -427,3 +431,27 @@ class WorkspacePagesDescriptionViewSet(BaseViewSet): return Response({"message": "Updated successfully"}) else: return Response({"error": "No binary data provided"}) + + +class WorkspacePageVersionEndpoint(BaseAPIView): + @check_feature_flag(FeatureFlag.WORKSPACE_PAGES) + def get(self, request, slug, page_id, pk=None): + # Check if pk is provided + if pk: + # Return a single page version + page_version = PageVersion.objects.get( + workspace__slug=slug, + page_id=page_id, + pk=pk, + ) + # Serialize the page version + serializer = WorkspacePageVersionDetailSerializer(page_version) + return Response(serializer.data, status=status.HTTP_200_OK) + # Return all page versions + page_versions = PageVersion.objects.filter( + workspace__slug=slug, + page_id=page_id, + ) + # Serialize the page versions + serializer = WorkspacePageVersionSerializer(page_versions, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/web/ee/components/pages/editor/page-root.tsx b/web/ee/components/pages/editor/page-root.tsx index a3fff0eb22..133f235471 100644 --- a/web/ee/components/pages/editor/page-root.tsx +++ b/web/ee/components/pages/editor/page-root.tsx @@ -1,16 +1,26 @@ -import { useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { observer } from "mobx-react"; -// editor +import { useSearchParams } from "next/navigation"; +// plane editor import { EditorRefApi, useEditorMarkings } from "@plane/editor"; -// types +// plane types import { TPage } from "@plane/types"; +// plane ui import { setToast, TOAST_TYPE } from "@plane/ui"; -import { PageEditorHeaderRoot } from "@/components/pages"; +// components +import { PageEditorHeaderRoot, PageVersionsOverlay } from "@/components/pages"; +// hooks import { useAppRouter } from "@/hooks/use-app-router"; -// ee specific +import { useQueryParams } from "@/hooks/use-query-params"; +// plane web components import { WorkspacePageEditorBody } from "@/plane-web/components/pages"; +// plane web hooks import { useWorkspacePages } from "@/plane-web/hooks/store"; import { useWorkspacePageDescription } from "@/plane-web/hooks/use-workspace-page-description"; +// plane web services +import { WorkspacePageVersionService } from "@/plane-web/services/page"; +const workspacePageVersionService = new WorkspacePageVersionService(); +// plane web store import { IWorkspacePageDetails } from "@/plane-web/store/pages/page"; type TPageRootProps = { @@ -19,32 +29,39 @@ type TPageRootProps = { }; export const WorkspacePageRoot = observer((props: TPageRootProps) => { - // router - const router = useAppRouter(); - const { workspaceSlug, page } = props; - const { createPage } = useWorkspacePages(); - const { access, description_html, name } = page; - // states const [editorReady, setEditorReady] = useState(false); const [readOnlyEditorReady, setReadOnlyEditorReady] = useState(false); - + const [sidePeekVisible, setSidePeekVisible] = useState(window.innerWidth >= 768); + const [isVersionsOverlayOpen, setIsVersionsOverlayOpen] = useState(false); // refs const editorRef = useRef(null); const readOnlyEditorRef = useRef(null); - + // router + const router = useAppRouter(); + const { workspaceSlug, page } = props; + // search params + const searchParams = useSearchParams(); + // store hooks + const { createPage } = useWorkspacePages(); + // derived values + const { access, description_html, name } = page; // editor markings hook const { markings, updateMarkings } = useEditorMarkings(); - - const [sidePeekVisible, setSidePeekVisible] = useState(window.innerWidth >= 768 ? true : false); - - // project-description - const { handleDescriptionChange, isDescriptionReady, pageDescriptionYJS, handleSaveDescription } = - useWorkspacePageDescription({ - editorRef, - page, - workspaceSlug, - }); + // page description + const { + handleDescriptionChange, + isDescriptionReady, + pageDescriptionYJS, + handleSaveDescription, + manuallyUpdateDescription, + } = useWorkspacePageDescription({ + editorRef, + page, + workspaceSlug, + }); + // update query params + const { updateQueryParams } = useQueryParams(); const handleCreatePage = async (payload: Partial) => await createPage(payload); @@ -66,8 +83,39 @@ export const WorkspacePageRoot = observer((props: TPageRootProps) => { ); }; + const version = searchParams.get("version"); + useEffect(() => { + if (!version) { + setIsVersionsOverlayOpen(false); + return; + } + setIsVersionsOverlayOpen(true); + }, [version]); + + const handleCloseVersionsOverlay = () => { + const updatedRoute = updateQueryParams({ + paramsToRemove: ["version"], + }); + router.push(updatedRoute); + }; + return ( <> + { + if (!workspaceSlug) return; + return await workspacePageVersionService.fetchAllVersions(workspaceSlug.toString(), pageId); + }} + fetchVersionDetails={async (pageId, versionId) => { + if (!workspaceSlug) return; + return await workspacePageVersionService.fetchVersionById(workspaceSlug.toString(), pageId, versionId); + }} + handleRestore={manuallyUpdateDescription} + isOpen={isVersionsOverlayOpen} + onClose={handleCloseVersionsOverlay} + pageId={page.id ?? ""} + /> = observer((props) => { + const { activeVersion, isCurrentVersionActive, versionDetails } = props; + // params + const { workspaceSlug, projectId, pageId } = useParams(); + // store hooks + const { data: currentUser } = useUser(); + const { + getUserDetails, + workspace: { workspaceMemberIds }, + } = useMember(); + const { description_html } = useWorkspacePageDetails(pageId.toString() ?? ""); + // derived values + const workspaceMemberDetails = workspaceMemberIds?.map((id) => getUserDetails(id) as IUserLite); + // issue-embed + const { issueEmbedProps } = useIssueEmbed(workspaceSlug?.toString() ?? "", projectId?.toString() ?? ""); + // use-mention + const { mentionHighlights } = useWorkspaceMention({ + workspaceSlug: workspaceSlug?.toString() ?? "", + members: workspaceMemberDetails, + user: currentUser ?? undefined, + }); + // page filters + const { fontSize, fontStyle } = usePageFilters(); + + const displayConfig: TDisplayConfig = { + fontSize, + fontStyle, + }; + + if (!isCurrentVersionActive && !versionDetails) + return ( +
+ + +
+
+ +
+ +
+ + +
+
+ +
+ + +
+ + +
+
+ +
+ +
+ +
+ +
+
+ +
+ +
+
+
+
+ ); + + return ( +

"} + containerClassName="p-0 pb-64 border-none" + displayConfig={displayConfig} + editorClassName="pl-10" + mentionHandler={{ + highlights: mentionHighlights, + }} + embedHandler={{ + issue: { + widgetCallback: issueEmbedProps.widgetCallback, + }, + }} + /> + ); +}); diff --git a/web/ee/components/pages/version/index.ts b/web/ee/components/pages/version/index.ts new file mode 100644 index 0000000000..8b1fd904bb --- /dev/null +++ b/web/ee/components/pages/version/index.ts @@ -0,0 +1 @@ +export * from "./editor"; diff --git a/web/ee/hooks/use-workspace-page-description.ts b/web/ee/hooks/use-workspace-page-description.ts index 822df2d29b..2b027b7f6e 100644 --- a/web/ee/hooks/use-workspace-page-description.ts +++ b/web/ee/hooks/use-workspace-page-description.ts @@ -178,6 +178,19 @@ export const useWorkspacePageDescription = (props: Props) => { ] ); + const manuallyUpdateDescription = async (descriptionHTML: string) => { + const { contentJSON, editorSchema } = generateJSONfromHTMLForDocumentEditor(descriptionHTML ?? "

"); + const yDocBinaryString = proseMirrorJSONToBinaryString(contentJSON, "default", editorSchema); + + try { + editorRef.current?.clearEditor(true); + await updateDescription(yDocBinaryString, descriptionHTML ?? "

"); + await mutateDescriptionYJS(); + } catch (error) { + console.log("error", error); + } + }; + useAutoSave(handleSaveDescription); return { @@ -185,5 +198,6 @@ export const useWorkspacePageDescription = (props: Props) => { isDescriptionReady, pageDescriptionYJS, handleSaveDescription, + manuallyUpdateDescription, }; }; diff --git a/web/ee/services/page/index.ts b/web/ee/services/page/index.ts index 3bbf7d450c..818aadfcfa 100644 --- a/web/ee/services/page/index.ts +++ b/web/ee/services/page/index.ts @@ -1,2 +1,3 @@ export * from "./publish-page.service"; +export * from "./workspace-page-version.service"; export * from "./workspace-page.service"; diff --git a/web/ee/services/page/workspace-page-version.service.ts b/web/ee/services/page/workspace-page-version.service.ts new file mode 100644 index 0000000000..bc496098b3 --- /dev/null +++ b/web/ee/services/page/workspace-page-version.service.ts @@ -0,0 +1,28 @@ +// plane types +import { TPageVersion } from "@plane/types"; +// helpers +import { API_BASE_URL } from "@/helpers/common.helper"; +// services +import { APIService } from "@/services/api.service"; + +export class WorkspacePageVersionService extends APIService { + constructor() { + super(API_BASE_URL); + } + + async fetchAllVersions(workspaceSlug: string, pageId: string): Promise { + return this.get(`/api/workspaces/${workspaceSlug}/pages/${pageId}/versions/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async fetchVersionById(workspaceSlug: string, pageId: string, versionId: string): Promise { + return this.get(`/api/workspaces/${workspaceSlug}/pages/${pageId}/versions/${versionId}/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } +}