From 800801276dd0bd3e12728f86a2271706a4fbfc69 Mon Sep 17 00:00:00 2001
From: Aaryan Khandelwal
Date: Fri, 28 Mar 2025 19:27:03 +0530
Subject: [PATCH 01/12] chore: work item description versions
---
packages/editor/src/core/hooks/use-editor.ts | 4 +-
.../src/core/hooks/use-read-only-editor.ts | 4 +-
packages/editor/src/core/types/editor.ts | 2 +-
packages/types/src/description_version.d.ts | 29 +++
packages/types/src/index.d.ts | 1 +
.../description-versions/dropdown-item.tsx | 32 +++
.../core/description-versions/dropdown.tsx | 57 ++++++
.../core/description-versions/index.ts | 1 +
.../core/description-versions/modal.tsx | 182 ++++++++++++++++++
.../core/description-versions/root.tsx | 97 ++++++++++
.../components/issues/description-input.tsx | 24 +--
.../issues/issue-detail/main-content.tsx | 51 +++--
.../issues/peek-overview/issue-detail.tsx | 58 ++++--
web/core/services/issue/index.ts | 1 +
.../services/issue/issue_version.service.ts | 49 +++++
15 files changed, 553 insertions(+), 39 deletions(-)
create mode 100644 packages/types/src/description_version.d.ts
create mode 100644 web/core/components/core/description-versions/dropdown-item.tsx
create mode 100644 web/core/components/core/description-versions/dropdown.tsx
create mode 100644 web/core/components/core/description-versions/index.ts
create mode 100644 web/core/components/core/description-versions/modal.tsx
create mode 100644 web/core/components/core/description-versions/root.tsx
create mode 100644 web/core/services/issue/issue_version.service.ts
diff --git a/packages/editor/src/core/hooks/use-editor.ts b/packages/editor/src/core/hooks/use-editor.ts
index e520305ba8a..cf9d04d83e1 100644
--- a/packages/editor/src/core/hooks/use-editor.ts
+++ b/packages/editor/src/core/hooks/use-editor.ts
@@ -145,8 +145,8 @@ export const useEditor = (props: CustomEditorProps) => {
clearEditor: (emitUpdate = false) => {
editor?.chain().setMeta("skipImageDeletion", true).clearContent(emitUpdate).run();
},
- setEditorValue: (content: string) => {
- editor?.commands.setContent(content, false, { preserveWhitespace: "full" });
+ setEditorValue: (content: string, emitUpdate = false) => {
+ editor?.commands.setContent(content, emitUpdate, { preserveWhitespace: "full" });
},
setEditorValueAtCursorPosition: (content: string) => {
if (editor?.state.selection) {
diff --git a/packages/editor/src/core/hooks/use-read-only-editor.ts b/packages/editor/src/core/hooks/use-read-only-editor.ts
index 6d33c0f8a91..b50b56b02dc 100644
--- a/packages/editor/src/core/hooks/use-read-only-editor.ts
+++ b/packages/editor/src/core/hooks/use-read-only-editor.ts
@@ -77,8 +77,8 @@ export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => {
clearEditor: (emitUpdate = false) => {
editor?.chain().setMeta("skipImageDeletion", true).clearContent(emitUpdate).run();
},
- setEditorValue: (content: string) => {
- editor?.commands.setContent(content, false, { preserveWhitespace: "full" });
+ setEditorValue: (content: string, emitUpdate = false) => {
+ editor?.commands.setContent(content, emitUpdate, { preserveWhitespace: "full" });
},
getMarkDown: (): string => {
const markdownOutput = editor?.storage.markdown.getMarkdown();
diff --git a/packages/editor/src/core/types/editor.ts b/packages/editor/src/core/types/editor.ts
index edf696ab8d8..1936d7730e9 100644
--- a/packages/editor/src/core/types/editor.ts
+++ b/packages/editor/src/core/types/editor.ts
@@ -84,7 +84,7 @@ export type EditorReadOnlyRefApi = {
json: JSONContent | null;
};
clearEditor: (emitUpdate?: boolean) => void;
- setEditorValue: (content: string) => void;
+ setEditorValue: (content: string, emitUpdate?: boolean) => void;
scrollSummary: (marking: IMarking) => void;
getDocumentInfo: () => {
characters: number;
diff --git a/packages/types/src/description_version.d.ts b/packages/types/src/description_version.d.ts
new file mode 100644
index 00000000000..8b9816b0119
--- /dev/null
+++ b/packages/types/src/description_version.d.ts
@@ -0,0 +1,29 @@
+export type TDescriptionVersion = {
+ created_at: string;
+ created_by: string | null;
+ id: string;
+ last_saved_at: string;
+ owned_by: string;
+ project: string;
+ updated_at: string;
+ updated_by: string | null;
+};
+
+export type TDescriptionVersionDetails = TDescriptionVersion & {
+ description_binary: string | null;
+ description_html: string | null;
+ description_json: object | null;
+ description_stripped: string | null;
+};
+
+export type TDescriptionVersionsListResponse = {
+ cursor: string;
+ next_cursor: string | null;
+ next_page_results: boolean;
+ page_count: number;
+ prev_cursor: string | null;
+ prev_page_results: boolean;
+ results: TDescriptionVersion[];
+ total_pages: number;
+ total_results: number;
+};
diff --git a/packages/types/src/index.d.ts b/packages/types/src/index.d.ts
index bd4e593cc26..cb916a2f230 100644
--- a/packages/types/src/index.d.ts
+++ b/packages/types/src/index.d.ts
@@ -3,6 +3,7 @@ export * from "./workspace";
export * from "./cycle";
export * from "./dashboard";
export * from "./de-dupe";
+export * from "./description_version";
export * from "./project";
export * from "./state";
export * from "./issues";
diff --git a/web/core/components/core/description-versions/dropdown-item.tsx b/web/core/components/core/description-versions/dropdown-item.tsx
new file mode 100644
index 00000000000..aafa59cdc56
--- /dev/null
+++ b/web/core/components/core/description-versions/dropdown-item.tsx
@@ -0,0 +1,32 @@
+import { observer } from "mobx-react";
+// plane imports
+import { TDescriptionVersion } from "@plane/types";
+import { Avatar, CustomMenu } from "@plane/ui";
+import { calculateTimeAgo } from "@plane/utils";
+// hooks
+import { useMember } from "@/hooks/store";
+
+type Props = {
+ onClick: (versionId: string) => void;
+ version: TDescriptionVersion;
+};
+
+export const DescriptionVersionsDropdownItem: React.FC = observer((props) => {
+ const { onClick, version } = props;
+ // store hooks
+ const { getUserDetails } = useMember();
+ // derived values
+ const versionCreator = version.owned_by ? getUserDetails(version.owned_by) : null;
+
+ return (
+ onClick(version.id)}>
+
+
+
+
+ {versionCreator?.display_name}
+ {calculateTimeAgo(version.last_saved_at)}
+
+
+ );
+});
diff --git a/web/core/components/core/description-versions/dropdown.tsx b/web/core/components/core/description-versions/dropdown.tsx
new file mode 100644
index 00000000000..660457be798
--- /dev/null
+++ b/web/core/components/core/description-versions/dropdown.tsx
@@ -0,0 +1,57 @@
+import { observer } from "mobx-react";
+import { History } from "lucide-react";
+// plane imports
+import { useTranslation } from "@plane/i18n";
+import { TDescriptionVersion } from "@plane/types";
+import { CustomMenu } from "@plane/ui";
+import { calculateTimeAgo } from "@plane/utils";
+// hooks
+import { useMember } from "@/hooks/store";
+// local imports
+import { DescriptionVersionsDropdownItem } from "./dropdown-item";
+import { TDescriptionVersionEntityInformation } from "./root";
+
+type Props = {
+ disabled: boolean;
+ entityInformation: TDescriptionVersionEntityInformation;
+ onVersionClick: (versionId: string) => void;
+ versions: TDescriptionVersion[] | undefined;
+};
+
+export const DescriptionVersionsDropdown: React.FC = observer((props) => {
+ const { disabled, entityInformation, onVersionClick, versions } = props;
+ // store hooks
+ const { getUserDetails } = useMember();
+ // derived values
+ const lastUpdatedByUserDetails = getUserDetails(entityInformation.lastUpdatedBy);
+ // translation
+ const { t } = useTranslation();
+
+ return (
+
+
+
+
+
+ {t("description_versions.last_edited_by")}{" "}
+ {lastUpdatedByUserDetails?.display_name}{" "}
+ {calculateTimeAgo(entityInformation.lastUpdatedAt.toISOString())}
+
+
+ }
+ noBorder
+ noChevron={disabled}
+ placement="bottom-end"
+ optionsClassName="w-[300px]"
+ disabled={disabled}
+ closeOnSelect
+ >
+ {t("description_versions.previously_edited_by")}
+ {versions?.map((version) => (
+
+ ))}
+
+ );
+});
diff --git a/web/core/components/core/description-versions/index.ts b/web/core/components/core/description-versions/index.ts
new file mode 100644
index 00000000000..1efe34c51ec
--- /dev/null
+++ b/web/core/components/core/description-versions/index.ts
@@ -0,0 +1 @@
+export * from "./root";
diff --git a/web/core/components/core/description-versions/modal.tsx b/web/core/components/core/description-versions/modal.tsx
new file mode 100644
index 00000000000..dcd2705d53f
--- /dev/null
+++ b/web/core/components/core/description-versions/modal.tsx
@@ -0,0 +1,182 @@
+import { useCallback, useRef } from "react";
+import { observer } from "mobx-react";
+import { ChevronLeft, ChevronRight, Copy } from "lucide-react";
+// plane imports
+import { EditorReadOnlyRefApi } from "@plane/editor";
+import { useTranslation } from "@plane/i18n";
+import { TDescriptionVersion } from "@plane/types";
+import { Avatar, Button, getButtonStyling, Loader, ModalCore, setToast, TOAST_TYPE, Tooltip } from "@plane/ui";
+import { calculateTimeAgo, cn, copyTextToClipboard } from "@plane/utils";
+// components
+import { RichTextReadOnlyEditor } from "@/components/editor";
+// hooks
+import { useMember, useWorkspace } from "@/hooks/store";
+
+type Props = {
+ activeVersionDescription: string | undefined;
+ activeVersionDetails: TDescriptionVersion | undefined;
+ handleClose: () => void;
+ handleNavigation: (direction: "prev" | "next") => void;
+ handleRestore: (descriptionHTML: string) => void;
+ isNextDisabled: boolean;
+ isOpen: boolean;
+ isPrevDisabled: boolean;
+ isRestoreEnabled: boolean;
+ projectId: string | undefined;
+ workspaceSlug: string;
+};
+
+export const DescriptionVersionsModal: React.FC = observer((props) => {
+ const {
+ activeVersionDescription,
+ activeVersionDetails,
+ handleClose,
+ handleNavigation,
+ handleRestore,
+ isNextDisabled,
+ isPrevDisabled,
+ isOpen,
+ isRestoreEnabled,
+ projectId,
+ workspaceSlug,
+ } = props;
+ // refs
+ const editorRef = useRef(null);
+ // store hooks
+ const { getUserDetails } = useMember();
+ const { getWorkspaceBySlug } = useWorkspace();
+ // derived values
+ const activeVersionId = activeVersionDetails?.id;
+ const workspaceId = getWorkspaceBySlug(workspaceSlug?.toString() ?? "")?.id ?? "";
+ const versionCreator = activeVersionDetails?.owned_by ? getUserDetails(activeVersionDetails.owned_by) : null;
+ // translation
+ const { t } = useTranslation();
+
+ const handleCopyMarkdown = useCallback(() => {
+ if (!editorRef.current) return;
+ copyTextToClipboard(editorRef.current.getMarkDown()).then(() =>
+ setToast({
+ type: TOAST_TYPE.SUCCESS,
+ title: t("toast.success"),
+ message: "Markdown copied to clipboard.",
+ })
+ );
+ }, [t]);
+
+ return (
+
+
+ {/* Header */}
+
+
+
+ {t("description_versions.edited_by")}
+
+
+
+
+
+ {calculateTimeAgo(activeVersionDetails?.last_saved_at ?? "")}
+
+
+
+
+
+
+
+ {/* End header */}
+ {/* Version description */}
+
+ {activeVersionDescription ? (
+
"}
+ projectId={projectId}
+ ref={editorRef}
+ workspaceId={workspaceId}
+ workspaceSlug={workspaceSlug}
+ />
+ ) : (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ {/* End version description */}
+ {/* Footer */}
+
+
+
+
+
+
+ {isRestoreEnabled && (
+
+ )}
+
+
+ {/* End footer */}
+
+
+ );
+});
diff --git a/web/core/components/core/description-versions/root.tsx b/web/core/components/core/description-versions/root.tsx
new file mode 100644
index 00000000000..c4f922121c3
--- /dev/null
+++ b/web/core/components/core/description-versions/root.tsx
@@ -0,0 +1,97 @@
+import { useCallback, useState } from "react";
+import { observer } from "mobx-react";
+import useSWR from "swr";
+// plane imports
+import { TDescriptionVersionDetails, TDescriptionVersionsListResponse } from "@plane/types";
+import { cn } from "@plane/utils";
+// local imports
+import { DescriptionVersionsDropdown } from "./dropdown";
+import { DescriptionVersionsModal } from "./modal";
+
+export type TDescriptionVersionEntityInformation = {
+ id: string;
+ isRestoreEnabled: boolean;
+ lastUpdatedAt: Date;
+ lastUpdatedBy: string;
+};
+
+type Props = {
+ className?: string;
+ entityInformation: TDescriptionVersionEntityInformation;
+ fetchHandlers: {
+ listDescriptionVersions: (entityId: string) => Promise;
+ retrieveDescriptionVersion: (entityId: string, versionId: string) => Promise;
+ };
+ handleRestore: (descriptionHTML: string) => void;
+ projectId?: string;
+ workspaceSlug: string;
+};
+
+export const DescriptionVersionsRoot: React.FC = observer((props) => {
+ const { className, entityInformation, fetchHandlers, handleRestore, projectId, workspaceSlug } = props;
+ // states
+ const [isModalOpen, setIsModalOpen] = useState(false);
+ const [activeVersionId, setActiveVersionId] = useState(null);
+ // derived values
+ const entityId = entityInformation.id;
+ // fetch versions list
+ const { data: versionsListResponse } = useSWR(
+ entityId ? `DESCRIPTION_VERSIONS_LIST_${entityId}` : null,
+ entityId ? () => fetchHandlers.listDescriptionVersions(entityId) : null
+ );
+ // fetch active version details
+ const { data: activeVersionResponse } = useSWR(
+ entityId && activeVersionId ? `DESCRIPTION_VERSION_DETAILS_${activeVersionId}` : null,
+ entityId && activeVersionId ? () => fetchHandlers.retrieveDescriptionVersion(entityId, activeVersionId) : null
+ );
+ const versions = versionsListResponse?.results;
+ const versionsCount = versions?.length ?? 0;
+ const activeVersionDetails = versions?.find((version) => version.id === activeVersionId);
+ const activeVersionIndex = versions?.findIndex((version) => version.id === activeVersionId);
+
+ const handleNavigation = useCallback(
+ (direction: "prev" | "next") => {
+ if (activeVersionIndex === undefined) return;
+ if (direction === "prev" && activeVersionIndex > 0) {
+ setActiveVersionId(versions?.[activeVersionIndex - 1].id ?? null);
+ } else if (direction === "next" && activeVersionIndex < versionsCount - 1) {
+ setActiveVersionId(versions?.[activeVersionIndex + 1].id ?? null);
+ }
+ },
+ [activeVersionIndex, versions, versionsCount]
+ );
+
+ return (
+ <>
+
"}
+ activeVersionDetails={activeVersionDetails}
+ handleClose={() => {
+ setIsModalOpen(false);
+ setTimeout(() => {
+ setActiveVersionId(null);
+ }, 300);
+ }}
+ handleNavigation={handleNavigation}
+ handleRestore={handleRestore}
+ isNextDisabled={activeVersionIndex === versionsCount - 1}
+ isOpen={isModalOpen}
+ isPrevDisabled={activeVersionIndex === 0}
+ isRestoreEnabled={entityInformation.isRestoreEnabled}
+ projectId={projectId}
+ workspaceSlug={workspaceSlug}
+ />
+
+ {
+ setIsModalOpen(true);
+ setActiveVersionId(versionId);
+ }}
+ versions={versions}
+ />
+
+ >
+ );
+});
diff --git a/web/core/components/issues/description-input.tsx b/web/core/components/issues/description-input.tsx
index df1276dc9fa..f04d887e391 100644
--- a/web/core/components/issues/description-input.tsx
+++ b/web/core/components/issues/description-input.tsx
@@ -4,12 +4,11 @@ import { FC, useCallback, useEffect, useState } from "react";
import debounce from "lodash/debounce";
import { observer } from "mobx-react";
import { Controller, useForm } from "react-hook-form";
-// i18n
+// plane imports
+import { EditorReadOnlyRefApi, EditorRefApi } from "@plane/editor";
import { useTranslation } from "@plane/i18n";
-// types
import { TIssue, TNameDescriptionLoader } from "@plane/types";
import { EFileAssetType } from "@plane/types/src/enums";
-// ui
import { Loader } from "@plane/ui";
// components
import { RichTextEditor, RichTextReadOnlyEditor } from "@/components/editor";
@@ -24,6 +23,8 @@ const workspaceService = new WorkspaceService();
export type IssueDescriptionInputProps = {
containerClassName?: string;
+ editorReadOnlyRef?: React.RefObject;
+ editorRef?: React.RefObject;
workspaceSlug: string;
projectId: string;
issueId: string;
@@ -38,6 +39,8 @@ export type IssueDescriptionInputProps = {
export const IssueDescriptionInput: FC = observer((props) => {
const {
containerClassName,
+ editorReadOnlyRef,
+ editorRef,
workspaceSlug,
projectId,
issueId,
@@ -55,16 +58,17 @@ export const IssueDescriptionInput: FC = observer((p
});
// store hooks
const { uploadEditorAsset } = useEditorAsset();
+ const { getWorkspaceBySlug } = useWorkspace();
+ // derived values
+ const workspaceId = getWorkspaceBySlug(workspaceSlug)?.id?.toString() ?? "";
// form info
-
- // i18n
- const { t } = useTranslation();
-
const { handleSubmit, reset, control } = useForm({
defaultValues: {
description_html: initialValue,
},
});
+ // i18n
+ const { t } = useTranslation();
const handleDescriptionFormSubmit = useCallback(
async (formData: Partial) => {
@@ -75,10 +79,6 @@ export const IssueDescriptionInput: FC = observer((p
[workspaceSlug, projectId, issueId, issueOperations]
);
- const { getWorkspaceBySlug } = useWorkspace();
- // computed values
- const workspaceId = getWorkspaceBySlug(workspaceSlug)?.id as string;
-
// reset form values
useEffect(() => {
if (!issueId) return;
@@ -154,6 +154,7 @@ export const IssueDescriptionInput: FC = observer((p
throw new Error("Asset upload failed. Please try again later.");
}
}}
+ ref={editorRef}
/>
) : (
= observer((p
workspaceId={workspaceId}
workspaceSlug={workspaceSlug}
projectId={projectId}
+ ref={editorReadOnlyRef}
/>
)
}
diff --git a/web/core/components/issues/issue-detail/main-content.tsx b/web/core/components/issues/issue-detail/main-content.tsx
index f484761641b..ff77f96835b 100644
--- a/web/core/components/issues/issue-detail/main-content.tsx
+++ b/web/core/components/issues/issue-detail/main-content.tsx
@@ -1,9 +1,12 @@
"use client";
-import { useEffect, useState } from "react";
+import { useEffect, useRef, useState } from "react";
import { observer } from "mobx-react";
+// plane imports
+import { EditorRefApi } from "@plane/editor";
import { TNameDescriptionLoader } from "@plane/types";
// components
+import { DescriptionVersionsRoot } from "@/components/core/description-versions";
import {
IssueActivity,
NameDescriptionUpdateStatus,
@@ -24,8 +27,12 @@ import useSize from "@/hooks/use-window-size";
import { DeDupeIssuePopoverRoot } from "@/plane-web/components/de-dupe";
import { IssueTypeSwitcher } from "@/plane-web/components/issues";
import { useDebouncedDuplicateIssues } from "@/plane-web/hooks/use-debounced-duplicate-issues";
-// types
+// services
+import { IssueVersionService } from "@/services/issue";
+// local imports
import { TIssueOperations } from "./root";
+// services init
+const issueVersionService = new IssueVersionService();
type Props = {
workspaceSlug: string;
@@ -38,6 +45,8 @@ type Props = {
export const IssueMainContent: React.FC = observer((props) => {
const { workspaceSlug, projectId, issueId, issueOperations, isEditable, isArchived } = props;
+ // refs
+ const editorRef = useRef(null);
// states
const [isSubmitting, setIsSubmitting] = useState("saved");
// hooks
@@ -49,11 +58,9 @@ export const IssueMainContent: React.FC = observer((props) => {
} = useIssueDetail();
const { getProjectById } = useProject();
const { setShowAlert } = useReloadConfirmations(isSubmitting === "submitting");
-
// derived values
const projectDetails = getProjectById(projectId);
const issue = issueId ? getIssueById(issueId) : undefined;
-
// debounced duplicate issues swr
const { duplicateIssues } = useDebouncedDuplicateIssues(
workspaceSlug,
@@ -120,6 +127,7 @@ export const IssueMainContent: React.FC = observer((props) => {
/>
= observer((props) => {
containerClassName="-ml-3 border-none"
/>
- {currentUser && (
-
+ {currentUser && (
+
+ )}
+
+ issueVersionService.listDescriptionVersions(workspaceSlug, projectId, issueId),
+ retrieveDescriptionVersion: (issueId, versionId) =>
+ issueVersionService.retrieveDescriptionVersion(workspaceSlug, projectId, issueId, versionId),
+ }}
+ handleRestore={(descriptionHTML) => editorRef.current?.setEditorValue(descriptionHTML, true)}
projectId={projectId}
- issueId={issueId}
- currentUser={currentUser}
- disabled={isArchived}
+ workspaceSlug={workspaceSlug}
/>
- )}
+
= observer((props) => {
const { workspaceSlug, issueId, issueOperations, disabled, isArchived, isSubmitting, setIsSubmitting } = props;
+ // refs
+ const editorRef = useRef(null);
// store hooks
const { data: currentUser } = useUser();
const {
@@ -113,6 +121,7 @@ export const PeekOverviewIssueDetails: FC = observer(
/>
= observer(
containerClassName="-ml-3 border-none"
/>
- {currentUser && (
-
+ {currentUser && (
+
+ )}
+
+ issueVersionService.listDescriptionVersions(workspaceSlug, issue.project_id?.toString() ?? "", issueId),
+ retrieveDescriptionVersion: (issueId, versionId) =>
+ issueVersionService.retrieveDescriptionVersion(
+ workspaceSlug,
+ issue.project_id?.toString() ?? "",
+ issueId,
+ versionId
+ ),
+ }}
+ handleRestore={(descriptionHTML) => editorRef.current?.setEditorValue(descriptionHTML, true)}
projectId={issue.project_id}
- issueId={issueId}
- currentUser={currentUser}
- disabled={isArchived}
+ workspaceSlug={workspaceSlug}
/>
- )}
+
);
});
diff --git a/web/core/services/issue/index.ts b/web/core/services/issue/index.ts
index ad809eae5c2..59b923fa12a 100644
--- a/web/core/services/issue/index.ts
+++ b/web/core/services/issue/index.ts
@@ -7,4 +7,5 @@ export * from "./issue_attachment.service";
export * from "./issue_activity.service";
export * from "./issue_comment.service";
export * from "./issue_relation.service";
+export * from "./issue_version.service";
export * from "./workspace_draft.service";
diff --git a/web/core/services/issue/issue_version.service.ts b/web/core/services/issue/issue_version.service.ts
new file mode 100644
index 00000000000..2fe5f9d2e97
--- /dev/null
+++ b/web/core/services/issue/issue_version.service.ts
@@ -0,0 +1,49 @@
+// plane imports
+import { EIssueServiceType } from "@plane/constants";
+import {
+ type TDescriptionVersionsListResponse,
+ type TDescriptionVersionDetails,
+ type TIssueServiceType,
+} from "@plane/types";
+// helpers
+import { API_BASE_URL } from "@/helpers/common.helper";
+// services
+import { APIService } from "@/services/api.service";
+
+export class IssueVersionService extends APIService {
+ private serviceType: TIssueServiceType;
+
+ constructor(serviceType: TIssueServiceType = EIssueServiceType.ISSUES) {
+ super(API_BASE_URL);
+ this.serviceType = serviceType;
+ }
+
+ async listDescriptionVersions(
+ workspaceSlug: string,
+ projectId: string,
+ issueId: string
+ ): Promise {
+ return this.get(
+ `/api/workspaces/${workspaceSlug}/projects/${projectId}/${this.serviceType}/${issueId}/description-versions/`
+ )
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async retrieveDescriptionVersion(
+ workspaceSlug: string,
+ projectId: string,
+ issueId: string,
+ versionId: string
+ ): Promise {
+ return this.get(
+ `/api/workspaces/${workspaceSlug}/projects/${projectId}/${this.serviceType}/${issueId}/description-versions/${versionId}/`
+ )
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+}
From aaa526d96752c3cf987c7c128a7e77817fd3b3ed Mon Sep 17 00:00:00 2001
From: NarayanBavisetti
Date: Tue, 1 Apr 2025 15:38:51 +0530
Subject: [PATCH 02/12] chore: intake issue description
---
apiserver/plane/app/views/intake/base.py | 29 +++++++++++++++++-------
apiserver/plane/app/views/issue/base.py | 2 +-
2 files changed, 22 insertions(+), 9 deletions(-)
diff --git a/apiserver/plane/app/views/intake/base.py b/apiserver/plane/app/views/intake/base.py
index fb10bc002c0..ea01cfe5f89 100644
--- a/apiserver/plane/app/views/intake/base.py
+++ b/apiserver/plane/app/views/intake/base.py
@@ -30,14 +30,14 @@
)
from plane.app.serializers import (
IssueCreateSerializer,
- IssueSerializer,
+ IssueDetailSerializer,
IntakeSerializer,
IntakeIssueSerializer,
IntakeIssueDetailSerializer,
)
from plane.utils.issue_filters import issue_filters
from plane.bgtasks.issue_activities_task import issue_activity
-
+from plane.bgtasks.issue_description_version_task import issue_description_version_task
class IntakeViewSet(BaseViewSet):
serializer_class = IntakeSerializer
@@ -87,7 +87,7 @@ class IntakeIssueViewSet(BaseViewSet):
serializer_class = IntakeIssueSerializer
model = IntakeIssue
- filterset_fields = ["statulls"]
+ filterset_fields = ["status"]
def get_queryset(self):
return (
@@ -286,6 +286,13 @@ def create(self, request, slug, project_id):
origin=request.META.get("HTTP_ORIGIN"),
intake=str(intake_issue.id),
)
+ # updated issue description version
+ issue_description_version_task.delay(
+ updated_issue=json.dumps(request.data, cls=DjangoJSONEncoder),
+ issue_id=str(serializer.data["id"]),
+ user_id=request.user.id,
+ is_creating=True,
+ )
intake_issue = (
IntakeIssue.objects.select_related("issue")
.prefetch_related("issue__labels", "issue__assignees")
@@ -385,13 +392,16 @@ def partial_update(self, request, slug, project_id, pk):
),
"description": issue_data.get("description", issue.description),
}
+ current_instance = json.dumps(
+ IssueDetailSerializer(issue).data, cls=DjangoJSONEncoder
+ )
issue_serializer = IssueCreateSerializer(
issue, data=issue_data, partial=True, context={"project_id": project_id}
)
if issue_serializer.is_valid():
- current_instance = issue
+
# Log all the updates
requested_data = json.dumps(issue_data, cls=DjangoJSONEncoder)
if issue is not None:
@@ -401,15 +411,18 @@ def partial_update(self, request, slug, project_id, pk):
actor_id=str(request.user.id),
issue_id=str(issue.id),
project_id=str(project_id),
- current_instance=json.dumps(
- IssueSerializer(current_instance).data,
- cls=DjangoJSONEncoder,
- ),
+ current_instance=current_instance,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
intake=str(intake_issue.id),
)
+ # updated issue description version
+ issue_description_version_task.delay(
+ updated_issue=current_instance,
+ issue_id=str(pk),
+ user_id=request.user.id,
+ )
issue_serializer.save()
else:
return Response(
diff --git a/apiserver/plane/app/views/issue/base.py b/apiserver/plane/app/views/issue/base.py
index 71e794fec93..53bd50dad17 100644
--- a/apiserver/plane/app/views/issue/base.py
+++ b/apiserver/plane/app/views/issue/base.py
@@ -632,7 +632,7 @@ def partial_update(self, request, slug, project_id, pk=None):
)
current_instance = json.dumps(
- IssueSerializer(issue).data, cls=DjangoJSONEncoder
+ IssueDetailSerializer(issue).data, cls=DjangoJSONEncoder
)
requested_data = json.dumps(self.request.data, cls=DjangoJSONEncoder)
From 512f87d1757b462137e24109e4dd546bb9e9af2f Mon Sep 17 00:00:00 2001
From: Aaryan Khandelwal
Date: Wed, 2 Apr 2025 14:02:41 +0530
Subject: [PATCH 03/12] chore: intake work item description versions
---
apiserver/plane/app/views/issue/version.py | 2 +-
.../core/description-versions/dropdown.tsx | 6 +-
.../core/description-versions/root.tsx | 4 +-
.../components/inbox/content/issue-root.tsx | 63 ++++++++++++++-----
.../issues/issue-detail/main-content.tsx | 4 +-
.../issues/peek-overview/issue-detail.tsx | 4 +-
6 files changed, 58 insertions(+), 25 deletions(-)
diff --git a/apiserver/plane/app/views/issue/version.py b/apiserver/plane/app/views/issue/version.py
index ab26ca5a65a..45928ce16d2 100644
--- a/apiserver/plane/app/views/issue/version.py
+++ b/apiserver/plane/app/views/issue/version.py
@@ -106,7 +106,7 @@ def get(self, request, slug, project_id, issue_id, pk=None):
issue_description_versions_queryset = IssueDescriptionVersion.objects.filter(
workspace__slug=slug, project_id=project_id, issue_id=issue_id
- )
+ ).order_by("-created_at")
paginated_data = paginate(
base_queryset=issue_description_versions_queryset,
queryset=issue_description_versions_queryset,
diff --git a/web/core/components/core/description-versions/dropdown.tsx b/web/core/components/core/description-versions/dropdown.tsx
index 660457be798..27d6eaff472 100644
--- a/web/core/components/core/description-versions/dropdown.tsx
+++ b/web/core/components/core/description-versions/dropdown.tsx
@@ -23,7 +23,9 @@ export const DescriptionVersionsDropdown: React.FC = observer((props) =>
// store hooks
const { getUserDetails } = useMember();
// derived values
- const lastUpdatedByUserDetails = getUserDetails(entityInformation.lastUpdatedBy);
+ const latestVersion = versions?.[0];
+ const lastUpdatedAt = latestVersion?.created_at ?? entityInformation.createdAt;
+ const lastUpdatedByUserDetails = getUserDetails(latestVersion?.owned_by ?? entityInformation.createdBy);
// translation
const { t } = useTranslation();
@@ -37,7 +39,7 @@ export const DescriptionVersionsDropdown: React.FC = observer((props) =>
{t("description_versions.last_edited_by")}{" "}
{lastUpdatedByUserDetails?.display_name}{" "}
- {calculateTimeAgo(entityInformation.lastUpdatedAt.toISOString())}
+ {calculateTimeAgo(lastUpdatedAt)}
}
diff --git a/web/core/components/core/description-versions/root.tsx b/web/core/components/core/description-versions/root.tsx
index c4f922121c3..dc1aeaea7f9 100644
--- a/web/core/components/core/description-versions/root.tsx
+++ b/web/core/components/core/description-versions/root.tsx
@@ -9,10 +9,10 @@ import { DescriptionVersionsDropdown } from "./dropdown";
import { DescriptionVersionsModal } from "./modal";
export type TDescriptionVersionEntityInformation = {
+ createdAt: Date;
+ createdBy: string;
id: string;
isRestoreEnabled: boolean;
- lastUpdatedAt: Date;
- lastUpdatedBy: string;
};
type Props = {
diff --git a/web/core/components/inbox/content/issue-root.tsx b/web/core/components/inbox/content/issue-root.tsx
index 0b64b220a55..116c376494f 100644
--- a/web/core/components/inbox/content/issue-root.tsx
+++ b/web/core/components/inbox/content/issue-root.tsx
@@ -1,14 +1,15 @@
"use client";
-import { Dispatch, SetStateAction, useEffect, useMemo } from "react";
+import { Dispatch, SetStateAction, useEffect, useMemo, useRef } from "react";
import { observer } from "mobx-react";
import { usePathname } from "next/navigation";
-// plane types
+// plane imports
import { ISSUE_ARCHIVED, ISSUE_DELETED } from "@plane/constants";
+import { EditorRefApi } from "@plane/editor";
import { TIssue, TNameDescriptionLoader } from "@plane/types";
-// plane ui
import { Loader, TOAST_TYPE, setToast } from "@plane/ui";
// components
+import { DescriptionVersionsRoot } from "@/components/core/description-versions";
import { InboxIssueContentProperties } from "@/components/inbox/content";
import {
IssueDescriptionInput,
@@ -18,7 +19,6 @@ import {
TIssueOperations,
IssueAttachmentRoot,
} from "@/components/issues";
-// constants
// helpers
import { getTextContent } from "@/helpers/editor.helper";
// hooks
@@ -27,7 +27,12 @@ import useReloadConfirmations from "@/hooks/use-reload-confirmation";
// store types
import { DeDupeIssuePopoverRoot } from "@/plane-web/components/de-dupe";
import { useDebouncedDuplicateIssues } from "@/plane-web/hooks/use-debounced-duplicate-issues";
+// services
+import { IssueVersionService } from "@/services/issue";
+// stores
import { IInboxIssueStore } from "@/store/inbox/inbox-issue.store";
+// services init
+const issueVersionService = new IssueVersionService();
type Props = {
workspaceSlug: string;
@@ -39,15 +44,20 @@ type Props = {
};
export const InboxIssueMainContent: React.FC = observer((props) => {
- const pathname = usePathname();
const { workspaceSlug, projectId, inboxIssue, isEditable, isSubmitting, setIsSubmitting } = props;
- // hooks
+ // navigation
+ const pathname = usePathname();
+ // refs
+ const editorRef = useRef(null);
+ // store hooks
const { data: currentUser } = useUser();
- const { setShowAlert } = useReloadConfirmations(isSubmitting === "submitting");
- const { captureIssueEvent } = useEventTracker();
const { loader } = useProjectInbox();
const { getProjectById } = useProject();
const { removeIssue, archiveIssue } = useIssueDetail();
+ // reload confirmation
+ const { setShowAlert } = useReloadConfirmations(isSubmitting === "submitting");
+ // event tracker
+ const { captureIssueEvent } = useEventTracker();
useEffect(() => {
if (isSubmitting === "submitted") {
@@ -60,7 +70,7 @@ export const InboxIssueMainContent: React.FC = observer((props) => {
}
}, [isSubmitting, setShowAlert, setIsSubmitting]);
- // dervied values
+ // derived values
const issue = inboxIssue.issue;
const projectDetails = issue?.project_id ? getProjectById(issue?.project_id) : undefined;
@@ -124,7 +134,7 @@ export const InboxIssueMainContent: React.FC = observer((props) => {
},
path: pathname,
});
- } catch (error) {
+ } catch {
setToast({
title: "Work item update failed",
type: TOAST_TYPE.ERROR,
@@ -195,6 +205,7 @@ export const InboxIssueMainContent: React.FC = observer((props) => {
) : (
= observer((props) => {
/>
)}
- {currentUser && (
-
+ {currentUser && (
+
+ )}
+
+ issueVersionService.listDescriptionVersions(workspaceSlug, projectId, issueId),
+ retrieveDescriptionVersion: (issueId, versionId) =>
+ issueVersionService.retrieveDescriptionVersion(workspaceSlug, projectId, issueId, versionId),
+ }}
+ handleRestore={(descriptionHTML) => editorRef.current?.setEditorValue(descriptionHTML, true)}
projectId={projectId}
- issueId={issue.id}
- currentUser={currentUser}
+ workspaceSlug={workspaceSlug}
/>
- )}
+
= observer((props) => {
diff --git a/web/core/components/issues/peek-overview/issue-detail.tsx b/web/core/components/issues/peek-overview/issue-detail.tsx
index 8345fa474a7..24e5a7d87d5 100644
--- a/web/core/components/issues/peek-overview/issue-detail.tsx
+++ b/web/core/components/issues/peek-overview/issue-detail.tsx
@@ -145,10 +145,10 @@ export const PeekOverviewIssueDetails: FC = observer(
From 3f771ccac73d187eb1e611c9d83abbd1ab8272b5 Mon Sep 17 00:00:00 2001
From: Aaryan Khandelwal
Date: Wed, 2 Apr 2025 14:11:37 +0530
Subject: [PATCH 04/12] chore: add missing translations
---
packages/i18n/src/locales/cs/translations.json | 6 ++++++
packages/i18n/src/locales/de/translations.json | 10 +++++++++-
packages/i18n/src/locales/en/translations.json | 9 ++++++++-
packages/i18n/src/locales/es/translations.json | 6 ++++++
packages/i18n/src/locales/fr/translations.json | 6 ++++++
packages/i18n/src/locales/id/translations.json | 6 ++++++
packages/i18n/src/locales/it/translations.json | 8 +++++++-
packages/i18n/src/locales/ja/translations.json | 6 ++++++
packages/i18n/src/locales/ko/translations.json | 6 ++++++
packages/i18n/src/locales/pl/translations.json | 10 +++++++++-
packages/i18n/src/locales/pt-BR/translations.json | 6 ++++++
packages/i18n/src/locales/ro/translations.json | 6 ++++++
packages/i18n/src/locales/ru/translations.json | 6 ++++++
packages/i18n/src/locales/sk/translations.json | 6 ++++++
packages/i18n/src/locales/ua/translations.json | 10 +++++++++-
packages/i18n/src/locales/vi-VN/translations.json | 8 ++++++++
packages/i18n/src/locales/zh-CN/translations.json | 6 ++++++
packages/i18n/src/locales/zh-TW/translations.json | 6 ++++++
18 files changed, 122 insertions(+), 5 deletions(-)
diff --git a/packages/i18n/src/locales/cs/translations.json b/packages/i18n/src/locales/cs/translations.json
index 51c0aa0e3f8..866b00257ad 100644
--- a/packages/i18n/src/locales/cs/translations.json
+++ b/packages/i18n/src/locales/cs/translations.json
@@ -2372,5 +2372,11 @@
"module": {
"label": "{count, plural, one {Modul} few {Moduly} other {Modulů}}",
"no_module": "Žádný modul"
+ },
+
+ "description_versions": {
+ "last_edited_by": "Naposledy upraveno uživatelem",
+ "previously_edited_by": "Dříve upraveno uživatelem",
+ "edited_by": "Upraveno uživatelem"
}
}
diff --git a/packages/i18n/src/locales/de/translations.json b/packages/i18n/src/locales/de/translations.json
index 77ab4251505..04ec01e34a1 100644
--- a/packages/i18n/src/locales/de/translations.json
+++ b/packages/i18n/src/locales/de/translations.json
@@ -500,7 +500,7 @@
"export": "Exportieren",
"member": "{count, plural, one{# Mitglied} few{# Mitglieder} other{# Mitglieder}}",
"new_password_must_be_different_from_old_password": "Das neue Passwort muss von dem alten Passwort abweichen",
-
+
"project_view": {
"sort_by": {
"created_at": "Erstellt am",
@@ -2321,12 +2321,20 @@
"manual": "Manuell"
}
},
+
"cycle": {
"label": "{count, plural, one {Zyklus} few {Zyklen} other {Zyklen}}",
"no_cycle": "Kein Zyklus"
},
+
"module": {
"label": "{count, plural, one {Modul} few {Module} other {Module}}",
"no_module": "Kein Modul"
+ },
+
+ "description_versions": {
+ "last_edited_by": "Zuletzt bearbeitet von",
+ "previously_edited_by": "Zuvor bearbeitet von",
+ "edited_by": "Bearbeitet von"
}
}
diff --git a/packages/i18n/src/locales/en/translations.json b/packages/i18n/src/locales/en/translations.json
index 0be5219bef7..abd828561f1 100644
--- a/packages/i18n/src/locales/en/translations.json
+++ b/packages/i18n/src/locales/en/translations.json
@@ -630,7 +630,8 @@
"clear_sorting": "Clear sorting",
"show_weekends": "Show weekends",
"enable": "Enable",
- "disable": "Disable"
+ "disable": "Disable",
+ "copy_markdown": "Copy markdown"
},
"name": "Name",
"discard": "Discard",
@@ -2206,5 +2207,11 @@
"module": {
"label": "{count, plural, one {Module} other {Modules}}",
"no_module": "No module"
+ },
+
+ "description_versions": {
+ "last_edited_by": "Last edited by",
+ "previously_edited_by": "Previously edited by",
+ "edited_by": "Edited by"
}
}
diff --git a/packages/i18n/src/locales/es/translations.json b/packages/i18n/src/locales/es/translations.json
index d3a0c1f6c1b..ca99b798199 100644
--- a/packages/i18n/src/locales/es/translations.json
+++ b/packages/i18n/src/locales/es/translations.json
@@ -2376,5 +2376,11 @@
"module": {
"label": "{count, plural, one {Módulo} other {Módulos}}",
"no_module": "Sin módulo"
+ },
+
+ "description_versions": {
+ "last_edited_by": "Última edición por",
+ "previously_edited_by": "Editado anteriormente por",
+ "edited_by": "Editado por"
}
}
diff --git a/packages/i18n/src/locales/fr/translations.json b/packages/i18n/src/locales/fr/translations.json
index 0cda7965623..fb488764be5 100644
--- a/packages/i18n/src/locales/fr/translations.json
+++ b/packages/i18n/src/locales/fr/translations.json
@@ -2374,5 +2374,11 @@
"module": {
"label": "{count, plural, one {Module} other {Modules}}",
"no_module": "Pas de module"
+ },
+
+ "description_versions": {
+ "last_edited_by": "Dernière modification par",
+ "previously_edited_by": "Précédemment modifié par",
+ "edited_by": "Modifié par"
}
}
diff --git a/packages/i18n/src/locales/id/translations.json b/packages/i18n/src/locales/id/translations.json
index 64338a04f3b..58a010833f4 100644
--- a/packages/i18n/src/locales/id/translations.json
+++ b/packages/i18n/src/locales/id/translations.json
@@ -2368,5 +2368,11 @@
"module": {
"label": "{count, plural, one {Modul} other {Modul}}",
"no_module": "Tidak ada modul"
+ },
+
+ "description_versions": {
+ "last_edited_by": "Terakhir disunting oleh",
+ "previously_edited_by": "Sebelumnya disunting oleh",
+ "edited_by": "Disunting oleh"
}
}
diff --git a/packages/i18n/src/locales/it/translations.json b/packages/i18n/src/locales/it/translations.json
index 352d7fce499..716401a2608 100644
--- a/packages/i18n/src/locales/it/translations.json
+++ b/packages/i18n/src/locales/it/translations.json
@@ -501,7 +501,7 @@
"export": "Esporta",
"member": "{count, plural, one {# membro} other {# membri}}",
"new_password_must_be_different_from_old_password": "La nuova password deve essere diversa dalla password precedente",
-
+
"edited": "Modificato",
"bot": "Bot",
@@ -2373,5 +2373,11 @@
"module": {
"label": "{count, plural, one {Modulo} other {Moduli}}",
"no_module": "Nessun modulo"
+ },
+
+ "description_versions": {
+ "last_edited_by": "Ultima modifica di",
+ "previously_edited_by": "Precedentemente modificato da",
+ "edited_by": "Modificato da"
}
}
diff --git a/packages/i18n/src/locales/ja/translations.json b/packages/i18n/src/locales/ja/translations.json
index 7d0d09175fd..0e381fa8a86 100644
--- a/packages/i18n/src/locales/ja/translations.json
+++ b/packages/i18n/src/locales/ja/translations.json
@@ -2374,5 +2374,11 @@
"module": {
"label": "{count, plural, one {モジュール} other {モジュール}}",
"no_module": "モジュールなし"
+ },
+
+ "description_versions": {
+ "last_edited_by": "最終編集者",
+ "previously_edited_by": "以前の編集者",
+ "edited_by": "編集者"
}
}
diff --git a/packages/i18n/src/locales/ko/translations.json b/packages/i18n/src/locales/ko/translations.json
index d6b88df276b..b9fe86df2cb 100644
--- a/packages/i18n/src/locales/ko/translations.json
+++ b/packages/i18n/src/locales/ko/translations.json
@@ -2376,5 +2376,11 @@
"module": {
"label": "{count, plural, one {모듈} other {모듈}}",
"no_module": "모듈 없음"
+ },
+
+ "description_versions": {
+ "last_edited_by": "마지막 편집자",
+ "previously_edited_by": "이전 편집자",
+ "edited_by": "편집자"
}
}
diff --git a/packages/i18n/src/locales/pl/translations.json b/packages/i18n/src/locales/pl/translations.json
index 08543fcde0a..025b7e4f99b 100644
--- a/packages/i18n/src/locales/pl/translations.json
+++ b/packages/i18n/src/locales/pl/translations.json
@@ -500,7 +500,7 @@
"export": "Eksportuj",
"member": "{count, plural, one{# członek} few{# członkowie} other{# członków}}",
"new_password_must_be_different_from_old_password": "Nowe hasło musi być innym niż stare hasło",
-
+
"edited": "Edytowano",
"bot": "Bot",
@@ -2324,12 +2324,20 @@
"manual": "Ręcznie"
}
},
+
"cycle": {
"label": "{count, plural, one {Cykl} few {Cykle} other {Cyklów}}",
"no_cycle": "Brak cyklu"
},
+
"module": {
"label": "{count, plural, one {Moduł} few {Moduły} other {Modułów}}",
"no_module": "Brak modułu"
+ },
+
+ "description_versions": {
+ "last_edited_by": "Ostatnio edytowane przez",
+ "previously_edited_by": "Wcześniej edytowane przez",
+ "edited_by": "Edytowane przez"
}
}
diff --git a/packages/i18n/src/locales/pt-BR/translations.json b/packages/i18n/src/locales/pt-BR/translations.json
index bfb8fe6014e..4f3d27eab6b 100644
--- a/packages/i18n/src/locales/pt-BR/translations.json
+++ b/packages/i18n/src/locales/pt-BR/translations.json
@@ -2369,5 +2369,11 @@
"module": {
"label": "{count, plural, one {Módulo} other {Módulos}}",
"no_module": "Nenhum módulo"
+ },
+
+ "description_versions": {
+ "last_edited_by": "Última edição por",
+ "previously_edited_by": "Anteriormente editado por",
+ "edited_by": "Editado por"
}
}
diff --git a/packages/i18n/src/locales/ro/translations.json b/packages/i18n/src/locales/ro/translations.json
index 67a642292ac..1b23c68114d 100644
--- a/packages/i18n/src/locales/ro/translations.json
+++ b/packages/i18n/src/locales/ro/translations.json
@@ -2368,5 +2368,11 @@
"module": {
"label": "{count, plural, one {Modul} other {Module}}",
"no_module": "Niciun modul"
+ },
+
+ "description_versions": {
+ "last_edited_by": "Ultima editare de către",
+ "previously_edited_by": "Editat anterior de către",
+ "edited_by": "Editat de"
}
}
diff --git a/packages/i18n/src/locales/ru/translations.json b/packages/i18n/src/locales/ru/translations.json
index 753d82cd9d4..4883476dd4c 100644
--- a/packages/i18n/src/locales/ru/translations.json
+++ b/packages/i18n/src/locales/ru/translations.json
@@ -2374,5 +2374,11 @@
"module": {
"label": "{count, plural, one {Модуль} other {Модули}}",
"no_module": "Нет модуля"
+ },
+
+ "description_versions": {
+ "last_edited_by": "Последнее редактирование",
+ "previously_edited_by": "Ранее отредактировано",
+ "edited_by": "Отредактировано"
}
}
diff --git a/packages/i18n/src/locales/sk/translations.json b/packages/i18n/src/locales/sk/translations.json
index 5964af5f414..b63162bbf50 100644
--- a/packages/i18n/src/locales/sk/translations.json
+++ b/packages/i18n/src/locales/sk/translations.json
@@ -2373,5 +2373,11 @@
"module": {
"label": "{count, plural, one {Modul} few {Moduly} other {Modulov}}",
"no_module": "Žiadny modul"
+ },
+
+ "description_versions": {
+ "last_edited_by": "Naposledy upravené používateľom",
+ "previously_edited_by": "Predtým upravené používateľom",
+ "edited_by": "Upravené používateľom"
}
}
diff --git a/packages/i18n/src/locales/ua/translations.json b/packages/i18n/src/locales/ua/translations.json
index 3d4048612ef..de0f1acec21 100644
--- a/packages/i18n/src/locales/ua/translations.json
+++ b/packages/i18n/src/locales/ua/translations.json
@@ -502,7 +502,7 @@
"new_password_must_be_different_from_old_password": "Новий пароль повинен бути відмінним від старого пароля",
"edited": "Редагувано",
"bot": "Бот",
-
+
"project_view": {
"sort_by": {
"created_at": "Створено",
@@ -2323,12 +2323,20 @@
"manual": "Вручну"
}
},
+
"cycle": {
"label": "{count, plural, one {Цикл} few {Цикли} other {Циклів}}",
"no_cycle": "Немає циклу"
},
+
"module": {
"label": "{count, plural, one {Модуль} few {Модулі} other {Модулів}}",
"no_module": "Немає модуля"
+ },
+
+ "description_versions": {
+ "last_edited_by": "Останнє редагування",
+ "previously_edited_by": "Раніше відредаговано",
+ "edited_by": "Відредаговано"
}
}
diff --git a/packages/i18n/src/locales/vi-VN/translations.json b/packages/i18n/src/locales/vi-VN/translations.json
index 7aa8af5dd10..0af4be39863 100644
--- a/packages/i18n/src/locales/vi-VN/translations.json
+++ b/packages/i18n/src/locales/vi-VN/translations.json
@@ -2322,12 +2322,20 @@
"manual": "Thủ công"
}
},
+
"cycle": {
"label": "{count, plural, one {chu kỳ} other {chu kỳ}}",
"no_cycle": "Không có chu kỳ"
},
+
"module": {
"label": "{count, plural, one {mô-đun} other {mô-đun}}",
"no_module": "Không có mô-đun"
+ },
+
+ "description_versions": {
+ "last_edited_by": "Chỉnh sửa lần cuối bởi",
+ "previously_edited_by": "Trước đây được chỉnh sửa bởi",
+ "edited_by": "Được chỉnh sửa bởi"
}
}
diff --git a/packages/i18n/src/locales/zh-CN/translations.json b/packages/i18n/src/locales/zh-CN/translations.json
index 536c5eb9b85..495ccc69415 100644
--- a/packages/i18n/src/locales/zh-CN/translations.json
+++ b/packages/i18n/src/locales/zh-CN/translations.json
@@ -2374,5 +2374,11 @@
"module": {
"label": "{count, plural, one {模块} other {模块}}",
"no_module": "无模块"
+ },
+
+ "description_versions": {
+ "last_edited_by": "最后编辑者",
+ "previously_edited_by": "之前编辑者",
+ "edited_by": "编辑者"
}
}
diff --git a/packages/i18n/src/locales/zh-TW/translations.json b/packages/i18n/src/locales/zh-TW/translations.json
index c78ad813838..ad531da8e32 100644
--- a/packages/i18n/src/locales/zh-TW/translations.json
+++ b/packages/i18n/src/locales/zh-TW/translations.json
@@ -2376,5 +2376,11 @@
"module": {
"label": "{count, plural, one {模組} other {模組}}",
"no_module": "無模組"
+ },
+
+ "description_versions": {
+ "last_edited_by": "最後編輯者",
+ "previously_edited_by": "先前編輯者",
+ "edited_by": "編輯者"
}
}
From 97e28de44869d171a8413434a63f9cdaf217da86 Mon Sep 17 00:00:00 2001
From: NarayanBavisetti
Date: Wed, 2 Apr 2025 16:48:43 +0530
Subject: [PATCH 05/12] chore: endpoint for intake description version
---
apiserver/plane/app/urls/intake.py | 16 ++++-
apiserver/plane/app/urls/issue.py | 8 +--
apiserver/plane/app/views/__init__.py | 2 +-
apiserver/plane/app/views/intake/base.py | 79 ++++++++++++++++++++++
apiserver/plane/app/views/issue/version.py | 29 +++++++-
5 files changed, 127 insertions(+), 7 deletions(-)
diff --git a/apiserver/plane/app/urls/intake.py b/apiserver/plane/app/urls/intake.py
index 397579262e8..68b21467734 100644
--- a/apiserver/plane/app/urls/intake.py
+++ b/apiserver/plane/app/urls/intake.py
@@ -1,7 +1,11 @@
from django.urls import path
-from plane.app.views import IntakeViewSet, IntakeIssueViewSet
+from plane.app.views import (
+ IntakeViewSet,
+ IntakeIssueViewSet,
+ IntakeIssueDescriptionVersionEndpoint,
+)
urlpatterns = [
@@ -53,4 +57,14 @@
),
name="inbox-issue",
),
+ path(
+ "workspaces//projects//intake-issues//description-versions/",
+ IntakeIssueDescriptionVersionEndpoint.as_view(),
+ name="intake-issue-versions",
+ ),
+ path(
+ "workspaces//projects//intake-issues//description-versions//",
+ IntakeIssueDescriptionVersionEndpoint.as_view(),
+ name="intake-issue-versions",
+ ),
]
diff --git a/apiserver/plane/app/urls/issue.py b/apiserver/plane/app/urls/issue.py
index 6c5e450331f..508153cfb22 100644
--- a/apiserver/plane/app/urls/issue.py
+++ b/apiserver/plane/app/urls/issue.py
@@ -263,22 +263,22 @@
path(
"workspaces//projects//issues//versions/",
IssueVersionEndpoint.as_view(),
- name="page-versions",
+ name="issue-versions",
),
path(
"workspaces//projects//issues//versions//",
IssueVersionEndpoint.as_view(),
- name="page-versions",
+ name="issue-versions",
),
path(
"workspaces//projects//issues//description-versions/",
IssueDescriptionVersionEndpoint.as_view(),
- name="page-versions",
+ name="issue-versions",
),
path(
"workspaces//projects//issues//description-versions//",
IssueDescriptionVersionEndpoint.as_view(),
- name="page-versions",
+ name="issue-versions",
),
path(
"workspaces//projects//issues//meta/",
diff --git a/apiserver/plane/app/views/__init__.py b/apiserver/plane/app/views/__init__.py
index ba63920f6c5..0fbe5231e12 100644
--- a/apiserver/plane/app/views/__init__.py
+++ b/apiserver/plane/app/views/__init__.py
@@ -184,7 +184,7 @@
EstimatePointEndpoint,
)
-from .intake.base import IntakeViewSet, IntakeIssueViewSet
+from .intake.base import IntakeViewSet, IntakeIssueViewSet, IntakeIssueDescriptionVersionEndpoint
from .analytic.base import (
AnalyticsEndpoint,
diff --git a/apiserver/plane/app/views/intake/base.py b/apiserver/plane/app/views/intake/base.py
index ea01cfe5f89..1b436a1b3af 100644
--- a/apiserver/plane/app/views/intake/base.py
+++ b/apiserver/plane/app/views/intake/base.py
@@ -27,6 +27,7 @@
Project,
ProjectMember,
CycleIssue,
+ IssueDescriptionVersion,
)
from plane.app.serializers import (
IssueCreateSerializer,
@@ -34,10 +35,14 @@
IntakeSerializer,
IntakeIssueSerializer,
IntakeIssueDetailSerializer,
+ IssueDescriptionVersionDetailSerializer,
)
from plane.utils.issue_filters import issue_filters
from plane.bgtasks.issue_activities_task import issue_activity
from plane.bgtasks.issue_description_version_task import issue_description_version_task
+from plane.app.views.base import BaseAPIView
+from plane.utils.timezone_converter import user_timezone_converter
+
class IntakeViewSet(BaseViewSet):
serializer_class = IntakeSerializer
@@ -597,3 +602,77 @@ def destroy(self, request, slug, project_id, pk):
intake_issue.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
+
+
+class IntakeIssueDescriptionVersionEndpoint(BaseAPIView):
+
+ def process_paginated_result(self, fields, results, timezone):
+ paginated_data = results.values(*fields)
+
+ datetime_fields = ["created_at", "updated_at"]
+ paginated_data = user_timezone_converter(
+ paginated_data, datetime_fields, timezone
+ )
+
+ return paginated_data
+
+ @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
+ def get(self, request, slug, project_id, issue_id, pk=None):
+ project = Project.objects.get(pk=project_id)
+ issue = Issue.objects.get(
+ workspace__slug=slug, project_id=project_id, pk=issue_id
+ )
+
+ if (
+ ProjectMember.objects.filter(
+ workspace__slug=slug,
+ project_id=project_id,
+ member=request.user,
+ role=5,
+ is_active=True,
+ ).exists()
+ and not project.guest_view_all_features
+ and not issue.created_by == request.user
+ ):
+ return Response(
+ {"error": "You are not allowed to view this issue"},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ if pk:
+ issue_description_version = IssueDescriptionVersion.objects.get(
+ workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk
+ )
+
+ serializer = IssueDescriptionVersionDetailSerializer(
+ issue_description_version
+ )
+ return Response(serializer.data, status=status.HTTP_200_OK)
+
+ cursor = request.GET.get("cursor", None)
+
+ required_fields = [
+ "id",
+ "workspace",
+ "project",
+ "issue",
+ "last_saved_at",
+ "owned_by",
+ "created_at",
+ "updated_at",
+ "created_by",
+ "updated_by",
+ ]
+
+ issue_description_versions_queryset = IssueDescriptionVersion.objects.filter(
+ workspace__slug=slug, project_id=project_id, issue_id=issue_id
+ )
+ paginated_data = self.paginate(
+ base_queryset=issue_description_versions_queryset,
+ queryset=issue_description_versions_queryset,
+ cursor=cursor,
+ on_result=lambda results: self.process_paginated_result(
+ required_fields, results, request.user.user_timezone
+ ),
+ )
+ return Response(paginated_data, status=status.HTTP_200_OK)
diff --git a/apiserver/plane/app/views/issue/version.py b/apiserver/plane/app/views/issue/version.py
index 45928ce16d2..ffc0766c04c 100644
--- a/apiserver/plane/app/views/issue/version.py
+++ b/apiserver/plane/app/views/issue/version.py
@@ -3,7 +3,13 @@
from rest_framework.response import Response
# Module imports
-from plane.db.models import IssueVersion, IssueDescriptionVersion
+from plane.db.models import (
+ IssueVersion,
+ IssueDescriptionVersion,
+ Project,
+ ProjectMember,
+ Issue,
+)
from ..base import BaseAPIView
from plane.app.serializers import (
IssueVersionDetailSerializer,
@@ -79,6 +85,27 @@ def process_paginated_result(self, fields, results, timezone):
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def get(self, request, slug, project_id, issue_id, pk=None):
+ project = Project.objects.get(pk=project_id)
+ issue = Issue.objects.get(
+ workspace__slug=slug, project_id=project_id, pk=issue_id
+ )
+
+ if (
+ ProjectMember.objects.filter(
+ workspace__slug=slug,
+ project_id=project_id,
+ member=request.user,
+ role=5,
+ is_active=True,
+ ).exists()
+ and not project.guest_view_all_features
+ and not issue.created_by == request.user
+ ):
+ return Response(
+ {"error": "You are not allowed to view this issue"},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
if pk:
issue_description_version = IssueDescriptionVersion.objects.get(
workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk
From e0bc6f8c310b0b308bcc018b8214f33fc6241d53 Mon Sep 17 00:00:00 2001
From: NarayanBavisetti
Date: Wed, 2 Apr 2025 16:57:04 +0530
Subject: [PATCH 06/12] chore: renamed key to work item
---
apiserver/plane/app/urls/intake.py | 14 +++++++-------
apiserver/plane/app/urls/issue.py | 14 +++++++-------
apiserver/plane/app/views/__init__.py | 8 ++++++--
apiserver/plane/app/views/intake/base.py | 13 ++++++++-----
apiserver/plane/app/views/issue/version.py | 13 ++++++++-----
5 files changed, 36 insertions(+), 26 deletions(-)
diff --git a/apiserver/plane/app/urls/intake.py b/apiserver/plane/app/urls/intake.py
index 68b21467734..ac4b7ca5cd4 100644
--- a/apiserver/plane/app/urls/intake.py
+++ b/apiserver/plane/app/urls/intake.py
@@ -4,7 +4,7 @@
from plane.app.views import (
IntakeViewSet,
IntakeIssueViewSet,
- IntakeIssueDescriptionVersionEndpoint,
+ IntakeWorkItemDescriptionVersionEndpoint,
)
@@ -58,13 +58,13 @@
name="inbox-issue",
),
path(
- "workspaces//projects//intake-issues//description-versions/",
- IntakeIssueDescriptionVersionEndpoint.as_view(),
- name="intake-issue-versions",
+ "workspaces//projects//intake-work-items//description-versions/",
+ IntakeWorkItemDescriptionVersionEndpoint.as_view(),
+ name="intake-work-item-versions",
),
path(
- "workspaces//projects//intake-issues//description-versions//",
- IntakeIssueDescriptionVersionEndpoint.as_view(),
- name="intake-issue-versions",
+ "workspaces//projects//intake-work-items//description-versions//",
+ IntakeWorkItemDescriptionVersionEndpoint.as_view(),
+ name="intake-work-item-versions",
),
]
diff --git a/apiserver/plane/app/urls/issue.py b/apiserver/plane/app/urls/issue.py
index 508153cfb22..db56a6240e0 100644
--- a/apiserver/plane/app/urls/issue.py
+++ b/apiserver/plane/app/urls/issue.py
@@ -25,7 +25,7 @@
IssueAttachmentV2Endpoint,
IssueBulkUpdateDateEndpoint,
IssueVersionEndpoint,
- IssueDescriptionVersionEndpoint,
+ WorkItemDescriptionVersionEndpoint,
IssueMetaEndpoint,
IssueDetailIdentifierEndpoint,
)
@@ -271,14 +271,14 @@
name="issue-versions",
),
path(
- "workspaces//projects//issues//description-versions/",
- IssueDescriptionVersionEndpoint.as_view(),
- name="issue-versions",
+ "workspaces//projects//work-items//description-versions/",
+ WorkItemDescriptionVersionEndpoint.as_view(),
+ name="work-item-versions",
),
path(
- "workspaces//projects//issues//description-versions//",
- IssueDescriptionVersionEndpoint.as_view(),
- name="issue-versions",
+ "workspaces//projects//work-items//description-versions//",
+ WorkItemDescriptionVersionEndpoint.as_view(),
+ name="work-item-versions",
),
path(
"workspaces//projects//issues//meta/",
diff --git a/apiserver/plane/app/views/__init__.py b/apiserver/plane/app/views/__init__.py
index 0fbe5231e12..7baba9bb075 100644
--- a/apiserver/plane/app/views/__init__.py
+++ b/apiserver/plane/app/views/__init__.py
@@ -144,7 +144,7 @@
from .issue.subscriber import IssueSubscriberViewSet
-from .issue.version import IssueVersionEndpoint, IssueDescriptionVersionEndpoint
+from .issue.version import IssueVersionEndpoint, WorkItemDescriptionVersionEndpoint
from .module.base import (
ModuleViewSet,
@@ -184,7 +184,11 @@
EstimatePointEndpoint,
)
-from .intake.base import IntakeViewSet, IntakeIssueViewSet, IntakeIssueDescriptionVersionEndpoint
+from .intake.base import (
+ IntakeViewSet,
+ IntakeIssueViewSet,
+ IntakeWorkItemDescriptionVersionEndpoint,
+)
from .analytic.base import (
AnalyticsEndpoint,
diff --git a/apiserver/plane/app/views/intake/base.py b/apiserver/plane/app/views/intake/base.py
index 1b436a1b3af..8923619ad95 100644
--- a/apiserver/plane/app/views/intake/base.py
+++ b/apiserver/plane/app/views/intake/base.py
@@ -604,7 +604,7 @@ def destroy(self, request, slug, project_id, pk):
return Response(status=status.HTTP_204_NO_CONTENT)
-class IntakeIssueDescriptionVersionEndpoint(BaseAPIView):
+class IntakeWorkItemDescriptionVersionEndpoint(BaseAPIView):
def process_paginated_result(self, fields, results, timezone):
paginated_data = results.values(*fields)
@@ -617,10 +617,10 @@ def process_paginated_result(self, fields, results, timezone):
return paginated_data
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
- def get(self, request, slug, project_id, issue_id, pk=None):
+ def get(self, request, slug, project_id, work_item_id, pk=None):
project = Project.objects.get(pk=project_id)
issue = Issue.objects.get(
- workspace__slug=slug, project_id=project_id, pk=issue_id
+ workspace__slug=slug, project_id=project_id, pk=work_item_id
)
if (
@@ -641,7 +641,10 @@ def get(self, request, slug, project_id, issue_id, pk=None):
if pk:
issue_description_version = IssueDescriptionVersion.objects.get(
- workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk
+ workspace__slug=slug,
+ project_id=project_id,
+ issue_id=work_item_id,
+ pk=pk,
)
serializer = IssueDescriptionVersionDetailSerializer(
@@ -665,7 +668,7 @@ def get(self, request, slug, project_id, issue_id, pk=None):
]
issue_description_versions_queryset = IssueDescriptionVersion.objects.filter(
- workspace__slug=slug, project_id=project_id, issue_id=issue_id
+ workspace__slug=slug, project_id=project_id, issue_id=work_item_id
)
paginated_data = self.paginate(
base_queryset=issue_description_versions_queryset,
diff --git a/apiserver/plane/app/views/issue/version.py b/apiserver/plane/app/views/issue/version.py
index ffc0766c04c..d316b8ba1ac 100644
--- a/apiserver/plane/app/views/issue/version.py
+++ b/apiserver/plane/app/views/issue/version.py
@@ -72,7 +72,7 @@ def get(self, request, slug, project_id, issue_id, pk=None):
return Response(paginated_data, status=status.HTTP_200_OK)
-class IssueDescriptionVersionEndpoint(BaseAPIView):
+class WorkItemDescriptionVersionEndpoint(BaseAPIView):
def process_paginated_result(self, fields, results, timezone):
paginated_data = results.values(*fields)
@@ -84,10 +84,10 @@ def process_paginated_result(self, fields, results, timezone):
return paginated_data
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
- def get(self, request, slug, project_id, issue_id, pk=None):
+ def get(self, request, slug, project_id, work_item_id, pk=None):
project = Project.objects.get(pk=project_id)
issue = Issue.objects.get(
- workspace__slug=slug, project_id=project_id, pk=issue_id
+ workspace__slug=slug, project_id=project_id, pk=work_item_id
)
if (
@@ -108,7 +108,10 @@ def get(self, request, slug, project_id, issue_id, pk=None):
if pk:
issue_description_version = IssueDescriptionVersion.objects.get(
- workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk
+ workspace__slug=slug,
+ project_id=project_id,
+ issue_id=work_item_id,
+ pk=pk,
)
serializer = IssueDescriptionVersionDetailSerializer(
@@ -132,7 +135,7 @@ def get(self, request, slug, project_id, issue_id, pk=None):
]
issue_description_versions_queryset = IssueDescriptionVersion.objects.filter(
- workspace__slug=slug, project_id=project_id, issue_id=issue_id
+ workspace__slug=slug, project_id=project_id, issue_id=work_item_id
).order_by("-created_at")
paginated_data = paginate(
base_queryset=issue_description_versions_queryset,
From 3129fca9ee3044db60c6bef431113a8616314393 Mon Sep 17 00:00:00 2001
From: NarayanBavisetti
Date: Wed, 2 Apr 2025 17:07:56 +0530
Subject: [PATCH 07/12] chore: changed the paginator class
---
apiserver/plane/app/views/intake/base.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/apiserver/plane/app/views/intake/base.py b/apiserver/plane/app/views/intake/base.py
index 8923619ad95..76482949db9 100644
--- a/apiserver/plane/app/views/intake/base.py
+++ b/apiserver/plane/app/views/intake/base.py
@@ -42,7 +42,7 @@
from plane.bgtasks.issue_description_version_task import issue_description_version_task
from plane.app.views.base import BaseAPIView
from plane.utils.timezone_converter import user_timezone_converter
-
+from plane.utils.global_paginator import paginate
class IntakeViewSet(BaseViewSet):
serializer_class = IntakeSerializer
@@ -670,7 +670,7 @@ def get(self, request, slug, project_id, work_item_id, pk=None):
issue_description_versions_queryset = IssueDescriptionVersion.objects.filter(
workspace__slug=slug, project_id=project_id, issue_id=work_item_id
)
- paginated_data = self.paginate(
+ paginated_data = paginate(
base_queryset=issue_description_versions_queryset,
queryset=issue_description_versions_queryset,
cursor=cursor,
From 5e20066c7afe67197e57d805e0322821b3a95dda Mon Sep 17 00:00:00 2001
From: Aaryan Khandelwal
Date: Wed, 2 Apr 2025 17:45:16 +0530
Subject: [PATCH 08/12] chore: authorization added
---
packages/constants/src/issue/common.ts | 1 +
packages/types/src/issues/issue.d.ts | 2 +-
.../core/description-versions/modal.tsx | 6 +-
.../core/description-versions/root.tsx | 4 +-
.../components/inbox/content/issue-root.tsx | 42 ++++++-------
.../issues/issue-detail/main-content.tsx | 46 +++++++-------
.../components/issues/issue-detail/root.tsx | 2 +-
.../issues/peek-overview/issue-detail.tsx | 60 ++++++++++---------
.../components/issues/peek-overview/view.tsx | 4 +-
web/core/services/inbox/index.ts | 1 +
.../inbox/intake-work_item_version.service.ts | 41 +++++++++++++
web/core/services/issue/index.ts | 2 +-
...ervice.ts => work_item_version.service.ts} | 12 ++--
13 files changed, 138 insertions(+), 85 deletions(-)
create mode 100644 web/core/services/inbox/intake-work_item_version.service.ts
rename web/core/services/issue/{issue_version.service.ts => work_item_version.service.ts} (83%)
diff --git a/packages/constants/src/issue/common.ts b/packages/constants/src/issue/common.ts
index cccf44b41d3..03634337a7d 100644
--- a/packages/constants/src/issue/common.ts
+++ b/packages/constants/src/issue/common.ts
@@ -41,6 +41,7 @@ export enum EIssueGroupBYServerToProperty {
export enum EIssueServiceType {
ISSUES = "issues",
EPICS = "epics",
+ WORK_ITEMS = "work-items",
}
export enum EIssuesStoreType {
diff --git a/packages/types/src/issues/issue.d.ts b/packages/types/src/issues/issue.d.ts
index e38810004bc..18a150c4921 100644
--- a/packages/types/src/issues/issue.d.ts
+++ b/packages/types/src/issues/issue.d.ts
@@ -120,7 +120,7 @@ export type TBulkOperationsPayload = {
export type TIssueDetailWidget = "sub-issues" | "relations" | "links" | "attachments";
-export type TIssueServiceType = EIssueServiceType.ISSUES | EIssueServiceType.EPICS;
+export type TIssueServiceType = EIssueServiceType.ISSUES | EIssueServiceType.EPICS | EIssueServiceType.WORK_ITEMS;
export interface IPublicIssue
extends Pick<
diff --git a/web/core/components/core/description-versions/modal.tsx b/web/core/components/core/description-versions/modal.tsx
index dcd2705d53f..b093f72ff2f 100644
--- a/web/core/components/core/description-versions/modal.tsx
+++ b/web/core/components/core/description-versions/modal.tsx
@@ -21,7 +21,7 @@ type Props = {
isNextDisabled: boolean;
isOpen: boolean;
isPrevDisabled: boolean;
- isRestoreEnabled: boolean;
+ isRestoreDisabled: boolean;
projectId: string | undefined;
workspaceSlug: string;
};
@@ -36,7 +36,7 @@ export const DescriptionVersionsModal: React.FC = observer((props) => {
isNextDisabled,
isPrevDisabled,
isOpen,
- isRestoreEnabled,
+ isRestoreDisabled,
projectId,
workspaceSlug,
} = props;
@@ -161,7 +161,7 @@ export const DescriptionVersionsModal: React.FC = observer((props) => {
- {isRestoreEnabled && (
+ {!isRestoreDisabled && (