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; + }); + } +}