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 && (