diff --git a/apiserver/plane/app/serializers/__init__.py b/apiserver/plane/app/serializers/__init__.py index 83981ab53c6..618a9ec20f1 100644 --- a/apiserver/plane/app/serializers/__init__.py +++ b/apiserver/plane/app/serializers/__init__.py @@ -92,6 +92,7 @@ SubPageSerializer, PageDetailSerializer, PageVersionSerializer, + PageVersionDetailSerializer, ) from .estimate import ( diff --git a/apiserver/plane/app/serializers/page.py b/apiserver/plane/app/serializers/page.py index c754bd431cc..e7f273d408a 100644 --- a/apiserver/plane/app/serializers/page.py +++ b/apiserver/plane/app/serializers/page.py @@ -167,7 +167,40 @@ class Meta: class PageVersionSerializer(BaseSerializer): class Meta: model = PageVersion - fields = "__all__" + fields = [ + "id", + "workspace", + "page", + "last_saved_at", + "owned_by", + "created_at", + "updated_at", + "created_by", + "updated_by", + ] + read_only_fields = [ + "workspace", + "page", + ] + + +class PageVersionDetailSerializer(BaseSerializer): + class Meta: + model = PageVersion + fields = [ + "id", + "workspace", + "page", + "last_saved_at", + "description_binary", + "description_html", + "description_json", + "owned_by", + "created_at", + "updated_at", + "created_by", + "updated_by", + ] read_only_fields = [ "workspace", "page", diff --git a/apiserver/plane/app/views/page/version.py b/apiserver/plane/app/views/page/version.py index 70f6bd978f4..995a0626362 100644 --- a/apiserver/plane/app/views/page/version.py +++ b/apiserver/plane/app/views/page/version.py @@ -5,16 +5,18 @@ # Module imports from plane.db.models import PageVersion from ..base import BaseAPIView -from plane.app.permissions import ProjectEntityPermission -from plane.app.serializers import PageVersionSerializer +from plane.app.serializers import ( + PageVersionSerializer, + PageVersionDetailSerializer, +) +from plane.app.permissions import allow_permission, ROLE class PageVersionEndpoint(BaseAPIView): - permission_classes = [ - ProjectEntityPermission, - ] - + @allow_permission( + allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST] + ) def get(self, request, slug, project_id, page_id, pk=None): # Check if pk is provided if pk: @@ -25,7 +27,7 @@ def get(self, request, slug, project_id, page_id, pk=None): pk=pk, ) # Serialize the page version - serializer = PageVersionSerializer(page_version) + serializer = PageVersionDetailSerializer(page_version) return Response(serializer.data, status=status.HTTP_200_OK) # Return all page versions page_versions = PageVersion.objects.filter( diff --git a/packages/editor/src/core/hooks/use-editor.ts b/packages/editor/src/core/hooks/use-editor.ts index f9e8fdd609f..523c4be0fa1 100644 --- a/packages/editor/src/core/hooks/use-editor.ts +++ b/packages/editor/src/core/hooks/use-editor.ts @@ -126,8 +126,8 @@ export const useEditor = (props: CustomEditorProps) => { useImperativeHandle( forwardedRef, () => ({ - clearEditor: () => { - editorRef.current?.commands.clearContent(); + clearEditor: (emitUpdate = false) => { + editorRef.current?.commands.clearContent(emitUpdate); }, setEditorValue: (content: string) => { editorRef.current?.commands.setContent(content); diff --git a/packages/editor/src/core/types/editor.ts b/packages/editor/src/core/types/editor.ts index ac804b9b14f..b26fac38445 100644 --- a/packages/editor/src/core/types/editor.ts +++ b/packages/editor/src/core/types/editor.ts @@ -6,7 +6,7 @@ import { IMentionHighlight, IMentionSuggestion, TDisplayConfig, TEditorCommands, export type EditorReadOnlyRefApi = { getMarkDown: () => string; getHTML: () => string; - clearEditor: () => void; + clearEditor: (emitUpdate?: boolean) => void; setEditorValue: (content: string) => void; scrollSummary: (marking: IMarking) => void; }; diff --git a/packages/types/src/pages.d.ts b/packages/types/src/pages.d.ts index ea9b8b8ea5b..a78ff30568b 100644 --- a/packages/types/src/pages.d.ts +++ b/packages/types/src/pages.d.ts @@ -48,3 +48,19 @@ export type TPageFilters = { }; export type TPageEmbedType = "mention" | "issue"; + +export type TPageVersion = { + created_at: string; + created_by: string; + deleted_at: string | null; + description_binary?: string | null; + description_html?: string | null; + description_json?: object; + id: string; + last_saved_at: string; + owned_by: string; + page: string; + updated_at: string; + updated_by: string; + workspace: string; +} \ No newline at end of file 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 8c96f2bcf80..e9debb2bcf4 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 @@ -64,7 +64,7 @@ const PageDetailsPage = observer(() => { <>
-
+
diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/header.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/header.tsx index c04c6d94484..4cfe11d353d 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/header.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/header.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import { observer } from "mobx-react"; -import { useParams } from "next/navigation"; +import { useParams, useSearchParams } from "next/navigation"; import { FileText } from "lucide-react"; // types import { TLogoProps } from "@plane/types"; @@ -25,6 +25,7 @@ export interface IPagesHeaderProps { export const PageDetailsHeader = observer(() => { // router const { workspaceSlug, pageId } = useParams(); + const searchParams = useSearchParams(); // state const [isOpen, setIsOpen] = useState(false); // store hooks @@ -55,6 +56,8 @@ export const PageDetailsHeader = observer(() => { } }; + const isVersionHistoryOverlayActive = !!searchParams.get("version"); + return (
@@ -157,7 +160,7 @@ export const PageDetailsHeader = observer(() => {
- {isContentEditable && ( + {isContentEditable && !isVersionHistoryOverlayActive && ( +
+
+ ) : ( + <> +
+
+ {isCurrentVersionActive + ? "Current version" + : versionDetails + ? `${renderFormattedDate(versionDetails.last_saved_at)} ${renderFormattedTime(versionDetails.last_saved_at)}` + : "Loading version details"} +
+ {!isCurrentVersionActive && ( + + )} +
+
+ +
+ + )} + + ); +}); diff --git a/web/core/components/pages/version/root.tsx b/web/core/components/pages/version/root.tsx new file mode 100644 index 00000000000..443053d56db --- /dev/null +++ b/web/core/components/pages/version/root.tsx @@ -0,0 +1,50 @@ +// plane types +import { TPageVersion } from "@plane/types"; +// components +import { PageVersionsMainContent, PageVersionsSidebarRoot } from "@/components/pages"; +// helpers +import { cn } from "@/helpers/common.helper"; + +type Props = { + activeVersion: string | null; + fetchAllVersions: (pageId: string) => Promise; + fetchVersionDetails: (pageId: string, versionId: string) => Promise; + handleRestore: (descriptionHTML: string) => Promise; + isOpen: boolean; + onClose: () => void; + pageId: string; +}; + +export const PageVersionsOverlay: React.FC = (props) => { + const { activeVersion, fetchAllVersions, fetchVersionDetails, handleRestore, isOpen, onClose, pageId } = props; + + const handleClose = () => { + onClose(); + }; + + return ( +
+ + +
+ ); +}; diff --git a/web/core/components/pages/version/sidebar-list-item.tsx b/web/core/components/pages/version/sidebar-list-item.tsx new file mode 100644 index 00000000000..9c1c13e0de8 --- /dev/null +++ b/web/core/components/pages/version/sidebar-list-item.tsx @@ -0,0 +1,48 @@ +import { observer } from "mobx-react"; +import Link from "next/link"; +// plane types +import { TPageVersion } from "@plane/types"; +// plane ui +import { Avatar } from "@plane/ui"; +// helpers +import { cn } from "@/helpers/common.helper"; +import { renderFormattedDate, renderFormattedTime } from "@/helpers/date-time.helper"; +// hooks +import { useMember } from "@/hooks/store"; + +type Props = { + href: string; + isActive: boolean; + version: TPageVersion; +}; + +export const PlaneVersionsSidebarListItem: React.FC = observer((props) => { + const { href, isActive, version } = props; + // store hooks + const { getUserDetails } = useMember(); + // derived values + const ownerDetails = getUserDetails(version.owned_by); + + return ( + +

+ {renderFormattedDate(version.last_saved_at)} {renderFormattedTime(version.last_saved_at)} +

+

+ + {ownerDetails?.display_name} +

+ + ); +}); diff --git a/web/core/components/pages/version/sidebar-list.tsx b/web/core/components/pages/version/sidebar-list.tsx new file mode 100644 index 00000000000..cf276742b02 --- /dev/null +++ b/web/core/components/pages/version/sidebar-list.tsx @@ -0,0 +1,99 @@ +import { useState } from "react"; +import Link from "next/link"; +import useSWR from "swr"; +import { TriangleAlert } from "lucide-react"; +// plane types +import { TPageVersion } from "@plane/types"; +// plane ui +import { Button, Loader } from "@plane/ui"; +// components +import { PlaneVersionsSidebarListItem } from "@/components/pages"; +// helpers +import { cn } from "@/helpers/common.helper"; +// hooks +import { useQueryParams } from "@/hooks/use-query-params"; + +type Props = { + activeVersion: string | null; + fetchAllVersions: (pageId: string) => Promise; + isOpen: boolean; + pageId: string; +}; + +export const PageVersionsSidebarList: React.FC = (props) => { + const { activeVersion, fetchAllVersions, isOpen, pageId } = props; + // states + const [isRetrying, setIsRetrying] = useState(false); + // update query params + const { updateQueryParams } = useQueryParams(); + + const { + data: versionsList, + error: versionsListError, + mutate: mutateVersionsList, + } = useSWR( + pageId && isOpen ? `PAGE_VERSIONS_LIST_${pageId}` : null, + pageId && isOpen ? () => fetchAllVersions(pageId) : null + ); + + const handleRetry = async () => { + setIsRetrying(true); + await mutateVersionsList(); + setIsRetrying(false); + }; + + const getVersionLink = (versionID: string) => + updateQueryParams({ + paramsToAdd: { version: versionID }, + }); + + return ( +
+ +

Current version

+ + {versionsListError ? ( +
+
+ + + +
+
Something went wrong!
+

+ There was a problem while loading previous +
+ versions, please try again. +

+
+ +
+
+ ) : versionsList ? ( + versionsList.map((version) => ( + + )) + ) : ( + + + + + + + + )} +
+ ); +}; diff --git a/web/core/components/pages/version/sidebar-root.tsx b/web/core/components/pages/version/sidebar-root.tsx new file mode 100644 index 00000000000..793d7fed90f --- /dev/null +++ b/web/core/components/pages/version/sidebar-root.tsx @@ -0,0 +1,38 @@ +import { X } from "lucide-react"; +// plane types +import { TPageVersion } from "@plane/types"; +// components +import { PageVersionsSidebarList } from "@/components/pages"; + +type Props = { + activeVersion: string | null; + fetchAllVersions: (pageId: string) => Promise; + handleClose: () => void; + isOpen: boolean; + pageId: string; +}; + +export const PageVersionsSidebarRoot: React.FC = (props) => { + const { activeVersion, fetchAllVersions, handleClose, isOpen, pageId } = props; + + return ( +
+
+
Version history
+ +
+ +
+ ); +}; diff --git a/web/core/hooks/use-page-description.ts b/web/core/hooks/use-page-description.ts index f7b467d4d0b..4273694505d 100644 --- a/web/core/hooks/use-page-description.ts +++ b/web/core/hooks/use-page-description.ts @@ -1,20 +1,19 @@ import React, { useCallback, useEffect, useState } from "react"; import useSWR from "swr"; - +// plane editor import { EditorRefApi, proseMirrorJSONToBinaryString, applyUpdates, generateJSONfromHTMLForDocumentEditor, } from "@plane/editor"; - // hooks import { setToast, TOAST_TYPE } from "@plane/ui"; import useAutoSave from "@/hooks/use-auto-save"; import useReloadConfirmations from "@/hooks/use-reload-confirmation"; - // services import { ProjectPageService } from "@/services/page"; +// store import { IPage } from "@/store/pages/page"; const projectPageService = new ProjectPageService(); @@ -183,6 +182,19 @@ export const usePageDescription = (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 { @@ -190,5 +202,6 @@ export const usePageDescription = (props: Props) => { isDescriptionReady, pageDescriptionYJS, handleSaveDescription, + manuallyUpdateDescription, }; }; diff --git a/web/core/hooks/use-query-params.ts b/web/core/hooks/use-query-params.ts new file mode 100644 index 00000000000..8b689f0cbe0 --- /dev/null +++ b/web/core/hooks/use-query-params.ts @@ -0,0 +1,39 @@ +import { useSearchParams, usePathname } from "next/navigation"; + +type TParamsToAdd = { + [key: string]: string; +}; + +export const useQueryParams = () => { + // next navigation + const searchParams = useSearchParams(); + const pathname = usePathname(); + + const updateQueryParams = ({ + paramsToAdd = {}, + paramsToRemove = [], + }: { + paramsToAdd?: TParamsToAdd; + paramsToRemove?: string[]; + }) => { + const currentParams = new URLSearchParams(searchParams.toString()); + + // add or update query parameters + Object.keys(paramsToAdd).forEach((key) => { + currentParams.set(key, paramsToAdd[key]); + }); + + // remove specified query parameters + paramsToRemove.forEach((key) => { + currentParams.delete(key); + }); + + // construct the new route with the updated query parameters + const newRoute = `${pathname}?${currentParams.toString()}`; + return newRoute; + }; + + return { + updateQueryParams, + }; +}; diff --git a/web/core/services/page/index.ts b/web/core/services/page/index.ts index d89b175d633..b25199e7f3a 100644 --- a/web/core/services/page/index.ts +++ b/web/core/services/page/index.ts @@ -1 +1,2 @@ +export * from "./project-page-version.service"; export * from "./project-page.service"; diff --git a/web/core/services/page/project-page-version.service.ts b/web/core/services/page/project-page-version.service.ts new file mode 100644 index 00000000000..05732e3d225 --- /dev/null +++ b/web/core/services/page/project-page-version.service.ts @@ -0,0 +1,33 @@ +// 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 ProjectPageVersionService extends APIService { + constructor() { + super(API_BASE_URL); + } + + async fetchAllVersions(workspaceSlug: string, projectId: string, pageId: string): Promise { + return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/versions/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async fetchVersionById( + workspaceSlug: string, + projectId: string, + pageId: string, + versionId: string + ): Promise { + return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/versions/${versionId}/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } +}