[WEB-3748, 3749] feat: work item description version history (#6863)

* chore: work item description versions

* chore: intake issue description

* chore: intake work item description versions

* chore: add missing translations

* chore: endpoint for intake description version

* chore: renamed key to work item

* chore: changed the paginator class

* chore: authorization added

* chore: added the enum validation

* chore: removed extra validations

* chore: added extra validations

* chore: modal position

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
Co-authored-by: Bavisetti Narayan <72156168+NarayanBavisetti@users.noreply.github.com>
This commit is contained in:
Aaryan Khandelwal 2025-04-04 20:09:02 +05:30 committed by GitHub
parent 4f68aaafa6
commit 34337f90c1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
47 changed files with 978 additions and 102 deletions

View file

@ -1,7 +1,11 @@
from django.urls import path
from plane.app.views import IntakeViewSet, IntakeIssueViewSet
from plane.app.views import (
IntakeViewSet,
IntakeIssueViewSet,
IntakeWorkItemDescriptionVersionEndpoint,
)
urlpatterns = [
@ -53,4 +57,14 @@ urlpatterns = [
),
name="inbox-issue",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/intake-work-items/<uuid:work_item_id>/description-versions/",
IntakeWorkItemDescriptionVersionEndpoint.as_view(),
name="intake-work-item-versions",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/intake-work-items/<uuid:work_item_id>/description-versions/<uuid:pk>/",
IntakeWorkItemDescriptionVersionEndpoint.as_view(),
name="intake-work-item-versions",
),
]

View file

@ -25,7 +25,7 @@ from plane.app.views import (
IssueAttachmentV2Endpoint,
IssueBulkUpdateDateEndpoint,
IssueVersionEndpoint,
IssueDescriptionVersionEndpoint,
WorkItemDescriptionVersionEndpoint,
IssueMetaEndpoint,
IssueDetailIdentifierEndpoint,
)
@ -263,22 +263,22 @@ urlpatterns = [
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/versions/",
IssueVersionEndpoint.as_view(),
name="page-versions",
name="issue-versions",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/versions/<uuid:pk>/",
IssueVersionEndpoint.as_view(),
name="page-versions",
name="issue-versions",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/description-versions/",
IssueDescriptionVersionEndpoint.as_view(),
name="page-versions",
"workspaces/<str:slug>/projects/<uuid:project_id>/work-items/<uuid:work_item_id>/description-versions/",
WorkItemDescriptionVersionEndpoint.as_view(),
name="work-item-versions",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/description-versions/<uuid:pk>/",
IssueDescriptionVersionEndpoint.as_view(),
name="page-versions",
"workspaces/<str:slug>/projects/<uuid:project_id>/work-items/<uuid:work_item_id>/description-versions/<uuid:pk>/",
WorkItemDescriptionVersionEndpoint.as_view(),
name="work-item-versions",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/meta/",

View file

@ -144,7 +144,7 @@ from .issue.sub_issue import SubIssuesEndpoint
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 @@ from .estimate.base import (
EstimatePointEndpoint,
)
from .intake.base import IntakeViewSet, IntakeIssueViewSet
from .intake.base import (
IntakeViewSet,
IntakeIssueViewSet,
IntakeWorkItemDescriptionVersionEndpoint,
)
from .analytic.base import (
AnalyticsEndpoint,

View file

@ -27,16 +27,22 @@ from plane.db.models import (
Project,
ProjectMember,
CycleIssue,
IssueDescriptionVersion,
)
from plane.app.serializers import (
IssueCreateSerializer,
IssueSerializer,
IssueDetailSerializer,
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
from plane.utils.global_paginator import paginate
from plane.utils.host import base_host
@ -88,7 +94,7 @@ class IntakeIssueViewSet(BaseViewSet):
serializer_class = IntakeIssueSerializer
model = IntakeIssue
filterset_fields = ["statulls"]
filterset_fields = ["status"]
def get_queryset(self):
return (
@ -219,7 +225,7 @@ class IntakeIssueViewSet(BaseViewSet):
workspace__slug=slug,
project_id=project_id,
member=request.user,
role=5,
role=ROLE.GUEST.value,
is_active=True,
).exists()
and not project.guest_view_all_features
@ -287,6 +293,13 @@ class IntakeIssueViewSet(BaseViewSet):
origin=base_host(request=request, is_app=True),
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")
@ -386,13 +399,16 @@ class IntakeIssueViewSet(BaseViewSet):
),
"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:
@ -402,15 +418,18 @@ class IntakeIssueViewSet(BaseViewSet):
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=base_host(request=request, is_app=True),
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(
@ -550,7 +569,7 @@ class IntakeIssueViewSet(BaseViewSet):
workspace__slug=slug,
project_id=project_id,
member=request.user,
role=5,
role=ROLE.GUEST.value,
is_active=True,
).exists()
and not project.guest_view_all_features
@ -558,7 +577,7 @@ class IntakeIssueViewSet(BaseViewSet):
):
return Response(
{"error": "You are not allowed to view this issue"},
status=status.HTTP_400_BAD_REQUEST,
status=status.HTTP_403_FORBIDDEN,
)
issue = IntakeIssueDetailSerializer(intake_issue).data
return Response(issue, status=status.HTTP_200_OK)
@ -585,3 +604,81 @@ class IntakeIssueViewSet(BaseViewSet):
intake_issue.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
class IntakeWorkItemDescriptionVersionEndpoint(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, work_item_id, pk=None):
project = Project.objects.get(pk=project_id)
issue = Issue.objects.get(
workspace__slug=slug, project_id=project_id, pk=work_item_id
)
if (
ProjectMember.objects.filter(
workspace__slug=slug,
project_id=project_id,
member=request.user,
role=ROLE.GUEST.value,
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_403_FORBIDDEN,
)
if pk:
issue_description_version = IssueDescriptionVersion.objects.get(
workspace__slug=slug,
project_id=project_id,
issue_id=work_item_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=work_item_id
)
paginated_data = 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)

View file

@ -565,7 +565,7 @@ class IssueViewSet(BaseViewSet):
):
return Response(
{"error": "You are not allowed to view this issue"},
status=status.HTTP_400_BAD_REQUEST,
status=status.HTTP_403_FORBIDDEN,
)
recent_visited_task.delay(
@ -632,7 +632,7 @@ class IssueViewSet(BaseViewSet):
)
current_instance = json.dumps(
IssueSerializer(issue).data, cls=DjangoJSONEncoder
IssueDetailSerializer(issue).data, cls=DjangoJSONEncoder
)
requested_data = json.dumps(self.request.data, cls=DjangoJSONEncoder)
@ -1278,7 +1278,7 @@ class IssueDetailIdentifierEndpoint(BaseAPIView):
):
return Response(
{"error": "You are not allowed to view this issue"},
status=status.HTTP_400_BAD_REQUEST,
status=status.HTTP_403_FORBIDDEN,
)
recent_visited_task.delay(

View file

@ -3,7 +3,13 @@ from rest_framework import status
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,
@ -66,7 +72,7 @@ class IssueVersionEndpoint(BaseAPIView):
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)
@ -78,10 +84,34 @@ class IssueDescriptionVersionEndpoint(BaseAPIView):
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=work_item_id
)
if (
ProjectMember.objects.filter(
workspace__slug=slug,
project_id=project_id,
member=request.user,
role=ROLE.GUEST.value,
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_403_FORBIDDEN,
)
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(
@ -105,8 +135,8 @@ class IssueDescriptionVersionEndpoint(BaseAPIView):
]
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,
queryset=issue_description_versions_queryset,

View file

@ -432,7 +432,7 @@ class IssueViewViewSet(BaseViewSet):
):
return Response(
{"error": "You are not allowed to view this issue"},
status=status.HTTP_400_BAD_REQUEST,
status=status.HTTP_403_FORBIDDEN,
)
serializer = IssueViewSerializer(issue_view)

View file

@ -41,6 +41,7 @@ export enum EIssueGroupBYServerToProperty {
export enum EIssueServiceType {
ISSUES = "issues",
EPICS = "epics",
WORK_ITEMS = "work-items",
}
export enum EIssuesStoreType {

View file

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

View file

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

View file

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

View file

@ -2428,5 +2428,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"
}
}

View file

@ -2377,12 +2377,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"
}
}

View file

@ -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",
@ -2262,5 +2263,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"
}
}

View file

@ -2432,5 +2432,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"
}
}

View file

@ -2430,5 +2430,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"
}
}

View file

@ -2424,5 +2424,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"
}
}

View file

@ -2429,5 +2429,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"
}
}

View file

@ -2430,5 +2430,11 @@
"module": {
"label": "{count, plural, one {モジュール} other {モジュール}}",
"no_module": "モジュールなし"
},
"description_versions": {
"last_edited_by": "最終編集者",
"previously_edited_by": "以前の編集者",
"edited_by": "編集者"
}
}

View file

@ -2432,5 +2432,11 @@
"module": {
"label": "{count, plural, one {모듈} other {모듈}}",
"no_module": "모듈 없음"
},
"description_versions": {
"last_edited_by": "마지막 편집자",
"previously_edited_by": "이전 편집자",
"edited_by": "편집자"
}
}

View file

@ -2380,12 +2380,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"
}
}

View file

@ -2425,5 +2425,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"
}
}

View file

@ -2424,5 +2424,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"
}
}

View file

@ -2430,5 +2430,11 @@
"module": {
"label": "{count, plural, one {Модуль} other {Модули}}",
"no_module": "Нет модуля"
},
"description_versions": {
"last_edited_by": "Последнее редактирование",
"previously_edited_by": "Ранее отредактировано",
"edited_by": "Отредактировано"
}
}

View file

@ -2429,5 +2429,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"
}
}

View file

@ -2379,12 +2379,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": "Відредаговано"
}
}

View file

@ -2378,12 +2378,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"
}
}

View file

@ -2421,5 +2421,11 @@
"module": {
"label": "{count, plural, one {模块} other {模块}}",
"no_module": "无模块"
},
"description_versions": {
"last_edited_by": "最后编辑者",
"previously_edited_by": "之前编辑者",
"edited_by": "编辑者"
}
}

View file

@ -2432,5 +2432,11 @@
"module": {
"label": "{count, plural, one {模組} other {模組}}",
"no_module": "無模組"
},
"description_versions": {
"last_edited_by": "最後編輯者",
"previously_edited_by": "先前編輯者",
"edited_by": "編輯者"
}
}

View file

@ -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;
};

View file

@ -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";

View file

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

View file

@ -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<Props> = observer((props) => {
const { onClick, version } = props;
// store hooks
const { getUserDetails } = useMember();
// derived values
const versionCreator = version.owned_by ? getUserDetails(version.owned_by) : null;
return (
<CustomMenu.MenuItem key={version.id} className="flex items-center gap-1" onClick={() => onClick(version.id)}>
<span className="flex-shrink-0">
<Avatar name={versionCreator?.display_name} size="sm" src={versionCreator?.avatar_url} />
</span>
<p className="text-xs text-custom-text-200 flex items-center gap-1.5">
<span className="font-medium">{versionCreator?.display_name}</span>
<span>{calculateTimeAgo(version.last_saved_at)}</span>
</p>
</CustomMenu.MenuItem>
);
});

View file

@ -0,0 +1,59 @@
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<Props> = observer((props) => {
const { disabled, entityInformation, onVersionClick, versions } = props;
// store hooks
const { getUserDetails } = useMember();
// derived values
const latestVersion = versions?.[0];
const lastUpdatedAt = latestVersion?.created_at ?? entityInformation.createdAt;
const lastUpdatedByUserDetails = getUserDetails(latestVersion?.owned_by ?? entityInformation.createdBy);
// translation
const { t } = useTranslation();
return (
<CustomMenu
label={
<div className="flex items-center gap-1 text-custom-text-300">
<span className="flex-shrink-0 size-4 grid place-items-center">
<History className="size-3.5" />
</span>
<p className="text-xs">
{t("description_versions.last_edited_by")}{" "}
<span className="font-medium">{lastUpdatedByUserDetails?.display_name}</span>{" "}
{calculateTimeAgo(lastUpdatedAt)}
</p>
</div>
}
noBorder
noChevron={disabled}
placement="bottom-end"
optionsClassName="w-[300px]"
disabled={disabled}
closeOnSelect
>
<p className="text-xs text-custom-text-300 font-medium mb-1">{t("description_versions.previously_edited_by")}</p>
{versions?.map((version) => (
<DescriptionVersionsDropdownItem key={version.id} onClick={onVersionClick} version={version} />
))}
</CustomMenu>
);
});

View file

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

View file

@ -0,0 +1,194 @@
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,
EModalPosition,
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;
isRestoreDisabled: boolean;
projectId: string | undefined;
workspaceSlug: string;
};
export const DescriptionVersionsModal: React.FC<Props> = observer((props) => {
const {
activeVersionDescription,
activeVersionDetails,
handleClose,
handleNavigation,
handleRestore,
isNextDisabled,
isPrevDisabled,
isOpen,
isRestoreDisabled,
projectId,
workspaceSlug,
} = props;
// refs
const editorRef = useRef<EditorReadOnlyRefApi>(null);
// store hooks
const { getUserDetails } = useMember();
const { getWorkspaceBySlug } = useWorkspace();
// derived values
const activeVersionId = activeVersionDetails?.id;
const workspaceId = getWorkspaceBySlug(workspaceSlug)?.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]);
if (!workspaceId) return null;
return (
<ModalCore isOpen={isOpen} handleClose={handleClose} position={EModalPosition.TOP}>
<div className="p-4" data-prevent-outside-click>
{/* Header */}
<div className="flex items-center justify-between gap-2 py-0.5">
<div className="flex-shrink-0 flex items-center gap-2 text-sm">
<p className="flex items-center gap-1">
{t("description_versions.edited_by")}
<span className="flex-shrink-0">
<Avatar size="sm" src={versionCreator?.avatar_url} name={versionCreator?.display_name} />
</span>
</p>
<p className="flex-shrink-0 text-custom-text-200">
{calculateTimeAgo(activeVersionDetails?.last_saved_at ?? "")}
</p>
</div>
<div className="flex-shrink-0 flex items-center">
<button
type="button"
onClick={() => handleNavigation("prev")}
className={cn(
"size-6 text-custom-text-200 grid place-items-center rounded outline-none transition-colors",
{
"hover:bg-custom-background-80": !isPrevDisabled,
"opacity-50": isPrevDisabled,
}
)}
disabled={isPrevDisabled}
>
<ChevronLeft className="size-4" />
</button>
<button
type="button"
onClick={() => handleNavigation("next")}
className={cn(
"size-6 text-custom-text-200 grid place-items-center rounded outline-none transition-colors",
{
"hover:bg-custom-background-80": !isNextDisabled,
"opacity-50": isNextDisabled,
}
)}
disabled={isNextDisabled}
>
<ChevronRight className="size-4" />
</button>
</div>
</div>
{/* End header */}
{/* Version description */}
<div className="mt-4 pb-4">
{activeVersionDescription ? (
<RichTextReadOnlyEditor
containerClassName="p-0 !pl-0 border-none"
editorClassName="pl-0"
id={activeVersionId ?? ""}
initialValue={activeVersionDescription ?? "<p></p>"}
projectId={projectId}
ref={editorRef}
workspaceId={workspaceId}
workspaceSlug={workspaceSlug}
/>
) : (
<div className="space-y-1">
<Loader.Item width="300px" height="15px" />
<Loader.Item width="400px" height="15px" />
<div className="flex items-center gap-2">
<Loader.Item width="20px" height="15px" />
<Loader.Item width="500px" height="15px" />
</div>
<div className="flex items-center gap-2">
<Loader.Item width="20px" height="15px" />
<Loader.Item width="200px" height="15px" />
</div>
<Loader.Item width="300px" height="15px" />
<Loader.Item width="200px" height="15px" />
</div>
)}
</div>
{/* End version description */}
{/* Footer */}
<div className="flex items-center justify-between gap-2 pt-4 border-t-[0.5px] border-custom-border-200">
<Tooltip tooltipContent={t("common.actions.copy_markdown")}>
<button
type="button"
className={cn(
"flex-shrink-0",
getButtonStyling("neutral-primary", "sm"),
"border-none grid place-items-center"
)}
onClick={handleCopyMarkdown}
>
<Copy className="size-4" />
</button>
</Tooltip>
<div className="flex items-center gap-2">
<Button variant="neutral-primary" size="sm" onClick={handleClose}>
{t("common.cancel")}
</Button>
{!isRestoreDisabled && (
<Button
variant="primary"
size="sm"
onClick={() => {
handleRestore(activeVersionDescription ?? "<p></p>");
handleClose();
}}
>
{t("common.actions.restore")}
</Button>
)}
</div>
</div>
{/* End footer */}
</div>
</ModalCore>
);
});

View file

@ -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 = {
createdAt: Date;
createdBy: string;
id: string;
isRestoreDisabled: boolean;
};
type Props = {
className?: string;
entityInformation: TDescriptionVersionEntityInformation;
fetchHandlers: {
listDescriptionVersions: (entityId: string) => Promise<TDescriptionVersionsListResponse>;
retrieveDescriptionVersion: (entityId: string, versionId: string) => Promise<TDescriptionVersionDetails>;
};
handleRestore: (descriptionHTML: string) => void;
projectId?: string;
workspaceSlug: string;
};
export const DescriptionVersionsRoot: React.FC<Props> = observer((props) => {
const { className, entityInformation, fetchHandlers, handleRestore, projectId, workspaceSlug } = props;
// states
const [isModalOpen, setIsModalOpen] = useState(false);
const [activeVersionId, setActiveVersionId] = useState<string | null>(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 (
<>
<DescriptionVersionsModal
activeVersionDescription={activeVersionResponse?.description_html ?? "<p></p>"}
activeVersionDetails={activeVersionDetails}
handleClose={() => {
setIsModalOpen(false);
setTimeout(() => {
setActiveVersionId(null);
}, 300);
}}
handleNavigation={handleNavigation}
handleRestore={handleRestore}
isNextDisabled={activeVersionIndex === versionsCount - 1}
isOpen={isModalOpen}
isPrevDisabled={activeVersionIndex === 0}
isRestoreDisabled={entityInformation.isRestoreDisabled}
projectId={projectId}
workspaceSlug={workspaceSlug}
/>
<div className={cn(className)}>
<DescriptionVersionsDropdown
disabled={versionsCount === 0}
entityInformation={entityInformation}
onVersionClick={(versionId) => {
setIsModalOpen(true);
setActiveVersionId(versionId);
}}
versions={versions}
/>
</div>
</>
);
});

View file

@ -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 { IntakeWorkItemVersionService } from "@/services/inbox";
// stores
import { IInboxIssueStore } from "@/store/inbox/inbox-issue.store";
// services init
const intakeWorkItemVersionService = new IntakeWorkItemVersionService();
type Props = {
workspaceSlug: string;
@ -39,15 +44,20 @@ type Props = {
};
export const InboxIssueMainContent: React.FC<Props> = observer((props) => {
const pathname = usePathname();
const { workspaceSlug, projectId, inboxIssue, isEditable, isSubmitting, setIsSubmitting } = props;
// hooks
// navigation
const pathname = usePathname();
// refs
const editorRef = useRef<EditorRefApi>(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<Props> = 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<Props> = 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<Props> = observer((props) => {
</Loader>
) : (
<IssueDescriptionInput
editorRef={editorRef}
workspaceSlug={workspaceSlug}
projectId={issue.project_id}
issueId={issue.id}
@ -207,14 +218,36 @@ export const InboxIssueMainContent: React.FC<Props> = observer((props) => {
/>
)}
{currentUser && (
<IssueReaction
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issue.id}
currentUser={currentUser}
/>
)}
<div className="flex items-center justify-between gap-2">
{currentUser && (
<IssueReaction
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issue.id}
currentUser={currentUser}
/>
)}
{isEditable && (
<DescriptionVersionsRoot
className="flex-shrink-0"
entityInformation={{
createdAt: new Date(issue.created_at ?? ""),
createdBy: issue.created_by ?? "",
id: issue.id,
isRestoreDisabled: !isEditable,
}}
fetchHandlers={{
listDescriptionVersions: (issueId) =>
intakeWorkItemVersionService.listDescriptionVersions(workspaceSlug, projectId, issueId),
retrieveDescriptionVersion: (issueId, versionId) =>
intakeWorkItemVersionService.retrieveDescriptionVersion(workspaceSlug, projectId, issueId, versionId),
}}
handleRestore={(descriptionHTML) => editorRef.current?.setEditorValue(descriptionHTML, true)}
projectId={projectId}
workspaceSlug={workspaceSlug}
/>
)}
</div>
</div>
<IssueAttachmentRoot

View file

@ -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<EditorReadOnlyRefApi>;
editorRef?: React.RefObject<EditorRefApi>;
workspaceSlug: string;
projectId: string;
issueId: string;
@ -38,6 +39,8 @@ export type IssueDescriptionInputProps = {
export const IssueDescriptionInput: FC<IssueDescriptionInputProps> = observer((props) => {
const {
containerClassName,
editorReadOnlyRef,
editorRef,
workspaceSlug,
projectId,
issueId,
@ -55,16 +58,17 @@ export const IssueDescriptionInput: FC<IssueDescriptionInputProps> = 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<TIssue>({
defaultValues: {
description_html: initialValue,
},
});
// i18n
const { t } = useTranslation();
const handleDescriptionFormSubmit = useCallback(
async (formData: Partial<TIssue>) => {
@ -75,10 +79,6 @@ export const IssueDescriptionInput: FC<IssueDescriptionInputProps> = 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;
@ -102,6 +102,8 @@ export const IssueDescriptionInput: FC<IssueDescriptionInputProps> = observer((p
[handleSubmit, issueId]
);
if (!workspaceId) return null;
return (
<>
{localIssueDescription.description_html ? (
@ -154,6 +156,7 @@ export const IssueDescriptionInput: FC<IssueDescriptionInputProps> = observer((p
throw new Error("Asset upload failed. Please try again later.");
}
}}
ref={editorRef}
/>
) : (
<RichTextReadOnlyEditor
@ -163,6 +166,7 @@ export const IssueDescriptionInput: FC<IssueDescriptionInputProps> = observer((p
workspaceId={workspaceId}
workspaceSlug={workspaceSlug}
projectId={projectId}
ref={editorReadOnlyRef}
/>
)
}

View file

@ -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 { WorkItemVersionService } from "@/services/issue";
// local imports
import { TIssueOperations } from "./root";
// services init
const workItemVersionService = new WorkItemVersionService();
type Props = {
workspaceSlug: string;
@ -38,6 +45,8 @@ type Props = {
export const IssueMainContent: React.FC<Props> = observer((props) => {
const { workspaceSlug, projectId, issueId, issueOperations, isEditable, isArchived } = props;
// refs
const editorRef = useRef<EditorRefApi>(null);
// states
const [isSubmitting, setIsSubmitting] = useState<TNameDescriptionLoader>("saved");
// hooks
@ -49,11 +58,9 @@ export const IssueMainContent: React.FC<Props> = 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,
@ -114,31 +121,55 @@ export const IssueMainContent: React.FC<Props> = observer((props) => {
isSubmitting={isSubmitting}
setIsSubmitting={(value) => setIsSubmitting(value)}
issueOperations={issueOperations}
disabled={!isEditable}
disabled={isArchived || !isEditable}
value={issue.name}
containerClassName="-ml-3"
/>
<IssueDescriptionInput
editorRef={editorRef}
workspaceSlug={workspaceSlug}
projectId={issue.project_id}
issueId={issue.id}
initialValue={issue.description_html}
disabled={!isEditable}
disabled={isArchived || !isEditable}
issueOperations={issueOperations}
setIsSubmitting={(value) => setIsSubmitting(value)}
containerClassName="-ml-3 border-none"
/>
{currentUser && (
<IssueReaction
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
currentUser={currentUser}
disabled={isArchived}
/>
)}
<div className="flex items-center justify-between gap-2">
{currentUser && (
<IssueReaction
className="flex-shrink-0"
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
currentUser={currentUser}
disabled={isArchived}
/>
)}
{isEditable && (
<DescriptionVersionsRoot
className="flex-shrink-0"
entityInformation={{
createdAt: new Date(issue.created_at),
createdBy: issue.created_by,
id: issueId,
isRestoreDisabled: !isEditable || isArchived,
}}
fetchHandlers={{
listDescriptionVersions: (issueId) =>
workItemVersionService.listDescriptionVersions(workspaceSlug, projectId, issueId),
retrieveDescriptionVersion: (issueId, versionId) =>
workItemVersionService.retrieveDescriptionVersion(workspaceSlug, projectId, issueId, versionId),
}}
handleRestore={(descriptionHTML) => editorRef.current?.setEditorValue(descriptionHTML, true)}
projectId={projectId}
workspaceSlug={workspaceSlug}
/>
)}
</div>
</div>
<IssueDetailWidgets

View file

@ -366,7 +366,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => {
projectId={projectId}
issueId={issueId}
issueOperations={issueOperations}
isEditable={!is_archived && isEditable}
isEditable={isEditable}
isArchived={is_archived}
/>
</div>

View file

@ -1,24 +1,30 @@
"use-client";
import { FC, useEffect } from "react";
import { FC, useEffect, useRef } from "react";
import { observer } from "mobx-react";
// types
// plane imports
import { EditorRefApi } from "@plane/editor";
import { TNameDescriptionLoader } from "@plane/types";
// components
import { DescriptionVersionsRoot } from "@/components/core/description-versions";
import { IssueParentDetail, TIssueOperations } from "@/components/issues";
// helpers
import { getTextContent } from "@/helpers/editor.helper";
// store hooks
import { useIssueDetail, useProject, useUser } from "@/hooks/store";
// hooks
import { useIssueDetail, useProject, useUser } from "@/hooks/store";
import useReloadConfirmations from "@/hooks/use-reload-confirmation";
// plane web components
import { DeDupeIssuePopoverRoot } from "@/plane-web/components/de-dupe";
import { IssueTypeSwitcher } from "@/plane-web/components/issues";
// local components
// plane web hooks
import { useDebouncedDuplicateIssues } from "@/plane-web/hooks/use-debounced-duplicate-issues";
// services
import { WorkItemVersionService } from "@/services/issue";
// local components
import { IssueDescriptionInput } from "../description-input";
import { IssueReaction } from "../issue-detail/reactions";
import { IssueTitleInput } from "../title-input";
// services init
const workItemVersionService = new WorkItemVersionService();
interface IPeekOverviewIssueDetails {
workspaceSlug: string;
@ -33,6 +39,8 @@ interface IPeekOverviewIssueDetails {
export const PeekOverviewIssueDetails: FC<IPeekOverviewIssueDetails> = observer((props) => {
const { workspaceSlug, issueId, issueOperations, disabled, isArchived, isSubmitting, setIsSubmitting } = props;
// refs
const editorRef = useRef<EditorRefApi>(null);
// store hooks
const { data: currentUser } = useUser();
const {
@ -107,31 +115,63 @@ export const PeekOverviewIssueDetails: FC<IPeekOverviewIssueDetails> = observer(
isSubmitting={isSubmitting}
setIsSubmitting={(value) => setIsSubmitting(value)}
issueOperations={issueOperations}
disabled={disabled}
disabled={disabled || isArchived}
value={issue.name}
containerClassName="-ml-3"
/>
<IssueDescriptionInput
editorRef={editorRef}
workspaceSlug={workspaceSlug}
projectId={issue.project_id}
issueId={issue.id}
initialValue={issueDescription}
disabled={disabled}
disabled={disabled || isArchived}
issueOperations={issueOperations}
setIsSubmitting={(value) => setIsSubmitting(value)}
containerClassName="-ml-3 border-none"
/>
{currentUser && (
<IssueReaction
workspaceSlug={workspaceSlug}
projectId={issue.project_id}
issueId={issueId}
currentUser={currentUser}
disabled={isArchived}
/>
)}
<div className="flex items-center justify-between gap-2">
{currentUser && (
<IssueReaction
workspaceSlug={workspaceSlug}
projectId={issue.project_id}
issueId={issueId}
currentUser={currentUser}
disabled={isArchived}
/>
)}
{!disabled && (
<DescriptionVersionsRoot
className="flex-shrink-0"
entityInformation={{
createdAt: new Date(issue.created_at),
createdBy: issue.created_by,
id: issueId,
isRestoreDisabled: disabled || isArchived,
}}
fetchHandlers={{
listDescriptionVersions: (issueId) =>
workItemVersionService.listDescriptionVersions(
workspaceSlug,
issue.project_id?.toString() ?? "",
issueId
),
retrieveDescriptionVersion: (issueId, versionId) =>
workItemVersionService.retrieveDescriptionVersion(
workspaceSlug,
issue.project_id?.toString() ?? "",
issueId,
versionId
),
}}
handleRestore={(descriptionHTML) => editorRef.current?.setEditorValue(descriptionHTML, true)}
projectId={issue.project_id}
workspaceSlug={workspaceSlug}
/>
)}
</div>
</div>
);
});

View file

@ -187,7 +187,7 @@ export const IssueView: FC<IIssueView> = observer((props) => {
projectId={projectId}
issueId={issueId}
issueOperations={issueOperations}
disabled={disabled || is_archived || isLocalDBIssueDescription}
disabled={disabled || isLocalDBIssueDescription}
isArchived={is_archived}
isSubmitting={isSubmitting}
setIsSubmitting={(value) => setIsSubmitting(value)}
@ -226,7 +226,7 @@ export const IssueView: FC<IIssueView> = observer((props) => {
projectId={projectId}
issueId={issueId}
issueOperations={issueOperations}
disabled={disabled || is_archived || isLocalDBIssueDescription}
disabled={disabled || isLocalDBIssueDescription}
isArchived={is_archived}
isSubmitting={isSubmitting}
setIsSubmitting={(value) => setIsSubmitting(value)}

View file

@ -1 +1,2 @@
export * from "./inbox-issue.service";
export * from "./intake-work_item_version.service";

View file

@ -0,0 +1,41 @@
// plane imports
import { type TDescriptionVersionsListResponse, type TDescriptionVersionDetails } from "@plane/types";
// helpers
import { API_BASE_URL } from "@/helpers/common.helper";
// services
import { APIService } from "@/services/api.service";
export class IntakeWorkItemVersionService extends APIService {
constructor() {
super(API_BASE_URL);
}
async listDescriptionVersions(
workspaceSlug: string,
projectId: string,
intakeWorkItemId: string
): Promise<TDescriptionVersionsListResponse> {
return this.get(
`/api/workspaces/${workspaceSlug}/projects/${projectId}/intake-work-items/${intakeWorkItemId}/description-versions/`
)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async retrieveDescriptionVersion(
workspaceSlug: string,
projectId: string,
intakeWorkItemId: string,
versionId: string
): Promise<TDescriptionVersionDetails> {
return this.get(
`/api/workspaces/${workspaceSlug}/projects/${projectId}/intake-work-items/${intakeWorkItemId}/description-versions/${versionId}/`
)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
}

View file

@ -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 "./work_item_version.service";
export * from "./workspace_draft.service";

View file

@ -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 WorkItemVersionService extends APIService {
private serviceType: TIssueServiceType;
constructor(serviceType: TIssueServiceType = EIssueServiceType.WORK_ITEMS) {
super(API_BASE_URL);
this.serviceType = serviceType;
}
async listDescriptionVersions(
workspaceSlug: string,
projectId: string,
workItemId: string
): Promise<TDescriptionVersionsListResponse> {
return this.get(
`/api/workspaces/${workspaceSlug}/projects/${projectId}/${this.serviceType}/${workItemId}/description-versions/`
)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async retrieveDescriptionVersion(
workspaceSlug: string,
projectId: string,
workItemId: string,
versionId: string
): Promise<TDescriptionVersionDetails> {
return this.get(
`/api/workspaces/${workspaceSlug}/projects/${projectId}/${this.serviceType}/${workItemId}/description-versions/${versionId}/`
)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
}