diff --git a/apiserver/plane/api/permissions/__init__.py b/apiserver/plane/api/permissions/__init__.py index 71ec4815de3..91b3aea356c 100644 --- a/apiserver/plane/api/permissions/__init__.py +++ b/apiserver/plane/api/permissions/__init__.py @@ -1,2 +1,2 @@ -from .workspace import WorkSpaceBasePermission, WorkSpaceAdminPermission -from .project import ProjectBasePermission, ProjectEntityPermission, ProjectMemberPermission +from .workspace import WorkSpaceBasePermission, WorkSpaceAdminPermission, WorkspaceEntityPermission +from .project import ProjectBasePermission, ProjectEntityPermission, ProjectMemberPermission, ProjectLitePermission diff --git a/apiserver/plane/api/permissions/project.py b/apiserver/plane/api/permissions/project.py index eea5192d5da..e4e3e0f9bc3 100644 --- a/apiserver/plane/api/permissions/project.py +++ b/apiserver/plane/api/permissions/project.py @@ -89,3 +89,16 @@ def has_permission(self, request, view): role__in=[Admin, Member], project_id=view.project_id, ).exists() + + +class ProjectLitePermission(BasePermission): + + def has_permission(self, request, view): + if request.user.is_anonymous: + return False + + return ProjectMember.objects.filter( + workspace__slug=view.workspace_slug, + member=request.user, + project_id=view.project_id, + ).exists() \ No newline at end of file diff --git a/apiserver/plane/api/permissions/workspace.py b/apiserver/plane/api/permissions/workspace.py index 2a2e1d33915..7fccc455e94 100644 --- a/apiserver/plane/api/permissions/workspace.py +++ b/apiserver/plane/api/permissions/workspace.py @@ -5,7 +5,6 @@ from plane.db.models import WorkspaceMember - # Permission Mappings Owner = 20 Admin = 15 @@ -44,7 +43,6 @@ def has_permission(self, request, view): class WorkSpaceAdminPermission(BasePermission): def has_permission(self, request, view): - if request.user.is_anonymous: return False @@ -53,3 +51,13 @@ def has_permission(self, request, view): workspace__slug=view.workspace_slug, role__in=[Owner, Admin], ).exists() + + +class WorkspaceEntityPermission(BasePermission): + def has_permission(self, request, view): + if request.user.is_anonymous: + return False + + return WorkspaceMember.objects.filter( + member=request.user, workspace__slug=view.workspace_slug + ).exists() diff --git a/apiserver/plane/api/serializers/__init__.py b/apiserver/plane/api/serializers/__init__.py index 505a9978d5b..cb94f8068da 100644 --- a/apiserver/plane/api/serializers/__init__.py +++ b/apiserver/plane/api/serializers/__init__.py @@ -69,6 +69,11 @@ from .page import PageSerializer, PageBlockSerializer, PageFavoriteSerializer -from .estimate import EstimateSerializer, EstimatePointSerializer, EstimateReadSerializer +from .estimate import ( + EstimateSerializer, + EstimatePointSerializer, + EstimateReadSerializer, +) +from .inbox import InboxSerializer, InboxIssueSerializer, IssueStateInboxSerializer from .analytic import AnalyticViewSerializer diff --git a/apiserver/plane/api/serializers/inbox.py b/apiserver/plane/api/serializers/inbox.py new file mode 100644 index 00000000000..ae17b749bfa --- /dev/null +++ b/apiserver/plane/api/serializers/inbox.py @@ -0,0 +1,58 @@ +# Third party frameworks +from rest_framework import serializers + +# Module imports +from .base import BaseSerializer +from .issue import IssueFlatSerializer, LabelLiteSerializer +from .project import ProjectLiteSerializer +from .state import StateLiteSerializer +from .project import ProjectLiteSerializer +from .user import UserLiteSerializer +from plane.db.models import Inbox, InboxIssue, Issue + + +class InboxSerializer(BaseSerializer): + project_detail = ProjectLiteSerializer(source="project", read_only=True) + pending_issue_count = serializers.IntegerField(read_only=True) + + class Meta: + model = Inbox + fields = "__all__" + read_only_fields = [ + "project", + "workspace", + ] + + +class InboxIssueSerializer(BaseSerializer): + issue_detail = IssueFlatSerializer(source="issue", read_only=True) + project_detail = ProjectLiteSerializer(source="project", read_only=True) + + class Meta: + model = InboxIssue + fields = "__all__" + read_only_fields = [ + "project", + "workspace", + ] + + +class InboxIssueLiteSerializer(BaseSerializer): + class Meta: + model = InboxIssue + fields = ["id", "status", "duplicate_to", "snoozed_till", "source"] + read_only_fields = fields + + +class IssueStateInboxSerializer(BaseSerializer): + state_detail = StateLiteSerializer(read_only=True, source="state") + project_detail = ProjectLiteSerializer(read_only=True, source="project") + label_details = LabelLiteSerializer(read_only=True, source="labels", many=True) + assignee_details = UserLiteSerializer(read_only=True, source="assignees", many=True) + sub_issues_count = serializers.IntegerField(read_only=True) + bridge_id = serializers.UUIDField(read_only=True) + issue_inbox = InboxIssueLiteSerializer(read_only=True, many=True) + + class Meta: + model = Issue + fields = "__all__" diff --git a/apiserver/plane/api/serializers/issue.py b/apiserver/plane/api/serializers/issue.py index d3c17d05722..e4825937002 100644 --- a/apiserver/plane/api/serializers/issue.py +++ b/apiserver/plane/api/serializers/issue.py @@ -41,6 +41,7 @@ class Meta: "id", "name", "description", + "description_html", "priority", "start_date", "target_date", diff --git a/apiserver/plane/api/urls.py b/apiserver/plane/api/urls.py index bf5180ff85f..92f8339edd0 100644 --- a/apiserver/plane/api/urls.py +++ b/apiserver/plane/api/urls.py @@ -140,6 +140,10 @@ # Release Notes ReleaseNotesEndpoint, ## End Release Notes + # Inbox + InboxViewSet, + InboxIssueViewSet, + ## End Inbox # Analytics AnalyticsEndpoint, AnalyticViewViewset, @@ -1242,6 +1246,50 @@ name="release-notes", ), ## End Release Notes + # Inbox + path( + "workspaces//projects//inboxes/", + InboxViewSet.as_view( + { + "get": "list", + "post": "create", + } + ), + name="inbox", + ), + path( + "workspaces//projects//inboxes//", + InboxViewSet.as_view( + { + "get": "retrieve", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="inbox", + ), + path( + "workspaces//projects//inboxes//inbox-issues/", + InboxIssueViewSet.as_view( + { + "get": "list", + "post": "create", + } + ), + name="inbox-issue", + ), + path( + "workspaces//projects//inboxes//inbox-issues//", + InboxIssueViewSet.as_view( + { + "get": "retrieve", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="inbox-issue", + ), + ## End Inbox # Analytics path( "workspaces//analytics/", diff --git a/apiserver/plane/api/views/__init__.py b/apiserver/plane/api/views/__init__.py index 4177b137162..5f3e6a9e937 100644 --- a/apiserver/plane/api/views/__init__.py +++ b/apiserver/plane/api/views/__init__.py @@ -133,6 +133,7 @@ from .release import ReleaseNotesEndpoint +from .inbox import InboxViewSet, InboxIssueViewSet from .analytic import ( AnalyticsEndpoint, AnalyticViewViewset, diff --git a/apiserver/plane/api/views/analytic.py b/apiserver/plane/api/views/analytic.py index 56ca12baef2..a096c2700d3 100644 --- a/apiserver/plane/api/views/analytic.py +++ b/apiserver/plane/api/views/analytic.py @@ -40,7 +40,7 @@ def get(self, request, slug): segment = request.GET.get("segment", False) filters = issue_filters(request.GET, "GET") - queryset = Issue.objects.filter(workspace__slug=slug, **filters) + queryset = Issue.issue_objects.filter(workspace__slug=slug, **filters) total_issues = queryset.count() distribution = build_graph_plot( @@ -79,7 +79,7 @@ def get(self, request, slug): assignee_details = {} if x_axis in ["assignees__email"] or segment in ["assignees__email"]: assignee_details = ( - Issue.objects.filter(workspace__slug=slug, **filters, assignees__avatar__isnull=False) + Issue.issue_objects.filter(workspace__slug=slug, **filters, assignees__avatar__isnull=False) .order_by("assignees__id") .distinct("assignees__id") .values("assignees__avatar", "assignees__email", "assignees__first_name", "assignees__last_name") @@ -132,7 +132,7 @@ def get(self, request, slug, analytic_id): ) filter = analytic_view.query - queryset = Issue.objects.filter(**filter) + queryset = Issue.issue_objects.filter(**filter) x_axis = analytic_view.query_dict.get("x_axis", False) y_axis = analytic_view.query_dict.get("y_axis", False) @@ -209,7 +209,7 @@ def get(self, request, slug): try: filters = issue_filters(request.GET, "GET") - queryset = Issue.objects.filter(workspace__slug=slug, **filters) + queryset = Issue.issue_objects.filter(workspace__slug=slug, **filters) total_issues = queryset.count() diff --git a/apiserver/plane/api/views/cycle.py b/apiserver/plane/api/views/cycle.py index 63c832e71fc..86c1002d15f 100644 --- a/apiserver/plane/api/views/cycle.py +++ b/apiserver/plane/api/views/cycle.py @@ -323,7 +323,7 @@ def get_queryset(self): super() .get_queryset() .annotate( - sub_issues_count=Issue.objects.filter(parent=OuterRef("issue_id")) + sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("issue_id")) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") @@ -347,9 +347,9 @@ def list(self, request, slug, project_id, cycle_id): group_by = request.GET.get("group_by", False) filters = issue_filters(request.query_params, "GET") issues = ( - Issue.objects.filter(issue_cycle__cycle_id=cycle_id) + Issue.issue_objects.filter(issue_cycle__cycle_id=cycle_id) .annotate( - sub_issues_count=Issue.objects.filter(parent=OuterRef("id")) + sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") diff --git a/apiserver/plane/api/views/importer.py b/apiserver/plane/api/views/importer.py index 2e0f1cec00e..53bcae0e8f0 100644 --- a/apiserver/plane/api/views/importer.py +++ b/apiserver/plane/api/views/importer.py @@ -241,7 +241,7 @@ def delete(self, request, slug, service, pk): ) # Delete all imported Issues imported_issues = importer.imported_data.get("issues", []) - Issue.objects.filter(id__in=imported_issues).delete() + Issue.issue_objects.filter(id__in=imported_issues).delete() # Delete all imported Labels imported_labels = importer.imported_data.get("labels", []) diff --git a/apiserver/plane/api/views/inbox.py b/apiserver/plane/api/views/inbox.py new file mode 100644 index 00000000000..f76638c1708 --- /dev/null +++ b/apiserver/plane/api/views/inbox.py @@ -0,0 +1,349 @@ +# Python imports +import json + +# Django import +from django.utils import timezone +from django.db.models import Q, Count, OuterRef, Func, F, Prefetch +from django.core.serializers.json import DjangoJSONEncoder + +# Third party imports +from rest_framework import status +from rest_framework.response import Response +from sentry_sdk import capture_exception + +# Module imports +from .base import BaseViewSet +from plane.api.permissions import ProjectBasePermission, ProjectLitePermission +from plane.db.models import ( + Project, + Inbox, + InboxIssue, + Issue, + State, + IssueLink, + IssueAttachment, + IssueActivity, +) +from plane.api.serializers import ( + IssueSerializer, + InboxSerializer, + InboxIssueSerializer, + IssueCreateSerializer, + IssueStateInboxSerializer, +) +from plane.utils.issue_filters import issue_filters +from plane.bgtasks.issue_activites_task import issue_activity + + +class InboxViewSet(BaseViewSet): + permission_classes = [ + ProjectBasePermission, + ] + + serializer_class = InboxSerializer + model = Inbox + + def get_queryset(self): + return ( + super() + .get_queryset() + .filter( + workspace__slug=self.kwargs.get("slug"), + project_id=self.kwargs.get("project_id"), + ) + .annotate( + pending_issue_count=Count( + "issue_inbox", + filter=Q(issue_inbox__status=-2), + ) + ) + .select_related("workspace", "project") + ) + + def perform_create(self, serializer): + serializer.save(project_id=self.kwargs.get("project_id")) + + def destroy(self, request, slug, project_id, pk): + try: + inbox = Inbox.objects.get( + workspace__slug=slug, project_id=project_id, pk=pk + ) + + if inbox.is_default: + return Response( + {"error": "You cannot delete the default inbox"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + inbox.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + except Exception as e: + capture_exception(e) + return Response( + {"error": "Something went wronf please try again later"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + +class InboxIssueViewSet(BaseViewSet): + permission_classes = [ + ProjectLitePermission, + ] + + serializer_class = InboxIssueSerializer + model = InboxIssue + + filterset_fields = [ + "status", + ] + + def get_queryset(self): + return self.filter_queryset( + super() + .get_queryset() + .filter( + Q(snoozed_till__gte=timezone.now()) | Q(snoozed_till__isnull=True), + workspace__slug=self.kwargs.get("slug"), + project_id=self.kwargs.get("project_id"), + inbox_id=self.kwargs.get("inbox_id"), + ) + .select_related("issue", "workspace", "project") + ) + + def list(self, request, slug, project_id, inbox_id): + try: + order_by = request.GET.get("order_by", "created_at") + filters = issue_filters(request.query_params, "GET") + issues = ( + Issue.objects.filter( + issue_inbox__inbox_id=inbox_id, + workspace__slug=slug, + project_id=project_id, + ) + .annotate( + sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate(bridge_id=F("issue_inbox__id")) + .filter(project_id=project_id) + .filter(workspace__slug=slug) + .select_related("project") + .select_related("workspace") + .select_related("state") + .select_related("parent") + .prefetch_related("assignees") + .prefetch_related("labels") + .order_by(order_by) + .filter(**filters) + .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") + ) + .prefetch_related( + Prefetch( + "issue_inbox", + queryset=InboxIssue.objects.only( + "status", "duplicate_to", "snoozed_till", "source" + ), + ) + ) + ) + issues_data = IssueStateInboxSerializer(issues, many=True).data + return Response( + issues_data, + 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, + ) + + def create(self, request, slug, project_id, inbox_id): + try: + if not request.data.get("issue", {}).get("name", False): + return Response( + {"error": "Name is required"}, status=status.HTTP_400_BAD_REQUEST + ) + + if not request.data.get("issue", {}).get("priority", "low") in [ + "low", + "medium", + "high", + "urgent", + None, + ]: + return Response( + {"error": "Invalid priority"}, status=status.HTTP_400_BAD_REQUEST + ) + + # Create or get state + state, _ = State.objects.get_or_create( + name="Triage", + group="backlog", + description="Default state for managing all Inbox Issues", + project_id=project_id, + color="#ff7700", + ) + + # create an issue + issue = Issue.objects.create( + name=request.data.get("issue", {}).get("name"), + description=request.data.get("issue", {}).get("description", {}), + description_html=request.data.get("issue", {}).get( + "description_html", "

" + ), + priority=request.data.get("issue", {}).get("priority", "low"), + project_id=project_id, + state=state, + ) + + # Create an Issue Activity + # Track the issue + issue_activity.delay( + type="issue.activity.created", + requested_data=json.dumps(request.data, cls=DjangoJSONEncoder), + actor_id=str(request.user.id), + issue_id=str(issue.id), + project_id=str(project_id), + current_instance=None, + ) + # create an inbox issue + InboxIssue.objects.create( + inbox_id=inbox_id, + project_id=project_id, + issue=issue, + source=request.data.get("source", "in-app"), + ) + + serializer = IssueStateInboxSerializer(issue) + + return Response(serializer.data, 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, + ) + + def partial_update(self, request, slug, project_id, inbox_id, pk): + try: + inbox_issue = InboxIssue.objects.get( + pk=pk, workspace__slug=slug, project_id=project_id, inbox_id=inbox_id + ) + + issue_data = request.data.pop("issue", False) + + if bool(issue_data): + issue = Issue.objects.get( + pk=inbox_issue.issue_id, workspace__slug=slug, project_id=project_id + ) + issue_serializer = IssueCreateSerializer( + issue, data=issue_data, partial=True + ) + + if issue_serializer.is_valid(): + current_instance = issue + issue_serializer.save() + # Log all the updates + requested_data = json.dumps(issue_data, cls=DjangoJSONEncoder) + if issue is not None: + issue_activity.delay( + type="issue.activity.updated", + requested_data=requested_data, + actor_id=str(request.user.id), + issue_id=str(issue.id), + project_id=str(project_id), + current_instance=json.dumps( + IssueSerializer(current_instance).data, + cls=DjangoJSONEncoder, + ), + ) + else: + return Response( + issue_serializer.errors, status=status.HTTP_400_BAD_REQUEST + ) + + serializer = InboxIssueSerializer( + inbox_issue, data=request.data, partial=True + ) + + if serializer.is_valid(): + serializer.save() + # Update the issue state if the issue is rejected or marked as duplicate + if serializer.data["status"] in [-1, 2]: + issue = Issue.objects.get( + pk=inbox_issue.issue_id, + workspace__slug=slug, + project_id=project_id, + ) + state = State.objects.filter( + group="cancelled", workspace__slug=slug, project_id=project_id + ).first() + if state is not None: + issue.state = state + issue.save() + + # Update the issue state if it is accepted + if serializer.data["status"] in [1]: + issue = Issue.objects.get( + pk=inbox_issue.issue_id, + workspace__slug=slug, + project_id=project_id, + ) + + # Update the issue state only if it is in triage state + if issue.state.name == "Triage": + # Move to default state + state = State.objects.filter( + workspace__slug=slug, project_id=project_id, default=True + ).first() + if state is not None: + issue.state = state + issue.save() + + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + except InboxIssue.DoesNotExist: + return Response( + {"error": "Inbox Issue does not exist"}, + status=status.HTTP_400_BAD_REQUEST, + ) + except Exception as e: + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + def retrieve(self, request, slug, project_id, inbox_id, pk): + try: + inbox_issue = InboxIssue.objects.get( + pk=pk, workspace__slug=slug, project_id=project_id, inbox_id=inbox_id + ) + issue = Issue.objects.get( + pk=inbox_issue.issue_id, workspace__slug=slug, project_id=project_id + ) + serializer = IssueStateInboxSerializer(issue) + return Response(serializer.data, 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, + ) diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py index e6c37374b34..794fecf5c1a 100644 --- a/apiserver/plane/api/views/issue.py +++ b/apiserver/plane/api/views/issue.py @@ -132,10 +132,8 @@ def perform_destroy(self, instance): def get_queryset(self): return ( - super() - .get_queryset() - .annotate( - sub_issues_count=Issue.objects.filter(parent=OuterRef("id")) + 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") @@ -248,7 +246,7 @@ def create(self, request, slug, project_id): def retrieve(self, request, slug, project_id, pk=None): try: - issue = Issue.objects.get( + issue = Issue.issue_objects.get( workspace__slug=slug, project_id=project_id, pk=pk ) return Response(IssueSerializer(issue).data, status=status.HTTP_200_OK) @@ -263,9 +261,9 @@ class UserWorkSpaceIssues(BaseAPIView): def get(self, request, slug): try: issues = ( - Issue.objects.filter(assignees__in=[request.user], workspace__slug=slug) + Issue.issue_objects.filter(assignees__in=[request.user], workspace__slug=slug) .annotate( - sub_issues_count=Issue.objects.filter(parent=OuterRef("id")) + sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") @@ -311,7 +309,7 @@ class WorkSpaceIssuesEndpoint(BaseAPIView): def get(self, request, slug): try: issues = ( - Issue.objects.filter(workspace__slug=slug) + Issue.issue_objects.filter(workspace__slug=slug) .filter(project__project_projectmember__member=self.request.user) .order_by("-created_at") ) @@ -581,7 +579,7 @@ def delete(self, request, slug, project_id): status=status.HTTP_400_BAD_REQUEST, ) - issues = Issue.objects.filter( + issues = Issue.issue_objects.filter( workspace__slug=slug, project_id=project_id, pk__in=issue_ids ) @@ -610,7 +608,7 @@ class SubIssuesEndpoint(BaseAPIView): def get(self, request, slug, project_id, issue_id): try: sub_issues = ( - Issue.objects.filter( + Issue.issue_objects.filter( parent_id=issue_id, workspace__slug=slug, project_id=project_id ) .select_related("project") @@ -656,7 +654,7 @@ def get(self, request, slug, project_id, issue_id): # Assign multiple sub issues def post(self, request, slug, project_id, issue_id): try: - parent_issue = Issue.objects.get(pk=issue_id) + parent_issue = Issue.issue_objects.get(pk=issue_id) sub_issue_ids = request.data.get("sub_issue_ids", []) if not len(sub_issue_ids): @@ -665,14 +663,14 @@ def post(self, request, slug, project_id, issue_id): status=status.HTTP_400_BAD_REQUEST, ) - sub_issues = Issue.objects.filter(id__in=sub_issue_ids) + sub_issues = Issue.issue_objects.filter(id__in=sub_issue_ids) for sub_issue in sub_issues: sub_issue.parent = parent_issue _ = Issue.objects.bulk_update(sub_issues, ["parent"], batch_size=10) - updated_sub_issues = Issue.objects.filter(id__in=sub_issue_ids) + updated_sub_issues = Issue.issue_objects.filter(id__in=sub_issue_ids) return Response( IssueFlatSerializer(updated_sub_issues, many=True).data, diff --git a/apiserver/plane/api/views/module.py b/apiserver/plane/api/views/module.py index 8f0cabeaf35..7c285c24244 100644 --- a/apiserver/plane/api/views/module.py +++ b/apiserver/plane/api/views/module.py @@ -201,7 +201,7 @@ def get_queryset(self): super() .get_queryset() .annotate( - sub_issues_count=Issue.objects.filter(parent=OuterRef("issue")) + sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("issue")) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") @@ -226,9 +226,9 @@ def list(self, request, slug, project_id, module_id): group_by = request.GET.get("group_by", False) filters = issue_filters(request.query_params, "GET") issues = ( - Issue.objects.filter(issue_module__module_id=module_id) + Issue.issue_objects.filter(issue_module__module_id=module_id) .annotate( - sub_issues_count=Issue.objects.filter(parent=OuterRef("id")) + sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") diff --git a/apiserver/plane/api/views/people.py b/apiserver/plane/api/views/people.py index fcf95ff6474..9dad5380bda 100644 --- a/apiserver/plane/api/views/people.py +++ b/apiserver/plane/api/views/people.py @@ -37,7 +37,7 @@ def retrieve(self, request): workspace_invites = WorkspaceMemberInvite.objects.filter( email=request.user.email ).count() - assigned_issues = Issue.objects.filter(assignees__in=[request.user]).count() + assigned_issues = Issue.issue_objects.filter(assignees__in=[request.user]).count() serialized_data = UserSerializer(request.user).data serialized_data["workspace"] = { @@ -59,7 +59,7 @@ def retrieve(self, request): workspace_invites = WorkspaceMemberInvite.objects.filter( email=request.user.email ).count() - assigned_issues = Issue.objects.filter(assignees__in=[request.user]).count() + assigned_issues = Issue.issue_objects.filter(assignees__in=[request.user]).count() fallback_workspace = Workspace.objects.filter( workspace_member__member=request.user diff --git a/apiserver/plane/api/views/project.py b/apiserver/plane/api/views/project.py index bdb758ac9c3..c91878658bc 100644 --- a/apiserver/plane/api/views/project.py +++ b/apiserver/plane/api/views/project.py @@ -47,9 +47,9 @@ Page, IssueAssignee, ModuleMember, + Inbox, ) - from plane.bgtasks.project_invitation_task import project_invitation @@ -238,6 +238,20 @@ def partial_update(self, request, slug, pk=None): if serializer.is_valid(): serializer.save() + if serializer.data["inbox_view"]: + Inbox.objects.get_or_create( + name=f"{project.name} Inbox", project=project, is_default=True + ) + + # Create the triage state in Backlog group + State.objects.get_or_create( + name="Triage", + group="backlog", + description="Default state for managing all Inbox Issues", + project_id=pk, + color="#ff7700" + ) + return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -467,7 +481,9 @@ def destroy(self, request, slug, project_id, pk): ) if requesting_project_member.role < project_member.role: return Response( - {"error": "You cannot remove a user having role higher than yourself"}, + { + "error": "You cannot remove a user having role higher than yourself" + }, status=status.HTTP_400_BAD_REQUEST, ) diff --git a/apiserver/plane/api/views/search.py b/apiserver/plane/api/views/search.py index 88dddc43c05..078a9a6a5e2 100644 --- a/apiserver/plane/api/views/search.py +++ b/apiserver/plane/api/views/search.py @@ -57,7 +57,7 @@ def filter_issues(self, query, slug, project_id): else: q |= Q(**{f"{field}__icontains": query}) return ( - Issue.objects.filter( + Issue.issue_objects.filter( q, project__project_projectmember__member=self.request.user, workspace__slug=slug, @@ -210,7 +210,7 @@ def get(self, request, slug, project_id): blocker_blocked_by = request.query_params.get("blocker_blocked_by", False) issue_id = request.query_params.get("issue_id", False) - issues = Issue.objects.filter( + issues = Issue.issue_objects.filter( workspace__slug=slug, project_id=project_id, project__project_projectmember__member=self.request.user, @@ -220,16 +220,16 @@ def get(self, request, slug, project_id): issues = search_issues(query, issues) if parent == "true" and issue_id: - issue = Issue.objects.get(pk=issue_id) + issue = Issue.issue_objects.get(pk=issue_id) issues = issues.filter( ~Q(pk=issue_id), ~Q(pk=issue.parent_id), parent__isnull=True ).exclude( - pk__in=Issue.objects.filter(parent__isnull=False).values_list( + pk__in=Issue.issue_objects.filter(parent__isnull=False).values_list( "parent_id", flat=True ) ) if blocker_blocked_by == "true" and issue_id: - issue = Issue.objects.get(pk=issue_id) + issue = Issue.issue_objects.get(pk=issue_id) issues = issues.filter( ~Q(pk=issue_id), ~Q(blocked_issues__block=issue), diff --git a/apiserver/plane/api/views/state.py b/apiserver/plane/api/views/state.py index b217a662d06..29cba7a74ff 100644 --- a/apiserver/plane/api/views/state.py +++ b/apiserver/plane/api/views/state.py @@ -89,7 +89,7 @@ def destroy(self, request, slug, project_id, pk): ) # Check for any issues in the state - issue_exist = Issue.objects.filter(state=pk).exists() + issue_exist = Issue.issue_objects.filter(state=pk).exists() if issue_exist: return Response( diff --git a/apiserver/plane/api/views/view.py b/apiserver/plane/api/views/view.py index 1b6fb42ccb6..874bb94fb16 100644 --- a/apiserver/plane/api/views/view.py +++ b/apiserver/plane/api/views/view.py @@ -67,7 +67,7 @@ def get(self, request, slug, project_id, view_id): filters = issue_filters(request.query_params, "GET") issues = ( - Issue.objects.filter( + Issue.issue_objects.filter( **queries, project_id=project_id, workspace__slug=slug ) .filter(**filters) diff --git a/apiserver/plane/api/views/workspace.py b/apiserver/plane/api/views/workspace.py index 2f3fcb55835..7fb98b60bc3 100644 --- a/apiserver/plane/api/views/workspace.py +++ b/apiserver/plane/api/views/workspace.py @@ -744,7 +744,7 @@ def get(self, request, slug): month = request.GET.get("month", 1) issues = ( - Issue.objects.filter( + Issue.issue_objects.filter( assignees__in=[request.user], workspace__slug=slug, completed_at__month=month, @@ -789,7 +789,7 @@ def get(self, request, slug): month = request.GET.get("month", 1) completed_issues = ( - Issue.objects.filter( + Issue.issue_objects.filter( assignees__in=[request.user], workspace__slug=slug, completed_at__month=month, @@ -802,24 +802,24 @@ def get(self, request, slug): .order_by("week_in_month") ) - assigned_issues = Issue.objects.filter( + assigned_issues = Issue.issue_objects.filter( workspace__slug=slug, assignees__in=[request.user] ).count() - pending_issues_count = Issue.objects.filter( + pending_issues_count = Issue.issue_objects.filter( ~Q(state__group__in=["completed", "cancelled"]), workspace__slug=slug, assignees__in=[request.user], ).count() - completed_issues_count = Issue.objects.filter( + completed_issues_count = Issue.issue_objects.filter( workspace__slug=slug, assignees__in=[request.user], state__group="completed", ).count() issues_due_week = ( - Issue.objects.filter( + Issue.issue_objects.filter( workspace__slug=slug, assignees__in=[request.user], ) @@ -829,14 +829,14 @@ def get(self, request, slug): ) state_distribution = ( - Issue.objects.filter(workspace__slug=slug, assignees__in=[request.user]) + Issue.issue_objects.filter(workspace__slug=slug, assignees__in=[request.user]) .annotate(state_group=F("state__group")) .values("state_group") .annotate(state_count=Count("state_group")) .order_by("state_group") ) - overdue_issues = Issue.objects.filter( + overdue_issues = Issue.issue_objects.filter( ~Q(state__group__in=["completed", "cancelled"]), workspace__slug=slug, assignees__in=[request.user], @@ -844,7 +844,7 @@ def get(self, request, slug): completed_at__isnull=True, ).values("id", "name", "workspace__slug", "project_id", "target_date") - upcoming_issues = Issue.objects.filter( + upcoming_issues = Issue.issue_objects.filter( ~Q(state__group__in=["completed", "cancelled"]), target_date__gte=timezone.now(), workspace__slug=slug, diff --git a/apiserver/plane/bgtasks/analytic_plot_export.py b/apiserver/plane/bgtasks/analytic_plot_export.py index 7f276be82c7..37362416f8e 100644 --- a/apiserver/plane/bgtasks/analytic_plot_export.py +++ b/apiserver/plane/bgtasks/analytic_plot_export.py @@ -36,7 +36,7 @@ def analytic_export_task(email, data, slug): try: filters = issue_filters(data, "POST") - queryset = Issue.objects.filter(**filters, workspace__slug=slug) + queryset = Issue.issue_objects.filter(**filters, workspace__slug=slug) x_axis = data.get("x_axis", False) y_axis = data.get("y_axis", False) @@ -53,7 +53,7 @@ def analytic_export_task(email, data, slug): assignee_details = {} if x_axis in ["assignees__email"] or segment in ["assignees__email"]: assignee_details = ( - Issue.objects.filter(workspace__slug=slug, **filters, assignees__avatar__isnull=False) + Issue.issue_objects.filter(workspace__slug=slug, **filters, assignees__avatar__isnull=False) .order_by("assignees__id") .distinct("assignees__id") .values("assignees__avatar", "assignees__email", "assignees__first_name", "assignees__last_name") diff --git a/apiserver/plane/bgtasks/issue_activites_task.py b/apiserver/plane/bgtasks/issue_activites_task.py index 417fe2324f7..b1096e30bea 100644 --- a/apiserver/plane/bgtasks/issue_activites_task.py +++ b/apiserver/plane/bgtasks/issue_activites_task.py @@ -44,7 +44,7 @@ def track_name( field="name", project=project, workspace=project.workspace, - comment=f"{actor.email} updated the start date to {requested_data.get('name')}", + comment=f"{actor.email} updated the name to {requested_data.get('name')}", ) ) diff --git a/apiserver/plane/db/models/__init__.py b/apiserver/plane/db/models/__init__.py index 53b501716e9..0d236fe2100 100644 --- a/apiserver/plane/db/models/__init__.py +++ b/apiserver/plane/db/models/__init__.py @@ -68,4 +68,5 @@ from .estimate import Estimate, EstimatePoint -from .analytic import AnalyticView \ No newline at end of file +from .inbox import Inbox, InboxIssue +from .analytic import AnalyticView diff --git a/apiserver/plane/db/models/inbox.py b/apiserver/plane/db/models/inbox.py new file mode 100644 index 00000000000..497a20f001f --- /dev/null +++ b/apiserver/plane/db/models/inbox.py @@ -0,0 +1,51 @@ +# Django imports +from django.db import models + +# Module imports +from plane.db.models import ProjectBaseModel + + +class Inbox(ProjectBaseModel): + name = models.CharField(max_length=255) + description = models.TextField(verbose_name="Inbox Description", blank=True) + is_default = models.BooleanField(default=False) + view_props = models.JSONField(default=dict) + + def __str__(self): + """Return name of the Inbox""" + return f"{self.name} <{self.project.name}>" + + class Meta: + unique_together = ["name", "project"] + verbose_name = "Inbox" + verbose_name_plural = "Inboxes" + db_table = "inboxes" + ordering = ("name",) + + +class InboxIssue(ProjectBaseModel): + inbox = models.ForeignKey( + "db.Inbox", related_name="issue_inbox", on_delete=models.CASCADE + ) + issue = models.ForeignKey( + "db.Issue", related_name="issue_inbox", on_delete=models.CASCADE + ) + status = models.IntegerField( + choices=((-2, "Pending"), (-1, "Rejected"), (0, "Snoozed"), (1, "Accepted"), (2, "Duplicate")), + default=-2, + ) + snoozed_till = models.DateTimeField(null=True) + duplicate_to = models.ForeignKey( + "db.Issue", related_name="inbox_duplicate", on_delete=models.SET_NULL, null=True + ) + source = models.TextField(blank=True, null=True) + + class Meta: + verbose_name = "InboxIssue" + verbose_name_plural = "InboxIssues" + db_table = "inbox_issues" + ordering = ("-created_at",) + + def __str__(self): + """Return name of the Issue""" + return f"{self.issue.name} <{self.inbox.name}>" diff --git a/apiserver/plane/db/models/issue.py b/apiserver/plane/db/models/issue.py index e25695c424e..dcb7d20c448 100644 --- a/apiserver/plane/db/models/issue.py +++ b/apiserver/plane/db/models/issue.py @@ -17,6 +17,20 @@ # TODO: Handle identifiers for Bulk Inserts - nk +class IssueManager(models.Manager): + def get_queryset(self): + return ( + super() + .get_queryset() + .filter( + models.Q(issue_inbox__status=1) + | models.Q(issue_inbox__status=-1) + | models.Q(issue_inbox__status=2) + | models.Q(issue_inbox__isnull=True) + ) + ) + + class Issue(ProjectBaseModel): PRIORITY_CHOICES = ( ("urgent", "Urgent"), @@ -68,6 +82,9 @@ class Issue(ProjectBaseModel): sort_order = models.FloatField(default=65535) completed_at = models.DateTimeField(null=True) + objects = models.Manager() + issue_objects = IssueManager() + class Meta: verbose_name = "Issue" verbose_name_plural = "Issues" diff --git a/apiserver/plane/db/models/project.py b/apiserver/plane/db/models/project.py index 41b1ac654c7..0b6c4b50da0 100644 --- a/apiserver/plane/db/models/project.py +++ b/apiserver/plane/db/models/project.py @@ -69,6 +69,7 @@ class Project(BaseModel): cycle_view = models.BooleanField(default=True) issue_views_view = models.BooleanField(default=True) page_view = models.BooleanField(default=True) + inbox_view = models.BooleanField(default=False) cover_image = models.URLField(blank=True, null=True, max_length=800) estimate = models.ForeignKey( "db.Estimate", on_delete=models.SET_NULL, related_name="projects", null=True diff --git a/apiserver/plane/utils/issue_filters.py b/apiserver/plane/utils/issue_filters.py index 944906f9287..c5f147ea1af 100644 --- a/apiserver/plane/utils/issue_filters.py +++ b/apiserver/plane/utils/issue_filters.py @@ -231,6 +231,17 @@ def filter_module(params, filter, method): return filter +def filter_inbox_status(params, filter, method): + if method == "GET": + status = params.get("inbox_status").split(",") + if len(status) and "" not in status: + filter["issue_inbox__status__in"] = status + else: + if params.get("inbox_status", None) and len(params.get("inbox_status")): + filter["issue_inbox__status__in"] = params.get("inbox_status") + return filter + + def issue_filters(query_params, method): filter = dict() @@ -252,6 +263,7 @@ def issue_filters(query_params, method): "project": filter_project, "cycle": filter_cycle, "module": filter_module, + "inbox_status": filter_inbox_status } for key, value in ISSUE_FILTER.items(): diff --git a/apps/app/components/command-palette/command-pallette.tsx b/apps/app/components/command-palette/command-pallette.tsx index ff889898e9d..ffbe67ca598 100644 --- a/apps/app/components/command-palette/command-pallette.tsx +++ b/apps/app/components/command-palette/command-pallette.tsx @@ -102,7 +102,7 @@ export const CommandPalette: React.FC = () => { const page = pages[pages.length - 1]; const router = useRouter(); - const { workspaceSlug, projectId, issueId } = router.query; + const { workspaceSlug, projectId, issueId, inboxId } = router.query; const { user } = useUser(); const { setToastAlert } = useToast(); @@ -145,7 +145,7 @@ export const CommandPalette: React.FC = () => { console.error(e); }); }, - [workspaceSlug, issueId, projectId] + [workspaceSlug, issueId, projectId, user] ); const handleIssueAssignees = (assignee: string) => { @@ -372,6 +372,7 @@ export const CommandPalette: React.FC = () => { setIsIssueModalOpen(false)} + fieldsToShow={inboxId ? ["name", "description", "priority"] : ["all"]} /> = ({ console.log(error); }); }, - [workspaceSlug, projectId, cycleId, moduleId, groupTitle, index, selectedGroup, orderBy, params] + [ + workspaceSlug, + projectId, + cycleId, + moduleId, + groupTitle, + index, + selectedGroup, + orderBy, + params, + user, + ] ); const getStyle = ( diff --git a/apps/app/components/core/calendar-view/single-issue.tsx b/apps/app/components/core/calendar-view/single-issue.tsx index 4fa9def3b86..12fa60d018d 100644 --- a/apps/app/components/core/calendar-view/single-issue.tsx +++ b/apps/app/components/core/calendar-view/single-issue.tsx @@ -105,7 +105,7 @@ export const SingleCalendarIssue: React.FC = ({ console.log(error); }); }, - [workspaceSlug, projectId, cycleId, moduleId, params] + [workspaceSlug, projectId, cycleId, moduleId, viewId, params, user] ); const handleCopyText = () => { diff --git a/apps/app/components/core/issues-view.tsx b/apps/app/components/core/issues-view.tsx index bfa6b9f4644..2e9e14ae3cc 100644 --- a/apps/app/components/core/issues-view.tsx +++ b/apps/app/components/core/issues-view.tsx @@ -276,6 +276,7 @@ export const IssuesView: React.FC = ({ handleDeleteIssue, params, states, + user, ] ); diff --git a/apps/app/components/core/list-view/single-issue.tsx b/apps/app/components/core/list-view/single-issue.tsx index ea4ebc811ea..f2a5878999d 100644 --- a/apps/app/components/core/list-view/single-issue.tsx +++ b/apps/app/components/core/list-view/single-issue.tsx @@ -154,7 +154,18 @@ export const SingleListIssue: React.FC = ({ } else mutate(PROJECT_ISSUES_LIST_WITH_PARAMS(projectId as string, params)); }); }, - [workspaceSlug, projectId, cycleId, moduleId, groupTitle, index, selectedGroup, orderBy, params] + [ + workspaceSlug, + projectId, + cycleId, + moduleId, + groupTitle, + index, + selectedGroup, + orderBy, + params, + user, + ] ); const handleCopyText = () => { diff --git a/apps/app/components/icons/inbox-icon.tsx b/apps/app/components/icons/inbox-icon.tsx new file mode 100644 index 00000000000..6013c88616b --- /dev/null +++ b/apps/app/components/icons/inbox-icon.tsx @@ -0,0 +1,24 @@ +import React from "react"; + +import type { Props } from "./types"; + +export const InboxIcon: React.FC = ({ + width = "24", + height = "24", + color = "#858E96", + className, +}) => ( + + + +); diff --git a/apps/app/components/icons/index.ts b/apps/app/components/icons/index.ts index 968a14a931d..07ecafd24b1 100644 --- a/apps/app/components/icons/index.ts +++ b/apps/app/components/icons/index.ts @@ -78,3 +78,5 @@ export * from "./video-file-icon"; export * from "./audio-file-icon"; export * from "./command-icon"; export * from "./color-picker-icon"; +export * from "./inbox-icon"; +export * from "./stacked-layers-horizontal-icon"; diff --git a/apps/app/components/icons/stacked-layers-horizontal-icon.tsx b/apps/app/components/icons/stacked-layers-horizontal-icon.tsx new file mode 100644 index 00000000000..2d5f75ba993 --- /dev/null +++ b/apps/app/components/icons/stacked-layers-horizontal-icon.tsx @@ -0,0 +1,24 @@ +import React from "react"; + +import type { Props } from "./types"; + +export const StackedLayersHorizontalIcon: React.FC = ({ + width = "24", + height = "24", + className, + color = "#858e96", +}) => ( + + + +); diff --git a/apps/app/components/inbox/decline-issue-modal.tsx b/apps/app/components/inbox/decline-issue-modal.tsx new file mode 100644 index 00000000000..64fe1682a4a --- /dev/null +++ b/apps/app/components/inbox/decline-issue-modal.tsx @@ -0,0 +1,164 @@ +import React, { useEffect, useState } from "react"; + +import { useRouter } from "next/router"; + +import { mutate } from "swr"; + +// headless ui +import { Dialog, Transition } from "@headlessui/react"; +// services +import inboxServices from "services/inbox.service"; +// hooks +import useToast from "hooks/use-toast"; +import useInboxView from "hooks/use-inbox-view"; +import useUser from "hooks/use-user"; +// icons +import { ExclamationTriangleIcon } from "@heroicons/react/24/outline"; +// ui +import { SecondaryButton, DangerButton } from "components/ui"; +// types +import type { IInboxIssue, ICurrentUserResponse, IInboxIssueDetail } from "types"; +// fetch-keys +import { INBOX_ISSUES, INBOX_ISSUE_DETAILS } from "constants/fetch-keys"; + +type Props = { + isOpen: boolean; + handleClose: () => void; + data: IInboxIssue | undefined; +}; + +export const DeclineIssueModal: React.FC = ({ isOpen, handleClose, data }) => { + const [isDeclining, setIsDeclining] = useState(false); + + const router = useRouter(); + const { workspaceSlug, projectId, inboxId } = router.query; + + const { user } = useUser(); + const { setToastAlert } = useToast(); + const { params } = useInboxView(); + + const onClose = () => { + setIsDeclining(false); + handleClose(); + }; + + const handleDecline = () => { + if (!workspaceSlug || !projectId || !inboxId || !data) return; + + setIsDeclining(true); + + inboxServices + .markInboxStatus( + workspaceSlug.toString(), + projectId.toString(), + inboxId.toString(), + data.bridge_id, + { + status: -1, + }, + user + ) + .then(() => { + mutate( + INBOX_ISSUE_DETAILS(inboxId.toString(), data.bridge_id), + (prevData) => { + if (!prevData) return prevData; + + return { + ...prevData, + issue_inbox: [{ ...prevData.issue_inbox[0], status: -1 }], + }; + }, + false + ); + mutate( + INBOX_ISSUES(inboxId.toString(), params), + (prevData) => + prevData?.map((i) => + i.bridge_id === data.bridge_id + ? { ...i, issue_inbox: [{ ...i.issue_inbox[0], status: -1 }] } + : i + ), + false + ); + + setToastAlert({ + type: "success", + title: "Success!", + message: "Issue declined successfully.", + }); + onClose(); + }) + .catch(() => + setToastAlert({ + type: "error", + title: "Error!", + message: "Issue could not be declined. Please try again.", + }) + ) + .finally(() => setIsDeclining(false)); + }; + + return ( + + + +
+ + +
+
+ + +
+
+ + + +

Decline Issue

+
+
+ +

+ Are you sure you want to decline issue{" "} + + {data?.project_detail?.identifier}-{data?.sequence_id} + + {""}? This action cannot be undone. +

+
+
+ Cancel + + {isDeclining ? "Declining..." : "Decline Issue"} + +
+
+
+
+
+
+
+
+ ); +}; diff --git a/apps/app/components/inbox/delete-issue-modal.tsx b/apps/app/components/inbox/delete-issue-modal.tsx new file mode 100644 index 00000000000..46ba1ebdd03 --- /dev/null +++ b/apps/app/components/inbox/delete-issue-modal.tsx @@ -0,0 +1,150 @@ +import React, { useState } from "react"; + +import { useRouter } from "next/router"; + +import { mutate } from "swr"; + +// headless ui +import { Dialog, Transition } from "@headlessui/react"; +// services +import inboxServices from "services/inbox.service"; +// hooks +import useToast from "hooks/use-toast"; +import useInboxView from "hooks/use-inbox-view"; +import useUser from "hooks/use-user"; +// icons +import { ExclamationTriangleIcon } from "@heroicons/react/24/outline"; +// ui +import { SecondaryButton, DangerButton } from "components/ui"; +// types +import type { IInboxIssue } from "types"; +// fetch-keys +import { INBOX_ISSUES } from "constants/fetch-keys"; + +type Props = { + isOpen: boolean; + handleClose: () => void; + data: IInboxIssue | undefined; +}; + +export const DeleteIssueModal: React.FC = ({ isOpen, handleClose, data }) => { + const [isDeleting, setIsDeleting] = useState(false); + + const router = useRouter(); + const { workspaceSlug, projectId, inboxId } = router.query; + + const { user } = useUser(); + const { setToastAlert } = useToast(); + const { params } = useInboxView(); + + const onClose = () => { + setIsDeleting(false); + handleClose(); + }; + + const handleDelete = () => { + if (!workspaceSlug || !projectId || !inboxId || !data) return; + + setIsDeleting(true); + + inboxServices + .deleteInboxIssue( + workspaceSlug.toString(), + projectId.toString(), + inboxId.toString(), + data.bridge_id.toString(), + user + ) + .then(() => { + mutate( + INBOX_ISSUES(inboxId.toString(), params), + (prevData) => (prevData ?? []).filter((i) => i.id !== data.id), + false + ); + + setToastAlert({ + type: "success", + title: "Success!", + message: "Issue deleted successfully.", + }); + + // remove inboxIssueId from the url + router.push({ + pathname: `/${workspaceSlug}/projects/${projectId}/inbox/${inboxId}`, + }); + + onClose(); + }) + .catch(() => + setToastAlert({ + type: "error", + title: "Error!", + message: "Issue could not be deleted. Please try again.", + }) + ) + .finally(() => setIsDeleting(false)); + }; + + return ( + + + +
+ + +
+
+ + +
+
+ + + +

Delete Issue

+
+
+ +

+ Are you sure you want to delete issue{" "} + + {data?.project_detail?.identifier}-{data?.sequence_id} + + {""}? This action cannot be undone. +

+
+
+ Cancel + + {isDeleting ? "Deleting..." : "Delete Issue"} + +
+
+
+
+
+
+
+
+ ); +}; diff --git a/apps/app/components/inbox/filters-dropdown.tsx b/apps/app/components/inbox/filters-dropdown.tsx new file mode 100644 index 00000000000..938e4fc01d5 --- /dev/null +++ b/apps/app/components/inbox/filters-dropdown.tsx @@ -0,0 +1,63 @@ +// ui +import { MultiLevelDropdown } from "components/ui"; +// icons +import { getPriorityIcon } from "components/icons"; +// types +import { IInboxFilterOptions } from "types"; +// constants +import { PRIORITIES } from "constants/project"; +import { STATUS } from "constants/inbox"; + +type Props = { + filters: Partial; + onSelect: (option: any) => void; + direction?: "left" | "right"; + height?: "sm" | "md" | "rg" | "lg"; +}; + +export const FiltersDropdown: React.FC = ({ filters, onSelect, direction, height }) => ( + ({ + id: priority ?? "none", + label: ( +
+ {getPriorityIcon(priority)} {priority ?? "None"} +
+ ), + value: { + key: "priority", + value: priority, + }, + selected: filters?.priority?.includes(priority ?? "none"), + })), + ], + }, + { + id: "inbox_status", + label: "Status", + value: Object.values(STATUS), + children: [ + ...Object.keys(STATUS).map((status) => ({ + id: status, + label: status, + value: { + key: "inbox_status", + value: STATUS[status], + }, + selected: filters?.inbox_status?.includes(STATUS[status]), + })), + ], + }, + ]} + /> +); diff --git a/apps/app/components/inbox/inbox-action-headers.tsx b/apps/app/components/inbox/inbox-action-headers.tsx new file mode 100644 index 00000000000..8be550d9844 --- /dev/null +++ b/apps/app/components/inbox/inbox-action-headers.tsx @@ -0,0 +1,226 @@ +import { useEffect, useState } from "react"; + +import { useRouter } from "next/router"; + +// react-datepicker +import DatePicker from "react-datepicker"; +// headless ui +import { Popover } from "@headlessui/react"; +// contexts +import { useProjectMyMembership } from "contexts/project-member.context"; +// hooks +import useInboxView from "hooks/use-inbox-view"; +import useUserAuth from "hooks/use-user-auth"; +// components +import { FiltersDropdown } from "components/inbox"; +// ui +import { PrimaryButton, SecondaryButton } from "components/ui"; +// icons +import { InboxIcon, StackedLayersHorizontalIcon } from "components/icons"; +import { + ChevronDownIcon, + ChevronUpIcon, + CheckCircleIcon, + ClockIcon, + XCircleIcon, + TrashIcon, +} from "@heroicons/react/24/outline"; +// types +import type { IInboxIssue } from "types"; + +type Props = { + issueCount: number; + currentIssueIndex: number; + issue?: IInboxIssue; + onAccept: () => Promise; + onDecline: () => void; + onMarkAsDuplicate: () => void; + onSnooze: (date: Date | string) => void; + onDelete: () => void; +}; + +export const InboxActionHeader: React.FC = (props) => { + const { + issueCount, + currentIssueIndex, + onAccept, + onDecline, + onMarkAsDuplicate, + onSnooze, + onDelete, + issue, + } = props; + + const [isAccepting, setIsAccepting] = useState(false); + const [date, setDate] = useState(new Date()); + + const router = useRouter(); + const { inboxIssueId } = router.query; + + const { memberRole } = useProjectMyMembership(); + const { filters, setFilters, filtersLength } = useInboxView(); + const { user } = useUserAuth(); + + const handleAcceptIssue = () => { + setIsAccepting(true); + + onAccept().finally(() => setIsAccepting(false)); + }; + + useEffect(() => { + if (!issue?.issue_inbox[0].snoozed_till) return; + + setDate(new Date(issue.issue_inbox[0].snoozed_till)); + }, [issue]); + + const issueStatus = issue?.issue_inbox[0].status; + const isAllowed = memberRole.isMember || memberRole.isOwner; + + return ( +
+
+
+ +

Inbox

+
+
+ { + const key = option.key as keyof typeof filters; + + const valueExists = (filters[key] as any[])?.includes(option.value); + + if (valueExists) { + setFilters({ + [option.key]: ((filters[key] ?? []) as any[])?.filter( + (val) => val !== option.value + ), + }); + } else { + setFilters({ + [option.key]: [...((filters[key] ?? []) as any[]), option.value], + }); + } + }} + direction="right" + height="rg" + /> + {filtersLength > 0 && ( +
+ {filtersLength} +
+ )} +
+
+ {inboxIssueId && ( +
+
+ + +
+ {currentIssueIndex + 1}/{issueCount} +
+
+
+ {isAllowed && ( +
+ + + + + Snooze + + + + {({ close }) => ( +
+ { + if (!val) return; + setDate(val); + }} + dateFormat="dd-MM-yyyy" + inline + /> + { + close(); + onSnooze(date); + }} + > + Snooze + +
+ )} +
+
+ + + Mark as duplicate + + + + {isAccepting ? "Accepting..." : "Accept"} + + + + Decline + +
+ )} + {(isAllowed || user?.id === issue?.created_by) && ( +
+ + + Delete + +
+ )} +
+
+ )} +
+ ); +}; diff --git a/apps/app/components/inbox/inbox-issue-card.tsx b/apps/app/components/inbox/inbox-issue-card.tsx new file mode 100644 index 00000000000..682c305a7c4 --- /dev/null +++ b/apps/app/components/inbox/inbox-issue-card.tsx @@ -0,0 +1,117 @@ +import { useRouter } from "next/router"; +import Link from "next/link"; + +// ui +import { Tooltip } from "components/ui"; +// icons +import { getPriorityIcon, getStateGroupIcon } from "components/icons"; +import { CalendarDaysIcon, ClockIcon } from "@heroicons/react/24/outline"; +// helpers +import { renderShortNumericDateFormat } from "helpers/date-time.helper"; +import { addSpaceIfCamelCase } from "helpers/string.helper"; +// types +import type { IInboxIssue } from "types"; + +type Props = { + issue: IInboxIssue; + active: boolean; +}; + +export const InboxIssueCard: React.FC = (props) => { + const { issue, active } = props; + + const router = useRouter(); + const { workspaceSlug, projectId, inboxId } = router.query; + + const issueStatus = issue.issue_inbox[0].status; + + return ( + + + +
+
+

+ {issue.project_detail?.identifier}-{issue.sequence_id} +

+
{issue.name}
+
+
+ +
+ {getStateGroupIcon( + issue.state_detail?.group ?? "backlog", + "14", + "14", + issue.state_detail?.color + )} + {issue.state_detail?.name ?? "Triage"} +
+
+ +
+ {getPriorityIcon( + issue.priority && issue.priority !== "" ? issue.priority ?? "" : "None", + "text-sm" + )} +
+
+ +
+ + {renderShortNumericDateFormat(issue.created_at ?? "")} +
+
+ {issue.issue_inbox[0].snoozed_till && ( +
+ + + Snoozed till {renderShortNumericDateFormat(issue.issue_inbox[0].snoozed_till)} + +
+ )} +
+
+
+
+ + ); +}; diff --git a/apps/app/components/inbox/inbox-main-content.tsx b/apps/app/components/inbox/inbox-main-content.tsx new file mode 100644 index 00000000000..1bf330e5e60 --- /dev/null +++ b/apps/app/components/inbox/inbox-main-content.tsx @@ -0,0 +1,259 @@ +import { useCallback, useEffect } from "react"; + +import { useRouter } from "next/router"; + +import useSWR, { mutate } from "swr"; + +// react hook form +import { useForm } from "react-hook-form"; +// contexts +import { useProjectMyMembership } from "contexts/project-member.context"; +// services +import inboxServices from "services/inbox.service"; +// hooks +import useInboxView from "hooks/use-inbox-view"; +import useUserAuth from "hooks/use-user-auth"; +// components +import { + AddComment, + IssueActivitySection, + IssueDescriptionForm, + IssueDetailsSidebar, +} from "components/issues"; +// ui +import { Loader } from "components/ui"; +// icons +import { + ArrowTopRightOnSquareIcon, + CheckCircleIcon, + ClockIcon, + DocumentDuplicateIcon, + ExclamationTriangleIcon, + XCircleIcon, +} from "@heroicons/react/24/outline"; +// helpers +import { renderShortNumericDateFormat } from "helpers/date-time.helper"; +// types +import type { IInboxIssue, IIssue } from "types"; +// fetch-keys +import { INBOX_ISSUES, INBOX_ISSUE_DETAILS } from "constants/fetch-keys"; + +const defaultValues = { + name: "", + description: "", + description_html: "", + estimate_point: null, + assignees_list: [], + priority: "low", + target_date: new Date().toString(), + labels_list: [], +}; + +export const InboxMainContent: React.FC = () => { + const router = useRouter(); + const { workspaceSlug, projectId, inboxId, inboxIssueId } = router.query; + + const { user } = useUserAuth(); + const { memberRole } = useProjectMyMembership(); + const { params } = useInboxView(); + + const { reset, control, watch } = useForm({ + defaultValues, + }); + + const { data: issueDetails, mutate: mutateIssueDetails } = useSWR( + workspaceSlug && projectId && inboxId && inboxIssueId + ? INBOX_ISSUE_DETAILS(inboxId.toString(), inboxIssueId.toString()) + : null, + workspaceSlug && projectId && inboxId && inboxIssueId + ? () => + inboxServices.getInboxIssueById( + workspaceSlug.toString(), + projectId.toString(), + inboxId.toString(), + inboxIssueId.toString() + ) + : null + ); + + useEffect(() => { + if (!issueDetails || !inboxIssueId) return; + + reset({ + ...issueDetails, + assignees_list: + issueDetails.assignees_list ?? (issueDetails.assignee_details ?? []).map((user) => user.id), + labels_list: issueDetails.labels_list ?? issueDetails.labels, + }); + }, [issueDetails, reset, inboxIssueId]); + + const submitChanges = useCallback( + async (formData: Partial) => { + if (!workspaceSlug || !projectId || !inboxIssueId || !inboxId || !issueDetails) return; + + mutateIssueDetails((prevData: any) => { + if (!prevData) return prevData; + + return { + ...prevData, + ...formData, + }; + }, false); + mutate( + INBOX_ISSUES(inboxId.toString(), params), + (prevData) => + (prevData ?? []).map((i) => { + if (i.bridge_id === inboxIssueId) { + return { + ...i, + ...formData, + }; + } + + return i; + }), + false + ); + + const payload = { issue: { ...formData } }; + + await inboxServices + .patchInboxIssue( + workspaceSlug.toString(), + projectId.toString(), + inboxId.toString(), + issueDetails.issue_inbox[0].id, + payload, + user + ) + .then(() => { + mutateIssueDetails(); + mutate(INBOX_ISSUES(inboxId.toString(), params)); + }); + }, + [ + workspaceSlug, + inboxIssueId, + projectId, + mutateIssueDetails, + inboxId, + user, + issueDetails, + params, + ] + ); + + const issueStatus = issueDetails?.issue_inbox[0].status; + + return ( + <> + {issueDetails ? ( +
+
+
+ {issueStatus === -2 ? ( + <> + +

This issue is still pending.

+ + ) : issueStatus === -1 ? ( + <> + +

This issue has been declined.

+ + ) : issueStatus === 0 ? ( + <> + +

+ This issue has been snoozed till{" "} + {renderShortNumericDateFormat(issueDetails.issue_inbox[0].snoozed_till ?? "")}. +

+ + ) : issueStatus === 1 ? ( + <> + +

This issue has been accepted.

+ + ) : issueStatus === 2 ? ( + <> + +

+ This issue has been marked as a duplicate of + + this issue + + . +

+ + ) : null} +
+
+ +
+
+

Comments/Activity

+ + +
+
+ +
+ +
+
+ ) : ( + +
+ + + + +
+
+ + + + +
+
+ )} + + ); + + return null; +}; diff --git a/apps/app/components/inbox/index.ts b/apps/app/components/inbox/index.ts new file mode 100644 index 00000000000..3f66790bcd7 --- /dev/null +++ b/apps/app/components/inbox/index.ts @@ -0,0 +1,8 @@ +export * from "./decline-issue-modal"; +export * from "./delete-issue-modal"; +export * from "./filters-dropdown"; +export * from "./inbox-action-headers"; +export * from "./inbox-issue-card"; +export * from "./inbox-main-content"; +export * from "./issues-list-sidebar"; +export * from "./select-duplicate"; diff --git a/apps/app/components/inbox/issues-list-sidebar.tsx b/apps/app/components/inbox/issues-list-sidebar.tsx new file mode 100644 index 00000000000..02181c02a4e --- /dev/null +++ b/apps/app/components/inbox/issues-list-sidebar.tsx @@ -0,0 +1,44 @@ +import { useRouter } from "next/router"; + +// hooks +import useInboxView from "hooks/use-inbox-view"; +// components +import { InboxIssueCard } from "components/inbox"; +// ui +import { Loader } from "components/ui"; + +export const IssuesListSidebar = () => { + const router = useRouter(); + const { inboxIssueId } = router.query; + + const { issues: inboxIssues } = useInboxView(); + + return ( + <> + {inboxIssues ? ( + inboxIssues.length > 0 ? ( +
+ {inboxIssues.map((issue) => ( + + ))} +
+ ) : ( +
+ No issues found for the selected filters. Try changing the filters. +
+ ) + ) : ( + + + + + + + )} + + ); +}; diff --git a/apps/app/components/inbox/select-duplicate.tsx b/apps/app/components/inbox/select-duplicate.tsx new file mode 100644 index 00000000000..fdf7034df95 --- /dev/null +++ b/apps/app/components/inbox/select-duplicate.tsx @@ -0,0 +1,193 @@ +import React, { useEffect, useState } from "react"; + +import { useRouter } from "next/router"; + +import useSWR from "swr"; + +// react-hook-form +import { SubmitHandler, useForm, UseFormWatch } from "react-hook-form"; +// headless ui +import { Combobox, Dialog, Transition } from "@headlessui/react"; +// hooks +import useToast from "hooks/use-toast"; +// services +import issuesServices from "services/issues.service"; +// ui +import { PrimaryButton, SecondaryButton } from "components/ui"; +// icons +import { MagnifyingGlassIcon } from "@heroicons/react/24/outline"; +import { LayerDiagonalIcon } from "components/icons"; +// fetch-keys +import { PROJECT_ISSUES_LIST } from "constants/fetch-keys"; + +type Props = { + isOpen: boolean; + value?: string | null; + onClose: () => void; + onSubmit: (issueId: string) => void; +}; + +export const SelectDuplicateInboxIssueModal: React.FC = (props) => { + const { isOpen, onClose, onSubmit, value } = props; + + const [query, setQuery] = useState(""); + const [selectedItem, setSelectedItem] = useState(""); + + const { setToastAlert } = useToast(); + + const router = useRouter(); + const { workspaceSlug, projectId, issueId } = router.query; + + const { data: issues } = useSWR( + workspaceSlug && projectId + ? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string) + : null, + workspaceSlug && projectId + ? () => + issuesServices + .getIssues(workspaceSlug as string, projectId as string) + .then((res) => res.filter((issue) => issue.id !== issueId)) + : null + ); + + useEffect(() => { + if (!value) { + setSelectedItem(""); + return; + } else setSelectedItem(value); + }, [value]); + + const handleClose = () => { + onClose(); + }; + + const handleSubmit = () => { + if (!selectedItem || selectedItem.length === 0) + return setToastAlert({ + title: "Error", + type: "error", + }); + onSubmit(selectedItem); + handleClose(); + }; + + const filteredIssues = + (query === "" ? issues : issues?.filter((issue) => issue.name.includes(query))) ?? []; + + return ( + setQuery("")} appear> +
+
+ + +
+ + +
+ + + { + setSelectedItem(value); + }} + > +
+
+ + + {filteredIssues.length > 0 ? ( +
  • + {query === "" && ( +

    + Select issue +

    + )} +
      + {filteredIssues.map((issue) => ( + + `flex w-full cursor-pointer select-none items-center gap-2 rounded-md px-3 py-2 text-brand-secondary ${ + active || selected ? "bg-brand-surface-2 text-brand-base" : "" + } ` + } + > +
      + + + { + issues?.find((i) => i.id === issue.id)?.project_detail + ?.identifier + } + -{issue.sequence_id} + + {issue.name} +
      +
      + ))} +
    +
  • + ) : ( +
    + +

    + No issues found. Create a new issue with{" "} +
    C
    . +

    +
    + )} +
    +
    + + {filteredIssues.length > 0 && ( +
    + Cancel + Mark as original +
    + )} +
    +
    +
    +
    +
    +
    +
    + ); +}; diff --git a/apps/app/components/issues/activity.tsx b/apps/app/components/issues/activity.tsx index 3b9ecc8533e..8d708b85612 100644 --- a/apps/app/components/issues/activity.tsx +++ b/apps/app/components/issues/activity.tsx @@ -4,6 +4,14 @@ import { useRouter } from "next/router"; import useSWR from "swr"; +// services +import issuesService from "services/issues.service"; +// hooks +import useEstimateOption from "hooks/use-estimate-option"; +// components +import { CommentCard } from "components/issues/comment"; +// ui +import { Loader } from "components/ui"; // icons import { CalendarDaysIcon, @@ -17,20 +25,13 @@ import { UserIcon, } from "@heroicons/react/24/outline"; import { BlockedIcon, BlockerIcon, CyclesIcon, TagIcon, UserGroupIcon } from "components/icons"; -// services -import issuesService from "services/issues.service"; -// components -import { CommentCard } from "components/issues/comment"; -// ui -import { Loader } from "components/ui"; - // helpers import { renderShortNumericDateFormat, timeAgo } from "helpers/date-time.helper"; import { addSpaceIfCamelCase } from "helpers/string.helper"; // types import { ICurrentUserResponse, IIssueComment, IIssueLabels } from "types"; +// fetch-keys import { PROJECT_ISSUES_ACTIVITY, PROJECT_ISSUE_LABELS } from "constants/fetch-keys"; -import useEstimateOption from "hooks/use-estimate-option"; const activityDetails: { [key: string]: { @@ -60,7 +61,7 @@ const activityDetails: { }, estimate_point: { message: "set the estimate point to", - icon: