mirror of
https://github.com/makeplane/plane.git
synced 2026-07-13 14:01:45 +02:00
Merge pull request #965 from makeplane/workspace-pages-revision-history
[WEB-2293] feat: workspace pages version history
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -17,6 +17,7 @@ from plane.ee.views.app.page import (
|
||||
WorkspacePagePublishEndpoint,
|
||||
WorkspacePageViewSet,
|
||||
WorkspacePagesDescriptionViewSet,
|
||||
WorkspacePageVersionEndpoint,
|
||||
)
|
||||
from plane.ee.views.app.views import (
|
||||
IssueViewEEViewSet,
|
||||
|
||||
@@ -2,4 +2,5 @@ from .publish import ProjectPagePublishEndpoint, WorkspacePagePublishEndpoint
|
||||
from .workspace import (
|
||||
WorkspacePageViewSet,
|
||||
WorkspacePagesDescriptionViewSet,
|
||||
WorkspacePageVersionEndpoint,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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";
|
||||
|
||||
111
web/ee/components/pages/version/editor.tsx
Normal file
111
web/ee/components/pages/version/editor.tsx
Normal 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,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
1
web/ee/components/pages/version/index.ts
Normal file
1
web/ee/components/pages/version/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./editor";
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from "./publish-page.service";
|
||||
export * from "./workspace-page-version.service";
|
||||
export * from "./workspace-page.service";
|
||||
|
||||
28
web/ee/services/page/workspace-page-version.service.ts
Normal file
28
web/ee/services/page/workspace-page-version.service.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user