diff --git a/apiserver/plane/app/urls/intake.py b/apiserver/plane/app/urls/intake.py
index 397579262e8..ac4b7ca5cd4 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,
+ IntakeWorkItemDescriptionVersionEndpoint,
+)
urlpatterns = [
@@ -53,4 +57,14 @@
),
name="inbox-issue",
),
+ path(
+ "workspaces//projects//intake-work-items//description-versions/",
+ IntakeWorkItemDescriptionVersionEndpoint.as_view(),
+ name="intake-work-item-versions",
+ ),
+ path(
+ "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 6c5e450331f..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,
)
@@ -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",
+ "workspaces//projects//work-items//description-versions/",
+ WorkItemDescriptionVersionEndpoint.as_view(),
+ name="work-item-versions",
),
path(
- "workspaces//projects//issues//description-versions//",
- IssueDescriptionVersionEndpoint.as_view(),
- name="page-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 ba63920f6c5..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
+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 9c97415b511..92b2f62d2e7 100644
--- a/apiserver/plane/app/views/intake/base.py
+++ b/apiserver/plane/app/views/intake/base.py
@@ -27,16 +27,22 @@
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 @@ def list(self, request, slug, project_id):
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 @@ def create(self, request, slug, project_id):
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 @@ 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:
@@ -402,15 +418,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=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 @@ def retrieve(self, request, slug, project_id, pk):
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 @@ def retrieve(self, request, slug, project_id, pk):
):
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 @@ def destroy(self, request, slug, project_id, pk):
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)
diff --git a/apiserver/plane/app/views/issue/base.py b/apiserver/plane/app/views/issue/base.py
index d73288897af..7a6d6045dd3 100644
--- a/apiserver/plane/app/views/issue/base.py
+++ b/apiserver/plane/app/views/issue/base.py
@@ -565,7 +565,7 @@ def retrieve(self, request, slug, project_id, pk=None):
):
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 @@ 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)
@@ -1278,7 +1278,7 @@ def get(self, request, slug, project_identifier, issue_identifier):
):
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(
diff --git a/apiserver/plane/app/views/issue/version.py b/apiserver/plane/app/views/issue/version.py
index ab26ca5a65a..9f8d5c29d3a 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,
@@ -66,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)
@@ -78,10 +84,34 @@ 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=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 @@ 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,
queryset=issue_description_versions_queryset,
diff --git a/apiserver/plane/app/views/view/base.py b/apiserver/plane/app/views/view/base.py
index 2016c9b282f..5d66fc65cc0 100644
--- a/apiserver/plane/app/views/view/base.py
+++ b/apiserver/plane/app/views/view/base.py
@@ -432,7 +432,7 @@ def retrieve(self, request, slug, project_id, pk):
):
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)
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/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 a55a1a84aa1..647f52e7d43 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/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": "編輯者"
}
}
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/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/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..27d6eaff472
--- /dev/null
+++ b/web/core/components/core/description-versions/dropdown.tsx
@@ -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 = 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 (
+
+
+
+
+
+ {t("description_versions.last_edited_by")}{" "}
+ {lastUpdatedByUserDetails?.display_name}{" "}
+ {calculateTimeAgo(lastUpdatedAt)}
+
+
+ }
+ 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..46308ca3440
--- /dev/null
+++ b/web/core/components/core/description-versions/modal.tsx
@@ -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 = observer((props) => {
+ const {
+ activeVersionDescription,
+ activeVersionDetails,
+ handleClose,
+ handleNavigation,
+ handleRestore,
+ isNextDisabled,
+ isPrevDisabled,
+ isOpen,
+ isRestoreDisabled,
+ 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)?.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 (
+
+
+ {/* 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 */}
+
+
+
+
+
+
+ {!isRestoreDisabled && (
+
+ )}
+
+
+ {/* 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..174ab807d9c
--- /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 = {
+ createdAt: Date;
+ createdBy: string;
+ id: string;
+ isRestoreDisabled: boolean;
+};
+
+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}
+ isRestoreDisabled={entityInformation.isRestoreDisabled}
+ projectId={projectId}
+ workspaceSlug={workspaceSlug}
+ />
+
+ {
+ setIsModalOpen(true);
+ setActiveVersionId(versionId);
+ }}
+ versions={versions}
+ />
+
+ >
+ );
+});
diff --git a/web/core/components/inbox/content/issue-root.tsx b/web/core/components/inbox/content/issue-root.tsx
index 0b64b220a55..ac81d56b44d 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 { 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 = 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 && (
+
+ )}
+ {isEditable && (
+
+ 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}
+ />
+ )}
+
;
+ 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;
@@ -102,6 +102,8 @@ export const IssueDescriptionInput: FC = observer((p
[handleSubmit, issueId]
);
+ if (!workspaceId) return null;
+
return (
<>
{localIssueDescription.description_html ? (
@@ -154,6 +156,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..dff83d33331 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 { 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 = 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,
@@ -114,31 +121,55 @@ export const IssueMainContent: React.FC = observer((props) => {
isSubmitting={isSubmitting}
setIsSubmitting={(value) => setIsSubmitting(value)}
issueOperations={issueOperations}
- disabled={!isEditable}
+ disabled={isArchived || !isEditable}
value={issue.name}
containerClassName="-ml-3"
/>
setIsSubmitting(value)}
containerClassName="-ml-3 border-none"
/>
- {currentUser && (
-
- )}
+
+ {currentUser && (
+
+ )}
+ {isEditable && (
+
+ 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}
+ />
+ )}
+
= observer((props) => {
projectId={projectId}
issueId={issueId}
issueOperations={issueOperations}
- isEditable={!is_archived && isEditable}
+ isEditable={isEditable}
isArchived={is_archived}
/>
diff --git a/web/core/components/issues/peek-overview/issue-detail.tsx b/web/core/components/issues/peek-overview/issue-detail.tsx
index 421aa6521e4..9322b39c4c4 100644
--- a/web/core/components/issues/peek-overview/issue-detail.tsx
+++ b/web/core/components/issues/peek-overview/issue-detail.tsx
@@ -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 = observer((props) => {
const { workspaceSlug, issueId, issueOperations, disabled, isArchived, isSubmitting, setIsSubmitting } = props;
+ // refs
+ const editorRef = useRef(null);
// store hooks
const { data: currentUser } = useUser();
const {
@@ -107,31 +115,63 @@ export const PeekOverviewIssueDetails: FC = observer(
isSubmitting={isSubmitting}
setIsSubmitting={(value) => setIsSubmitting(value)}
issueOperations={issueOperations}
- disabled={disabled}
+ disabled={disabled || isArchived}
value={issue.name}
containerClassName="-ml-3"
/>
setIsSubmitting(value)}
containerClassName="-ml-3 border-none"
/>
- {currentUser && (
-
- )}
+
+ {currentUser && (
+
+ )}
+ {!disabled && (
+
+ 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}
+ />
+ )}
+
);
});
diff --git a/web/core/components/issues/peek-overview/view.tsx b/web/core/components/issues/peek-overview/view.tsx
index c80212556cb..7eb8786d76d 100644
--- a/web/core/components/issues/peek-overview/view.tsx
+++ b/web/core/components/issues/peek-overview/view.tsx
@@ -187,7 +187,7 @@ export const IssueView: FC = 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 = 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)}
diff --git a/web/core/services/inbox/index.ts b/web/core/services/inbox/index.ts
index f4a25e36136..64bbf162b2a 100644
--- a/web/core/services/inbox/index.ts
+++ b/web/core/services/inbox/index.ts
@@ -1 +1,2 @@
export * from "./inbox-issue.service";
+export * from "./intake-work_item_version.service";
diff --git a/web/core/services/inbox/intake-work_item_version.service.ts b/web/core/services/inbox/intake-work_item_version.service.ts
new file mode 100644
index 00000000000..c8ebaa28476
--- /dev/null
+++ b/web/core/services/inbox/intake-work_item_version.service.ts
@@ -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 {
+ 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 {
+ 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;
+ });
+ }
+}
diff --git a/web/core/services/issue/index.ts b/web/core/services/issue/index.ts
index ad809eae5c2..77acc8625bd 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 "./work_item_version.service";
export * from "./workspace_draft.service";
diff --git a/web/core/services/issue/work_item_version.service.ts b/web/core/services/issue/work_item_version.service.ts
new file mode 100644
index 00000000000..cf36842fae4
--- /dev/null
+++ b/web/core/services/issue/work_item_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 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 {
+ 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 {
+ return this.get(
+ `/api/workspaces/${workspaceSlug}/projects/${projectId}/${this.serviceType}/${workItemId}/description-versions/${versionId}/`
+ )
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+}