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;
+ });
+ }
+}