Merge pull request #965 from makeplane/workspace-pages-revision-history

[WEB-2293] feat: workspace pages version history
This commit is contained in:
pushya22
2024-08-27 20:29:03 +05:30
committed by GitHub
13 changed files with 326 additions and 26 deletions

View File

@@ -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,

View File

@@ -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",
]

View File

@@ -5,6 +5,7 @@ from plane.ee.views import (
WorkspacePagePublishEndpoint,
WorkspacePageViewSet,
WorkspacePagesDescriptionViewSet,
WorkspacePageVersionEndpoint,
)
@@ -79,4 +80,14 @@ urlpatterns = [
),
name="page-description",
),
path(
"workspaces/<str:slug>/pages/<uuid:page_id>/versions/",
WorkspacePageVersionEndpoint.as_view(),
name="workspace-page-version",
),
path(
"workspaces/<str:slug>/pages/<uuid:page_id>/versions/<uuid:pk>/",
WorkspacePageVersionEndpoint.as_view(),
name="workspace-page-version",
),
]

View File

@@ -17,6 +17,7 @@ from plane.ee.views.app.page import (
WorkspacePagePublishEndpoint,
WorkspacePageViewSet,
WorkspacePagesDescriptionViewSet,
WorkspacePageVersionEndpoint,
)
from plane.ee.views.app.views import (
IssueViewEEViewSet,

View File

@@ -2,4 +2,5 @@ from .publish import ProjectPagePublishEndpoint, WorkspacePagePublishEndpoint
from .workspace import (
WorkspacePageViewSet,
WorkspacePagesDescriptionViewSet,
WorkspacePageVersionEndpoint,
)

View File

@@ -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)

View File

@@ -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<EditorRefApi>(null);
const readOnlyEditorRef = useRef<EditorRefApi>(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<TPage>) => 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 (
<>
<PageVersionsOverlay
activeVersion={version}
fetchAllVersions={async (pageId) => {
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 ?? ""}
/>
<PageEditorHeaderRoot
editorRef={editorRef}
readOnlyEditorRef={readOnlyEditorRef}

View File

@@ -3,6 +3,7 @@ export * from "./editor";
export * from "./list";
export * from "./modals";
export * from "./sidebar";
export * from "./version";
export * from "./extra-actions";
export * from "./quick-actions";
export * from "./workspace-pages-upgrade";

View File

@@ -0,0 +1,111 @@
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane editor
import { DocumentReadOnlyEditorWithRef, TDisplayConfig } from "@plane/editor";
// plane types
import { IUserLite, TPageVersion } from "@plane/types";
// plane ui
import { Loader } from "@plane/ui";
// hooks
import { useMember, useUser } from "@/hooks/store";
import { usePageFilters } from "@/hooks/use-page-filters";
// plane web hooks
import { useWorkspacePageDetails } from "@/plane-web/hooks/store";
import { useIssueEmbed } from "@/plane-web/hooks/use-issue-embed";
import { useWorkspaceMention } from "@/plane-web/hooks/use-workspace-mention";
type Props = {
activeVersion: string | null;
isCurrentVersionActive: boolean;
versionDetails: TPageVersion | undefined;
};
export const PagesVersionEditor: React.FC<Props> = 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 (
<div className="size-full px-5">
<Loader className="relative space-y-4">
<Loader.Item width="50%" height="36px" />
<div className="space-y-2">
<div className="py-2">
<Loader.Item width="100%" height="36px" />
</div>
<Loader.Item width="80%" height="22px" />
<div className="relative flex items-center gap-2">
<Loader.Item width="30px" height="30px" />
<Loader.Item width="30%" height="22px" />
</div>
<div className="py-2">
<Loader.Item width="60%" height="36px" />
</div>
<Loader.Item width="70%" height="22px" />
<Loader.Item width="30%" height="22px" />
<div className="relative flex items-center gap-2">
<Loader.Item width="30px" height="30px" />
<Loader.Item width="30%" height="22px" />
</div>
<div className="py-2">
<Loader.Item width="50%" height="30px" />
</div>
<Loader.Item width="100%" height="22px" />
<div className="py-2">
<Loader.Item width="30%" height="30px" />
</div>
<Loader.Item width="30%" height="22px" />
<div className="relative flex items-center gap-2">
<div className="py-2">
<Loader.Item width="30px" height="30px" />
</div>
<Loader.Item width="30%" height="22px" />
</div>
</div>
</Loader>
</div>
);
return (
<DocumentReadOnlyEditorWithRef
id={activeVersion ?? ""}
initialValue={(isCurrentVersionActive ? description_html : versionDetails?.description_html) ?? "<p></p>"}
containerClassName="p-0 pb-64 border-none"
displayConfig={displayConfig}
editorClassName="pl-10"
mentionHandler={{
highlights: mentionHighlights,
}}
embedHandler={{
issue: {
widgetCallback: issueEmbedProps.widgetCallback,
},
}}
/>
);
});

View File

@@ -0,0 +1 @@
export * from "./editor";

View File

@@ -178,6 +178,19 @@ export const useWorkspacePageDescription = (props: Props) => {
]
);
const manuallyUpdateDescription = async (descriptionHTML: string) => {
const { contentJSON, editorSchema } = generateJSONfromHTMLForDocumentEditor(descriptionHTML ?? "<p></p>");
const yDocBinaryString = proseMirrorJSONToBinaryString(contentJSON, "default", editorSchema);
try {
editorRef.current?.clearEditor(true);
await updateDescription(yDocBinaryString, descriptionHTML ?? "<p></p>");
await mutateDescriptionYJS();
} catch (error) {
console.log("error", error);
}
};
useAutoSave(handleSaveDescription);
return {
@@ -185,5 +198,6 @@ export const useWorkspacePageDescription = (props: Props) => {
isDescriptionReady,
pageDescriptionYJS,
handleSaveDescription,
manuallyUpdateDescription,
};
};

View File

@@ -1,2 +1,3 @@
export * from "./publish-page.service";
export * from "./workspace-page-version.service";
export * from "./workspace-page.service";

View File

@@ -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<TPageVersion[]> {
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<TPageVersion> {
return this.get(`/api/workspaces/${workspaceSlug}/pages/${pageId}/versions/${versionId}/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
}