diff --git a/.github/workflows/Build_Test_Pull_Request.yml b/.github/workflows/Build_Test_Pull_Request.yml index 438bdbef3..6dc7ae1e5 100644 --- a/.github/workflows/Build_Test_Pull_Request.yml +++ b/.github/workflows/Build_Test_Pull_Request.yml @@ -33,14 +33,9 @@ jobs: deploy: - space/** - - name: Setup .npmrc for repository - run: | - echo -e "@tiptap-pro:registry=https://registry.tiptap.dev/\n//registry.tiptap.dev/:_authToken=${{ secrets.TIPTAP_TOKEN }}" > .npmrc - - name: Build Plane's Main App if: steps.changed-files.outputs.web_any_changed == 'true' run: | - mv ./.npmrc ./web cd web yarn yarn build diff --git a/.github/workflows/Update_Docker_Images.yml b/.github/workflows/Update_Docker_Images.yml index 64b7eb085..30593b584 100644 --- a/.github/workflows/Update_Docker_Images.yml +++ b/.github/workflows/Update_Docker_Images.yml @@ -22,10 +22,6 @@ jobs: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Setup .npmrc for repository - run: | - echo -e "@tiptap-pro:registry=https://registry.tiptap.dev/\n//registry.tiptap.dev/:_authToken=${{ secrets.TIPTAP_TOKEN }}" > .npmrc - - name: Extract metadata (tags, labels) for Docker (Docker Hub) from Github Release id: metaFrontend uses: docker/metadata-action@v4.3.0 diff --git a/apiserver/.env.example b/apiserver/.env.example index a2a214fe6..4969f1766 100644 --- a/apiserver/.env.example +++ b/apiserver/.env.example @@ -1,6 +1,7 @@ # Backend # Debug value for api server use it as 0 for production use DEBUG=0 +DJANGO_SETTINGS_MODULE="plane.settings.selfhosted" # Error logs SENTRY_DSN="" diff --git a/apiserver/plane/api/serializers/__init__.py b/apiserver/plane/api/serializers/__init__.py index 610b527ca..dbf7ca049 100644 --- a/apiserver/plane/api/serializers/__init__.py +++ b/apiserver/plane/api/serializers/__init__.py @@ -23,7 +23,7 @@ from .project import ( ProjectPublicMemberSerializer ) from .state import StateSerializer, StateLiteSerializer -from .view import IssueViewSerializer, IssueViewFavoriteSerializer +from .view import GlobalViewSerializer, IssueViewSerializer, IssueViewFavoriteSerializer from .cycle import CycleSerializer, CycleIssueSerializer, CycleFavoriteSerializer, CycleWriteSerializer from .asset import FileAssetSerializer from .issue import ( diff --git a/apiserver/plane/api/serializers/view.py b/apiserver/plane/api/serializers/view.py index 076228ae0..a3b6f48be 100644 --- a/apiserver/plane/api/serializers/view.py +++ b/apiserver/plane/api/serializers/view.py @@ -5,10 +5,39 @@ from rest_framework import serializers from .base import BaseSerializer from .workspace import WorkspaceLiteSerializer from .project import ProjectLiteSerializer -from plane.db.models import IssueView, IssueViewFavorite +from plane.db.models import GlobalView, IssueView, IssueViewFavorite from plane.utils.issue_filters import issue_filters +class GlobalViewSerializer(BaseSerializer): + workspace_detail = WorkspaceLiteSerializer(source="workspace", read_only=True) + + class Meta: + model = GlobalView + fields = "__all__" + read_only_fields = [ + "workspace", + "query", + ] + + def create(self, validated_data): + query_params = validated_data.get("query_data", {}) + if bool(query_params): + validated_data["query"] = issue_filters(query_params, "POST") + else: + validated_data["query"] = dict() + return GlobalView.objects.create(**validated_data) + + def update(self, instance, validated_data): + query_params = validated_data.get("query_data", {}) + if bool(query_params): + validated_data["query"] = issue_filters(query_params, "POST") + else: + validated_data["query"] = dict() + validated_data["query"] = issue_filters(query_params, "PATCH") + return super().update(instance, validated_data) + + class IssueViewSerializer(BaseSerializer): is_favorite = serializers.BooleanField(read_only=True) project_detail = ProjectLiteSerializer(source="project", read_only=True) diff --git a/apiserver/plane/api/urls.py b/apiserver/plane/api/urls.py index 2b83b0b94..c10c4a745 100644 --- a/apiserver/plane/api/urls.py +++ b/apiserver/plane/api/urls.py @@ -102,6 +102,8 @@ from plane.api.views import ( BulkEstimatePointEndpoint, ## End Estimates # Views + GlobalViewViewSet, + GlobalViewIssuesViewSet, IssueViewViewSet, ViewIssuesEndpoint, IssueViewFavoriteViewSet, @@ -184,7 +186,6 @@ from plane.api.views import ( ## Exporter ExportIssuesEndpoint, ## End Exporter - ) @@ -241,7 +242,11 @@ urlpatterns = [ UpdateUserTourCompletedEndpoint.as_view(), name="user-tour", ), - path("users/workspaces//activities/", UserActivityEndpoint.as_view(), name="user-activities"), + path( + "users/workspaces//activities/", + UserActivityEndpoint.as_view(), + name="user-activities", + ), # user workspaces path( "users/me/workspaces/", @@ -649,6 +654,37 @@ urlpatterns = [ ViewIssuesEndpoint.as_view(), name="project-view-issues", ), + path( + "workspaces//views/", + GlobalViewViewSet.as_view( + { + "get": "list", + "post": "create", + } + ), + name="global-view", + ), + path( + "workspaces//views//", + GlobalViewViewSet.as_view( + { + "get": "retrieve", + "put": "update", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="global-view", + ), + path( + "workspaces//issues/", + GlobalViewIssuesViewSet.as_view( + { + "get": "list", + } + ), + name="global-view-issues", + ), path( "workspaces//projects//user-favorite-views/", IssueViewFavoriteViewSet.as_view( @@ -767,11 +803,6 @@ urlpatterns = [ ), name="project-issue", ), - path( - "workspaces//issues/", - WorkSpaceIssuesEndpoint.as_view(), - name="workspace-issue", - ), path( "workspaces//projects//issue-labels/", LabelViewSet.as_view( diff --git a/apiserver/plane/api/views/__init__.py b/apiserver/plane/api/views/__init__.py index 265ed9c90..c03d6d5b7 100644 --- a/apiserver/plane/api/views/__init__.py +++ b/apiserver/plane/api/views/__init__.py @@ -56,7 +56,7 @@ from .workspace import ( LeaveWorkspaceEndpoint, ) from .state import StateViewSet -from .view import IssueViewViewSet, ViewIssuesEndpoint, IssueViewFavoriteViewSet +from .view import GlobalViewViewSet, GlobalViewIssuesViewSet, IssueViewViewSet, ViewIssuesEndpoint, IssueViewFavoriteViewSet from .cycle import ( CycleViewSet, CycleIssueViewSet, diff --git a/apiserver/plane/api/views/cycle.py b/apiserver/plane/api/views/cycle.py index 4f9e1db32..4f895aeba 100644 --- a/apiserver/plane/api/views/cycle.py +++ b/apiserver/plane/api/views/cycle.py @@ -80,6 +80,7 @@ class CycleViewSet(BaseViewSet): issue_id=str(self.kwargs.get("pk", None)), project_id=str(self.kwargs.get("project_id", None)), current_instance=None, + epoch = int(timezone.now().timestamp()) ) return super().perform_destroy(instance) @@ -487,6 +488,7 @@ class CycleIssueViewSet(BaseViewSet): issue_id=str(self.kwargs.get("pk", None)), project_id=str(self.kwargs.get("project_id", None)), current_instance=None, + epoch = int(timezone.now().timestamp()) ) return super().perform_destroy(instance) @@ -662,6 +664,7 @@ class CycleIssueViewSet(BaseViewSet): ), } ), + epoch = int(timezone.now().timestamp()) ) # Return all Cycle Issues diff --git a/apiserver/plane/api/views/inbox.py b/apiserver/plane/api/views/inbox.py index 4fbea5f87..1a0284ea4 100644 --- a/apiserver/plane/api/views/inbox.py +++ b/apiserver/plane/api/views/inbox.py @@ -213,6 +213,7 @@ class InboxIssueViewSet(BaseViewSet): issue_id=str(issue.id), project_id=str(project_id), current_instance=None, + epoch = int(timezone.now().timestamp()) ) # create an inbox issue InboxIssue.objects.create( @@ -277,6 +278,7 @@ class InboxIssueViewSet(BaseViewSet): IssueSerializer(current_instance).data, cls=DjangoJSONEncoder, ), + epoch = int(timezone.now().timestamp()) ) issue_serializer.save() else: @@ -518,6 +520,7 @@ class InboxIssuePublicViewSet(BaseViewSet): issue_id=str(issue.id), project_id=str(project_id), current_instance=None, + epoch = int(timezone.now().timestamp()) ) # create an inbox issue InboxIssue.objects.create( @@ -582,6 +585,7 @@ class InboxIssuePublicViewSet(BaseViewSet): IssueSerializer(current_instance).data, cls=DjangoJSONEncoder, ), + epoch = int(timezone.now().timestamp()) ) issue_serializer.save() return Response(issue_serializer.data, status=status.HTTP_200_OK) diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py index 79141d78d..6ec48babf 100644 --- a/apiserver/plane/api/views/issue.py +++ b/apiserver/plane/api/views/issue.py @@ -4,6 +4,7 @@ import random from itertools import chain # Django imports +from django.utils import timezone from django.db.models import ( Prefetch, OuterRef, @@ -129,6 +130,7 @@ class IssueViewSet(BaseViewSet): current_instance=json.dumps( IssueSerializer(current_instance).data, cls=DjangoJSONEncoder ), + epoch = int(timezone.now().time()) ) return super().perform_update(serializer) @@ -149,6 +151,7 @@ class IssueViewSet(BaseViewSet): current_instance=json.dumps( IssueSerializer(current_instance).data, cls=DjangoJSONEncoder ), + epoch = int(timezone.now().timestamp()) ) return super().perform_destroy(instance) @@ -315,6 +318,7 @@ class IssueViewSet(BaseViewSet): issue_id=str(serializer.data.get("id", None)), project_id=str(project_id), current_instance=None, + epoch = int(timezone.now().timestamp()) ) return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -568,6 +572,7 @@ class IssueCommentViewSet(BaseViewSet): issue_id=str(self.kwargs.get("issue_id")), project_id=str(self.kwargs.get("project_id")), current_instance=None, + epoch = int(timezone.now().timestamp()) ) def perform_update(self, serializer): @@ -586,6 +591,7 @@ class IssueCommentViewSet(BaseViewSet): IssueCommentSerializer(current_instance).data, cls=DjangoJSONEncoder, ), + epoch = int(timezone.now().timestamp()) ) return super().perform_update(serializer) @@ -607,6 +613,7 @@ class IssueCommentViewSet(BaseViewSet): IssueCommentSerializer(current_instance).data, cls=DjangoJSONEncoder, ), + epoch = int(timezone.now().timestamp()) ) return super().perform_destroy(instance) @@ -890,6 +897,7 @@ class IssueLinkViewSet(BaseViewSet): issue_id=str(self.kwargs.get("issue_id")), project_id=str(self.kwargs.get("project_id")), current_instance=None, + epoch = int(timezone.now().timestamp()) ) def perform_update(self, serializer): @@ -908,6 +916,7 @@ class IssueLinkViewSet(BaseViewSet): IssueLinkSerializer(current_instance).data, cls=DjangoJSONEncoder, ), + epoch = int(timezone.now().timestamp()) ) return super().perform_update(serializer) @@ -929,6 +938,7 @@ class IssueLinkViewSet(BaseViewSet): IssueLinkSerializer(current_instance).data, cls=DjangoJSONEncoder, ), + epoch = int(timezone.now().timestamp()) ) return super().perform_destroy(instance) @@ -1007,6 +1017,7 @@ class IssueAttachmentEndpoint(BaseAPIView): serializer.data, cls=DjangoJSONEncoder, ), + epoch = int(timezone.now().timestamp()) ) return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -1029,6 +1040,7 @@ class IssueAttachmentEndpoint(BaseAPIView): issue_id=str(self.kwargs.get("issue_id", None)), project_id=str(self.kwargs.get("project_id", None)), current_instance=None, + epoch = int(timezone.now().timestamp()) ) return Response(status=status.HTTP_204_NO_CONTENT) @@ -1231,6 +1243,7 @@ class IssueArchiveViewSet(BaseViewSet): issue_id=str(issue.id), project_id=str(project_id), current_instance=None, + epoch = int(timezone.now().timestamp()) ) return Response(IssueSerializer(issue).data, status=status.HTTP_200_OK) @@ -1435,6 +1448,7 @@ class IssueReactionViewSet(BaseViewSet): issue_id=str(self.kwargs.get("issue_id", None)), project_id=str(self.kwargs.get("project_id", None)), current_instance=None, + epoch = int(timezone.now().timestamp()) ) def destroy(self, request, slug, project_id, issue_id, reaction_code): @@ -1458,6 +1472,7 @@ class IssueReactionViewSet(BaseViewSet): "identifier": str(issue_reaction.id), } ), + epoch = int(timezone.now().timestamp()) ) issue_reaction.delete() return Response(status=status.HTTP_204_NO_CONTENT) @@ -1506,6 +1521,7 @@ class CommentReactionViewSet(BaseViewSet): issue_id=None, project_id=str(self.kwargs.get("project_id", None)), current_instance=None, + epoch = int(timezone.now().timestamp()) ) def destroy(self, request, slug, project_id, comment_id, reaction_code): @@ -1530,6 +1546,7 @@ class CommentReactionViewSet(BaseViewSet): "comment_id": str(comment_id), } ), + epoch = int(timezone.now().timestamp()) ) comment_reaction.delete() return Response(status=status.HTTP_204_NO_CONTENT) @@ -1626,6 +1643,7 @@ class IssueCommentPublicViewSet(BaseViewSet): issue_id=str(issue_id), project_id=str(project_id), current_instance=None, + epoch = int(timezone.now().timestamp()) ) if not ProjectMember.objects.filter( project_id=project_id, @@ -1675,6 +1693,7 @@ class IssueCommentPublicViewSet(BaseViewSet): IssueCommentSerializer(comment).data, cls=DjangoJSONEncoder, ), + epoch = int(timezone.now().timestamp()) ) return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -1708,6 +1727,7 @@ class IssueCommentPublicViewSet(BaseViewSet): IssueCommentSerializer(comment).data, cls=DjangoJSONEncoder, ), + epoch = int(timezone.now().timestamp()) ) comment.delete() return Response(status=status.HTTP_204_NO_CONTENT) @@ -1782,6 +1802,7 @@ class IssueReactionPublicViewSet(BaseViewSet): issue_id=str(self.kwargs.get("issue_id", None)), project_id=str(self.kwargs.get("project_id", None)), current_instance=None, + epoch = int(timezone.now().timestamp()) ) return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -1826,6 +1847,7 @@ class IssueReactionPublicViewSet(BaseViewSet): "identifier": str(issue_reaction.id), } ), + epoch = int(timezone.now().timestamp()) ) issue_reaction.delete() return Response(status=status.HTTP_204_NO_CONTENT) @@ -1899,6 +1921,7 @@ class CommentReactionPublicViewSet(BaseViewSet): issue_id=None, project_id=str(self.kwargs.get("project_id", None)), current_instance=None, + epoch = int(timezone.now().timestamp()) ) return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -1950,6 +1973,7 @@ class CommentReactionPublicViewSet(BaseViewSet): "comment_id": str(comment_id), } ), + epoch = int(timezone.now().timestamp()) ) comment_reaction.delete() return Response(status=status.HTTP_204_NO_CONTENT) @@ -2013,6 +2037,7 @@ class IssueVotePublicViewSet(BaseViewSet): issue_id=str(self.kwargs.get("issue_id", None)), project_id=str(self.kwargs.get("project_id", None)), current_instance=None, + epoch = int(timezone.now().timestamp()) ) serializer = IssueVoteSerializer(issue_vote) return Response(serializer.data, status=status.HTTP_201_CREATED) @@ -2047,6 +2072,7 @@ class IssueVotePublicViewSet(BaseViewSet): "identifier": str(issue_vote.id), } ), + epoch = int(timezone.now().timestamp()) ) issue_vote.delete() return Response(status=status.HTTP_204_NO_CONTENT) @@ -2080,6 +2106,7 @@ class IssueRelationViewSet(BaseViewSet): IssueRelationSerializer(current_instance).data, cls=DjangoJSONEncoder, ), + epoch = int(timezone.now().timestamp()) ) return super().perform_destroy(instance) @@ -2113,6 +2140,7 @@ class IssueRelationViewSet(BaseViewSet): issue_id=str(issue_id), project_id=str(project_id), current_instance=None, + epoch = int(timezone.now().timestamp()) ) if relation == "blocking": @@ -2157,6 +2185,8 @@ class IssueRelationViewSet(BaseViewSet): .select_related("issue") .distinct() ) + + class IssueRetrievePublicEndpoint(BaseAPIView): permission_classes = [ AllowAny, @@ -2382,6 +2412,7 @@ class IssueDraftViewSet(BaseViewSet): current_instance=json.dumps( IssueSerializer(current_instance).data, cls=DjangoJSONEncoder ), + epoch = int(timezone.now().time()) ) return super().perform_update(serializer) @@ -2566,6 +2597,7 @@ class IssueDraftViewSet(BaseViewSet): issue_id=str(serializer.data.get("id", None)), project_id=str(project_id), current_instance=None, + epoch = int(timezone.now().timestamp()) ) return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) diff --git a/apiserver/plane/api/views/module.py b/apiserver/plane/api/views/module.py index 5a472945a..c2a15da1c 100644 --- a/apiserver/plane/api/views/module.py +++ b/apiserver/plane/api/views/module.py @@ -2,6 +2,7 @@ import json # Django Imports +from django.utils import timezone from django.db import IntegrityError from django.db.models import Prefetch, F, OuterRef, Func, Exists, Count, Q from django.core import serializers @@ -129,6 +130,7 @@ class ModuleViewSet(BaseViewSet): issue_id=str(self.kwargs.get("pk", None)), project_id=str(self.kwargs.get("project_id", None)), current_instance=None, + epoch = int(timezone.now().timestamp()) ) return super().perform_destroy(instance) @@ -277,6 +279,7 @@ class ModuleIssueViewSet(BaseViewSet): issue_id=str(self.kwargs.get("pk", None)), project_id=str(self.kwargs.get("project_id", None)), current_instance=None, + epoch = int(timezone.now().timestamp()) ) return super().perform_destroy(instance) @@ -444,6 +447,7 @@ class ModuleIssueViewSet(BaseViewSet): ), } ), + epoch = int(timezone.now().timestamp()) ) return Response( diff --git a/apiserver/plane/api/views/view.py b/apiserver/plane/api/views/view.py index 32ba24c8b..b6f1d7c4b 100644 --- a/apiserver/plane/api/views/view.py +++ b/apiserver/plane/api/views/view.py @@ -1,4 +1,18 @@ # Django imports +from django.db.models import ( + Prefetch, + OuterRef, + Func, + F, + Case, + Value, + CharField, + When, + Exists, + Max, +) +from django.utils.decorators import method_decorator +from django.views.decorators.gzip import gzip_page from django.db import IntegrityError from django.db.models import Prefetch, OuterRef, Exists @@ -10,18 +24,192 @@ from sentry_sdk import capture_exception # Module imports from . import BaseViewSet, BaseAPIView from plane.api.serializers import ( + GlobalViewSerializer, IssueViewSerializer, IssueLiteSerializer, IssueViewFavoriteSerializer, ) -from plane.api.permissions import ProjectEntityPermission +from plane.api.permissions import WorkspaceEntityPermission, ProjectEntityPermission from plane.db.models import ( + Workspace, + GlobalView, IssueView, Issue, IssueViewFavorite, IssueReaction, + IssueLink, + IssueAttachment, ) from plane.utils.issue_filters import issue_filters +from plane.utils.grouper import group_results + + +class GlobalViewViewSet(BaseViewSet): + serializer_class = GlobalViewSerializer + model = GlobalView + permission_classes = [ + WorkspaceEntityPermission, + ] + + def perform_create(self, serializer): + workspace = Workspace.objects.get(slug=self.kwargs.get("slug")) + serializer.save(workspace_id=workspace.id) + + def get_queryset(self): + return self.filter_queryset( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .select_related("workspace") + .order_by("-created_at") + .distinct() + ) + + +class GlobalViewIssuesViewSet(BaseViewSet): + permission_classes = [ + WorkspaceEntityPermission, + ] + + def get_queryset(self): + return ( + Issue.issue_objects.annotate( + sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .filter(workspace__slug=self.kwargs.get("slug")) + .select_related("project") + .select_related("workspace") + .select_related("state") + .select_related("parent") + .prefetch_related("assignees") + .prefetch_related("labels") + .prefetch_related( + Prefetch( + "issue_reactions", + queryset=IssueReaction.objects.select_related("actor"), + ) + ) + ) + + + @method_decorator(gzip_page) + def list(self, request, slug): + try: + filters = issue_filters(request.query_params, "GET") + + # Custom ordering for priority and state + priority_order = ["urgent", "high", "medium", "low", "none"] + state_order = ["backlog", "unstarted", "started", "completed", "cancelled"] + + order_by_param = request.GET.get("order_by", "-created_at") + + issue_queryset = ( + self.get_queryset() + .filter(**filters) + .filter(project__project_projectmember__member=self.request.user) + .annotate(cycle_id=F("issue_cycle__cycle_id")) + .annotate(module_id=F("issue_module__module_id")) + .annotate( + link_count=IssueLink.objects.filter(issue=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + attachment_count=IssueAttachment.objects.filter( + issue=OuterRef("id") + ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + ) + + # Priority Ordering + if order_by_param == "priority" or order_by_param == "-priority": + priority_order = ( + priority_order + if order_by_param == "priority" + else priority_order[::-1] + ) + issue_queryset = issue_queryset.annotate( + priority_order=Case( + *[ + When(priority=p, then=Value(i)) + for i, p in enumerate(priority_order) + ], + output_field=CharField(), + ) + ).order_by("priority_order") + + # State Ordering + elif order_by_param in [ + "state__name", + "state__group", + "-state__name", + "-state__group", + ]: + state_order = ( + state_order + if order_by_param in ["state__name", "state__group"] + else state_order[::-1] + ) + issue_queryset = issue_queryset.annotate( + state_order=Case( + *[ + When(state__group=state_group, then=Value(i)) + for i, state_group in enumerate(state_order) + ], + default=Value(len(state_order)), + output_field=CharField(), + ) + ).order_by("state_order") + # assignee and label ordering + elif order_by_param in [ + "labels__name", + "-labels__name", + "assignees__first_name", + "-assignees__first_name", + ]: + issue_queryset = issue_queryset.annotate( + max_values=Max( + order_by_param[1::] + if order_by_param.startswith("-") + else order_by_param + ) + ).order_by( + "-max_values" if order_by_param.startswith("-") else "max_values" + ) + else: + issue_queryset = issue_queryset.order_by(order_by_param) + + issues = IssueLiteSerializer(issue_queryset, many=True).data + + ## Grouping the results + group_by = request.GET.get("group_by", False) + sub_group_by = request.GET.get("sub_group_by", False) + if sub_group_by and sub_group_by == group_by: + return Response( + {"error": "Group by and sub group by cannot be same"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if group_by: + return Response( + group_results(issues, group_by, sub_group_by), status=status.HTTP_200_OK + ) + + return Response(issues, status=status.HTTP_200_OK) + + except Exception as e: + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_400_BAD_REQUEST, + ) class IssueViewViewSet(BaseViewSet): diff --git a/apiserver/plane/bgtasks/issue_activites_task.py b/apiserver/plane/bgtasks/issue_activites_task.py index 4f8ccfab1..733defe69 100644 --- a/apiserver/plane/bgtasks/issue_activites_task.py +++ b/apiserver/plane/bgtasks/issue_activites_task.py @@ -39,6 +39,7 @@ def track_name( project, actor, issue_activities, + epoch ): if current_instance.get("name") != requested_data.get("name"): issue_activities.append( @@ -52,6 +53,7 @@ def track_name( project=project, workspace=project.workspace, comment=f"updated the name to {requested_data.get('name')}", + epoch=epoch, ) ) @@ -64,6 +66,7 @@ def track_parent( project, actor, issue_activities, + epoch ): if current_instance.get("parent") != requested_data.get("parent"): if requested_data.get("parent") == None: @@ -81,6 +84,7 @@ def track_parent( comment=f"updated the parent issue to None", old_identifier=old_parent.id, new_identifier=None, + epoch=epoch, ) ) else: @@ -101,6 +105,7 @@ def track_parent( comment=f"updated the parent issue to {new_parent.name}", old_identifier=old_parent.id if old_parent is not None else None, new_identifier=new_parent.id, + epoch=epoch, ) ) @@ -113,6 +118,7 @@ def track_priority( project, actor, issue_activities, + epoch ): if current_instance.get("priority") != requested_data.get("priority"): if requested_data.get("priority") == None: @@ -127,6 +133,7 @@ def track_priority( project=project, workspace=project.workspace, comment=f"updated the priority to None", + epoch=epoch, ) ) else: @@ -141,6 +148,7 @@ def track_priority( project=project, workspace=project.workspace, comment=f"updated the priority to {requested_data.get('priority')}", + epoch=epoch, ) ) @@ -153,6 +161,7 @@ def track_state( project, actor, issue_activities, + epoch ): if current_instance.get("state") != requested_data.get("state"): new_state = State.objects.get(pk=requested_data.get("state", None)) @@ -171,6 +180,7 @@ def track_state( comment=f"updated the state to {new_state.name}", old_identifier=old_state.id, new_identifier=new_state.id, + epoch=epoch, ) ) @@ -183,6 +193,7 @@ def track_description( project, actor, issue_activities, + epoch ): if current_instance.get("description_html") != requested_data.get( "description_html" @@ -203,6 +214,7 @@ def track_description( project=project, workspace=project.workspace, comment=f"updated the description to {requested_data.get('description_html')}", + epoch=epoch, ) ) @@ -215,6 +227,7 @@ def track_target_date( project, actor, issue_activities, + epoch ): if current_instance.get("target_date") != requested_data.get("target_date"): if requested_data.get("target_date") == None: @@ -229,6 +242,7 @@ def track_target_date( project=project, workspace=project.workspace, comment=f"updated the target date to None", + epoch=epoch, ) ) else: @@ -243,6 +257,7 @@ def track_target_date( project=project, workspace=project.workspace, comment=f"updated the target date to {requested_data.get('target_date')}", + epoch=epoch, ) ) @@ -255,6 +270,7 @@ def track_start_date( project, actor, issue_activities, + epoch ): if current_instance.get("start_date") != requested_data.get("start_date"): if requested_data.get("start_date") == None: @@ -269,6 +285,7 @@ def track_start_date( project=project, workspace=project.workspace, comment=f"updated the start date to None", + epoch=epoch, ) ) else: @@ -283,6 +300,7 @@ def track_start_date( project=project, workspace=project.workspace, comment=f"updated the start date to {requested_data.get('start_date')}", + epoch=epoch, ) ) @@ -295,6 +313,7 @@ def track_labels( project, actor, issue_activities, + epoch ): # Label Addition if len(requested_data.get("labels_list")) > len(current_instance.get("labels")): @@ -314,6 +333,7 @@ def track_labels( comment=f"added label {label.name}", new_identifier=label.id, old_identifier=None, + epoch=epoch, ) ) @@ -335,6 +355,7 @@ def track_labels( comment=f"removed label {label.name}", old_identifier=label.id, new_identifier=None, + epoch=epoch, ) ) @@ -347,6 +368,7 @@ def track_assignees( project, actor, issue_activities, + epoch ): # Assignee Addition if len(requested_data.get("assignees_list")) > len( @@ -367,6 +389,7 @@ def track_assignees( workspace=project.workspace, comment=f"added assignee {assignee.display_name}", new_identifier=assignee.id, + epoch=epoch, ) ) @@ -389,12 +412,13 @@ def track_assignees( workspace=project.workspace, comment=f"removed assignee {assignee.display_name}", old_identifier=assignee.id, + epoch=epoch, ) ) def create_issue_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities + requested_data, current_instance, issue_id, project, actor, issue_activities, epoch ): issue_activities.append( IssueActivity( @@ -404,12 +428,13 @@ def create_issue_activity( comment=f"created the issue", verb="created", actor=actor, + epoch=epoch, ) ) def track_estimate_points( - requested_data, current_instance, issue_id, project, actor, issue_activities + requested_data, current_instance, issue_id, project, actor, issue_activities, epoch ): if current_instance.get("estimate_point") != requested_data.get("estimate_point"): if requested_data.get("estimate_point") == None: @@ -424,6 +449,7 @@ def track_estimate_points( project=project, workspace=project.workspace, comment=f"updated the estimate point to None", + epoch=epoch, ) ) else: @@ -438,12 +464,13 @@ def track_estimate_points( project=project, workspace=project.workspace, comment=f"updated the estimate point to {requested_data.get('estimate_point')}", + epoch=epoch, ) ) def track_archive_at( - requested_data, current_instance, issue_id, project, actor, issue_activities + requested_data, current_instance, issue_id, project, actor, issue_activities, epoch ): if requested_data.get("archived_at") is None: issue_activities.append( @@ -457,6 +484,7 @@ def track_archive_at( field="archived_at", old_value="archive", new_value="restore", + epoch=epoch, ) ) else: @@ -471,12 +499,13 @@ def track_archive_at( field="archived_at", old_value=None, new_value="archive", + epoch=epoch, ) ) def track_closed_to( - requested_data, current_instance, issue_id, project, actor, issue_activities + requested_data, current_instance, issue_id, project, actor, issue_activities, epoch ): if requested_data.get("closed_to") is not None: updated_state = State.objects.get( @@ -496,12 +525,13 @@ def track_closed_to( comment=f"Plane updated the state to {updated_state.name}", old_identifier=None, new_identifier=updated_state.id, + epoch=epoch, ) ) def update_issue_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities + requested_data, current_instance, issue_id, project, actor, issue_activities, epoch ): ISSUE_ACTIVITY_MAPPER = { "name": track_name, @@ -518,6 +548,11 @@ def update_issue_activity( "closed_to": track_closed_to, } + requested_data = json.loads(requested_data) if requested_data is not None else None + current_instance = ( + json.loads(current_instance) if current_instance is not None else None + ) + for key in requested_data: func = ISSUE_ACTIVITY_MAPPER.get(key, None) if func is not None: @@ -528,11 +563,12 @@ def update_issue_activity( project, actor, issue_activities, + epoch ) def delete_issue_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities + requested_data, current_instance, issue_id, project, actor, issue_activities, epoch ): issue_activities.append( IssueActivity( @@ -542,12 +578,13 @@ def delete_issue_activity( verb="deleted", actor=actor, field="issue", + epoch=epoch, ) ) def create_comment_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities + requested_data, current_instance, issue_id, project, actor, issue_activities, epoch ): requested_data = json.loads(requested_data) if requested_data is not None else None current_instance = ( @@ -566,12 +603,13 @@ def create_comment_activity( new_value=requested_data.get("comment_html", ""), new_identifier=requested_data.get("id", None), issue_comment_id=requested_data.get("id", None), + epoch=epoch, ) ) def update_comment_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities + requested_data, current_instance, issue_id, project, actor, issue_activities, epoch ): requested_data = json.loads(requested_data) if requested_data is not None else None current_instance = ( @@ -593,12 +631,13 @@ def update_comment_activity( new_value=requested_data.get("comment_html", ""), new_identifier=current_instance.get("id", None), issue_comment_id=current_instance.get("id", None), + epoch=epoch, ) ) def delete_comment_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities + requested_data, current_instance, issue_id, project, actor, issue_activities, epoch ): issue_activities.append( IssueActivity( @@ -609,12 +648,13 @@ def delete_comment_activity( verb="deleted", actor=actor, field="comment", + epoch=epoch, ) ) def create_cycle_issue_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities + requested_data, current_instance, issue_id, project, actor, issue_activities, epoch ): requested_data = json.loads(requested_data) if requested_data is not None else None current_instance = ( @@ -646,6 +686,7 @@ def create_cycle_issue_activity( comment=f"updated cycle from {old_cycle.name} to {new_cycle.name}", old_identifier=old_cycle.id, new_identifier=new_cycle.id, + epoch=epoch, ) ) @@ -666,12 +707,13 @@ def create_cycle_issue_activity( workspace=project.workspace, comment=f"added cycle {cycle.name}", new_identifier=cycle.id, + epoch=epoch, ) ) def delete_cycle_issue_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities + requested_data, current_instance, issue_id, project, actor, issue_activities, epoch ): requested_data = json.loads(requested_data) if requested_data is not None else None current_instance = ( @@ -695,12 +737,13 @@ def delete_cycle_issue_activity( workspace=project.workspace, comment=f"removed this issue from {cycle.name if cycle is not None else None}", old_identifier=cycle.id if cycle is not None else None, + epoch=epoch, ) ) def create_module_issue_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities + requested_data, current_instance, issue_id, project, actor, issue_activities, epoch ): requested_data = json.loads(requested_data) if requested_data is not None else None current_instance = ( @@ -732,6 +775,7 @@ def create_module_issue_activity( comment=f"updated module from {old_module.name} to {new_module.name}", old_identifier=old_module.id, new_identifier=new_module.id, + epoch=epoch, ) ) @@ -751,12 +795,13 @@ def create_module_issue_activity( workspace=project.workspace, comment=f"added module {module.name}", new_identifier=module.id, + epoch=epoch, ) ) def delete_module_issue_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities + requested_data, current_instance, issue_id, project, actor, issue_activities, epoch ): requested_data = json.loads(requested_data) if requested_data is not None else None current_instance = ( @@ -780,12 +825,13 @@ def delete_module_issue_activity( workspace=project.workspace, comment=f"removed this issue from {module.name if module is not None else None}", old_identifier=module.id if module is not None else None, + epoch=epoch, ) ) def create_link_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities + requested_data, current_instance, issue_id, project, actor, issue_activities, epoch ): requested_data = json.loads(requested_data) if requested_data is not None else None current_instance = ( @@ -803,12 +849,13 @@ def create_link_activity( field="link", new_value=requested_data.get("url", ""), new_identifier=requested_data.get("id", None), + epoch=epoch, ) ) def update_link_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities + requested_data, current_instance, issue_id, project, actor, issue_activities, epoch ): requested_data = json.loads(requested_data) if requested_data is not None else None current_instance = ( @@ -829,12 +876,13 @@ def update_link_activity( old_identifier=current_instance.get("id"), new_value=requested_data.get("url", ""), new_identifier=current_instance.get("id", None), + epoch=epoch, ) ) def delete_link_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities + requested_data, current_instance, issue_id, project, actor, issue_activities, epoch ): current_instance = ( @@ -851,13 +899,14 @@ def delete_link_activity( actor=actor, field="link", old_value=current_instance.get("url", ""), - new_value="" + new_value="", + epoch=epoch, ) ) def create_attachment_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities + requested_data, current_instance, issue_id, project, actor, issue_activities, epoch ): requested_data = json.loads(requested_data) if requested_data is not None else None current_instance = ( @@ -875,12 +924,13 @@ def create_attachment_activity( field="attachment", new_value=current_instance.get("asset", ""), new_identifier=current_instance.get("id", None), + epoch=epoch, ) ) def delete_attachment_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities + requested_data, current_instance, issue_id, project, actor, issue_activities, epoch ): issue_activities.append( IssueActivity( @@ -891,11 +941,12 @@ def delete_attachment_activity( verb="deleted", actor=actor, field="attachment", + epoch=epoch, ) ) def create_issue_reaction_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities + requested_data, current_instance, issue_id, project, actor, issue_activities, epoch ): requested_data = json.loads(requested_data) if requested_data is not None else None if requested_data and requested_data.get("reaction") is not None: @@ -914,12 +965,13 @@ def create_issue_reaction_activity( comment="added the reaction", old_identifier=None, new_identifier=issue_reaction, + epoch=epoch, ) ) def delete_issue_reaction_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities + requested_data, current_instance, issue_id, project, actor, issue_activities, epoch ): current_instance = ( json.loads(current_instance) if current_instance is not None else None @@ -938,12 +990,13 @@ def delete_issue_reaction_activity( comment="removed the reaction", old_identifier=current_instance.get("identifier"), new_identifier=None, + epoch=epoch, ) ) def create_comment_reaction_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities + requested_data, current_instance, issue_id, project, actor, issue_activities, epoch ): requested_data = json.loads(requested_data) if requested_data is not None else None if requested_data and requested_data.get("reaction") is not None: @@ -963,12 +1016,13 @@ def create_comment_reaction_activity( comment="added the reaction", old_identifier=None, new_identifier=comment_reaction_id, + epoch=epoch, ) ) def delete_comment_reaction_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities + requested_data, current_instance, issue_id, project, actor, issue_activities, epoch ): current_instance = ( json.loads(current_instance) if current_instance is not None else None @@ -989,12 +1043,13 @@ def delete_comment_reaction_activity( comment="removed the reaction", old_identifier=current_instance.get("identifier"), new_identifier=None, + epoch=epoch, ) ) def create_issue_vote_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities + requested_data, current_instance, issue_id, project, actor, issue_activities, epoch ): requested_data = json.loads(requested_data) if requested_data is not None else None if requested_data and requested_data.get("vote") is not None: @@ -1011,12 +1066,13 @@ def create_issue_vote_activity( comment="added the vote", old_identifier=None, new_identifier=None, + epoch=epoch, ) ) def delete_issue_vote_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities + requested_data, current_instance, issue_id, project, actor, issue_activities, epoch ): current_instance = ( json.loads(current_instance) if current_instance is not None else None @@ -1035,12 +1091,13 @@ def delete_issue_vote_activity( comment="removed the vote", old_identifier=current_instance.get("identifier"), new_identifier=None, + epoch=epoch, ) ) def create_issue_relation_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities + requested_data, current_instance, issue_id, project, actor, issue_activities, epoch ): requested_data = json.loads(requested_data) if requested_data is not None else None current_instance = ( @@ -1080,12 +1137,13 @@ def create_issue_relation_activity( workspace=project.workspace, comment=f'added {issue_relation.get("relation_type")} relation', old_identifier=issue_relation.get("related_issue"), + epoch=epoch, ) ) def delete_issue_relation_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities + requested_data, current_instance, issue_id, project, actor, issue_activities, epoch ): requested_data = json.loads(requested_data) if requested_data is not None else None current_instance = ( @@ -1109,6 +1167,7 @@ def delete_issue_relation_activity( workspace=project.workspace, comment=f'deleted {relation_type} relation', old_identifier=current_instance.get("issue"), + epoch=epoch, ) ) issue = Issue.objects.get(pk=current_instance.get("related_issue")) @@ -1124,12 +1183,13 @@ def delete_issue_relation_activity( workspace=project.workspace, comment=f'deleted {current_instance.get("relation_type")} relation', old_identifier=current_instance.get("related_issue"), + epoch=epoch, ) ) def create_draft_issue_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities + requested_data, current_instance, issue_id, project, actor, issue_activities, epoch ): issue_activities.append( IssueActivity( @@ -1140,12 +1200,13 @@ def create_draft_issue_activity( field="draft", verb="created", actor=actor, + epoch=epoch, ) ) def update_draft_issue_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities + requested_data, current_instance, issue_id, project, actor, issue_activities, epoch ): requested_data = json.loads(requested_data) if requested_data is not None else None current_instance = ( @@ -1160,6 +1221,7 @@ def update_draft_issue_activity( comment=f"created the issue", verb="updated", actor=actor, + epoch=epoch, ) ) else: @@ -1172,13 +1234,14 @@ def update_draft_issue_activity( field="draft", verb="updated", actor=actor, + epoch=epoch, ) ) def delete_draft_issue_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities + requested_data, current_instance, issue_id, project, actor, issue_activities, epoch ): issue_activities.append( IssueActivity( @@ -1188,6 +1251,7 @@ def delete_draft_issue_activity( field="draft", verb="deleted", actor=actor, + epoch=epoch, ) ) @@ -1200,6 +1264,7 @@ def issue_activity( issue_id, actor_id, project_id, + epoch, subscriber=True, ): try: @@ -1276,6 +1341,7 @@ def issue_activity( project, actor, issue_activities, + epoch, ) # Save all the values to database diff --git a/apiserver/plane/bgtasks/issue_automation_task.py b/apiserver/plane/bgtasks/issue_automation_task.py index 645772c94..f7b06c625 100644 --- a/apiserver/plane/bgtasks/issue_automation_task.py +++ b/apiserver/plane/bgtasks/issue_automation_task.py @@ -77,6 +77,7 @@ def archive_old_issues(): project_id=project_id, current_instance=None, subscriber=False, + epoch = int(timezone.now().timestamp()) ) for issue in updated_issues ] @@ -148,6 +149,7 @@ def close_old_issues(): project_id=project_id, current_instance=None, subscriber=False, + epoch = int(timezone.now().timestamp()) ) for issue in updated_issues ] diff --git a/apiserver/plane/db/migrations/0045_auto_20230915_0655.py b/apiserver/plane/db/migrations/0045_auto_20230915_0655.py index 7bd907e29..a757def07 100644 --- a/apiserver/plane/db/migrations/0045_auto_20230915_0655.py +++ b/apiserver/plane/db/migrations/0045_auto_20230915_0655.py @@ -1,6 +1,10 @@ # Generated by Django 4.2.3 on 2023-09-15 06:55 -from django.db import migrations +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + def update_issue_activity(apps, schema_editor): IssueActivityModel = apps.get_model("db", "IssueActivity") @@ -19,5 +23,27 @@ class Migration(migrations.Migration): ] operations = [ + migrations.CreateModel( + name='GlobalView', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), + ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('name', models.CharField(max_length=255, verbose_name='View Name')), + ('description', models.TextField(blank=True, verbose_name='View Description')), + ('query', models.JSONField(verbose_name='View Query')), + ('access', models.PositiveSmallIntegerField(choices=[(0, 'Private'), (1, 'Public')], default=1)), + ('query_data', models.JSONField(default=dict)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='global_views', to='db.workspace')), + ], + options={ + 'verbose_name': 'Global View', + 'verbose_name_plural': 'Global Views', + 'db_table': 'global_views', + 'ordering': ('-created_at',), + }, + ), migrations.RunPython(update_issue_activity), ] diff --git a/apiserver/plane/db/migrations/0045_issueactivity_epoch_alter_analyticview_created_by_and_more.py b/apiserver/plane/db/migrations/0045_issueactivity_epoch_alter_analyticview_created_by_and_more.py new file mode 100644 index 000000000..0d2cca641 --- /dev/null +++ b/apiserver/plane/db/migrations/0045_issueactivity_epoch_alter_analyticview_created_by_and_more.py @@ -0,0 +1,27 @@ +# Generated by Django 4.2.3 on 2023-09-14 06:49 + +from django.db import migrations, models + +def update_epoch(apps, schema_editor): + IssueActivity = apps.get_model('db', 'IssueActivity') + updated_issue_activity = [] + for obj in IssueActivity.objects.all(): + obj.epoch = int(obj.created_at.timestamp()) + updated_issue_activity.append(obj) + IssueActivity.objects.bulk_update(updated_issue_activity, ["epoch"], batch_size=100) + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0044_auto_20230913_0709'), + ] + + operations = [ + migrations.AddField( + model_name='issueactivity', + name='epoch', + field=models.FloatField(null=True), + ), + migrations.RunPython(update_epoch), + ] diff --git a/apiserver/plane/db/models/__init__.py b/apiserver/plane/db/models/__init__.py index f60f7ac81..9496b5906 100644 --- a/apiserver/plane/db/models/__init__.py +++ b/apiserver/plane/db/models/__init__.py @@ -50,7 +50,7 @@ from .state import State from .cycle import Cycle, CycleIssue, CycleFavorite -from .view import IssueView, IssueViewFavorite +from .view import GlobalView, IssueView, IssueViewFavorite from .module import Module, ModuleMember, ModuleIssue, ModuleLink, ModuleFavorite diff --git a/apiserver/plane/db/models/issue.py b/apiserver/plane/db/models/issue.py index 65f1bc965..3ba054d49 100644 --- a/apiserver/plane/db/models/issue.py +++ b/apiserver/plane/db/models/issue.py @@ -309,6 +309,7 @@ class IssueActivity(ProjectBaseModel): ) old_identifier = models.UUIDField(null=True) new_identifier = models.UUIDField(null=True) + epoch = models.FloatField(null=True) class Meta: verbose_name = "Issue Activity" diff --git a/apiserver/plane/db/models/view.py b/apiserver/plane/db/models/view.py index 6a968af53..6e0a47105 100644 --- a/apiserver/plane/db/models/view.py +++ b/apiserver/plane/db/models/view.py @@ -3,7 +3,30 @@ from django.db import models from django.conf import settings # Module import -from . import ProjectBaseModel +from . import ProjectBaseModel, BaseModel + + +class GlobalView(BaseModel): + workspace = models.ForeignKey( + "db.Workspace", on_delete=models.CASCADE, related_name="global_views" + ) + name = models.CharField(max_length=255, verbose_name="View Name") + description = models.TextField(verbose_name="View Description", blank=True) + query = models.JSONField(verbose_name="View Query") + access = models.PositiveSmallIntegerField( + default=1, choices=((0, "Private"), (1, "Public")) + ) + query_data = models.JSONField(default=dict) + + class Meta: + verbose_name = "Global View" + verbose_name_plural = "Global Views" + db_table = "global_views" + ordering = ("-created_at",) + + def __str__(self): + """Return name of the View""" + return f"{self.name} <{self.workspace.name}>" class IssueView(ProjectBaseModel): diff --git a/apiserver/plane/settings/production.py b/apiserver/plane/settings/production.py index acc1f34fe..e434f9742 100644 --- a/apiserver/plane/settings/production.py +++ b/apiserver/plane/settings/production.py @@ -1,10 +1,8 @@ """Production settings and globals.""" -from urllib.parse import urlparse import ssl import certifi import dj_database_url -from urllib.parse import urlparse import sentry_sdk from sentry_sdk.integrations.django import DjangoIntegration @@ -91,112 +89,89 @@ if bool(os.environ.get("SENTRY_DSN", False)): profiles_sample_rate=1.0, ) -if DOCKERIZED and USE_MINIO: - INSTALLED_APPS += ("storages",) - STORAGES["default"] = {"BACKEND": "storages.backends.s3boto3.S3Boto3Storage"} - # The AWS access key to use. - AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID", "access-key") - # The AWS secret access key to use. - AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY", "secret-key") - # The name of the bucket to store files in. - AWS_STORAGE_BUCKET_NAME = os.environ.get("AWS_S3_BUCKET_NAME", "uploads") - # The full URL to the S3 endpoint. Leave blank to use the default region URL. - AWS_S3_ENDPOINT_URL = os.environ.get( - "AWS_S3_ENDPOINT_URL", "http://plane-minio:9000" - ) - # Default permissions - AWS_DEFAULT_ACL = "public-read" - AWS_QUERYSTRING_AUTH = False - AWS_S3_FILE_OVERWRITE = False +# The AWS region to connect to. +AWS_REGION = os.environ.get("AWS_REGION", "") - # Custom Domain settings - parsed_url = urlparse(os.environ.get("WEB_URL", "http://localhost")) - AWS_S3_CUSTOM_DOMAIN = f"{parsed_url.netloc}/{AWS_STORAGE_BUCKET_NAME}" - AWS_S3_URL_PROTOCOL = f"{parsed_url.scheme}:" -else: - # The AWS region to connect to. - AWS_REGION = os.environ.get("AWS_REGION", "") +# The AWS access key to use. +AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID", "") - # The AWS access key to use. - AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID", "") +# The AWS secret access key to use. +AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY", "") - # The AWS secret access key to use. - AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY", "") +# The optional AWS session token to use. +# AWS_SESSION_TOKEN = "" - # The optional AWS session token to use. - # AWS_SESSION_TOKEN = "" +# The name of the bucket to store files in. +AWS_S3_BUCKET_NAME = os.environ.get("AWS_S3_BUCKET_NAME") - # The name of the bucket to store files in. - AWS_S3_BUCKET_NAME = os.environ.get("AWS_S3_BUCKET_NAME") +# How to construct S3 URLs ("auto", "path", "virtual"). +AWS_S3_ADDRESSING_STYLE = "auto" - # How to construct S3 URLs ("auto", "path", "virtual"). - AWS_S3_ADDRESSING_STYLE = "auto" +# The full URL to the S3 endpoint. Leave blank to use the default region URL. +AWS_S3_ENDPOINT_URL = os.environ.get("AWS_S3_ENDPOINT_URL", "") - # The full URL to the S3 endpoint. Leave blank to use the default region URL. - AWS_S3_ENDPOINT_URL = os.environ.get("AWS_S3_ENDPOINT_URL", "") +# A prefix to be applied to every stored file. This will be joined to every filename using the "/" separator. +AWS_S3_KEY_PREFIX = "" - # A prefix to be applied to every stored file. This will be joined to every filename using the "/" separator. - AWS_S3_KEY_PREFIX = "" +# Whether to enable authentication for stored files. If True, then generated URLs will include an authentication +# token valid for `AWS_S3_MAX_AGE_SECONDS`. If False, then generated URLs will not include an authentication token, +# and their permissions will be set to "public-read". +AWS_S3_BUCKET_AUTH = False - # Whether to enable authentication for stored files. If True, then generated URLs will include an authentication - # token valid for `AWS_S3_MAX_AGE_SECONDS`. If False, then generated URLs will not include an authentication token, - # and their permissions will be set to "public-read". - AWS_S3_BUCKET_AUTH = False +# How long generated URLs are valid for. This affects the expiry of authentication tokens if `AWS_S3_BUCKET_AUTH` +# is True. It also affects the "Cache-Control" header of the files. +# Important: Changing this setting will not affect existing files. +AWS_S3_MAX_AGE_SECONDS = 60 * 60 # 1 hours. - # How long generated URLs are valid for. This affects the expiry of authentication tokens if `AWS_S3_BUCKET_AUTH` - # is True. It also affects the "Cache-Control" header of the files. - # Important: Changing this setting will not affect existing files. - AWS_S3_MAX_AGE_SECONDS = 60 * 60 # 1 hours. +# A URL prefix to be used for generated URLs. This is useful if your bucket is served through a CDN. This setting +# cannot be used with `AWS_S3_BUCKET_AUTH`. +AWS_S3_PUBLIC_URL = "" - # A URL prefix to be used for generated URLs. This is useful if your bucket is served through a CDN. This setting - # cannot be used with `AWS_S3_BUCKET_AUTH`. - AWS_S3_PUBLIC_URL = "" +# If True, then files will be stored with reduced redundancy. Check the S3 documentation and make sure you +# understand the consequences before enabling. +# Important: Changing this setting will not affect existing files. +AWS_S3_REDUCED_REDUNDANCY = False - # If True, then files will be stored with reduced redundancy. Check the S3 documentation and make sure you - # understand the consequences before enabling. - # Important: Changing this setting will not affect existing files. - AWS_S3_REDUCED_REDUNDANCY = False +# The Content-Disposition header used when the file is downloaded. This can be a string, or a function taking a +# single `name` argument. +# Important: Changing this setting will not affect existing files. +AWS_S3_CONTENT_DISPOSITION = "" - # The Content-Disposition header used when the file is downloaded. This can be a string, or a function taking a - # single `name` argument. - # Important: Changing this setting will not affect existing files. - AWS_S3_CONTENT_DISPOSITION = "" +# The Content-Language header used when the file is downloaded. This can be a string, or a function taking a +# single `name` argument. +# Important: Changing this setting will not affect existing files. +AWS_S3_CONTENT_LANGUAGE = "" - # The Content-Language header used when the file is downloaded. This can be a string, or a function taking a - # single `name` argument. - # Important: Changing this setting will not affect existing files. - AWS_S3_CONTENT_LANGUAGE = "" +# A mapping of custom metadata for each file. Each value can be a string, or a function taking a +# single `name` argument. +# Important: Changing this setting will not affect existing files. +AWS_S3_METADATA = {} - # A mapping of custom metadata for each file. Each value can be a string, or a function taking a - # single `name` argument. - # Important: Changing this setting will not affect existing files. - AWS_S3_METADATA = {} +# If True, then files will be stored using AES256 server-side encryption. +# If this is a string value (e.g., "aws:kms"), that encryption type will be used. +# Otherwise, server-side encryption is not be enabled. +# Important: Changing this setting will not affect existing files. +AWS_S3_ENCRYPT_KEY = False - # If True, then files will be stored using AES256 server-side encryption. - # If this is a string value (e.g., "aws:kms"), that encryption type will be used. - # Otherwise, server-side encryption is not be enabled. - # Important: Changing this setting will not affect existing files. - AWS_S3_ENCRYPT_KEY = False +# The AWS S3 KMS encryption key ID (the `SSEKMSKeyId` parameter) is set from this string if present. +# This is only relevant if AWS S3 KMS server-side encryption is enabled (above). +# AWS_S3_KMS_ENCRYPTION_KEY_ID = "" - # The AWS S3 KMS encryption key ID (the `SSEKMSKeyId` parameter) is set from this string if present. - # This is only relevant if AWS S3 KMS server-side encryption is enabled (above). - # AWS_S3_KMS_ENCRYPTION_KEY_ID = "" +# If True, then text files will be stored using gzip content encoding. Files will only be gzipped if their +# compressed size is smaller than their uncompressed size. +# Important: Changing this setting will not affect existing files. +AWS_S3_GZIP = True - # If True, then text files will be stored using gzip content encoding. Files will only be gzipped if their - # compressed size is smaller than their uncompressed size. - # Important: Changing this setting will not affect existing files. - AWS_S3_GZIP = True +# The signature version to use for S3 requests. +AWS_S3_SIGNATURE_VERSION = None - # The signature version to use for S3 requests. - AWS_S3_SIGNATURE_VERSION = None +# If True, then files with the same name will overwrite each other. By default it's set to False to have +# extra characters appended. +AWS_S3_FILE_OVERWRITE = False - # If True, then files with the same name will overwrite each other. By default it's set to False to have - # extra characters appended. - AWS_S3_FILE_OVERWRITE = False - - STORAGES["default"] = { - "BACKEND": "django_s3_storage.storage.S3Storage", - } +STORAGES["default"] = { + "BACKEND": "django_s3_storage.storage.S3Storage", +} # AWS Settings End @@ -218,27 +193,16 @@ CSRF_COOKIE_SECURE = True REDIS_URL = os.environ.get("REDIS_URL") -if DOCKERIZED: - CACHES = { - "default": { - "BACKEND": "django_redis.cache.RedisCache", - "LOCATION": REDIS_URL, - "OPTIONS": { - "CLIENT_CLASS": "django_redis.client.DefaultClient", - }, - } - } -else: - CACHES = { - "default": { - "BACKEND": "django_redis.cache.RedisCache", - "LOCATION": REDIS_URL, - "OPTIONS": { - "CLIENT_CLASS": "django_redis.client.DefaultClient", - "CONNECTION_POOL_KWARGS": {"ssl_cert_reqs": False}, - }, - } +CACHES = { + "default": { + "BACKEND": "django_redis.cache.RedisCache", + "LOCATION": REDIS_URL, + "OPTIONS": { + "CLIENT_CLASS": "django_redis.client.DefaultClient", + "CONNECTION_POOL_KWARGS": {"ssl_cert_reqs": False}, + }, } +} WEB_URL = os.environ.get("WEB_URL", "https://app.plane.so") @@ -261,19 +225,16 @@ broker_url = ( f"{redis_url}?ssl_cert_reqs={ssl.CERT_NONE.name}&ssl_ca_certs={certifi.where()}" ) -if DOCKERIZED: - CELERY_BROKER_URL = REDIS_URL - CELERY_RESULT_BACKEND = REDIS_URL -else: - CELERY_RESULT_BACKEND = broker_url - CELERY_BROKER_URL = broker_url +CELERY_RESULT_BACKEND = broker_url +CELERY_BROKER_URL = broker_url GITHUB_ACCESS_TOKEN = os.environ.get("GITHUB_ACCESS_TOKEN", False) - +# Enable or Disable signups ENABLE_SIGNUP = os.environ.get("ENABLE_SIGNUP", "1") == "1" # Scout Settings SCOUT_MONITOR = os.environ.get("SCOUT_MONITOR", False) SCOUT_KEY = os.environ.get("SCOUT_KEY", "") SCOUT_NAME = "Plane" + diff --git a/apiserver/plane/settings/selfhosted.py b/apiserver/plane/settings/selfhosted.py new file mode 100644 index 000000000..948ba22da --- /dev/null +++ b/apiserver/plane/settings/selfhosted.py @@ -0,0 +1,128 @@ +"""Self hosted settings and globals.""" +from urllib.parse import urlparse + +import dj_database_url +from urllib.parse import urlparse + + +from .common import * # noqa + +# Database +DEBUG = int(os.environ.get("DEBUG", 0)) == 1 + +# Docker configurations +DOCKERIZED = 1 +USE_MINIO = 1 + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql", + "NAME": "plane", + "USER": os.environ.get("PGUSER", ""), + "PASSWORD": os.environ.get("PGPASSWORD", ""), + "HOST": os.environ.get("PGHOST", ""), + } +} + +# Parse database configuration from $DATABASE_URL +DATABASES["default"] = dj_database_url.config() +SITE_ID = 1 + +# File size limit +FILE_SIZE_LIMIT = int(os.environ.get("FILE_SIZE_LIMIT", 5242880)) + +CORS_ALLOW_METHODS = [ + "DELETE", + "GET", + "OPTIONS", + "PATCH", + "POST", + "PUT", +] + +CORS_ALLOW_HEADERS = [ + "accept", + "accept-encoding", + "authorization", + "content-type", + "dnt", + "origin", + "user-agent", + "x-csrftoken", + "x-requested-with", +] + +CORS_ALLOW_CREDENTIALS = True +CORS_ALLOW_ALL_ORIGINS = True + +STORAGES = { + "staticfiles": { + "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage", + }, +} + +INSTALLED_APPS += ("storages",) +STORAGES["default"] = {"BACKEND": "storages.backends.s3boto3.S3Boto3Storage"} +# The AWS access key to use. +AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID", "access-key") +# The AWS secret access key to use. +AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY", "secret-key") +# The name of the bucket to store files in. +AWS_STORAGE_BUCKET_NAME = os.environ.get("AWS_S3_BUCKET_NAME", "uploads") +# The full URL to the S3 endpoint. Leave blank to use the default region URL. +AWS_S3_ENDPOINT_URL = os.environ.get( + "AWS_S3_ENDPOINT_URL", "http://plane-minio:9000" +) +# Default permissions +AWS_DEFAULT_ACL = "public-read" +AWS_QUERYSTRING_AUTH = False +AWS_S3_FILE_OVERWRITE = False + +# Custom Domain settings +parsed_url = urlparse(os.environ.get("WEB_URL", "http://localhost")) +AWS_S3_CUSTOM_DOMAIN = f"{parsed_url.netloc}/{AWS_STORAGE_BUCKET_NAME}" +AWS_S3_URL_PROTOCOL = f"{parsed_url.scheme}:" + +# Honor the 'X-Forwarded-Proto' header for request.is_secure() +SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") + +# Allow all host headers +ALLOWED_HOSTS = [ + "*", +] + +# Security settings +SESSION_COOKIE_SECURE = True +CSRF_COOKIE_SECURE = True + +# Redis URL +REDIS_URL = os.environ.get("REDIS_URL") + +# Caches +CACHES = { + "default": { + "BACKEND": "django_redis.cache.RedisCache", + "LOCATION": REDIS_URL, + "OPTIONS": { + "CLIENT_CLASS": "django_redis.client.DefaultClient", + }, + } +} + +# URL used for email redirects +WEB_URL = os.environ.get("WEB_URL", "http://localhost") + +# Celery settings +CELERY_BROKER_URL = REDIS_URL +CELERY_RESULT_BACKEND = REDIS_URL + +# Enable or Disable signups +ENABLE_SIGNUP = os.environ.get("ENABLE_SIGNUP", "1") == "1" + +# Analytics +ANALYTICS_BASE_API = False + +# OPEN AI Settings +OPENAI_API_BASE = os.environ.get("OPENAI_API_BASE", "https://api.openai.com/v1") +OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", False) +GPT_ENGINE = os.environ.get("GPT_ENGINE", "gpt-3.5-turbo") diff --git a/docker-compose-hub.yml b/docker-compose-hub.yml index 0e42c83a8..0014dfe86 100644 --- a/docker-compose-hub.yml +++ b/docker-compose-hub.yml @@ -4,7 +4,7 @@ x-api-and-worker-env: &api-and-worker-env DEBUG: ${DEBUG} SENTRY_DSN: ${SENTRY_DSN} - DJANGO_SETTINGS_MODULE: plane.settings.production + DJANGO_SETTINGS_MODULE: plane.settings.selfhosted DATABASE_URL: postgres://${PGUSER}:${PGPASSWORD}@${PGHOST}:5432/${PGDATABASE} REDIS_URL: redis://plane-redis:6379/ EMAIL_HOST: ${EMAIL_HOST} diff --git a/setup.sh b/setup.sh index 87c0f445b..e028cc407 100755 --- a/setup.sh +++ b/setup.sh @@ -10,15 +10,4 @@ cp ./space/.env.example ./space/.env cp ./apiserver/.env.example ./apiserver/.env # Generate the SECRET_KEY that will be used by django -echo -e "SECRET_KEY=\"$(tr -dc 'a-z0-9' < /dev/urandom | head -c50)\"" >> ./apiserver/.env - -# Generate Prompt for taking tiptap auth key -echo -e "\n\e[1;38m Instructions for generating TipTap Pro Extensions Auth Token \e[0m \n" - -echo -e "\e[1;38m 1. Head over to TipTap cloud's Pro Extensions Page, https://collab.tiptap.dev/pro-extensions \e[0m" -echo -e "\e[1;38m 2. Copy the token given to you under the first paragraph, after 'Here it is' \e[0m \n" - -read -p $'\e[1;32m Please Enter Your TipTap Pro Extensions Authentication Token: \e[0m \e[1;36m' authToken - -echo "@tiptap-pro:registry=https://registry.tiptap.dev/ -//registry.tiptap.dev/:_authToken=${authToken}" > .npmrc \ No newline at end of file +echo -e "SECRET_KEY=\"$(tr -dc 'a-z0-9' < /dev/urandom | head -c50)\"" >> ./apiserver/.env \ No newline at end of file diff --git a/web/components/command-palette/command-pallette.tsx b/web/components/command-palette/command-pallette.tsx index 507d8a49c..f183de9c6 100644 --- a/web/components/command-palette/command-pallette.tsx +++ b/web/components/command-palette/command-pallette.tsx @@ -41,7 +41,7 @@ export const CommandPalette: React.FC = observer(() => { const [isCreateUpdatePageModalOpen, setIsCreateUpdatePageModalOpen] = useState(false); const router = useRouter(); - const { workspaceSlug, projectId, issueId, inboxId } = router.query; + const { workspaceSlug, projectId, issueId, inboxId, cycleId, moduleId } = router.query; const { user } = useUser(); @@ -183,6 +183,13 @@ export const CommandPalette: React.FC = observer(() => { isOpen={isIssueModalOpen} handleClose={() => setIsIssueModalOpen(false)} fieldsToShow={inboxId ? ["name", "description", "priority"] : ["all"]} + prePopulateData={ + cycleId + ? { cycle: cycleId.toString() } + : moduleId + ? { module: moduleId.toString() } + : undefined + } /> , }, - blocks: { + blocked_by: { message: (activity) => { if (activity.old_value === "") return ( @@ -176,6 +177,44 @@ const activityDetails: { }, icon: , }, + duplicate: { + message: (activity) => { + if (activity.old_value === "") + return ( + <> + marked this issue as duplicate of{" "} + {activity.new_value}. + + ); + else + return ( + <> + removed this issue as a duplicate of{" "} + {activity.old_value}. + + ); + }, + icon: , + }, + relates_to: { + message: (activity) => { + if (activity.old_value === "") + return ( + <> + marked that this issue relates to{" "} + {activity.new_value}. + + ); + else + return ( + <> + removed the relation from{" "} + {activity.old_value}. + + ); + }, + icon: , + }, cycles: { message: (activity, showIssue, workspaceSlug) => { if (activity.verb === "created") diff --git a/web/components/core/views/issues-view.tsx b/web/components/core/views/issues-view.tsx index 98b687207..55eefc396 100644 --- a/web/components/core/views/issues-view.tsx +++ b/web/components/core/views/issues-view.tsx @@ -23,7 +23,6 @@ import { CreateUpdateIssueModal, DeleteIssueModal, DeleteDraftIssueModal, - IssuePeekOverview, CreateUpdateDraftIssueModal, } from "components/issues"; import { CreateUpdateViewModal } from "components/views"; @@ -484,15 +483,7 @@ export const IssuesView: React.FC = ({ } : null } - fieldsToShow={[ - "name", - "description", - "label", - "assignee", - "priority", - "dueDate", - "priority", - ]} + fieldsToShow={["all"]} /> = (props) => { }; const handleDeletion = async () => { - if (!workspaceSlug || !data) return; + if (!workspaceSlug || !data || !user) return; setIsDeleteLoading(true); await issueServices - .deleteDraftIssue(workspaceSlug as string, data.project, data.id) + .deleteDraftIssue(workspaceSlug as string, data.project, data.id, user) .then(() => { setIsDeleteLoading(false); handleClose(); diff --git a/web/components/issues/draft-issue-modal.tsx b/web/components/issues/draft-issue-modal.tsx index 489a09d18..3b0664cb8 100644 --- a/web/components/issues/draft-issue-modal.tsx +++ b/web/components/issues/draft-issue-modal.tsx @@ -14,6 +14,7 @@ import useIssuesView from "hooks/use-issues-view"; import useCalendarIssuesView from "hooks/use-calendar-issues-view"; import useToast from "hooks/use-toast"; import useSpreadsheetIssuesView from "hooks/use-spreadsheet-issues-view"; +import useLocalStorage from "hooks/use-local-storage"; import useProjects from "hooks/use-projects"; import useMyIssues from "hooks/my-issues/use-my-issues"; // components @@ -79,6 +80,8 @@ export const CreateUpdateDraftIssueModal: React.FC = ({ const { user } = useUser(); const { projects } = useProjects(); + const { clearValue: clearDraftIssueLocalStorage } = useLocalStorage("draftedIssue", {}); + const { groupedIssues, mutateMyIssues } = useMyIssues(workspaceSlug?.toString()); const { setToastAlert } = useToast(); @@ -111,11 +114,14 @@ export const CreateUpdateDraftIssueModal: React.FC = ({ return; } + if (prePopulateData && prePopulateData.project) + return setActiveProject(prePopulateData.project); + // if data is not present, set active project to the project // in the url. This has the least priority. if (projects && projects.length > 0 && !activeProject) setActiveProject(projects?.find((p) => p.id === projectId)?.id ?? projects?.[0].id ?? null); - }, [activeProject, data, projectId, projects, isOpen]); + }, [activeProject, data, projectId, projects, isOpen, prePopulateData]); const calendarFetchKey = cycleId ? CYCLE_ISSUES_WITH_PARAMS(cycleId.toString(), calendarParams) @@ -228,6 +234,8 @@ export const CreateUpdateDraftIssueModal: React.FC = ({ if (!data) await createIssue(payload); else await updateIssue(payload); + clearDraftIssueLocalStorage(); + if (onSubmit) await onSubmit(payload); }; diff --git a/web/components/issues/form.tsx b/web/components/issues/form.tsx index e6b4f9e08..2e53f3866 100644 --- a/web/components/issues/form.tsx +++ b/web/components/issues/form.tsx @@ -8,7 +8,6 @@ import { Controller, useForm } from "react-hook-form"; import aiService from "services/ai.service"; // hooks import useToast from "hooks/use-toast"; -import useLocalStorage from "hooks/use-local-storage"; // components import { GptAssistantModal } from "components/core"; import { ParentIssuesListModal } from "components/issues"; @@ -62,11 +61,9 @@ export interface IssueFormProps { setActiveProject: React.Dispatch>; createMore: boolean; setCreateMore: React.Dispatch>; - handleClose: () => void; handleDiscardClose: () => void; status: boolean; user: ICurrentUserResponse | undefined; - setIsConfirmDiscardOpen: React.Dispatch>; handleFormDirty: (payload: Partial | null) => void; fieldsToShow: ( | "project" @@ -107,8 +104,6 @@ export const IssueForm: FC = (props) => { const [gptAssistantModal, setGptAssistantModal] = useState(false); const [iAmFeelingLucky, setIAmFeelingLucky] = useState(false); - const { setValue: setValueInLocalStorage } = useLocalStorage("draftedIssue", null); - const editorRef = useRef(null); const router = useRouter(); @@ -139,9 +134,11 @@ export const IssueForm: FC = (props) => { state: getValues("state"), priority: getValues("priority"), assignees: getValues("assignees"), - target_date: getValues("target_date"), labels: getValues("labels"), + start_date: getValues("start_date"), + target_date: getValues("target_date"), project: getValues("project"), + parent: getValues("parent"), }; useEffect(() => { @@ -571,8 +568,6 @@ export const IssueForm: FC = (props) => {
{ - const data = JSON.stringify(getValues()); - setValueInLocalStorage(data); handleDiscardClose(); }} > diff --git a/web/components/issues/modal.tsx b/web/components/issues/modal.tsx index d6ab43491..65580c94a 100644 --- a/web/components/issues/modal.tsx +++ b/web/components/issues/modal.tsx @@ -19,6 +19,7 @@ import useInboxView from "hooks/use-inbox-view"; import useSpreadsheetIssuesView from "hooks/use-spreadsheet-issues-view"; import useProjects from "hooks/use-projects"; import useMyIssues from "hooks/my-issues/use-my-issues"; +import useLocalStorage from "hooks/use-local-storage"; // components import { IssueForm, ConfirmIssueDiscard } from "components/issues"; // types @@ -92,10 +93,11 @@ export const CreateUpdateIssueModal: React.FC = ({ const { groupedIssues, mutateMyIssues } = useMyIssues(workspaceSlug?.toString()); + const { setValue: setValueInLocalStorage, clearValue: clearLocalStorageValue } = + useLocalStorage("draftedIssue", {}); + const { setToastAlert } = useToast(); - if (cycleId) prePopulateData = { ...prePopulateData, cycle: cycleId as string }; - if (moduleId) prePopulateData = { ...prePopulateData, module: moduleId as string }; if (router.asPath.includes("my-issues") || router.asPath.includes("assigned")) prePopulateData = { ...prePopulateData, @@ -103,6 +105,13 @@ export const CreateUpdateIssueModal: React.FC = ({ }; const onClose = () => { + if (!showConfirmDiscard) handleClose(); + if (formDirtyState === null) return setActiveProject(null); + const data = JSON.stringify(formDirtyState); + setValueInLocalStorage(data); + }; + + const onDiscardClose = () => { if (formDirtyState !== null) { setShowConfirmDiscard(true); } else { @@ -111,11 +120,6 @@ export const CreateUpdateIssueModal: React.FC = ({ } }; - const onDiscardClose = () => { - handleClose(); - setActiveProject(null); - }; - const handleFormDirty = (data: any) => { setFormDirtyState(data); }; @@ -397,6 +401,7 @@ export const CreateUpdateIssueModal: React.FC = ({ setActiveProject(null); setFormDirtyState(null); setShowConfirmDiscard(false); + clearLocalStorageValue(); }} /> @@ -431,9 +436,7 @@ export const CreateUpdateIssueModal: React.FC = ({ initialData={data ?? prePopulateData} createMore={createMore} setCreateMore={setCreateMore} - handleClose={onClose} handleDiscardClose={onDiscardClose} - setIsConfirmDiscardOpen={setShowConfirmDiscard} projectId={activeProject ?? ""} setActiveProject={setActiveProject} status={data ? true : false} diff --git a/web/components/issues/sidebar-select/blocked.tsx b/web/components/issues/sidebar-select/blocked.tsx index 9554a83ba..d7e448377 100644 --- a/web/components/issues/sidebar-select/blocked.tsx +++ b/web/components/issues/sidebar-select/blocked.tsx @@ -73,7 +73,7 @@ export const SidebarBlockedSelect: React.FC = ({ ...selectedIssues.map((issue) => ({ issue: issueId as string, relation_type: "blocked_by" as const, - related_issue_detail: issue.blocked_issue_detail, + issue_detail: issue.blocked_issue_detail, related_issue: issue.blocked_issue_detail.id, })), ], @@ -111,17 +111,17 @@ export const SidebarBlockedSelect: React.FC = ({ {blockedByIssue && blockedByIssue.length > 0 ? blockedByIssue.map((relation) => (
- {`${relation.related_issue_detail?.project_detail.identifier}-${relation.related_issue_detail?.sequence_id}`} + {`${relation.issue_detail?.project_detail.identifier}-${relation.issue_detail?.sequence_id}`}