diff --git a/apiserver/package.json b/apiserver/package.json index 2840f6bef..d357d5cb4 100644 --- a/apiserver/package.json +++ b/apiserver/package.json @@ -1,4 +1,4 @@ { "name": "plane-api", - "version": "0.18.0" + "version": "0.19.0" } diff --git a/apiserver/plane/api/serializers/inbox.py b/apiserver/plane/api/serializers/inbox.py index 78bb74d13..a0c79235d 100644 --- a/apiserver/plane/api/serializers/inbox.py +++ b/apiserver/plane/api/serializers/inbox.py @@ -1,9 +1,13 @@ # Module improts from .base import BaseSerializer +from .issue import IssueExpandSerializer from plane.db.models import InboxIssue class InboxIssueSerializer(BaseSerializer): + + issue_detail = IssueExpandSerializer(read_only=True, source="issue") + class Meta: model = InboxIssue fields = "__all__" diff --git a/apiserver/plane/api/views/base.py b/apiserver/plane/api/views/base.py index 13047eb78..1f6bd70af 100644 --- a/apiserver/plane/api/views/base.py +++ b/apiserver/plane/api/views/base.py @@ -19,7 +19,6 @@ from rest_framework.views import APIView # Module imports from plane.api.middleware.api_authentication import APIKeyAuthentication from plane.api.rate_limit import ApiKeyRateThrottle -from plane.bgtasks.webhook_task import send_webhook from plane.utils.exception_logger import log_exception from plane.utils.paginator import BasePaginator @@ -38,40 +37,6 @@ class TimezoneMixin: timezone.deactivate() -class WebhookMixin: - webhook_event = None - bulk = False - - def finalize_response(self, request, response, *args, **kwargs): - response = super().finalize_response( - request, response, *args, **kwargs - ) - - # Check for the case should webhook be sent - if ( - self.webhook_event - and self.request.method in ["POST", "PATCH", "DELETE"] - and response.status_code in [200, 201, 204] - ): - url = request.build_absolute_uri() - parsed_url = urlparse(url) - # Extract the scheme and netloc - scheme = parsed_url.scheme - netloc = parsed_url.netloc - # Push the object to delay - send_webhook.delay( - event=self.webhook_event, - payload=response.data, - kw=self.kwargs, - action=self.request.method, - slug=self.workspace_slug, - bulk=self.bulk, - current_site=f"{scheme}://{netloc}", - ) - - return response - - class BaseAPIView(TimezoneMixin, APIView, BasePaginator): authentication_classes = [ APIKeyAuthentication, diff --git a/apiserver/plane/api/views/cycle.py b/apiserver/plane/api/views/cycle.py index d9c75ff41..6e1e5e057 100644 --- a/apiserver/plane/api/views/cycle.py +++ b/apiserver/plane/api/views/cycle.py @@ -5,6 +5,7 @@ import json from django.core import serializers from django.db.models import Count, F, Func, OuterRef, Q, Sum from django.utils import timezone +from django.core.serializers.json import DjangoJSONEncoder # Third party imports from rest_framework import status @@ -26,10 +27,11 @@ from plane.db.models import ( ) from plane.utils.analytics_plot import burndown_plot -from .base import BaseAPIView, WebhookMixin +from .base import BaseAPIView +from plane.bgtasks.webhook_task import model_activity -class CycleAPIEndpoint(WebhookMixin, BaseAPIView): +class CycleAPIEndpoint(BaseAPIView): """ This viewset automatically provides `list`, `create`, `retrieve`, `update` and `destroy` actions related to cycle. @@ -277,6 +279,16 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView): project_id=project_id, owned_by=request.user, ) + # Send the model activity + model_activity.delay( + model_name="cycle", + model_id=str(serializer.data["id"]), + requested_data=request.data, + current_instance=None, + actor_id=request.user.id, + slug=slug, + origin=request.META.get("HTTP_ORIGIN"), + ) return Response( serializer.data, status=status.HTTP_201_CREATED ) @@ -295,6 +307,11 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView): cycle = Cycle.objects.get( workspace__slug=slug, project_id=project_id, pk=pk ) + + current_instance = json.dumps( + CycleSerializer(cycle).data, cls=DjangoJSONEncoder + ) + if cycle.archived_at: return Response( {"error": "Archived cycle cannot be edited"}, @@ -344,6 +361,17 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView): status=status.HTTP_409_CONFLICT, ) serializer.save() + + # Send the model activity + model_activity.delay( + model_name="cycle", + model_id=str(serializer.data["id"]), + requested_data=request.data, + current_instance=current_instance, + actor_id=request.user.id, + slug=slug, + origin=request.META.get("HTTP_ORIGIN"), + ) return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -515,7 +543,7 @@ class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView): return Response(status=status.HTTP_204_NO_CONTENT) -class CycleIssueAPIEndpoint(WebhookMixin, BaseAPIView): +class CycleIssueAPIEndpoint(BaseAPIView): """ This viewset automatically provides `list`, `create`, and `destroy` actions related to cycle issues. diff --git a/apiserver/plane/api/views/inbox.py b/apiserver/plane/api/views/inbox.py index 5e6e4a215..8987e4f63 100644 --- a/apiserver/plane/api/views/inbox.py +++ b/apiserver/plane/api/views/inbox.py @@ -154,6 +154,13 @@ class InboxIssueAPIEndpoint(BaseAPIView): state=state, ) + # create an inbox issue + inbox_issue = InboxIssue.objects.create( + inbox_id=inbox.id, + project_id=project_id, + issue=issue, + source=request.data.get("source", "in-app"), + ) # Create an Issue Activity issue_activity.delay( type="issue.activity.created", @@ -163,14 +170,7 @@ class InboxIssueAPIEndpoint(BaseAPIView): project_id=str(project_id), current_instance=None, epoch=int(timezone.now().timestamp()), - ) - - # create an inbox issue - inbox_issue = InboxIssue.objects.create( - inbox_id=inbox.id, - project_id=project_id, - issue=issue, - source=request.data.get("source", "in-app"), + inbox=str(inbox_issue.id), ) serializer = InboxIssueSerializer(inbox_issue) @@ -260,6 +260,7 @@ class InboxIssueAPIEndpoint(BaseAPIView): cls=DjangoJSONEncoder, ), epoch=int(timezone.now().timestamp()), + inbox=(inbox_issue.id), ) issue_serializer.save() else: @@ -327,6 +328,7 @@ class InboxIssueAPIEndpoint(BaseAPIView): epoch=int(timezone.now().timestamp()), notification=False, origin=request.META.get("HTTP_ORIGIN"), + inbox=str(inbox_issue.id), ) return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py index 8d72ac5db..a62278b19 100644 --- a/apiserver/plane/api/views/issue.py +++ b/apiserver/plane/api/views/issue.py @@ -48,11 +48,10 @@ from plane.db.models import ( ProjectMember, ) -from .base import BaseAPIView, WebhookMixin +from .base import BaseAPIView - -class WorkspaceIssueAPIEndpoint(WebhookMixin, BaseAPIView): +class WorkspaceIssueAPIEndpoint(BaseAPIView): """ This viewset provides `retrieveByIssueId` on workspace level @@ -60,12 +59,9 @@ class WorkspaceIssueAPIEndpoint(WebhookMixin, BaseAPIView): model = Issue webhook_event = "issue" - permission_classes = [ - ProjectEntityPermission - ] + permission_classes = [ProjectEntityPermission] serializer_class = IssueSerializer - @property def project__identifier(self): return self.kwargs.get("project__identifier", None) @@ -91,7 +87,9 @@ class WorkspaceIssueAPIEndpoint(WebhookMixin, BaseAPIView): .order_by(self.kwargs.get("order_by", "-created_at")) ).distinct() - def get(self, request, slug, project__identifier=None, issue__identifier=None): + def get( + self, request, slug, project__identifier=None, issue__identifier=None + ): if issue__identifier and project__identifier: issue = Issue.issue_objects.annotate( sub_issues_count=Issue.issue_objects.filter( @@ -100,7 +98,11 @@ class WorkspaceIssueAPIEndpoint(WebhookMixin, BaseAPIView): .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") - ).get(workspace__slug=slug, project__identifier=project__identifier, sequence_id=issue__identifier) + ).get( + workspace__slug=slug, + project__identifier=project__identifier, + sequence_id=issue__identifier, + ) return Response( IssueSerializer( issue, @@ -110,7 +112,8 @@ class WorkspaceIssueAPIEndpoint(WebhookMixin, BaseAPIView): status=status.HTTP_200_OK, ) -class IssueAPIEndpoint(WebhookMixin, BaseAPIView): + +class IssueAPIEndpoint(BaseAPIView): """ This viewset automatically provides `list`, `create`, `retrieve`, `update` and `destroy` actions related to issue. @@ -652,7 +655,7 @@ class IssueLinkAPIEndpoint(BaseAPIView): return Response(status=status.HTTP_204_NO_CONTENT) -class IssueCommentAPIEndpoint(WebhookMixin, BaseAPIView): +class IssueCommentAPIEndpoint(BaseAPIView): """ This viewset automatically provides `list`, `create`, `retrieve`, `update` and `destroy` actions related to comments of the particular issue. diff --git a/apiserver/plane/api/views/module.py b/apiserver/plane/api/views/module.py index 38744eaa5..eeb29dad2 100644 --- a/apiserver/plane/api/views/module.py +++ b/apiserver/plane/api/views/module.py @@ -5,6 +5,7 @@ import json from django.core import serializers from django.db.models import Count, F, Func, OuterRef, Prefetch, Q from django.utils import timezone +from django.core.serializers.json import DjangoJSONEncoder # Third party imports from rest_framework import status @@ -28,10 +29,11 @@ from plane.db.models import ( Project, ) -from .base import BaseAPIView, WebhookMixin +from .base import BaseAPIView +from plane.bgtasks.webhook_task import model_activity -class ModuleAPIEndpoint(WebhookMixin, BaseAPIView): +class ModuleAPIEndpoint(BaseAPIView): """ This viewset automatically provides `list`, `create`, `retrieve`, `update` and `destroy` actions related to module. @@ -163,6 +165,16 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView): status=status.HTTP_409_CONFLICT, ) serializer.save() + # Send the model activity + model_activity.delay( + model_name="module", + model_id=str(serializer.data["id"]), + requested_data=request.data, + current_instance=None, + actor_id=request.user.id, + slug=slug, + origin=request.META.get("HTTP_ORIGIN"), + ) module = Module.objects.get(pk=serializer.data["id"]) serializer = ModuleSerializer(module) return Response(serializer.data, status=status.HTTP_201_CREATED) @@ -172,6 +184,11 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView): module = Module.objects.get( pk=pk, project_id=project_id, workspace__slug=slug ) + + current_instance = json.dumps( + ModuleSerializer(module).data, cls=DjangoJSONEncoder + ) + if module.archived_at: return Response( {"error": "Archived module cannot be edited"}, @@ -204,6 +221,18 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView): status=status.HTTP_409_CONFLICT, ) serializer.save() + + # Send the model activity + model_activity.delay( + model_name="module", + model_id=str(serializer.data["id"]), + requested_data=request.data, + current_instance=current_instance, + actor_id=request.user.id, + slug=slug, + origin=request.META.get("HTTP_ORIGIN"), + ) + return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -260,7 +289,7 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView): return Response(status=status.HTTP_204_NO_CONTENT) -class ModuleIssueAPIEndpoint(WebhookMixin, BaseAPIView): +class ModuleIssueAPIEndpoint(BaseAPIView): """ This viewset automatically provides `list`, `create`, `retrieve`, `update` and `destroy` actions related to module issues. diff --git a/apiserver/plane/api/views/project.py b/apiserver/plane/api/views/project.py index fcb0cc4fb..019ab704e 100644 --- a/apiserver/plane/api/views/project.py +++ b/apiserver/plane/api/views/project.py @@ -1,7 +1,11 @@ +# Python imports +import json + # Django imports from django.db import IntegrityError from django.db.models import Exists, F, Func, OuterRef, Prefetch, Q, Subquery from django.utils import timezone +from django.core.serializers.json import DjangoJSONEncoder # Third party imports from rest_framework import status @@ -23,11 +27,11 @@ from plane.db.models import ( State, Workspace, ) - -from .base import BaseAPIView, WebhookMixin +from plane.bgtasks.webhook_task import model_activity +from .base import BaseAPIView -class ProjectAPIEndpoint(WebhookMixin, BaseAPIView): +class ProjectAPIEndpoint(BaseAPIView): """Project Endpoints to create, update, list, retrieve and delete endpoint""" serializer_class = ProjectSerializer @@ -236,6 +240,17 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView): .filter(pk=serializer.data["id"]) .first() ) + # Model activity + model_activity.delay( + model_name="project", + model_id=str(project.id), + requested_data=request.data, + current_instance=None, + actor_id=request.user.id, + slug=slug, + origin=request.META.get("HTTP_ORIGIN"), + ) + serializer = ProjectSerializer(project) return Response( serializer.data, status=status.HTTP_201_CREATED @@ -265,7 +280,9 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView): try: workspace = Workspace.objects.get(slug=slug) project = Project.objects.get(pk=pk) - + current_instance = json.dumps( + ProjectSerializer(project).data, cls=DjangoJSONEncoder + ) if project.archived_at: return Response( {"error": "Archived project cannot be updated"}, @@ -303,6 +320,17 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView): .filter(pk=serializer.data["id"]) .first() ) + + model_activity.delay( + model_name="project", + model_id=str(project.id), + requested_data=request.data, + current_instance=current_instance, + actor_id=request.user.id, + slug=slug, + origin=request.META.get("HTTP_ORIGIN"), + ) + serializer = ProjectSerializer(project) return Response(serializer.data, status=status.HTTP_200_OK) return Response( diff --git a/apiserver/plane/app/serializers/issue.py b/apiserver/plane/app/serializers/issue.py index 8c641b720..b884d60a3 100644 --- a/apiserver/plane/app/serializers/issue.py +++ b/apiserver/plane/app/serializers/issue.py @@ -442,7 +442,7 @@ class IssueLinkSerializer(BaseSerializer): raise serializers.ValidationError("Invalid URL format.") # Check URL scheme - if not value.startswith(('http://', 'https://')): + if not value.startswith(("http://", "https://")): raise serializers.ValidationError("Invalid URL scheme.") return value diff --git a/apiserver/plane/app/views/__init__.py b/apiserver/plane/app/views/__init__.py index e24ef9a67..c38ce64a7 100644 --- a/apiserver/plane/app/views/__init__.py +++ b/apiserver/plane/app/views/__init__.py @@ -29,7 +29,7 @@ from .user.base import ( ) -from .base import BaseAPIView, BaseViewSet, WebhookMixin +from .base import BaseAPIView, BaseViewSet from .workspace.base import ( WorkSpaceViewSet, diff --git a/apiserver/plane/app/views/base.py b/apiserver/plane/app/views/base.py index 42cac04fb..da42763d7 100644 --- a/apiserver/plane/app/views/base.py +++ b/apiserver/plane/app/views/base.py @@ -19,8 +19,6 @@ from rest_framework.views import APIView from rest_framework.viewsets import ModelViewSet # Module imports -from plane.authentication.session import BaseSessionAuthentication -from plane.bgtasks.webhook_task import send_webhook from plane.utils.exception_logger import log_exception from plane.utils.paginator import BasePaginator @@ -39,35 +37,6 @@ class TimezoneMixin: timezone.deactivate() -class WebhookMixin: - webhook_event = None - bulk = False - - def finalize_response(self, request, response, *args, **kwargs): - response = super().finalize_response( - request, response, *args, **kwargs - ) - - # Check for the case should webhook be sent - if ( - self.webhook_event - and self.request.method in ["POST", "PATCH", "DELETE"] - and response.status_code in [200, 201, 204] - ): - # Push the object to delay - send_webhook.delay( - event=self.webhook_event, - payload=response.data, - kw=self.kwargs, - action=self.request.method, - slug=self.workspace_slug, - bulk=self.bulk, - current_site=request.META.get("HTTP_ORIGIN"), - ) - - return response - - class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator): model = None diff --git a/apiserver/plane/app/views/cycle/base.py b/apiserver/plane/app/views/cycle/base.py index dd9826c56..621c1dcb7 100644 --- a/apiserver/plane/app/views/cycle/base.py +++ b/apiserver/plane/app/views/cycle/base.py @@ -20,6 +20,7 @@ from django.db.models import ( ) from django.db.models.functions import Coalesce from django.utils import timezone +from django.core.serializers.json import DjangoJSONEncoder # Third party imports from rest_framework import status @@ -47,10 +48,11 @@ from plane.db.models import ( from plane.utils.analytics_plot import burndown_plot # Module imports -from .. import BaseAPIView, BaseViewSet, WebhookMixin +from .. import BaseAPIView, BaseViewSet +from plane.bgtasks.webhook_task import model_activity -class CycleViewSet(WebhookMixin, BaseViewSet): +class CycleViewSet(BaseViewSet): serializer_class = CycleSerializer model = Cycle webhook_event = "cycle" @@ -412,6 +414,17 @@ class CycleViewSet(WebhookMixin, BaseViewSet): ) .first() ) + + # Send the model activity + model_activity.delay( + model_name="cycle", + model_id=str(cycle["id"]), + requested_data=request.data, + current_instance=None, + actor_id=request.user.id, + slug=slug, + origin=request.META.get("HTTP_ORIGIN"), + ) return Response(cycle, status=status.HTTP_201_CREATED) return Response( serializer.errors, status=status.HTTP_400_BAD_REQUEST @@ -434,6 +447,11 @@ class CycleViewSet(WebhookMixin, BaseViewSet): {"error": "Archived cycle cannot be updated"}, status=status.HTTP_400_BAD_REQUEST, ) + + current_instance = json.dumps( + CycleSerializer(cycle).data, cls=DjangoJSONEncoder + ) + request_data = request.data if ( @@ -487,6 +505,18 @@ class CycleViewSet(WebhookMixin, BaseViewSet): "assignee_ids", "status", ).first() + + # Send the model activity + model_activity.delay( + model_name="cycle", + model_id=str(cycle["id"]), + requested_data=request.data, + current_instance=current_instance, + actor_id=request.user.id, + slug=slug, + origin=request.META.get("HTTP_ORIGIN"), + ) + return Response(cycle, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) diff --git a/apiserver/plane/app/views/cycle/issue.py b/apiserver/plane/app/views/cycle/issue.py index 2a5505dd0..fdc998f6d 100644 --- a/apiserver/plane/app/views/cycle/issue.py +++ b/apiserver/plane/app/views/cycle/issue.py @@ -23,7 +23,7 @@ from rest_framework.response import Response from rest_framework import status # Module imports -from .. import BaseViewSet, WebhookMixin +from .. import BaseViewSet from plane.app.serializers import ( IssueSerializer, CycleIssueSerializer, @@ -38,9 +38,9 @@ from plane.db.models import ( ) from plane.bgtasks.issue_activites_task import issue_activity from plane.utils.issue_filters import issue_filters +from plane.utils.user_timezone_converter import user_timezone_converter - -class CycleIssueViewSet(WebhookMixin, BaseViewSet): +class CycleIssueViewSet(BaseViewSet): serializer_class = CycleIssueSerializer model = CycleIssue @@ -191,6 +191,11 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet): "is_draft", "archived_at", ) + datetime_fields = ["created_at", "updated_at"] + issues = user_timezone_converter( + issues, datetime_fields, request.user.user_timezone + ) + return Response(issues, status=status.HTTP_200_OK) def create(self, request, slug, project_id, cycle_id): @@ -249,6 +254,7 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet): update_cycle_issue_activity = [] # Iterate over each cycle_issue in cycle_issues for cycle_issue in cycle_issues: + old_cycle_id = cycle_issue.cycle_id # Update the cycle_issue's cycle_id cycle_issue.cycle_id = cycle_id # Add the modified cycle_issue to the records_to_update list @@ -256,7 +262,7 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet): # Record the update activity update_cycle_issue_activity.append( { - "old_cycle_id": str(cycle_issue.cycle_id), + "old_cycle_id": str(old_cycle_id), "new_cycle_id": str(cycle_id), "issue_id": str(cycle_issue.issue_id), } diff --git a/apiserver/plane/app/views/inbox/base.py b/apiserver/plane/app/views/inbox/base.py index 8e433a127..d688a8853 100644 --- a/apiserver/plane/app/views/inbox/base.py +++ b/apiserver/plane/app/views/inbox/base.py @@ -251,6 +251,16 @@ class InboxIssueViewSet(BaseViewSet): ) if serializer.is_valid(): serializer.save() + inbox_id = Inbox.objects.filter( + workspace__slug=slug, project_id=project_id + ).first() + # create an inbox issue + inbox_issue = InboxIssue.objects.create( + inbox_id=inbox_id.id, + project_id=project_id, + issue_id=serializer.data["id"], + source=request.data.get("source", "in-app"), + ) # Create an Issue Activity issue_activity.delay( type="issue.activity.created", @@ -262,16 +272,7 @@ class InboxIssueViewSet(BaseViewSet): epoch=int(timezone.now().timestamp()), notification=True, origin=request.META.get("HTTP_ORIGIN"), - ) - inbox_id = Inbox.objects.filter( - workspace__slug=slug, project_id=project_id - ).first() - # create an inbox issue - inbox_issue = InboxIssue.objects.create( - inbox_id=inbox_id.id, - project_id=project_id, - issue_id=serializer.data["id"], - source=request.data.get("source", "in-app"), + inbox=str(inbox_issue.id), ) inbox_issue = ( InboxIssue.objects.select_related("issue") @@ -339,7 +340,24 @@ class InboxIssueViewSet(BaseViewSet): # Get issue data issue_data = request.data.pop("issue", False) if bool(issue_data): - issue = Issue.objects.get( + issue = Issue.objects.annotate( + label_ids=Coalesce( + ArrayAgg( + "labels__id", + distinct=True, + filter=~Q(labels__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + assignee_ids=Coalesce( + ArrayAgg( + "assignees__id", + distinct=True, + filter=~Q(assignees__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + ).get( pk=inbox_issue.issue_id, workspace__slug=slug, project_id=project_id, @@ -379,6 +397,7 @@ class InboxIssueViewSet(BaseViewSet): epoch=int(timezone.now().timestamp()), notification=True, origin=request.META.get("HTTP_ORIGIN"), + inbox=str(inbox_issue.id), ) issue_serializer.save() else: @@ -444,6 +463,7 @@ class InboxIssueViewSet(BaseViewSet): epoch=int(timezone.now().timestamp()), notification=False, origin=request.META.get("HTTP_ORIGIN"), + inbox=(inbox_issue.id), ) inbox_issue = ( @@ -480,7 +500,8 @@ class InboxIssueViewSet(BaseViewSet): output_field=ArrayField(UUIDField()), ), ), - ).first() + ) + .first() ) serializer = InboxIssueDetailSerializer(inbox_issue).data return Response(serializer, status=status.HTTP_200_OK) diff --git a/apiserver/plane/app/views/issue/archive.py b/apiserver/plane/app/views/issue/archive.py index d9274ae4f..af019a7ec 100644 --- a/apiserver/plane/app/views/issue/archive.py +++ b/apiserver/plane/app/views/issue/archive.py @@ -47,7 +47,7 @@ from plane.db.models import ( ) from plane.bgtasks.issue_activites_task import issue_activity from plane.utils.issue_filters import issue_filters - +from plane.utils.user_timezone_converter import user_timezone_converter class IssueArchiveViewSet(BaseViewSet): permission_classes = [ @@ -239,6 +239,11 @@ class IssueArchiveViewSet(BaseViewSet): "is_draft", "archived_at", ) + datetime_fields = ["created_at", "updated_at"] + issues = user_timezone_converter( + issue_queryset, datetime_fields, request.user.user_timezone + ) + return Response(issues, status=status.HTTP_200_OK) def retrieve(self, request, slug, project_id, pk=None): diff --git a/apiserver/plane/app/views/issue/base.py b/apiserver/plane/app/views/issue/base.py index 23df58540..b1fd1a9bc 100644 --- a/apiserver/plane/app/views/issue/base.py +++ b/apiserver/plane/app/views/issue/base.py @@ -50,9 +50,10 @@ from plane.db.models import ( Project, ) from plane.utils.issue_filters import issue_filters +from plane.utils.user_timezone_converter import user_timezone_converter # Module imports -from .. import BaseAPIView, BaseViewSet, WebhookMixin +from .. import BaseAPIView, BaseViewSet class IssueListEndpoint(BaseAPIView): @@ -241,10 +242,14 @@ class IssueListEndpoint(BaseAPIView): "is_draft", "archived_at", ) + datetime_fields = ["created_at", "updated_at"] + issues = user_timezone_converter( + issues, datetime_fields, request.user.user_timezone + ) return Response(issues, status=status.HTTP_200_OK) -class IssueViewSet(WebhookMixin, BaseViewSet): +class IssueViewSet(BaseViewSet): def get_serializer_class(self): return ( IssueCreateSerializer @@ -440,6 +445,10 @@ class IssueViewSet(WebhookMixin, BaseViewSet): "is_draft", "archived_at", ) + datetime_fields = ["created_at", "updated_at"] + issues = user_timezone_converter( + issue_queryset, datetime_fields, request.user.user_timezone + ) return Response(issues, status=status.HTTP_200_OK) def create(self, request, slug, project_id): @@ -503,6 +512,10 @@ class IssueViewSet(WebhookMixin, BaseViewSet): ) .first() ) + datetime_fields = ["created_at", "updated_at"] + issue = user_timezone_converter( + issue, datetime_fields, request.user.user_timezone + ) return Response(issue, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) diff --git a/apiserver/plane/app/views/issue/comment.py b/apiserver/plane/app/views/issue/comment.py index 0d61f1325..1698efef8 100644 --- a/apiserver/plane/app/views/issue/comment.py +++ b/apiserver/plane/app/views/issue/comment.py @@ -11,7 +11,7 @@ from rest_framework.response import Response from rest_framework import status # Module imports -from .. import BaseViewSet, WebhookMixin +from .. import BaseViewSet from plane.app.serializers import ( IssueCommentSerializer, CommentReactionSerializer, @@ -25,7 +25,7 @@ from plane.db.models import ( from plane.bgtasks.issue_activites_task import issue_activity -class IssueCommentViewSet(WebhookMixin, BaseViewSet): +class IssueCommentViewSet(BaseViewSet): serializer_class = IssueCommentSerializer model = IssueComment webhook_event = "issue_comment" diff --git a/apiserver/plane/app/views/issue/draft.py b/apiserver/plane/app/views/issue/draft.py index 077d7dcaf..fe75c61f1 100644 --- a/apiserver/plane/app/views/issue/draft.py +++ b/apiserver/plane/app/views/issue/draft.py @@ -45,6 +45,7 @@ from plane.db.models import ( Project, ) from plane.utils.issue_filters import issue_filters +from plane.utils.user_timezone_converter import user_timezone_converter # Module imports from .. import BaseViewSet @@ -229,6 +230,10 @@ class IssueDraftViewSet(BaseViewSet): "is_draft", "archived_at", ) + datetime_fields = ["created_at", "updated_at"] + issues = user_timezone_converter( + issue_queryset, datetime_fields, request.user.user_timezone + ) return Response(issues, status=status.HTTP_200_OK) def create(self, request, slug, project_id): diff --git a/apiserver/plane/app/views/issue/sub_issue.py b/apiserver/plane/app/views/issue/sub_issue.py index da479e0e9..2ee4574eb 100644 --- a/apiserver/plane/app/views/issue/sub_issue.py +++ b/apiserver/plane/app/views/issue/sub_issue.py @@ -31,6 +31,7 @@ from plane.db.models import ( IssueAttachment, ) from plane.bgtasks.issue_activites_task import issue_activity +from plane.utils.user_timezone_converter import user_timezone_converter from collections import defaultdict @@ -132,6 +133,10 @@ class SubIssuesEndpoint(BaseAPIView): "is_draft", "archived_at", ) + datetime_fields = ["created_at", "updated_at"] + sub_issues = user_timezone_converter( + sub_issues, datetime_fields, request.user.user_timezone + ) return Response( { "sub_issues": sub_issues, diff --git a/apiserver/plane/app/views/module/archive.py b/apiserver/plane/app/views/module/archive.py index 9c0b6cca3..8a5345ff4 100644 --- a/apiserver/plane/app/views/module/archive.py +++ b/apiserver/plane/app/views/module/archive.py @@ -32,6 +32,8 @@ from plane.db.models import ( ModuleLink, ) from plane.utils.analytics_plot import burndown_plot +from plane.utils.user_timezone_converter import user_timezone_converter + # Module imports from .. import BaseAPIView @@ -199,6 +201,10 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView): "updated_at", "archived_at", ) + datetime_fields = ["created_at", "updated_at"] + modules = user_timezone_converter( + modules, datetime_fields, request.user.user_timezone + ) return Response(modules, status=status.HTTP_200_OK) else: queryset = ( diff --git a/apiserver/plane/app/views/module/base.py b/apiserver/plane/app/views/module/base.py index 4cd52b3b1..5a987dad8 100644 --- a/apiserver/plane/app/views/module/base.py +++ b/apiserver/plane/app/views/module/base.py @@ -1,6 +1,7 @@ # Python imports import json +# Django Imports from django.contrib.postgres.aggregates import ArrayAgg from django.contrib.postgres.fields import ArrayField from django.db.models import ( @@ -17,14 +18,14 @@ from django.db.models import ( Value, ) from django.db.models.functions import Coalesce - -# Django Imports +from django.core.serializers.json import DjangoJSONEncoder from django.utils import timezone -from rest_framework import status # Third party imports +from rest_framework import status from rest_framework.response import Response +# Module imports from plane.app.permissions import ( ProjectEntityPermission, ProjectLitePermission, @@ -48,12 +49,12 @@ from plane.db.models import ( Project, ) from plane.utils.analytics_plot import burndown_plot - -# Module imports -from .. import BaseAPIView, BaseViewSet, WebhookMixin +from plane.utils.user_timezone_converter import user_timezone_converter +from plane.bgtasks.webhook_task import model_activity +from .. import BaseAPIView, BaseViewSet -class ModuleViewSet(WebhookMixin, BaseViewSet): +class ModuleViewSet(BaseViewSet): model = Module permission_classes = [ ProjectEntityPermission, @@ -236,6 +237,20 @@ class ModuleViewSet(WebhookMixin, BaseViewSet): "updated_at", ) ).first() + # Send the model activity + model_activity.delay( + model_name="module", + model_id=str(module["id"]), + requested_data=request.data, + current_instance=None, + actor_id=request.user.id, + slug=slug, + origin=request.META.get("HTTP_ORIGIN"), + ) + datetime_fields = ["created_at", "updated_at"] + module = user_timezone_converter( + module, datetime_fields, request.user.user_timezone + ) return Response(module, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -277,6 +292,10 @@ class ModuleViewSet(WebhookMixin, BaseViewSet): "created_at", "updated_at", ) + datetime_fields = ["created_at", "updated_at"] + modules = user_timezone_converter( + modules, datetime_fields, request.user.user_timezone + ) return Response(modules, status=status.HTTP_200_OK) def retrieve(self, request, slug, project_id, pk): @@ -412,6 +431,9 @@ class ModuleViewSet(WebhookMixin, BaseViewSet): def partial_update(self, request, slug, project_id, pk): module = self.get_queryset().filter(pk=pk) + current_instance = json.dumps( + ModuleSerializer(module).data, cls=DjangoJSONEncoder + ) if module.first().archived_at: return Response( @@ -454,6 +476,22 @@ class ModuleViewSet(WebhookMixin, BaseViewSet): "created_at", "updated_at", ).first() + + # Send the model activity + model_activity.delay( + model_name="module", + model_id=str(module["id"]), + requested_data=request.data, + current_instance=current_instance, + actor_id=request.user.id, + slug=slug, + origin=request.META.get("HTTP_ORIGIN"), + ) + + datetime_fields = ["created_at", "updated_at"] + module = user_timezone_converter( + module, datetime_fields, request.user.user_timezone + ) return Response(module, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) diff --git a/apiserver/plane/app/views/module/issue.py b/apiserver/plane/app/views/module/issue.py index d26433340..3e79e7ec7 100644 --- a/apiserver/plane/app/views/module/issue.py +++ b/apiserver/plane/app/views/module/issue.py @@ -16,7 +16,7 @@ from rest_framework.response import Response from rest_framework import status # Module imports -from .. import BaseViewSet, WebhookMixin +from .. import BaseViewSet from plane.app.serializers import ( ModuleIssueSerializer, IssueSerializer, @@ -31,9 +31,9 @@ from plane.db.models import ( ) from plane.bgtasks.issue_activites_task import issue_activity from plane.utils.issue_filters import issue_filters +from plane.utils.user_timezone_converter import user_timezone_converter - -class ModuleIssueViewSet(WebhookMixin, BaseViewSet): +class ModuleIssueViewSet(BaseViewSet): serializer_class = ModuleIssueSerializer model = ModuleIssue webhook_event = "module_issue" @@ -150,6 +150,11 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet): "is_draft", "archived_at", ) + datetime_fields = ["created_at", "updated_at"] + issues = user_timezone_converter( + issues, datetime_fields, request.user.user_timezone + ) + return Response(issues, status=status.HTTP_200_OK) # create multiple issues inside a module diff --git a/apiserver/plane/app/views/project/base.py b/apiserver/plane/app/views/project/base.py index 50435e3a8..6017a420f 100644 --- a/apiserver/plane/app/views/project/base.py +++ b/apiserver/plane/app/views/project/base.py @@ -1,5 +1,6 @@ # Python imports import boto3 +import json # Django imports from django.db import IntegrityError @@ -14,6 +15,7 @@ from django.db.models import ( ) from django.conf import settings from django.utils import timezone +from django.core.serializers.json import DjangoJSONEncoder # Third Party imports from rest_framework.response import Response @@ -22,7 +24,7 @@ from rest_framework import serializers from rest_framework.permissions import AllowAny # Module imports -from plane.app.views.base import BaseViewSet, BaseAPIView, WebhookMixin +from plane.app.views.base import BaseViewSet, BaseAPIView from plane.app.serializers import ( ProjectSerializer, ProjectListSerializer, @@ -50,9 +52,10 @@ from plane.db.models import ( Issue, ) from plane.utils.cache import cache_response +from plane.bgtasks.webhook_task import model_activity -class ProjectViewSet(WebhookMixin, BaseViewSet): +class ProjectViewSet(BaseViewSet): serializer_class = ProjectListSerializer model = Project webhook_event = "project" @@ -185,7 +188,6 @@ class ProjectViewSet(WebhookMixin, BaseViewSet): .annotate( total_issues=Issue.issue_objects.filter( project_id=self.kwargs.get("pk"), - parent__isnull=True, ) .order_by() .annotate(count=Func(F("id"), function="Count")) @@ -204,7 +206,6 @@ class ProjectViewSet(WebhookMixin, BaseViewSet): archived_issues=Issue.objects.filter( project_id=self.kwargs.get("pk"), archived_at__isnull=False, - parent__isnull=True, ) .order_by() .annotate(count=Func(F("id"), function="Count")) @@ -224,7 +225,6 @@ class ProjectViewSet(WebhookMixin, BaseViewSet): draft_issues=Issue.objects.filter( project_id=self.kwargs.get("pk"), is_draft=True, - parent__isnull=True, ) .order_by() .annotate(count=Func(F("id"), function="Count")) @@ -337,6 +337,17 @@ class ProjectViewSet(WebhookMixin, BaseViewSet): .filter(pk=serializer.data["id"]) .first() ) + + model_activity.delay( + model_name="project", + model_id=str(project.id), + requested_data=request.data, + current_instance=None, + actor_id=request.user.id, + slug=slug, + origin=request.META.get("HTTP_ORIGIN"), + ) + serializer = ProjectListSerializer(project) return Response( serializer.data, status=status.HTTP_201_CREATED @@ -367,7 +378,9 @@ class ProjectViewSet(WebhookMixin, BaseViewSet): workspace = Workspace.objects.get(slug=slug) project = Project.objects.get(pk=pk) - + current_instance = json.dumps( + ProjectSerializer(project).data, cls=DjangoJSONEncoder + ) if project.archived_at: return Response( {"error": "Archived projects cannot be updated"}, @@ -405,6 +418,16 @@ class ProjectViewSet(WebhookMixin, BaseViewSet): .filter(pk=serializer.data["id"]) .first() ) + + model_activity.delay( + model_name="project", + model_id=str(project.id), + requested_data=request.data, + current_instance=current_instance, + actor_id=request.user.id, + slug=slug, + origin=request.META.get("HTTP_ORIGIN"), + ) serializer = ProjectListSerializer(project) return Response(serializer.data, status=status.HTTP_200_OK) return Response( diff --git a/apiserver/plane/app/views/view/base.py b/apiserver/plane/app/views/view/base.py index 35772ccf3..7736e465c 100644 --- a/apiserver/plane/app/views/view/base.py +++ b/apiserver/plane/app/views/view/base.py @@ -42,7 +42,7 @@ from plane.db.models import ( IssueAttachment, ) from plane.utils.issue_filters import issue_filters - +from plane.utils.user_timezone_converter import user_timezone_converter class GlobalViewViewSet(BaseViewSet): serializer_class = IssueViewSerializer @@ -255,6 +255,10 @@ class GlobalViewIssuesViewSet(BaseViewSet): "is_draft", "archived_at", ) + datetime_fields = ["created_at", "updated_at"] + issues = user_timezone_converter( + issues, datetime_fields, request.user.user_timezone + ) return Response(issues, status=status.HTTP_200_OK) diff --git a/apiserver/plane/app/views/workspace/cycle.py b/apiserver/plane/app/views/workspace/cycle.py index e85fa1cef..fa2954d67 100644 --- a/apiserver/plane/app/views/workspace/cycle.py +++ b/apiserver/plane/app/views/workspace/cycle.py @@ -27,7 +27,7 @@ class WorkspaceCyclesEndpoint(BaseAPIView): .select_related("project") .select_related("workspace") .select_related("owned_by") - .filter(archived_at__isnull=False) + .filter(archived_at__isnull=True) .annotate( total_issues=Count( "issue_cycle", diff --git a/apiserver/plane/app/views/workspace/module.py b/apiserver/plane/app/views/workspace/module.py index 085787694..7671692ec 100644 --- a/apiserver/plane/app/views/workspace/module.py +++ b/apiserver/plane/app/views/workspace/module.py @@ -30,7 +30,7 @@ class WorkspaceModulesEndpoint(BaseAPIView): .select_related("workspace") .select_related("lead") .prefetch_related("members") - .filter(archived_at__isnull=False) + .filter(archived_at__isnull=True) .prefetch_related( Prefetch( "link_module", diff --git a/apiserver/plane/bgtasks/issue_activites_task.py b/apiserver/plane/bgtasks/issue_activites_task.py index 2d55d5579..007b3e48c 100644 --- a/apiserver/plane/bgtasks/issue_activites_task.py +++ b/apiserver/plane/bgtasks/issue_activites_task.py @@ -31,6 +31,7 @@ from plane.db.models import ( ) from plane.settings.redis import redis_instance from plane.utils.exception_logger import log_exception +from plane.bgtasks.webhook_task import webhook_activity # Track Changes in name @@ -1296,7 +1297,7 @@ def create_issue_vote_activity( IssueActivity( issue_id=issue_id, actor_id=actor_id, - verb="created", + verb="updated", old_value=None, new_value=requested_data.get("vote"), field="vote", @@ -1365,7 +1366,7 @@ def create_issue_relation_activity( IssueActivity( issue_id=issue_id, actor_id=actor_id, - verb="created", + verb="updated", old_value="", new_value=f"{issue.project.identifier}-{issue.sequence_id}", field=requested_data.get("relation_type"), @@ -1380,7 +1381,7 @@ def create_issue_relation_activity( IssueActivity( issue_id=related_issue, actor_id=actor_id, - verb="created", + verb="updated", old_value="", new_value=f"{issue.project.identifier}-{issue.sequence_id}", field=( @@ -1606,6 +1607,7 @@ def issue_activity( subscriber=True, notification=False, origin=None, + inbox=None, ): try: issue_activities = [] @@ -1692,6 +1694,41 @@ def issue_activity( except Exception as e: log_exception(e) + for activity in issue_activities_created: + webhook_activity.delay( + event=( + "issue_comment" + if activity.field == "comment" + else "inbox_issue" if inbox else "issue" + ), + event_id=( + activity.issue_comment_id + if activity.field == "comment" + else inbox if inbox else activity.issue_id + ), + verb=activity.verb, + field=( + "description" + if activity.field == "comment" + else activity.field + ), + old_value=( + activity.old_value + if activity.old_value != "" + else None + ), + new_value=( + activity.new_value + if activity.new_value != "" + else None + ), + actor_id=activity.actor_id, + current_site=origin, + slug=activity.workspace.slug, + old_identifier=activity.old_identifier, + new_identifier=activity.new_identifier, + ) + if notification: notifications.delay( type=type, diff --git a/apiserver/plane/bgtasks/webhook_task.py b/apiserver/plane/bgtasks/webhook_task.py index 5ee0244c7..d1e1cb34c 100644 --- a/apiserver/plane/bgtasks/webhook_task.py +++ b/apiserver/plane/bgtasks/webhook_task.py @@ -25,6 +25,8 @@ from plane.api.serializers import ( ModuleIssueSerializer, ModuleSerializer, ProjectSerializer, + UserLiteSerializer, + InboxIssueSerializer, ) from plane.db.models import ( Cycle, @@ -37,6 +39,7 @@ from plane.db.models import ( User, Webhook, WebhookLog, + InboxIssue, ) from plane.license.utils.instance_value import get_email_configuration from plane.utils.exception_logger import log_exception @@ -49,6 +52,8 @@ SERIALIZER_MAPPER = { "cycle_issue": CycleIssueSerializer, "module_issue": ModuleIssueSerializer, "issue_comment": IssueCommentSerializer, + "user": UserLiteSerializer, + "inbox_issue": InboxIssueSerializer, } MODEL_MAPPER = { @@ -59,6 +64,8 @@ MODEL_MAPPER = { "cycle_issue": CycleIssue, "module_issue": ModuleIssue, "issue_comment": IssueComment, + "user": User, + "inbox_issue": InboxIssue, } @@ -179,64 +186,6 @@ def webhook_task(self, webhook, slug, event, event_data, action, current_site): return -@shared_task() -def send_webhook(event, payload, kw, action, slug, bulk, current_site): - try: - webhooks = Webhook.objects.filter(workspace__slug=slug, is_active=True) - - if event == "project": - webhooks = webhooks.filter(project=True) - - if event == "issue": - webhooks = webhooks.filter(issue=True) - - if event == "module" or event == "module_issue": - webhooks = webhooks.filter(module=True) - - if event == "cycle" or event == "cycle_issue": - webhooks = webhooks.filter(cycle=True) - - if event == "issue_comment": - webhooks = webhooks.filter(issue_comment=True) - - if webhooks: - if action in ["POST", "PATCH"]: - if bulk and event in ["cycle_issue", "module_issue"]: - return - else: - event_data = [ - get_model_data( - event=event, - event_id=( - payload.get("id") - if isinstance(payload, dict) - else kw.get("pk") - ), - many=False, - ) - ] - - if action == "DELETE": - event_data = [{"id": kw.get("pk")}] - - for webhook in webhooks: - for data in event_data: - webhook_task.delay( - webhook=webhook.id, - slug=slug, - event=event, - event_data=data, - action=action, - current_site=current_site, - ) - - except Exception as e: - if settings.DEBUG: - print(e) - log_exception(e) - return - - @shared_task def send_webhook_deactivation_email( webhook_id, receiver_id, current_site, reason @@ -294,3 +243,240 @@ def send_webhook_deactivation_email( except Exception as e: log_exception(e) return + + +@shared_task( + bind=True, + autoretry_for=(requests.RequestException,), + retry_backoff=600, + max_retries=5, + retry_jitter=True, +) +def webhook_send_task( + self, + webhook, + slug, + event, + event_data, + action, + current_site, + activity, +): + try: + webhook = Webhook.objects.get(id=webhook, workspace__slug=slug) + + headers = { + "Content-Type": "application/json", + "User-Agent": "Autopilot", + "X-Plane-Delivery": str(uuid.uuid4()), + "X-Plane-Event": event, + } + + # # Your secret key + event_data = ( + json.loads(json.dumps(event_data, cls=DjangoJSONEncoder)) + if event_data is not None + else None + ) + + activity = ( + json.loads(json.dumps(activity, cls=DjangoJSONEncoder)) + if activity is not None + else None + ) + + action = { + "POST": "create", + "PATCH": "update", + "PUT": "update", + "DELETE": "delete", + }.get(action, action) + + payload = { + "event": event, + "action": action, + "webhook_id": str(webhook.id), + "workspace_id": str(webhook.workspace_id), + "data": event_data, + "activity": activity, + } + + # Use HMAC for generating signature + if webhook.secret_key: + hmac_signature = hmac.new( + webhook.secret_key.encode("utf-8"), + json.dumps(payload).encode("utf-8"), + hashlib.sha256, + ) + signature = hmac_signature.hexdigest() + headers["X-Plane-Signature"] = signature + + # Send the webhook event + response = requests.post( + webhook.url, + headers=headers, + json=payload, + timeout=30, + ) + + # Log the webhook request + WebhookLog.objects.create( + workspace_id=str(webhook.workspace_id), + webhook_id=str(webhook.id), + event_type=str(event), + request_method=str(action), + request_headers=str(headers), + request_body=str(payload), + response_status=str(response.status_code), + response_headers=str(response.headers), + response_body=str(response.text), + retry_count=str(self.request.retries), + ) + + except requests.RequestException as e: + # Log the failed webhook request + WebhookLog.objects.create( + workspace_id=str(webhook.workspace_id), + webhook_id=str(webhook.id), + event_type=str(event), + request_method=str(action), + request_headers=str(headers), + request_body=str(payload), + response_status=500, + response_headers="", + response_body=str(e), + retry_count=str(self.request.retries), + ) + # Retry logic + if self.request.retries >= self.max_retries: + Webhook.objects.filter(pk=webhook.id).update(is_active=False) + if webhook: + # send email for the deactivation of the webhook + send_webhook_deactivation_email( + webhook_id=webhook.id, + receiver_id=webhook.created_by_id, + reason=str(e), + current_site=current_site, + ) + return + raise requests.RequestException() + + except Exception as e: + if settings.DEBUG: + print(e) + log_exception(e) + return + + +@shared_task +def webhook_activity( + event, + verb, + field, + old_value, + new_value, + actor_id, + slug, + current_site, + event_id, + old_identifier, + new_identifier, +): + try: + webhooks = Webhook.objects.filter(workspace__slug=slug, is_active=True) + + if event == "project": + webhooks = webhooks.filter(project=True) + + if event == "issue": + webhooks = webhooks.filter(issue=True) + + if event == "module" or event == "module_issue": + webhooks = webhooks.filter(module=True) + + if event == "cycle" or event == "cycle_issue": + webhooks = webhooks.filter(cycle=True) + + if event == "issue_comment": + webhooks = webhooks.filter(issue_comment=True) + + for webhook in webhooks: + webhook_send_task.delay( + webhook=webhook.id, + slug=slug, + event=event, + event_data=get_model_data( + event=event, + event_id=event_id, + ), + action=verb, + current_site=current_site, + activity={ + "field": field, + "new_value": new_value, + "old_value": old_value, + "actor": get_model_data(event="user", event_id=actor_id), + "old_identifier": old_identifier, + "new_identifier": new_identifier, + }, + ) + return + except Exception as e: + if settings.DEBUG: + print(e) + log_exception(e) + return + + +@shared_task +def model_activity( + model_name, + model_id, + requested_data, + current_instance, + actor_id, + slug, + origin=None, +): + """Function takes in two json and computes differences between keys of both the json""" + if current_instance is None: + webhook_activity.delay( + event=model_name, + verb="created", + field=None, + old_value=None, + new_value=None, + actor_id=actor_id, + slug=slug, + current_site=origin, + event_id=model_id, + old_identifier=None, + new_identifier=None, + ) + return + + # Load the current instance + current_instance = ( + json.loads(current_instance) if current_instance is not None else None + ) + + # Loop through all keys in requested data and check the current value and requested value + for key in requested_data: + current_value = current_instance.get(key, None) + requested_value = requested_data.get(key, None) + if current_value != requested_value: + webhook_activity.delay( + event=model_name, + verb="updated", + field=key, + old_value=current_value, + new_value=requested_value, + actor_id=actor_id, + slug=slug, + current_site=origin, + event_id=model_id, + old_identifier=None, + new_identifier=None, + ) + + return diff --git a/apiserver/plane/license/management/commands/register_instance.py b/apiserver/plane/license/management/commands/register_instance.py index 32a37879f..b5cc8a60d 100644 --- a/apiserver/plane/license/management/commands/register_instance.py +++ b/apiserver/plane/license/management/commands/register_instance.py @@ -46,7 +46,7 @@ class Command(BaseCommand): } instance = Instance.objects.create( - instance_name="Plane Free", + instance_name="Plane Community Edition", instance_id=secrets.token_hex(12), license_key=None, api_key=secrets.token_hex(8), diff --git a/apiserver/plane/utils/user_timezone_converter.py b/apiserver/plane/utils/user_timezone_converter.py new file mode 100644 index 000000000..579b96c26 --- /dev/null +++ b/apiserver/plane/utils/user_timezone_converter.py @@ -0,0 +1,25 @@ +import pytz + +def user_timezone_converter(queryset, datetime_fields, user_timezone): + # Create a timezone object for the user's timezone + user_tz = pytz.timezone(user_timezone) + + # Check if queryset is a dictionary (single item) or a list of dictionaries + if isinstance(queryset, dict): + queryset_values = [queryset] + else: + queryset_values = list(queryset.values()) + + # Iterate over the dictionaries in the list + for item in queryset_values: + # Iterate over the datetime fields + for field in datetime_fields: + # Convert the datetime field to the user's timezone + if item[field]: + item[field] = item[field].astimezone(user_tz) + + # If queryset was a single item, return a single item + if isinstance(queryset, dict): + return queryset_values[0] + else: + return queryset_values diff --git a/package.json b/package.json index af08af608..0c846ea0d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "repository": "https://github.com/makeplane/plane.git", - "version": "0.18.0", + "version": "0.19.0", "license": "AGPL-3.0", "private": true, "workspaces": [ diff --git a/packages/editor/core/package.json b/packages/editor/core/package.json index 760f1d372..6f2744dca 100644 --- a/packages/editor/core/package.json +++ b/packages/editor/core/package.json @@ -1,6 +1,6 @@ { "name": "@plane/editor-core", - "version": "0.18.0", + "version": "0.19.0", "description": "Core Editor that powers Plane", "private": true, "main": "./dist/index.mjs", diff --git a/packages/editor/core/src/styles/editor.css b/packages/editor/core/src/styles/editor.css index d602a1ddf..ae0155eec 100644 --- a/packages/editor/core/src/styles/editor.css +++ b/packages/editor/core/src/styles/editor.css @@ -1,3 +1,14 @@ +.ProseMirror { + --font-size-h1: 1.5rem; + --font-size-h2: 1.3125rem; + --font-size-h3: 1.125rem; + --font-size-h4: 0.9375rem; + --font-size-h5: 0.8125rem; + --font-size-h6: 0.75rem; + --font-size-regular: 0.9375rem; + --font-size-list: var(--font-size-regular); +} + .ProseMirror p.is-editor-empty:first-child::before { content: attr(data-placeholder); float: left; @@ -56,7 +67,7 @@ /* to-do list */ ul[data-type="taskList"] li { - font-size: 1rem; + font-size: var(--font-size-list); line-height: 1.5; } @@ -162,7 +173,7 @@ ul[data-type="taskList"] li[data-checked="true"] > div > p { cursor: text; line-height: 1.2; font-family: inherit; - font-size: 14px; + font-size: var(--font-size-regular); color: inherit; -moz-box-sizing: border-box; box-sizing: border-box; @@ -310,15 +321,15 @@ ul[data-type="taskList"] ul[data-type="taskList"] { .prose :where(h1):not(:where([class~="not-prose"], [class~="not-prose"] *)) { margin-top: 2rem; margin-bottom: 4px; - font-size: 1.875rem; - font-weight: 700; + font-size: var(--font-size-h1); + font-weight: 600; line-height: 1.3; } .prose :where(h2):not(:where([class~="not-prose"], [class~="not-prose"] *)) { margin-top: 1.4rem; margin-bottom: 1px; - font-size: 1.5rem; + font-size: var(--font-size-h2); font-weight: 600; line-height: 1.3; } @@ -326,21 +337,23 @@ ul[data-type="taskList"] ul[data-type="taskList"] { .prose :where(h3):not(:where([class~="not-prose"], [class~="not-prose"] *)) { margin-top: 1rem; margin-bottom: 1px; - font-size: 1.25rem; + font-size: var(--font-size-h3); + font-weight: 600; line-height: 1.3; } .prose :where(h4):not(:where([class~="not-prose"], [class~="not-prose"] *)) { margin-top: 1rem; margin-bottom: 1px; - font-size: 1rem; + font-size: var(--font-size-h4); + font-weight: 600; line-height: 1.5; } .prose :where(h5):not(:where([class~="not-prose"], [class~="not-prose"] *)) { margin-top: 1rem; margin-bottom: 1px; - font-size: 0.9rem; + font-size: var(--font-size-h5); font-weight: 600; line-height: 1.5; } @@ -348,7 +361,7 @@ ul[data-type="taskList"] ul[data-type="taskList"] { .prose :where(h6):not(:where([class~="not-prose"], [class~="not-prose"] *)) { margin-top: 1rem; margin-bottom: 1px; - font-size: 0.83rem; + font-size: var(--font-size-h6); font-weight: 600; line-height: 1.5; } @@ -356,14 +369,14 @@ ul[data-type="taskList"] ul[data-type="taskList"] { .prose :where(p):not(:where([class~="not-prose"], [class~="not-prose"] *)) { margin-top: 0.25rem; margin-bottom: 1px; - padding: 3px 2px; - font-size: 1rem; + padding: 3px 0; + font-size: var(--font-size-regular); line-height: 1.5; } .prose :where(ol):not(:where([class~="not-prose"], [class~="not-prose"] *)) li p, .prose :where(ul):not(:where([class~="not-prose"], [class~="not-prose"] *)) li p { - font-size: 1rem; + font-size: var(--font-size-list); line-height: 1.5; } diff --git a/packages/editor/document-editor/package.json b/packages/editor/document-editor/package.json index 3cde34825..c69d0c2c8 100644 --- a/packages/editor/document-editor/package.json +++ b/packages/editor/document-editor/package.json @@ -1,6 +1,6 @@ { "name": "@plane/document-editor", - "version": "0.18.0", + "version": "0.19.0", "description": "Package that powers Plane's Pages Editor", "main": "./dist/index.mjs", "module": "./dist/index.mjs", diff --git a/packages/editor/extensions/package.json b/packages/editor/extensions/package.json index 7dee7fb14..dfab06cde 100644 --- a/packages/editor/extensions/package.json +++ b/packages/editor/extensions/package.json @@ -1,6 +1,6 @@ { "name": "@plane/editor-extensions", - "version": "0.18.0", + "version": "0.19.0", "description": "Package that powers Plane's Editor with extensions", "private": true, "main": "./dist/index.mjs", diff --git a/packages/editor/lite-text-editor/package.json b/packages/editor/lite-text-editor/package.json index e7a500eb3..d0868e239 100644 --- a/packages/editor/lite-text-editor/package.json +++ b/packages/editor/lite-text-editor/package.json @@ -1,6 +1,6 @@ { "name": "@plane/lite-text-editor", - "version": "0.18.0", + "version": "0.19.0", "description": "Package that powers Plane's Comment Editor", "private": true, "main": "./dist/index.mjs", diff --git a/packages/editor/lite-text-editor/src/ui/index.tsx b/packages/editor/lite-text-editor/src/ui/index.tsx index fe9453110..6b22809d6 100644 --- a/packages/editor/lite-text-editor/src/ui/index.tsx +++ b/packages/editor/lite-text-editor/src/ui/index.tsx @@ -33,6 +33,7 @@ export interface ILiteTextEditor { }; tabIndex?: number; placeholder?: string | ((isFocused: boolean, value: string) => string); + id?: string; } const LiteTextEditor = (props: ILiteTextEditor) => { @@ -48,12 +49,14 @@ const LiteTextEditor = (props: ILiteTextEditor) => { tabIndex, mentionHandler, placeholder = "Add comment...", + id = "", } = props; const editor = useEditor({ onChange, initialValue, value, + id, editorClassName, restoreFile: fileHandler.restore, uploadFile: fileHandler.upload, diff --git a/packages/editor/rich-text-editor/package.json b/packages/editor/rich-text-editor/package.json index 0561dfb7a..fe1f29e1d 100644 --- a/packages/editor/rich-text-editor/package.json +++ b/packages/editor/rich-text-editor/package.json @@ -1,6 +1,6 @@ { "name": "@plane/rich-text-editor", - "version": "0.18.0", + "version": "0.19.0", "description": "Rich Text Editor that powers Plane", "private": true, "main": "./dist/index.mjs", diff --git a/packages/eslint-config-custom/package.json b/packages/eslint-config-custom/package.json index b45b82e7e..4eae64880 100644 --- a/packages/eslint-config-custom/package.json +++ b/packages/eslint-config-custom/package.json @@ -1,7 +1,7 @@ { "name": "eslint-config-custom", "private": true, - "version": "0.18.0", + "version": "0.19.0", "main": "index.js", "license": "MIT", "devDependencies": {}, diff --git a/packages/tailwind-config-custom/package.json b/packages/tailwind-config-custom/package.json index afc6db09f..076a9d352 100644 --- a/packages/tailwind-config-custom/package.json +++ b/packages/tailwind-config-custom/package.json @@ -1,6 +1,6 @@ { "name": "tailwind-config-custom", - "version": "0.18.0", + "version": "0.19.0", "description": "common tailwind configuration across monorepo", "main": "index.js", "private": true, diff --git a/packages/tsconfig/package.json b/packages/tsconfig/package.json index 0aadcc6d0..ef5580bff 100644 --- a/packages/tsconfig/package.json +++ b/packages/tsconfig/package.json @@ -1,6 +1,6 @@ { "name": "tsconfig", - "version": "0.18.0", + "version": "0.19.0", "private": true, "files": [ "base.json", diff --git a/packages/types/package.json b/packages/types/package.json index 48f356f42..68c0b86d9 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -1,6 +1,6 @@ { "name": "@plane/types", - "version": "0.18.0", + "version": "0.19.0", "private": true, "main": "./src/index.d.ts" } diff --git a/packages/types/src/inbox.d.ts b/packages/types/src/inbox.d.ts index 01a1dfce3..0f0b93e4f 100644 --- a/packages/types/src/inbox.d.ts +++ b/packages/types/src/inbox.d.ts @@ -20,7 +20,7 @@ export type TInboxIssueCurrentTab = EInboxIssueCurrentTab; export type TInboxIssueStatus = EInboxIssueStatus; // filters -export type TInboxIssueFilterMemberKeys = "assignee" | "created_by"; +export type TInboxIssueFilterMemberKeys = "assignees" | "created_by"; export type TInboxIssueFilterDateKeys = "created_at" | "updated_at"; diff --git a/packages/types/src/pages.d.ts b/packages/types/src/pages.d.ts index a1df4527e..4871ddc06 100644 --- a/packages/types/src/pages.d.ts +++ b/packages/types/src/pages.d.ts @@ -16,14 +16,9 @@ export type TPage = { project: string | undefined; updated_at: Date | undefined; updated_by: string | undefined; - view_props: TPageViewProps | undefined; workspace: string | undefined; }; -export type TPageViewProps = { - full_width?: boolean; -}; - // page filters export type TPageNavigationTabs = "public" | "private" | "archived"; diff --git a/packages/ui/package.json b/packages/ui/package.json index d627c94ba..1ffa822e2 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -2,7 +2,7 @@ "name": "@plane/ui", "description": "UI components shared across multiple apps internally", "private": true, - "version": "0.18.0", + "version": "0.19.0", "main": "./dist/index.js", "module": "./dist/index.mjs", "types": "./dist/index.d.ts", diff --git a/space/components/editor/rich-text-read-only-editor.tsx b/space/components/editor/rich-text-read-only-editor.tsx index 562e63581..56694e91f 100644 --- a/space/components/editor/rich-text-read-only-editor.tsx +++ b/space/components/editor/rich-text-read-only-editor.tsx @@ -18,7 +18,7 @@ export const RichTextReadOnlyEditor = React.forwardRef ); } diff --git a/space/package.json b/space/package.json index 7dabf45c9..cb7e3f23e 100644 --- a/space/package.json +++ b/space/package.json @@ -1,6 +1,6 @@ { "name": "space", - "version": "0.18.0", + "version": "0.19.0", "private": true, "scripts": { "dev": "turbo run develop", diff --git a/web/components/core/modals/existing-issues-list-modal.tsx b/web/components/core/modals/existing-issues-list-modal.tsx index 757888554..93e2c2ea7 100644 --- a/web/components/core/modals/existing-issues-list-modal.tsx +++ b/web/components/core/modals/existing-issues-list-modal.tsx @@ -85,7 +85,7 @@ export const ExistingIssuesListModal: React.FC = (props) => { setIsSearching(false); setIsLoading(false); }); - }, [debouncedSearchTerm, isOpen, isWorkspaceLevel, projectId, searchParams, workspaceSlug]); + }, [debouncedSearchTerm, isOpen, isWorkspaceLevel, projectId, workspaceSlug]); return ( <> diff --git a/web/components/cycles/quick-actions.tsx b/web/components/cycles/quick-actions.tsx index 194bdd068..9c130ef7a 100644 --- a/web/components/cycles/quick-actions.tsx +++ b/web/components/cycles/quick-actions.tsx @@ -180,6 +180,7 @@ export const CycleQuickActions: React.FC = observer((props) => { }, item.className )} + disabled={item.disabled} > {item.icon && }
diff --git a/web/components/dashboard/widgets/recent-collaborators/collaborators-list.tsx b/web/components/dashboard/widgets/recent-collaborators/collaborators-list.tsx index af9b795af..270cd8b77 100644 --- a/web/components/dashboard/widgets/recent-collaborators/collaborators-list.tsx +++ b/web/components/dashboard/widgets/recent-collaborators/collaborators-list.tsx @@ -33,7 +33,7 @@ const CollaboratorListItem: React.FC = observer((prop
); }); diff --git a/web/components/editor/rich-text-editor/rich-text-read-only-editor.tsx b/web/components/editor/rich-text-editor/rich-text-read-only-editor.tsx index 1415bc098..4aead5469 100644 --- a/web/components/editor/rich-text-editor/rich-text-read-only-editor.tsx +++ b/web/components/editor/rich-text-editor/rich-text-read-only-editor.tsx @@ -20,7 +20,7 @@ export const RichTextReadOnlyEditor = React.forwardRef ); } diff --git a/web/components/gantt-chart/chart/root.tsx b/web/components/gantt-chart/chart/root.tsx index 098ee76ed..a4ea8cbf2 100644 --- a/web/components/gantt-chart/chart/root.tsx +++ b/web/components/gantt-chart/chart/root.tsx @@ -161,7 +161,7 @@ export const ChartViewRoot: FC = observer((props) => { return (
diff --git a/web/components/headers/cycle-issues.tsx b/web/components/headers/cycle-issues.tsx index 2f0e0f766..5263f12cb 100644 --- a/web/components/headers/cycle-issues.tsx +++ b/web/components/headers/cycle-issues.tsx @@ -145,8 +145,8 @@ export const CycleIssuesHeader: React.FC = observer(() => { currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole); const issueCount = cycleDetails - ? issueFilters?.displayFilters?.sub_issue && cycleDetails?.sub_issues - ? cycleDetails.total_issues + cycleDetails?.sub_issues + ? !issueFilters?.displayFilters?.sub_issue && cycleDetails?.sub_issues + ? cycleDetails.total_issues - cycleDetails?.sub_issues : cycleDetails.total_issues : undefined; @@ -225,9 +225,7 @@ export const CycleIssuesHeader: React.FC = observer(() => { className="ml-1.5 flex-shrink-0 truncate" placement="bottom-start" > - {currentProjectCycleIds?.map((cycleId) => ( - - ))} + {currentProjectCycleIds?.map((cycleId) => )} } /> diff --git a/web/components/headers/module-issues.tsx b/web/components/headers/module-issues.tsx index d55657fdd..6f1478ca1 100644 --- a/web/components/headers/module-issues.tsx +++ b/web/components/headers/module-issues.tsx @@ -144,8 +144,8 @@ export const ModuleIssuesHeader: React.FC = observer(() => { currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole); const issueCount = moduleDetails - ? issueFilters?.displayFilters?.sub_issue && moduleDetails.sub_issues - ? moduleDetails.total_issues + moduleDetails.sub_issues + ? !issueFilters?.displayFilters?.sub_issue && moduleDetails.sub_issues + ? moduleDetails.total_issues - moduleDetails.sub_issues : moduleDetails.total_issues : undefined; @@ -224,9 +224,7 @@ export const ModuleIssuesHeader: React.FC = observer(() => { className="ml-1.5 flex-shrink-0" placement="bottom-start" > - {projectModuleIds?.map((moduleId) => ( - - ))} + {projectModuleIds?.map((moduleId) => )} } /> diff --git a/web/components/headers/project-archives.tsx b/web/components/headers/project-archives.tsx index 5cee12bdf..6e5638c71 100644 --- a/web/components/headers/project-archives.tsx +++ b/web/components/headers/project-archives.tsx @@ -27,8 +27,8 @@ export const ProjectArchivesHeader: FC = observer(() => { const { isMobile } = usePlatformOS(); const issueCount = currentProjectDetails - ? issueFilters?.displayFilters?.sub_issue - ? currentProjectDetails.archived_issues + currentProjectDetails.archived_sub_issues + ? !issueFilters?.displayFilters?.sub_issue && currentProjectDetails.archived_sub_issues + ? currentProjectDetails.archived_issues - currentProjectDetails.archived_sub_issues : currentProjectDetails.archived_issues : undefined; diff --git a/web/components/headers/project-draft-issues.tsx b/web/components/headers/project-draft-issues.tsx index 2f80c2182..fdad067a3 100644 --- a/web/components/headers/project-draft-issues.tsx +++ b/web/components/headers/project-draft-issues.tsx @@ -78,8 +78,8 @@ export const ProjectDraftIssueHeader: FC = observer(() => { ); const issueCount = currentProjectDetails - ? issueFilters?.displayFilters?.sub_issue - ? currentProjectDetails.draft_issues + currentProjectDetails.draft_sub_issues + ? !issueFilters?.displayFilters?.sub_issue && currentProjectDetails.draft_sub_issues + ? currentProjectDetails.draft_issues - currentProjectDetails.draft_sub_issues : currentProjectDetails.draft_issues : undefined; diff --git a/web/components/headers/project-issues.tsx b/web/components/headers/project-issues.tsx index 9363448b3..7466f2ba1 100644 --- a/web/components/headers/project-issues.tsx +++ b/web/components/headers/project-issues.tsx @@ -102,8 +102,8 @@ export const ProjectIssuesHeader: React.FC = observer(() => { currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole); const issueCount = currentProjectDetails - ? issueFilters?.displayFilters?.sub_issue - ? currentProjectDetails?.total_issues + currentProjectDetails?.sub_issues + ? !issueFilters?.displayFilters?.sub_issue && currentProjectDetails?.sub_issues + ? currentProjectDetails?.total_issues - currentProjectDetails?.sub_issues : currentProjectDetails?.total_issues : undefined; diff --git a/web/components/inbox/content/inbox-issue-header.tsx b/web/components/inbox/content/inbox-issue-header.tsx index ebe9898c0..7fd038faa 100644 --- a/web/components/inbox/content/inbox-issue-header.tsx +++ b/web/components/inbox/content/inbox-issue-header.tsx @@ -293,32 +293,36 @@ export const InboxIssueActionsHeader: FC = observer((p
) : ( - - {canMarkAsAccepted && ( - setIsSnoozeDateModalOpen(true)}> -
- - Snooze -
-
+ <> + {isAllowed && ( + + {canMarkAsAccepted && ( + setIsSnoozeDateModalOpen(true)}> +
+ + Snooze +
+
+ )} + {canMarkAsDuplicate && ( + setSelectDuplicateIssue(true)}> +
+ + Mark as duplicate +
+
+ )} + {canDelete && ( + setDeleteIssueModal(true)}> +
+ + Delete +
+
+ )} +
)} - {canMarkAsDuplicate && ( - setSelectDuplicateIssue(true)}> -
- - Mark as duplicate -
-
- )} - {canDelete && ( - setDeleteIssueModal(true)}> -
- - Delete -
-
- )} -
+ )}
diff --git a/web/components/inbox/content/issue-properties.tsx b/web/components/inbox/content/issue-properties.tsx index ae1fab3ed..9074f67ca 100644 --- a/web/components/inbox/content/issue-properties.tsx +++ b/web/components/inbox/content/issue-properties.tsx @@ -31,9 +31,10 @@ export const InboxIssueContentProperties: React.FC = observer((props) => const minDate = issue.start_date ? getDate(issue.start_date) : null; minDate?.setDate(minDate.getDate()); if (!issue || !issue?.id) return <>; + return (
-
+
Properties
diff --git a/web/components/inbox/content/issue-root.tsx b/web/components/inbox/content/issue-root.tsx index ce1625141..3d078bb2f 100644 --- a/web/components/inbox/content/issue-root.tsx +++ b/web/components/inbox/content/issue-root.tsx @@ -114,7 +114,7 @@ export const InboxIssueMainContent: React.FC = observer((props) => { return ( <> -
+
= observer((props) => { issueOperations={issueOperations} disabled={!isEditable} value={issue.name} + containerClassName="-ml-3" /> {loader === "issue-loading" ? ( @@ -135,11 +136,12 @@ export const InboxIssueMainContent: React.FC = observer((props) => { workspaceSlug={workspaceSlug} projectId={issue.project_id} issueId={issue.id} - swrIssueDescription={null} + swrIssueDescription={issue.description_html ?? "

"} initialValue={issue.description_html ?? "

"} disabled={!isEditable} issueOperations={issueOperations} setIsSubmitting={(value) => setIsSubmitting(value)} + containerClassName="-ml-3 !mb-6 border-none" /> )} @@ -152,12 +154,15 @@ export const InboxIssueMainContent: React.FC = observer((props) => { /> )}
- + +
+ +
= observer((props) => { duplicateIssueDetails={inboxIssue?.duplicate_issue_detail} /> -
+
diff --git a/web/components/inbox/content/root.tsx b/web/components/inbox/content/root.tsx index 719b1c19f..7874b74a5 100644 --- a/web/components/inbox/content/root.tsx +++ b/web/components/inbox/content/root.tsx @@ -52,7 +52,7 @@ export const InboxContentRoot: FC = observer((props) => { isSubmitting={isSubmitting} />
-
+
if (!optionDetail) return <>; return (
-
- +
+
{optionDetail?.display_name}
{ {/* priority */} {/* assignees */} - + {/* created_by */} {/* label */} diff --git a/web/components/inbox/inbox-filter/filters/filter-selection.tsx b/web/components/inbox/inbox-filter/filters/filter-selection.tsx index fd1dc951c..1d7ddd0ed 100644 --- a/web/components/inbox/inbox-filter/filters/filter-selection.tsx +++ b/web/components/inbox/inbox-filter/filters/filter-selection.tsx @@ -60,8 +60,8 @@ export const InboxIssueFilterSelection: FC = observer(() => { {/* assignees */}
diff --git a/web/components/inbox/modals/create-edit-modal/create-root.tsx b/web/components/inbox/modals/create-edit-modal/create-root.tsx index 6c5ee3ce2..010884451 100644 --- a/web/components/inbox/modals/create-edit-modal/create-root.tsx +++ b/web/components/inbox/modals/create-edit-modal/create-root.tsx @@ -133,6 +133,7 @@ export const InboxIssueCreateRoot: FC = observer((props) data={formData} handleData={handleFormData} editorRef={descriptionEditorRef} + containerClassName="border-[0.5px] border-custom-border-200 py-3 min-h-[150px]" />
diff --git a/web/components/inbox/modals/create-edit-modal/edit-root.tsx b/web/components/inbox/modals/create-edit-modal/edit-root.tsx index 6ffb6efee..90bb0e258 100644 --- a/web/components/inbox/modals/create-edit-modal/edit-root.tsx +++ b/web/components/inbox/modals/create-edit-modal/edit-root.tsx @@ -138,6 +138,7 @@ export const InboxIssueEditRoot: FC = observer((props) => { data={formData} handleData={handleFormData} editorRef={descriptionEditorRef} + containerClassName="border-[0.5px] border-custom-border-200 py-3 min-h-[150px]" />
diff --git a/web/components/inbox/modals/create-edit-modal/issue-description.tsx b/web/components/inbox/modals/create-edit-modal/issue-description.tsx index 74c62f97c..2c9a9cfd2 100644 --- a/web/components/inbox/modals/create-edit-modal/issue-description.tsx +++ b/web/components/inbox/modals/create-edit-modal/issue-description.tsx @@ -11,6 +11,7 @@ import { getDescriptionPlaceholder } from "@/helpers/issue.helper"; import { useProjectInbox } from "@/hooks/store"; type TInboxIssueDescription = { + containerClassName?: string; workspaceSlug: string; projectId: string; workspaceId: string; @@ -21,7 +22,7 @@ type TInboxIssueDescription = { // TODO: have to implement GPT Assistance export const InboxIssueDescription: FC = observer((props) => { - const { workspaceSlug, projectId, workspaceId, data, handleData, editorRef } = props; + const { containerClassName, workspaceSlug, projectId, workspaceId, data, handleData, editorRef } = props; // hooks const { loader } = useProjectInbox(); @@ -42,6 +43,7 @@ export const InboxIssueDescription: FC = observer((props dragDropEnabled={false} onChange={(_description: object, description_html: string) => handleData("description_html", description_html)} placeholder={getDescriptionPlaceholder} + containerClassName={containerClassName} />
); diff --git a/web/components/inbox/modals/create-issue-modal.tsx b/web/components/inbox/modals/create-issue-modal.tsx deleted file mode 100644 index c2c162c88..000000000 --- a/web/components/inbox/modals/create-issue-modal.tsx +++ /dev/null @@ -1,326 +0,0 @@ -import { Fragment, useRef, useState } from "react"; -import { observer } from "mobx-react"; -import { useRouter } from "next/router"; -import { Controller, useForm } from "react-hook-form"; -// icons -import { Sparkle } from "lucide-react"; -import { Transition, Dialog } from "@headlessui/react"; -import { EditorRefApi } from "@plane/rich-text-editor"; -// types -import { TIssue } from "@plane/types"; -// ui -import { Button, Input, ToggleSwitch, TOAST_TYPE, setToast } from "@plane/ui"; -// components -import { GptAssistantPopover } from "@/components/core"; -import { PriorityDropdown } from "@/components/dropdowns"; -import { RichTextEditor } from "@/components/editor/rich-text-editor/rich-text-editor"; -import { ISSUE_CREATED } from "@/constants/event-tracker"; -// hooks -import { useEventTracker, useWorkspace, useInstance, useProjectInbox } from "@/hooks/store"; -// services -import { AIService } from "@/services/ai.service"; -// components -// ui -// types -// constants - -type Props = { - isOpen: boolean; - onClose: () => void; -}; - -const defaultValues: Partial = { - name: "", - description_html: "

", - priority: "none", -}; - -// services -const aiService = new AIService(); - -export const CreateInboxIssueModal: React.FC = observer((props) => { - const { isOpen, onClose } = props; - // router - const router = useRouter(); - const { workspaceSlug, projectId } = router.query; - if (!workspaceSlug || !projectId) return null; - // states - const [createMore, setCreateMore] = useState(false); - const [gptAssistantModal, setGptAssistantModal] = useState(false); - const [iAmFeelingLucky, setIAmFeelingLucky] = useState(false); - // refs - const editorRef = useRef(null); - // hooks - const workspaceStore = useWorkspace(); - const workspaceId = workspaceStore.getWorkspaceBySlug(workspaceSlug.toString() as string)?.id.toString() as string; - - // store hooks - const { createInboxIssue } = useProjectInbox(); - const { instance } = useInstance(); - const { captureIssueEvent } = useEventTracker(); - // form info - const { - control, - formState: { errors, isSubmitting }, - handleSubmit, - reset, - watch, - getValues, - } = useForm>({ defaultValues }); - const issueName = watch("name"); - - const handleClose = () => { - onClose(); - reset(defaultValues); - editorRef?.current?.clearEditor(); - }; - - const handleFormSubmit = async (formData: Partial) => { - if (!workspaceSlug || !projectId) return; - await createInboxIssue(workspaceSlug.toString(), projectId.toString(), formData) - .then((res) => { - if (!createMore) { - router.push(`/${workspaceSlug}/projects/${projectId}/inbox/?currentTab=open&inboxIssueId=${res?.issue?.id}`); - handleClose(); - } else { - reset(defaultValues); - editorRef?.current?.clearEditor(); - } - captureIssueEvent({ - eventName: ISSUE_CREATED, - payload: { - ...formData, - state: "SUCCESS", - element: "Inbox page", - }, - path: router.pathname, - }); - }) - .catch((error) => { - console.error(error); - captureIssueEvent({ - eventName: ISSUE_CREATED, - payload: { - ...formData, - state: "FAILED", - element: "Inbox page", - }, - path: router.pathname, - }); - }); - }; - - const handleAiAssistance = async (response: string) => { - if (!workspaceSlug || !projectId) return; - editorRef.current?.setEditorValueAtCursorPosition(response); - }; - - const handleAutoGenerateDescription = async () => { - const issueName = getValues("name"); - if (!workspaceSlug || !projectId || !issueName) return; - - setIAmFeelingLucky(true); - - aiService - .createGptTask(workspaceSlug as string, projectId as string, { - prompt: issueName, - task: "Generate a proper description for this issue.", - }) - .then((res) => { - if (res.response === "") - setToast({ - type: TOAST_TYPE.ERROR, - title: "Error!", - message: - "Issue title isn't informative enough to generate the description. Please try with a different title.", - }); - else handleAiAssistance(res.response_html); - }) - .catch((err) => { - const error = err?.data?.error; - - if (err.status === 429) - setToast({ - type: TOAST_TYPE.ERROR, - title: "Error!", - message: error || "You have reached the maximum number of requests of 50 requests per month per user.", - }); - else - setToast({ - type: TOAST_TYPE.ERROR, - title: "Error!", - message: error || "Some error occurred. Please try again.", - }); - }) - .finally(() => setIAmFeelingLucky(false)); - }; - - return ( - - - -
- - -
-
- - -
-
-

Create Inbox Issue

-
-
-
- ( - - )} - /> -
-
-
- {watch("name") && issueName !== "" && ( - - )} - - {instance?.config?.has_openai_configured && ( - { - setGptAssistantModal((prevData) => !prevData); - // this is done so that the title do not reset after gpt popover closed - reset(getValues()); - }} - onResponse={(response) => { - handleAiAssistance(response); - }} - button={ - - } - className="!min-w-[38rem]" - placement="top-end" - /> - )} -
- ( -

" : value} - ref={editorRef} - workspaceSlug={workspaceSlug.toString()} - workspaceId={workspaceId} - projectId={projectId.toString()} - dragDropEnabled={false} - onChange={(_description: object, description_html: string) => { - onChange(description_html); - }} - /> - )} - /> -
- -
- ( -
- -
- )} - /> -
-
-
-
-
-
setCreateMore((prevData) => !prevData)} - > - Create more - {}} size="md" /> -
-
- - -
-
-
-
-
-
-
-
-
- ); -}); diff --git a/web/components/issues/attachment/attachment-detail.tsx b/web/components/issues/attachment/attachment-detail.tsx index 617bc7e07..9e6a35f5f 100644 --- a/web/components/issues/attachment/attachment-detail.tsx +++ b/web/components/issues/attachment/attachment-detail.tsx @@ -2,19 +2,19 @@ import { FC } from "react"; import { observer } from "mobx-react"; import Link from "next/link"; import { AlertCircle, X } from "lucide-react"; +// ui import { Tooltip } from "@plane/ui"; -import { getFileIcon } from "@/components/icons/attachment"; +// icons +import { getFileIcon } from "@/components/icons"; +// components +import { IssueAttachmentDeleteModal } from "@/components/issues"; +// helpers import { convertBytesToSize, getFileExtension, getFileName } from "@/helpers/attachment.helper"; import { renderFormattedDate } from "@/helpers/date-time.helper"; import { truncateText } from "@/helpers/string.helper"; +// hooks import { useIssueDetail, useMember } from "@/hooks/store"; import { usePlatformOS } from "@/hooks/use-platform-os"; -// hooks -// ui -// components -// icons -// helper -import { IssueAttachmentDeleteModal } from "./delete-attachment-confirmation-modal"; // types import { TAttachmentOperations } from "./root"; @@ -36,24 +36,24 @@ export const IssueAttachmentsDetail: FC = observer((pro isDeleteAttachmentModalOpen, toggleDeleteAttachmentModal, } = useIssueDetail(); - // states + // derived values + const attachment = attachmentId ? getAttachmentById(attachmentId) : undefined; + // hooks const { isMobile } = usePlatformOS(); - const attachment = attachmentId && getAttachmentById(attachmentId); if (!attachment) return <>; + return ( <> - toggleDeleteAttachmentModal(false)} - handleAttachmentOperations={handleAttachmentOperations} - data={attachment} - /> - -
+ {isDeleteAttachmentModalOpen === attachment.id && ( + toggleDeleteAttachmentModal(null)} + handleAttachmentOperations={handleAttachmentOperations} + data={attachment} + /> + )} +
{getFileIcon(getFileExtension(attachment.asset))}
@@ -83,7 +83,7 @@ export const IssueAttachmentsDetail: FC = observer((pro {!disabled && ( - )} diff --git a/web/components/issues/attachment/attachment-upload.tsx b/web/components/issues/attachment/attachment-upload.tsx index 44c64dd01..cc77f9244 100644 --- a/web/components/issues/attachment/attachment-upload.tsx +++ b/web/components/issues/attachment/attachment-upload.tsx @@ -25,24 +25,28 @@ export const IssueAttachmentUpload: React.FC = observer((props) => { // states const [isLoading, setIsLoading] = useState(false); - const onDrop = useCallback((acceptedFiles: File[]) => { - const currentFile: File = acceptedFiles[0]; - if (!currentFile || !workspaceSlug) return; + const onDrop = useCallback( + (acceptedFiles: File[]) => { + const currentFile: File = acceptedFiles[0]; + if (!currentFile || !workspaceSlug) return; - const uploadedFile: File = new File([currentFile], generateFileName(currentFile.name), { type: currentFile.type }); - const formData = new FormData(); - formData.append("asset", uploadedFile); - formData.append( - "attributes", - JSON.stringify({ - name: uploadedFile.name, - size: uploadedFile.size, - }) - ); - setIsLoading(true); - handleAttachmentOperations.create(formData).finally(() => setIsLoading(false)); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + const uploadedFile: File = new File([currentFile], generateFileName(currentFile.name), { + type: currentFile.type, + }); + const formData = new FormData(); + formData.append("asset", uploadedFile); + formData.append( + "attributes", + JSON.stringify({ + name: uploadedFile.name, + size: uploadedFile.size, + }) + ); + setIsLoading(true); + handleAttachmentOperations.create(formData).finally(() => setIsLoading(false)); + }, + [handleAttachmentOperations, workspaceSlug] + ); const { getRootProps, getInputProps, isDragActive, isDragReject, fileRejections } = useDropzone({ onDrop, diff --git a/web/components/issues/attachment/delete-attachment-confirmation-modal.tsx b/web/components/issues/attachment/delete-attachment-modal.tsx similarity index 94% rename from web/components/issues/attachment/delete-attachment-confirmation-modal.tsx rename to web/components/issues/attachment/delete-attachment-modal.tsx index bd69c3cac..4f58674f1 100644 --- a/web/components/issues/attachment/delete-attachment-confirmation-modal.tsx +++ b/web/components/issues/attachment/delete-attachment-modal.tsx @@ -1,4 +1,4 @@ -import { FC, Fragment, Dispatch, SetStateAction, useState } from "react"; +import { FC, Fragment, useState } from "react"; import { AlertTriangle } from "lucide-react"; import { Dialog, Transition } from "@headlessui/react"; import type { TIssueAttachment } from "@plane/types"; @@ -14,18 +14,18 @@ export type TAttachmentOperationsRemoveModal = Exclude>; + onClose: () => void; data: TIssueAttachment; handleAttachmentOperations: TAttachmentOperationsRemoveModal; }; export const IssueAttachmentDeleteModal: FC = (props) => { - const { isOpen, setIsOpen, data, handleAttachmentOperations } = props; - // state + const { isOpen, onClose, data, handleAttachmentOperations } = props; + // states const [loader, setLoader] = useState(false); const handleClose = () => { - setIsOpen(false); + onClose(); setLoader(false); }; diff --git a/web/components/issues/attachment/index.ts b/web/components/issues/attachment/index.ts index d4385e7da..928cd4613 100644 --- a/web/components/issues/attachment/index.ts +++ b/web/components/issues/attachment/index.ts @@ -1,7 +1,5 @@ -export * from "./root"; - -export * from "./attachment-upload"; -export * from "./delete-attachment-confirmation-modal"; - -export * from "./attachments-list"; export * from "./attachment-detail"; +export * from "./attachment-upload"; +export * from "./attachments-list"; +export * from "./delete-attachment-modal"; +export * from "./root"; diff --git a/web/components/issues/attachment/root.tsx b/web/components/issues/attachment/root.tsx index c915ebce9..b9dd5e290 100644 --- a/web/components/issues/attachment/root.tsx +++ b/web/components/issues/attachment/root.tsx @@ -95,7 +95,7 @@ export const IssueAttachmentRoot: FC = (props) => { } }, }), - [workspaceSlug, projectId, issueId, createAttachment, removeAttachment] + [captureIssueEvent, workspaceSlug, projectId, issueId, createAttachment, removeAttachment] ); return ( diff --git a/web/components/issues/description-input.tsx b/web/components/issues/description-input.tsx index b8cfbd582..538f5444e 100644 --- a/web/components/issues/description-input.tsx +++ b/web/components/issues/description-input.tsx @@ -15,6 +15,7 @@ import { getDescriptionPlaceholder } from "@/helpers/issue.helper"; import { useWorkspace } from "@/hooks/store"; export type IssueDescriptionInputProps = { + containerClassName?: string; workspaceSlug: string; projectId: string; issueId: string; @@ -28,6 +29,7 @@ export type IssueDescriptionInputProps = { export const IssueDescriptionInput: FC = observer((props) => { const { + containerClassName, workspaceSlug, projectId, issueId, @@ -110,11 +112,12 @@ export const IssueDescriptionInput: FC = observer((p placeholder={ placeholder ? placeholder : (isFocused, value) => getDescriptionPlaceholder(isFocused, value) } + containerClassName={containerClassName} /> ) : ( ) } diff --git a/web/components/issues/issue-detail/issue-activity/comments/comment-create.tsx b/web/components/issues/issue-detail/issue-activity/comments/comment-create.tsx index 0263a37e1..ae6e75c79 100644 --- a/web/components/issues/issue-detail/issue-activity/comments/comment-create.tsx +++ b/web/components/issues/issue-detail/issue-activity/comments/comment-create.tsx @@ -18,10 +18,11 @@ type TIssueCommentCreate = { workspaceSlug: string; activityOperations: TActivityOperations; showAccessSpecifier?: boolean; + issueId: string; }; export const IssueCommentCreate: FC = (props) => { - const { workspaceSlug, projectId, activityOperations, showAccessSpecifier = false } = props; + const { workspaceSlug, projectId, issueId, activityOperations, showAccessSpecifier = false } = props; // refs const editorRef = useRef(null); // store hooks @@ -72,6 +73,8 @@ export const IssueCommentCreate: FC = (props) => { render={({ field: { value, onChange } }) => (

"} projectId={projectId} workspaceSlug={workspaceSlug} onEnterKeyPress={(e) => { diff --git a/web/components/issues/issue-detail/issue-activity/root.tsx b/web/components/issues/issue-detail/issue-activity/root.tsx index 982fe0952..d8de769c1 100644 --- a/web/components/issues/issue-detail/issue-activity/root.tsx +++ b/web/components/issues/issue-detail/issue-activity/root.tsx @@ -146,6 +146,7 @@ export const IssueActivity: FC = observer((props) => { /> {!disabled && ( = observer((props) => { /> {!disabled && ( = observer((props) => { return ( <> -
+
{issue.parent_id && ( = observer((props) => { issueOperations={issueOperations} disabled={!isEditable} value={issue.name} + containerClassName="-ml-3" /> {/* {issue?.description_html === issueDescription && ( */} @@ -97,6 +98,7 @@ export const IssueMainContent: React.FC = observer((props) => { disabled={!isEditable} issueOperations={issueOperations} setIsSubmitting={(value) => setIsSubmitting(value)} + containerClassName="-ml-3 !mb-6 border-none" /> {/* )} */} @@ -121,14 +123,18 @@ export const IssueMainContent: React.FC = observer((props) => { )}
- +
+ +
- +
+ +
); }); diff --git a/web/components/issues/issue-detail/parent-select.tsx b/web/components/issues/issue-detail/parent-select.tsx index 1b4724a16..6c28bb879 100644 --- a/web/components/issues/issue-detail/parent-select.tsx +++ b/web/components/issues/issue-detail/parent-select.tsx @@ -47,7 +47,7 @@ export const IssueParentSelect: React.FC = observer((props) try { await issueOperations.update(workspaceSlug, projectId, issueId, { parent_id: _issueId }); await issueOperations.fetch(workspaceSlug, projectId, issueId); - toggleParentIssueModal(false); + toggleParentIssueModal(issueId); } catch (error) { console.error("something went wrong while fetching the issue"); } @@ -79,8 +79,8 @@ export const IssueParentSelect: React.FC = observer((props) toggleParentIssueModal(false)} + isOpen={isParentIssueModalOpen === issueId} + handleClose={() => toggleParentIssueModal(null)} onChange={(issue: any) => handleParentIssue(issue?.id)} />