From b2451ac03db46f7e011c6fc637b4a7ceaa12efca Mon Sep 17 00:00:00 2001 From: NarayanBavisetti Date: Wed, 21 Jun 2023 12:04:47 +0530 Subject: [PATCH 01/31] feat: added new issue subscriber table --- apiserver/plane/api/serializers/__init__.py | 1 + apiserver/plane/api/serializers/issue.py | 12 +++++++++ apiserver/plane/api/urls.py | 14 +++++++++++ apiserver/plane/api/views/__init__.py | 1 + apiserver/plane/api/views/issue.py | 28 +++++++++++++++++++++ apiserver/plane/db/models/__init__.py | 1 + apiserver/plane/db/models/issue.py | 21 ++++++++++++++++ 7 files changed, 78 insertions(+) diff --git a/apiserver/plane/api/serializers/__init__.py b/apiserver/plane/api/serializers/__init__.py index 2b72c5ae1fb..085bb9bd1f1 100644 --- a/apiserver/plane/api/serializers/__init__.py +++ b/apiserver/plane/api/serializers/__init__.py @@ -41,6 +41,7 @@ IssueLinkSerializer, IssueLiteSerializer, IssueAttachmentSerializer, + IssueSubscriberSerializer, ) from .module import ( diff --git a/apiserver/plane/api/serializers/issue.py b/apiserver/plane/api/serializers/issue.py index 14782dbe5cd..540ea909717 100644 --- a/apiserver/plane/api/serializers/issue.py +++ b/apiserver/plane/api/serializers/issue.py @@ -19,6 +19,7 @@ IssueProperty, IssueBlocker, IssueAssignee, + IssueSubscriber, IssueLabel, Label, IssueBlocker, @@ -530,3 +531,14 @@ class Meta: "created_at", "updated_at", ] + + +class IssueSubscriberSerializer(BaseSerializer): + project = serializers.PrimaryKeyRelatedField(read_only=True) + workspace = serializers.PrimaryKeyRelatedField(read_only=True) + issue = serializers.PrimaryKeyRelatedField(read_only=True) + subscriber = serializers.PrimaryKeyRelatedField(read_only=True) + + class Meta: + model = IssueSubscriber + fields = "__all__" diff --git a/apiserver/plane/api/urls.py b/apiserver/plane/api/urls.py index 936fd73abfe..bf370063ab9 100644 --- a/apiserver/plane/api/urls.py +++ b/apiserver/plane/api/urls.py @@ -76,6 +76,7 @@ IssueLinkViewSet, BulkCreateIssueLabelsEndpoint, IssueAttachmentEndpoint, + IssueSubscriberViewSet, ## End Issues # States StateViewSet, @@ -798,6 +799,19 @@ name="project-issue-comment", ), ## End IssueComments + # Issue Subscribers + path( + "workspaces//projects//issues//subscribers/", + IssueSubscriberViewSet.as_view( + { + "get": "list", + "post": "create", + "delete": "destroy" + } + ), + name="project-issue-subscriber", + ), + ## End Issue Subscribers ## IssueProperty path( "workspaces//projects//issue-properties/", diff --git a/apiserver/plane/api/views/__init__.py b/apiserver/plane/api/views/__init__.py index f8d170532be..a3c166e80d9 100644 --- a/apiserver/plane/api/views/__init__.py +++ b/apiserver/plane/api/views/__init__.py @@ -65,6 +65,7 @@ IssueLinkViewSet, BulkCreateIssueLabelsEndpoint, IssueAttachmentEndpoint, + IssueSubscriberViewSet, ) from .auth_extended import ( diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py index cd9f65e4825..85d205fed58 100644 --- a/apiserver/plane/api/views/issue.py +++ b/apiserver/plane/api/views/issue.py @@ -42,6 +42,7 @@ IssueLinkSerializer, IssueLiteSerializer, IssueAttachmentSerializer, + IssueSubscriberSerializer, ) from plane.api.permissions import ( ProjectEntityPermission, @@ -58,6 +59,7 @@ IssueLink, IssueAttachment, State, + IssueSubscriber, ) from plane.bgtasks.issue_activites_task import issue_activity from plane.utils.grouper import group_results @@ -842,3 +844,29 @@ def get(self, request, slug, project_id, issue_id): {"error": "Something went wrong please try again later"}, status=status.HTTP_400_BAD_REQUEST, ) + + +class IssueSubscriberViewSet(BaseViewSet): + serializer_class = IssueSubscriberSerializer + model = IssueSubscriber + + permission_classes = [ + ProjectEntityPermission, + ] + + def perform_create(self, serializer): + serializer.save( + subscriber_id=self.request.user.id, issue_id=self.kwargs.get("issue_id") + ) + + def get_queryset(self): + return ( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(project_id=self.kwargs.get("project_id")) + .filter(issue_id=self.kwargs.get("issue_id")) + .filter(project__project_projectmember__member=self.request.user) + .order_by("-created_at") + .distinct() + ) diff --git a/apiserver/plane/db/models/__init__.py b/apiserver/plane/db/models/__init__.py index 96c649a838c..47585207ce4 100644 --- a/apiserver/plane/db/models/__init__.py +++ b/apiserver/plane/db/models/__init__.py @@ -33,6 +33,7 @@ IssueLink, IssueSequence, IssueAttachment, + IssueSubscriber, ) from .asset import FileAsset diff --git a/apiserver/plane/db/models/issue.py b/apiserver/plane/db/models/issue.py index 7efe86d4604..ab6c6584068 100644 --- a/apiserver/plane/db/models/issue.py +++ b/apiserver/plane/db/models/issue.py @@ -399,6 +399,27 @@ class Meta: ordering = ("-created_at",) +class IssueSubscriber(ProjectBaseModel): + issue = models.ForeignKey( + Issue, on_delete=models.CASCADE, related_name="issue_subscribers" + ) + subscriber = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="issue_subscribers", + ) + + class Meta: + unique_together = ["issue", "subscriber"] + verbose_name = "Issue Subscriber" + verbose_name_plural = "Issue Subscribers" + db_table = "issue_subscribers" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.issue.name} {self.subscriber.email}" + + # TODO: Find a better method to save the model @receiver(post_save, sender=Issue) def create_issue_sequence(sender, instance, created, **kwargs): From f80f98ddef4d48770b86d59cb5a7f3620d52cafd Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Thu, 22 Jun 2023 13:38:45 +0530 Subject: [PATCH 02/31] dev: notification model --- apiserver/plane/db/models/notification.py | 33 +++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 apiserver/plane/db/models/notification.py diff --git a/apiserver/plane/db/models/notification.py b/apiserver/plane/db/models/notification.py new file mode 100644 index 00000000000..312f2933f7e --- /dev/null +++ b/apiserver/plane/db/models/notification.py @@ -0,0 +1,33 @@ +# Django imports +from django.db import models + +# Third party imports +from .base import BaseModel + + +class Notification(BaseModel): + workspace = models.ForeignKey( + "db.Workspace", related_name="notifications", on_delete=models.CASCADE + ) + project = models.ForeignKey( + "db.Project", related_name="notifications", on_delete=models.CASCADE, null=True + ) + entity_identifier = models.UUIDField(null=True) + title = models.TextField() + message = models.JSONField(null=True) + message_html = models.TextField(blank=True, default="

") + message_stripped = models.TextField(blank=True, null=True) + sender = models.ForeignKey("db.User", related_name="sent_notifications", on_delete=models.SET_NULL, null=True) + receiver = models.ForeignKey("db.User", related_name="received_notifications", on_delete=models.CASCADE) + read_at = models.DateTimeField(null=True) + snoozed_till = models.DateTimeField(null=True) + + class Meta: + verbose_name = "Notification" + verbose_name_plural = "Notifications" + db_table = "notifications" + ordering = ("-created_at",) + + def __str__(self): + """Return name of the notifications""" + return f"{self.receiver.name} <{self.workspace.name}>" From b22e0625768f0b096b5898936ace76d6882b0736 Mon Sep 17 00:00:00 2001 From: NarayanBavisetti Date: Thu, 22 Jun 2023 18:20:59 +0530 Subject: [PATCH 03/31] feat: added CRUD operation for issue subscriber --- apiserver/plane/api/serializers/issue.py | 10 +++++----- apiserver/plane/api/urls.py | 20 +++++++++++++++++++- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/apiserver/plane/api/serializers/issue.py b/apiserver/plane/api/serializers/issue.py index 540ea909717..7376cf0ff61 100644 --- a/apiserver/plane/api/serializers/issue.py +++ b/apiserver/plane/api/serializers/issue.py @@ -534,11 +534,11 @@ class Meta: class IssueSubscriberSerializer(BaseSerializer): - project = serializers.PrimaryKeyRelatedField(read_only=True) - workspace = serializers.PrimaryKeyRelatedField(read_only=True) - issue = serializers.PrimaryKeyRelatedField(read_only=True) - subscriber = serializers.PrimaryKeyRelatedField(read_only=True) - class Meta: model = IssueSubscriber fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "issue", + ] diff --git a/apiserver/plane/api/urls.py b/apiserver/plane/api/urls.py index bf370063ab9..9edd92867f0 100644 --- a/apiserver/plane/api/urls.py +++ b/apiserver/plane/api/urls.py @@ -801,16 +801,34 @@ ## End IssueComments # Issue Subscribers path( - "workspaces//projects//issues//subscribers/", + "workspaces//projects//issues//issue-subscribers/", IssueSubscriberViewSet.as_view( { "get": "list", "post": "create", + } + ), + name="project-issue-subscriber", + ), + path( + "workspaces//projects//issues//issue-subscribers/", + IssueSubscriberViewSet.as_view( + { "delete": "destroy" } ), name="project-issue-subscriber", ), + path( + "workspaces//projects//issues//subscribe/", + IssueSubscriberViewSet.as_view( + { + "get": "subscription_status", + "post": "subscribe", + } + ), + name="project-issue-subscriber", + ), ## End Issue Subscribers ## IssueProperty path( From 9c0a2b74b3d9ba878b5a7eb8b293fa4355a24e66 Mon Sep 17 00:00:00 2001 From: NarayanBavisetti Date: Thu, 22 Jun 2023 18:24:22 +0530 Subject: [PATCH 04/31] Revert "feat: added CRUD operation for issue subscriber" This reverts commit b22e0625768f0b096b5898936ace76d6882b0736. --- apiserver/plane/api/serializers/issue.py | 10 +++++----- apiserver/plane/api/urls.py | 20 +------------------- 2 files changed, 6 insertions(+), 24 deletions(-) diff --git a/apiserver/plane/api/serializers/issue.py b/apiserver/plane/api/serializers/issue.py index 7376cf0ff61..540ea909717 100644 --- a/apiserver/plane/api/serializers/issue.py +++ b/apiserver/plane/api/serializers/issue.py @@ -534,11 +534,11 @@ class Meta: class IssueSubscriberSerializer(BaseSerializer): + project = serializers.PrimaryKeyRelatedField(read_only=True) + workspace = serializers.PrimaryKeyRelatedField(read_only=True) + issue = serializers.PrimaryKeyRelatedField(read_only=True) + subscriber = serializers.PrimaryKeyRelatedField(read_only=True) + class Meta: model = IssueSubscriber fields = "__all__" - read_only_fields = [ - "workspace", - "project", - "issue", - ] diff --git a/apiserver/plane/api/urls.py b/apiserver/plane/api/urls.py index 9edd92867f0..bf370063ab9 100644 --- a/apiserver/plane/api/urls.py +++ b/apiserver/plane/api/urls.py @@ -801,34 +801,16 @@ ## End IssueComments # Issue Subscribers path( - "workspaces//projects//issues//issue-subscribers/", + "workspaces//projects//issues//subscribers/", IssueSubscriberViewSet.as_view( { "get": "list", "post": "create", - } - ), - name="project-issue-subscriber", - ), - path( - "workspaces//projects//issues//issue-subscribers/", - IssueSubscriberViewSet.as_view( - { "delete": "destroy" } ), name="project-issue-subscriber", ), - path( - "workspaces//projects//issues//subscribe/", - IssueSubscriberViewSet.as_view( - { - "get": "subscription_status", - "post": "subscribe", - } - ), - name="project-issue-subscriber", - ), ## End Issue Subscribers ## IssueProperty path( From cf4bb02a9f5d0942753a8fa7ed18d311c12b4ebf Mon Sep 17 00:00:00 2001 From: NarayanBavisetti Date: Thu, 22 Jun 2023 18:27:40 +0530 Subject: [PATCH 05/31] feat: added CRUD operation for issue subscriber --- apiserver/plane/api/serializers/issue.py | 10 +-- apiserver/plane/api/urls.py | 20 +++++- apiserver/plane/api/views/issue.py | 86 +++++++++++++++++++++++- 3 files changed, 109 insertions(+), 7 deletions(-) diff --git a/apiserver/plane/api/serializers/issue.py b/apiserver/plane/api/serializers/issue.py index 540ea909717..7376cf0ff61 100644 --- a/apiserver/plane/api/serializers/issue.py +++ b/apiserver/plane/api/serializers/issue.py @@ -534,11 +534,11 @@ class Meta: class IssueSubscriberSerializer(BaseSerializer): - project = serializers.PrimaryKeyRelatedField(read_only=True) - workspace = serializers.PrimaryKeyRelatedField(read_only=True) - issue = serializers.PrimaryKeyRelatedField(read_only=True) - subscriber = serializers.PrimaryKeyRelatedField(read_only=True) - class Meta: model = IssueSubscriber fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "issue", + ] diff --git a/apiserver/plane/api/urls.py b/apiserver/plane/api/urls.py index bf370063ab9..9edd92867f0 100644 --- a/apiserver/plane/api/urls.py +++ b/apiserver/plane/api/urls.py @@ -801,16 +801,34 @@ ## End IssueComments # Issue Subscribers path( - "workspaces//projects//issues//subscribers/", + "workspaces//projects//issues//issue-subscribers/", IssueSubscriberViewSet.as_view( { "get": "list", "post": "create", + } + ), + name="project-issue-subscriber", + ), + path( + "workspaces//projects//issues//issue-subscribers/", + IssueSubscriberViewSet.as_view( + { "delete": "destroy" } ), name="project-issue-subscriber", ), + path( + "workspaces//projects//issues//subscribe/", + IssueSubscriberViewSet.as_view( + { + "get": "subscription_status", + "post": "subscribe", + } + ), + name="project-issue-subscriber", + ), ## End Issue Subscribers ## IssueProperty path( diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py index 85d205fed58..7b64a211c8a 100644 --- a/apiserver/plane/api/views/issue.py +++ b/apiserver/plane/api/views/issue.py @@ -856,7 +856,8 @@ class IssueSubscriberViewSet(BaseViewSet): def perform_create(self, serializer): serializer.save( - subscriber_id=self.request.user.id, issue_id=self.kwargs.get("issue_id") + project_id=self.kwargs.get("project_id"), + issue_id=self.kwargs.get("issue_id"), ) def get_queryset(self): @@ -870,3 +871,86 @@ def get_queryset(self): .order_by("-created_at") .distinct() ) + + def destroy(self, request, slug, project_id, issue_id, subscriber_id): + try: + issue_subscriber = IssueSubscriber.objects.get( + project=project_id, + subscriber=subscriber_id, + workspace__slug=slug, + issue=issue_id, + ) + + issue_subscriber.delete() + + return Response( + {"message": "Removed Subscription"}, + status=status.HTTP_200_OK, + ) + + except IssueSubscriber.DoesNotExist: + return Response( + {"error": "User is not subscribed to this issue"}, + 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 subscribe(self, request, slug, project_id, issue_id): + try: + if IssueSubscriber.objects.filter( + issue_id=issue_id, + subscriber=request.user, + workspace__slug=slug, + project=project_id, + ).exists(): + return Response( + {"message": "User already subscribed to the issue."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + issue = Issue.objects.get(id=issue_id) + + subscriber = IssueSubscriber.objects.create( + issue_id=issue_id, + subscriber_id=request.user.id, + project_id=project_id, + ) + + serilaizer = IssueSubscriberSerializer(subscriber) + return Response(serilaizer.data, status=status.HTTP_201_CREATED) + + except Issue.DoesNotExist: + return Response( + {"error": "Issue does not exists"}, + 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 subscription_status(self, request, slug, project_id, issue_id): + try: + issue_subscriber = IssueSubscriber.objects.filter( + issue=issue_id, + subscriber=request.user, + workspace__slug=slug, + project=project_id, + ).exists() + return Response({"subscribed": issue_subscriber}, 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, + ) From 0af49306a9edc709e67726658ca4a8d57c453d35 Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Thu, 22 Jun 2023 18:38:51 +0530 Subject: [PATCH 06/31] dev: notification models and operations --- apiserver/plane/api/serializers/__init__.py | 3 + .../plane/api/serializers/notification.py | 10 +++ apiserver/plane/api/urls.py | 31 +++++++-- apiserver/plane/api/views/__init__.py | 3 + apiserver/plane/api/views/notification.py | 24 +++++++ .../plane/bgtasks/issue_activites_task.py | 65 +++++++++++++++---- apiserver/plane/db/models/__init__.py | 3 + apiserver/plane/db/models/notification.py | 5 +- 8 files changed, 126 insertions(+), 18 deletions(-) create mode 100644 apiserver/plane/api/serializers/notification.py create mode 100644 apiserver/plane/api/views/notification.py diff --git a/apiserver/plane/api/serializers/__init__.py b/apiserver/plane/api/serializers/__init__.py index 085bb9bd1f1..381891f2f00 100644 --- a/apiserver/plane/api/serializers/__init__.py +++ b/apiserver/plane/api/serializers/__init__.py @@ -75,4 +75,7 @@ ) from .inbox import InboxSerializer, InboxIssueSerializer, IssueStateInboxSerializer + from .analytic import AnalyticViewSerializer + +from .notification import NotificationSerializer diff --git a/apiserver/plane/api/serializers/notification.py b/apiserver/plane/api/serializers/notification.py new file mode 100644 index 00000000000..529cb9f9cea --- /dev/null +++ b/apiserver/plane/api/serializers/notification.py @@ -0,0 +1,10 @@ +# Module imports +from .base import BaseSerializer +from plane.db.models import Notification + +class NotificationSerializer(BaseSerializer): + + class Meta: + model = Notification + fields = "__all__" + diff --git a/apiserver/plane/api/urls.py b/apiserver/plane/api/urls.py index bf370063ab9..f871f6f3dd1 100644 --- a/apiserver/plane/api/urls.py +++ b/apiserver/plane/api/urls.py @@ -149,6 +149,9 @@ ExportAnalyticsEndpoint, DefaultAnalyticsEndpoint, ## End Analytics + # Notification + NotificationViewSet, + ## End Notification ) @@ -803,11 +806,7 @@ path( "workspaces//projects//issues//subscribers/", IssueSubscriberViewSet.as_view( - { - "get": "list", - "post": "create", - "delete": "destroy" - } + {"get": "list", "post": "create", "delete": "destroy"} ), name="project-issue-subscriber", ), @@ -1288,4 +1287,26 @@ name="default-analytics", ), ## End Analytics + # Notification + path( + "workspaces//users/notifications/", + NotificationViewSet.as_view( + { + "get": "list", + } + ), + name="notifications", + ), + path( + "workspaces//users/notifications//", + NotificationViewSet.as_view( + { + "get": "retrieve", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="notifications", + ), + ## End Notification ] diff --git a/apiserver/plane/api/views/__init__.py b/apiserver/plane/api/views/__init__.py index a3c166e80d9..327dd60375b 100644 --- a/apiserver/plane/api/views/__init__.py +++ b/apiserver/plane/api/views/__init__.py @@ -134,6 +134,7 @@ from .release import ReleaseNotesEndpoint from .inbox import InboxViewSet, InboxIssueViewSet + from .analytic import ( AnalyticsEndpoint, AnalyticViewViewset, @@ -141,3 +142,5 @@ ExportAnalyticsEndpoint, DefaultAnalyticsEndpoint, ) + +from .notification import NotificationViewSet \ No newline at end of file diff --git a/apiserver/plane/api/views/notification.py b/apiserver/plane/api/views/notification.py new file mode 100644 index 00000000000..add3c69941c --- /dev/null +++ b/apiserver/plane/api/views/notification.py @@ -0,0 +1,24 @@ +# Third party imports +from rest_framework import status +from rest_framework.response import Response + +# Module imports +from .base import BaseViewSet +from plane.db.models import Notification +from plane.api.serializers import NotificationSerializer + + +class NotificationViewSet(BaseViewSet): + model = Notification + serializer_class = NotificationSerializer + + def get_queryset(self): + return ( + super() + .get_queryset() + .filter( + workspace__slug=self.kwargs.get("slug"), + ) + .select_related("workspace") + ) + diff --git a/apiserver/plane/bgtasks/issue_activites_task.py b/apiserver/plane/bgtasks/issue_activites_task.py index b1096e30bea..9fa0d2fac41 100644 --- a/apiserver/plane/bgtasks/issue_activites_task.py +++ b/apiserver/plane/bgtasks/issue_activites_task.py @@ -20,8 +20,10 @@ State, Cycle, Module, + IssueSubscriber, + Notification, ) -from plane.api.serializers import IssueActivitySerializer +from plane.api.serializers import IssueActivitySerializer, IssueFlatSerializer # Track Chnages in name @@ -992,18 +994,57 @@ def issue_activity( # Post the updates to segway for integrations and webhooks if len(issue_activities_created): # Don't send activities if the actor is a bot - if settings.PROXY_BASE_URL: - for issue_activity in issue_activities_created: - headers = {"Content-Type": "application/json"} - issue_activity_json = json.dumps( - IssueActivitySerializer(issue_activity).data, - cls=DjangoJSONEncoder, - ) - _ = requests.post( - f"{settings.PROXY_BASE_URL}/hooks/workspaces/{str(issue_activity.workspace_id)}/projects/{str(issue_activity.project_id)}/issues/{str(issue_activity.issue_id)}/issue-activity-hooks/", - json=issue_activity_json, - headers=headers, + try: + if settings.PROXY_BASE_URL: + for issue_activity in issue_activities_created: + headers = {"Content-Type": "application/json"} + issue_activity_json = json.dumps( + IssueActivitySerializer(issue_activity).data, + cls=DjangoJSONEncoder, + ) + _ = requests.post( + f"{settings.PROXY_BASE_URL}/hooks/workspaces/{str(issue_activity.workspace_id)}/projects/{str(issue_activity.project_id)}/issues/{str(issue_activity.issue_id)}/issue-activity-hooks/", + json=issue_activity_json, + headers=headers, + ) + except Exception as e: + capture_exception(e) + + # Create Notifications + bulk_notifications = [] + + issue_subscribers = ( + IssueSubscriber.objects.filter(project=project) + .exclude(subscriber_id=actor_id) + .values_list("subscriber") + ) + + issue = Issue.objects.get(project=project, pk=issue_id) + for subscriber in issue_subscribers: + for issue_activity in issue_activities_created: + bulk_notifications.append( + Notification( + workspace=project.workspace, + sender="in_app:issue_activities", + triggered_by=actor, + receiver_id=subscriber, + entity_identifier=issue_id, + entity_name="issue", + project=project, + title=issue_activity.comment, + data={ + "issue": { + "id": str(issue_id), + "identifier": str(project.identifier), + "sequence_id": issue.sequence_id, + "state_name": issue.state.name, + "state_group": issue.state.group, + }, + "issue_activity": str(issue_activity.id), + }, ) + ) + return except Exception as e: capture_exception(e) diff --git a/apiserver/plane/db/models/__init__.py b/apiserver/plane/db/models/__init__.py index 47585207ce4..1c075478dd7 100644 --- a/apiserver/plane/db/models/__init__.py +++ b/apiserver/plane/db/models/__init__.py @@ -67,4 +67,7 @@ from .estimate import Estimate, EstimatePoint from .inbox import Inbox, InboxIssue + from .analytic import AnalyticView + +from .notification import Notification \ No newline at end of file diff --git a/apiserver/plane/db/models/notification.py b/apiserver/plane/db/models/notification.py index 312f2933f7e..10ee1e709e7 100644 --- a/apiserver/plane/db/models/notification.py +++ b/apiserver/plane/db/models/notification.py @@ -12,12 +12,15 @@ class Notification(BaseModel): project = models.ForeignKey( "db.Project", related_name="notifications", on_delete=models.CASCADE, null=True ) + data = models.JSONField(null=True) entity_identifier = models.UUIDField(null=True) + entity_name = models.CharField(max_length=255) title = models.TextField() message = models.JSONField(null=True) message_html = models.TextField(blank=True, default="

") message_stripped = models.TextField(blank=True, null=True) - sender = models.ForeignKey("db.User", related_name="sent_notifications", on_delete=models.SET_NULL, null=True) + sender = models.CharField(max_length=255) + triggered_by = models.ForeignKey("db.User", related_name="triggered_notifications", on_delete=models.SET_NULL, null=True) receiver = models.ForeignKey("db.User", related_name="received_notifications", on_delete=models.CASCADE) read_at = models.DateTimeField(null=True) snoozed_till = models.DateTimeField(null=True) From 2cd3dc2f9db7b926ae9dc19e90ccd02ba15d42db Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Thu, 22 Jun 2023 19:01:15 +0530 Subject: [PATCH 07/31] dev: remove delete endpoint response data --- apiserver/plane/api/views/issue.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py index d41d1574d96..5a08b0f0cbd 100644 --- a/apiserver/plane/api/views/issue.py +++ b/apiserver/plane/api/views/issue.py @@ -925,8 +925,7 @@ def destroy(self, request, slug, project_id, issue_id, subscriber_id): ) issue_subscriber.delete() return Response( - {"message": "Removed Subscription"}, - status=status.HTTP_200_OK, + status=status.HTTP_204_NO_CONTENT, ) except IssueSubscriber.DoesNotExist: return Response( From dcde6b2567eeeef652c594e0b8e2476785fca394 Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Thu, 22 Jun 2023 21:31:30 +0530 Subject: [PATCH 08/31] dev: notification endpoints and fix bg worker for saving notifications --- apiserver/plane/api/views/notification.py | 29 +++++++++++++++++++ .../plane/bgtasks/issue_activites_task.py | 9 ++++-- apiserver/plane/db/models/notification.py | 2 +- 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/apiserver/plane/api/views/notification.py b/apiserver/plane/api/views/notification.py index add3c69941c..1ccd4321257 100644 --- a/apiserver/plane/api/views/notification.py +++ b/apiserver/plane/api/views/notification.py @@ -1,6 +1,7 @@ # 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 @@ -18,7 +19,35 @@ def get_queryset(self): .get_queryset() .filter( workspace__slug=self.kwargs.get("slug"), + receiver_id=self.request.user.id, ) .select_related("workspace") ) + def partial_update(self, request, slug, pk): + try: + notification = Notification.objects.get( + workspace__slug=slug, pk=pk, receiver=request.user + ) + # Only read_at and snoozed_till can be updated + notification_data = { + "read_at": request.data.get("read_at", None), + "snoozed_till": request.data.get("snoozed_till", None), + } + serializer = NotificationSerializer(notification, data=notification_data, partial=True) + + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + except Notification.DoesNotExist: + return Response( + {"error": "Notification does not exists"}, + 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, + ) diff --git a/apiserver/plane/bgtasks/issue_activites_task.py b/apiserver/plane/bgtasks/issue_activites_task.py index 9e4f9475f25..f14d8ddc80e 100644 --- a/apiserver/plane/bgtasks/issue_activites_task.py +++ b/apiserver/plane/bgtasks/issue_activites_task.py @@ -1014,9 +1014,9 @@ def issue_activity( bulk_notifications = [] issue_subscribers = ( - IssueSubscriber.objects.filter(project=project) + IssueSubscriber.objects.filter(project=project, issue_id=issue_id) .exclude(subscriber_id=actor_id) - .values_list("subscriber") + .values_list("subscriber", flat=True) ) issue = Issue.objects.get(project=project, pk=issue_id) @@ -1026,7 +1026,7 @@ def issue_activity( Notification( workspace=project.workspace, sender="in_app:issue_activities", - triggered_by=actor, + triggered_by_id=actor_id, receiver_id=subscriber, entity_identifier=issue_id, entity_name="issue", @@ -1045,6 +1045,9 @@ def issue_activity( ) ) + # Bulk create notifications + Notification.objects.bulk_create(bulk_notifications, batch_size=100) + return except Exception as e: # Print logs if in DEBUG mode diff --git a/apiserver/plane/db/models/notification.py b/apiserver/plane/db/models/notification.py index 10ee1e709e7..6c4777bcd7d 100644 --- a/apiserver/plane/db/models/notification.py +++ b/apiserver/plane/db/models/notification.py @@ -33,4 +33,4 @@ class Meta: def __str__(self): """Return name of the notifications""" - return f"{self.receiver.name} <{self.workspace.name}>" + return f"{self.receiver.email} <{self.workspace.name}>" From be96ec47ba1160c618d4a10b15555ab30040371b Mon Sep 17 00:00:00 2001 From: NarayanBavisetti Date: Fri, 23 Jun 2023 01:15:35 +0530 Subject: [PATCH 09/31] feat: added list and unsubscribe function in issue subscriber --- apiserver/plane/api/serializers/project.py | 1 + apiserver/plane/api/urls.py | 1 + apiserver/plane/api/views/issue.py | 51 ++++++++++++++++++++++ 3 files changed, 53 insertions(+) diff --git a/apiserver/plane/api/serializers/project.py b/apiserver/plane/api/serializers/project.py index 18ee19e7bc4..cbea7171ae6 100644 --- a/apiserver/plane/api/serializers/project.py +++ b/apiserver/plane/api/serializers/project.py @@ -96,6 +96,7 @@ class ProjectMemberSerializer(BaseSerializer): workspace = WorkSpaceSerializer(read_only=True) project = ProjectSerializer(read_only=True) member = UserLiteSerializer(read_only=True) + is_subscribed = serializers.BooleanField(read_only=True) class Meta: model = ProjectMember diff --git a/apiserver/plane/api/urls.py b/apiserver/plane/api/urls.py index 0e084faea90..a75a878de90 100644 --- a/apiserver/plane/api/urls.py +++ b/apiserver/plane/api/urls.py @@ -824,6 +824,7 @@ { "get": "subscription_status", "post": "subscribe", + "delete": "unsubscribe", } ), name="project-issue-subscribers", diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py index 81f7889b512..935377d46c6 100644 --- a/apiserver/plane/api/views/issue.py +++ b/apiserver/plane/api/views/issue.py @@ -15,6 +15,7 @@ Value, CharField, When, + Exists, ) from django.core.serializers.json import DjangoJSONEncoder from django.utils.decorators import method_decorator @@ -43,6 +44,7 @@ IssueLiteSerializer, IssueAttachmentSerializer, IssueSubscriberSerializer, + ProjectMemberSerializer, ) from plane.api.permissions import ( ProjectEntityPermission, @@ -60,6 +62,7 @@ IssueAttachment, State, IssueSubscriber, + ProjectMember, ) from plane.bgtasks.issue_activites_task import issue_activity from plane.utils.grouper import group_results @@ -868,6 +871,30 @@ class IssueSubscriberViewSet(BaseViewSet): ProjectEntityPermission, ] + def list(self, request, slug, project_id, issue_id): + try: + members = ProjectMember.objects.filter( + workspace__slug=slug, project_id=project_id + ) + members = members.annotate( + is_subscribed=Exists( + IssueSubscriber.objects.filter( + workspace__slug=slug, + project_id=project_id, + issue_id=issue_id, + subscriber=OuterRef("member"), + ) + ) + ) + serializer = ProjectMemberSerializer(members, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + except Exception as e: + capture_exception(e) + return Response( + {"error": e}, + status=status.HTTP_400_BAD_REQUEST, + ) + def perform_create(self, serializer): serializer.save( project_id=self.kwargs.get("project_id"), @@ -913,6 +940,30 @@ def subscribe(self, request, slug, project_id, issue_id): status=status.HTTP_400_BAD_REQUEST, ) + def unsubscribe(self, request, slug, project_id, issue_id): + try: + issue_subscriber = IssueSubscriber.objects.get( + project=project_id, + subscriber=request.user, + workspace__slug=slug, + issue=issue_id, + ) + issue_subscriber.delete() + return Response( + status=status.HTTP_204_NO_CONTENT, + ) + except IssueSubscriber.DoesNotExist: + return Response( + {"error": "User subscribed to this issue"}, + 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 subscription_status(self, request, slug, project_id, issue_id): try: issue_subscriber = IssueSubscriber.objects.filter( From e73b39fcdfe7cfb7dd76c66d2e678032ac3d18db Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Fri, 23 Jun 2023 11:25:27 +0530 Subject: [PATCH 10/31] dev: filter by snoozed and response update for list and permissions --- apiserver/plane/api/serializers/__init__.py | 1 + apiserver/plane/api/serializers/project.py | 11 ++- apiserver/plane/api/views/issue.py | 103 +++++++++++--------- apiserver/plane/api/views/notification.py | 30 +++++- 4 files changed, 98 insertions(+), 47 deletions(-) diff --git a/apiserver/plane/api/serializers/__init__.py b/apiserver/plane/api/serializers/__init__.py index 381891f2f00..2ff210f98ca 100644 --- a/apiserver/plane/api/serializers/__init__.py +++ b/apiserver/plane/api/serializers/__init__.py @@ -21,6 +21,7 @@ ProjectIdentifierSerializer, ProjectFavoriteSerializer, ProjectLiteSerializer, + ProjectMemberLiteSerializer, ) from .state import StateSerializer, StateLiteSerializer from .view import IssueViewSerializer, IssueViewFavoriteSerializer diff --git a/apiserver/plane/api/serializers/project.py b/apiserver/plane/api/serializers/project.py index cbea7171ae6..f96be09ab4a 100644 --- a/apiserver/plane/api/serializers/project.py +++ b/apiserver/plane/api/serializers/project.py @@ -96,7 +96,6 @@ class ProjectMemberSerializer(BaseSerializer): workspace = WorkSpaceSerializer(read_only=True) project = ProjectSerializer(read_only=True) member = UserLiteSerializer(read_only=True) - is_subscribed = serializers.BooleanField(read_only=True) class Meta: model = ProjectMember @@ -135,3 +134,13 @@ class Meta: model = Project fields = ["id", "identifier", "name"] read_only_fields = fields + + +class ProjectMemberLiteSerializer(BaseSerializer): + member = UserLiteSerializer(read_only=True) + is_subscribed = serializers.BooleanField(read_only=True) + + class Meta: + model = ProjectMember + fields = ["member", "id", "is_subscribed"] + read_only_fields = fields diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py index 935377d46c6..f6821983434 100644 --- a/apiserver/plane/api/views/issue.py +++ b/apiserver/plane/api/views/issue.py @@ -45,11 +45,13 @@ IssueAttachmentSerializer, IssueSubscriberSerializer, ProjectMemberSerializer, + ProjectMemberLiteSerializer, ) from plane.api.permissions import ( ProjectEntityPermission, WorkSpaceAdminPermission, ProjectMemberPermission, + ProjectLitePermission, ) from plane.db.models import ( Project, @@ -871,12 +873,41 @@ class IssueSubscriberViewSet(BaseViewSet): ProjectEntityPermission, ] + def get_permissions(self): + if self.action in ["subscribe", "unsubscribe", "subscription_status"]: + self.permission_classes = [ + ProjectLitePermission, + ] + else: + self.permission_classes = [ + ProjectEntityPermission, + ] + + return super(IssueSubscriberViewSet, self).get_permissions() + + def perform_create(self, serializer): + serializer.save( + project_id=self.kwargs.get("project_id"), + issue_id=self.kwargs.get("issue_id"), + ) + + def get_queryset(self): + return ( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(project_id=self.kwargs.get("project_id")) + .filter(issue_id=self.kwargs.get("issue_id")) + .filter(project__project_projectmember__member=self.request.user) + .order_by("-created_at") + .distinct() + ) + def list(self, request, slug, project_id, issue_id): try: members = ProjectMember.objects.filter( workspace__slug=slug, project_id=project_id - ) - members = members.annotate( + ).annotate( is_subscribed=Exists( IssueSubscriber.objects.filter( workspace__slug=slug, @@ -885,8 +916,8 @@ def list(self, request, slug, project_id, issue_id): subscriber=OuterRef("member"), ) ) - ) - serializer = ProjectMemberSerializer(members, many=True) + ).select_related("member") + serializer = ProjectMemberLiteSerializer(members, many=True) return Response(serializer.data, status=status.HTTP_200_OK) except Exception as e: capture_exception(e) @@ -895,23 +926,29 @@ def list(self, request, slug, project_id, issue_id): status=status.HTTP_400_BAD_REQUEST, ) - def perform_create(self, serializer): - serializer.save( - project_id=self.kwargs.get("project_id"), - issue_id=self.kwargs.get("issue_id"), - ) - - def get_queryset(self): - return ( - super() - .get_queryset() - .filter(workspace__slug=self.kwargs.get("slug")) - .filter(project_id=self.kwargs.get("project_id")) - .filter(issue_id=self.kwargs.get("issue_id")) - .filter(project__project_projectmember__member=self.request.user) - .order_by("-created_at") - .distinct() - ) + def destroy(self, request, slug, project_id, issue_id, subscriber_id): + try: + issue_subscriber = IssueSubscriber.objects.get( + project=project_id, + subscriber=subscriber_id, + workspace__slug=slug, + issue=issue_id, + ) + issue_subscriber.delete() + return Response( + status=status.HTTP_204_NO_CONTENT, + ) + except IssueSubscriber.DoesNotExist: + return Response( + {"error": "User is not subscribed to this issue"}, + 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 subscribe(self, request, slug, project_id, issue_id): try: @@ -979,27 +1016,3 @@ def subscription_status(self, request, slug, project_id, issue_id): {"error": "Something went wrong, please try again later"}, status=status.HTTP_400_BAD_REQUEST, ) - - def destroy(self, request, slug, project_id, issue_id, subscriber_id): - try: - issue_subscriber = IssueSubscriber.objects.get( - project=project_id, - subscriber=subscriber_id, - workspace__slug=slug, - issue=issue_id, - ) - issue_subscriber.delete() - return Response( - status=status.HTTP_204_NO_CONTENT, - ) - except IssueSubscriber.DoesNotExist: - return Response( - {"error": "User is not subscribed to this issue"}, - 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, - ) diff --git a/apiserver/plane/api/views/notification.py b/apiserver/plane/api/views/notification.py index 1ccd4321257..6c3f3587bf6 100644 --- a/apiserver/plane/api/views/notification.py +++ b/apiserver/plane/api/views/notification.py @@ -1,3 +1,7 @@ +# Django imports +from django.db.models import Q +from django.utils import timezone + # Third party imports from rest_framework import status from rest_framework.response import Response @@ -24,6 +28,28 @@ def get_queryset(self): .select_related("workspace") ) + def list(self, request, slug): + try: + order_by = request.GET.get("ordeer_by", "-created_at") + snoozed = request.GET.get("snoozed", "false") + notifications = Notification.objects.filter( + workspace__slug=slug, receiver=request.user + ).order_by(order_by) + + if snoozed == "false": + notifications = notifications.filter( + Q(snoozed_till__gte=timezone.now()) | Q(snoozed_till__isnull=True), + ) + + serializer = NotificationSerializer(notifications, many=True) + 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, pk): try: notification = Notification.objects.get( @@ -34,7 +60,9 @@ def partial_update(self, request, slug, pk): "read_at": request.data.get("read_at", None), "snoozed_till": request.data.get("snoozed_till", None), } - serializer = NotificationSerializer(notification, data=notification_data, partial=True) + serializer = NotificationSerializer( + notification, data=notification_data, partial=True + ) if serializer.is_valid(): serializer.save() From ea605cf48c45a03abd37ee32492bab60ad361e51 Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Mon, 3 Jul 2023 17:28:09 +0530 Subject: [PATCH 11/31] dev: update issue notifications --- apiserver/plane/bgtasks/issue_activites_task.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/apiserver/plane/bgtasks/issue_activites_task.py b/apiserver/plane/bgtasks/issue_activites_task.py index f14d8ddc80e..78be2ae0636 100644 --- a/apiserver/plane/bgtasks/issue_activites_task.py +++ b/apiserver/plane/bgtasks/issue_activites_task.py @@ -22,6 +22,7 @@ Module, IssueSubscriber, Notification, + IssueAssignee, ) from plane.api.serializers import IssueActivitySerializer, IssueFlatSerializer @@ -960,6 +961,12 @@ def issue_activity( actor = User.objects.get(pk=actor_id) project = Project.objects.get(pk=project_id) + # add the user to issue subscriber + try: + _ = IssueSubscriber.objects.create(issue_id=issue_id, subscriber=actor) + except Exception as e: + pass + ACTIVITY_MAPPER = { "issue.activity.created": create_issue_activity, "issue.activity.updated": update_issue_activity, @@ -1013,12 +1020,20 @@ def issue_activity( # Create Notifications bulk_notifications = [] - issue_subscribers = ( + issue_subscribers = list( IssueSubscriber.objects.filter(project=project, issue_id=issue_id) .exclude(subscriber_id=actor_id) .values_list("subscriber", flat=True) ) + issue_assignees = list( + IssueAssignee.objects.filter(project=project, issue_id=issue_id) + .exclude(assignee_id=actor_id) + .values_list("assignee", flat=True) + ) + + issue_subscribers = issue_subscribers + issue_assignees + issue = Issue.objects.get(project=project, pk=issue_id) for subscriber in issue_subscribers: for issue_activity in issue_activities_created: From 581dff548213d43528dfa96c7c355436f8090af2 Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Fri, 7 Jul 2023 16:02:17 +0530 Subject: [PATCH 12/31] dev: notification segregation --- apiserver/plane/api/views/notification.py | 31 +++++++++++++++++-- .../plane/bgtasks/issue_activites_task.py | 3 ++ 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/apiserver/plane/api/views/notification.py b/apiserver/plane/api/views/notification.py index 6c3f3587bf6..83758484b25 100644 --- a/apiserver/plane/api/views/notification.py +++ b/apiserver/plane/api/views/notification.py @@ -9,7 +9,7 @@ # Module imports from .base import BaseViewSet -from plane.db.models import Notification +from plane.db.models import Notification, IssueAssignee, IssueSubscriber, Issue from plane.api.serializers import NotificationSerializer @@ -30,12 +30,37 @@ def get_queryset(self): def list(self, request, slug): try: - order_by = request.GET.get("ordeer_by", "-created_at") + order_by = request.GET.get("order_by", "-created_at") snoozed = request.GET.get("snoozed", "false") + + # Filter type + type = request.GET.get("type", "all") + notifications = Notification.objects.filter( - workspace__slug=slug, receiver=request.user + workspace__slug=slug, receiver_id=request.user.id ).order_by(order_by) + # Subscribed issues + if type == "watching": + issue_ids = IssueSubscriber.objects.filter( + workspace__slug=slug, subsriber_id=request.user.id + ).values_list("issue_id", flat=True) + notifications = notifications.filter(entity_id__in=issue_ids) + + # Assigned Issues + if type == "assigned": + issue_ids = IssueAssignee.objects.filter( + workspace__slug=slug, assignee_id=request.user.id + ).values_list("issue_id", flat=True) + notifications = notifications.filter(entity_id__in=issue_ids) + + # Created issues + if type == "created": + issue_ids = Issue.objects.filter( + workspace__slug=slug, created_by=request.user + ).values_list("pk", flat=True) + notifications = notifications.filter(entity_id__in=issue_ids) + if snoozed == "false": notifications = notifications.filter( Q(snoozed_till__gte=timezone.now()) | Q(snoozed_till__isnull=True), diff --git a/apiserver/plane/bgtasks/issue_activites_task.py b/apiserver/plane/bgtasks/issue_activites_task.py index 78be2ae0636..d5992256e97 100644 --- a/apiserver/plane/bgtasks/issue_activites_task.py +++ b/apiserver/plane/bgtasks/issue_activites_task.py @@ -1034,6 +1034,9 @@ def issue_activity( issue_subscribers = issue_subscribers + issue_assignees + if issue.created_by_id: + issue_subscribers = issue_subscribers + [issue.created_by_id] + issue = Issue.objects.get(project=project, pk=issue_id) for subscriber in issue_subscribers: for issue_activity in issue_activities_created: From 90c819b1ba066b0aef175a7d23107f0972a99053 Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Fri, 7 Jul 2023 18:07:06 +0530 Subject: [PATCH 13/31] dev: update notifications --- apiserver/plane/bgtasks/issue_activites_task.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apiserver/plane/bgtasks/issue_activites_task.py b/apiserver/plane/bgtasks/issue_activites_task.py index d5992256e97..bf3d0c91cbb 100644 --- a/apiserver/plane/bgtasks/issue_activites_task.py +++ b/apiserver/plane/bgtasks/issue_activites_task.py @@ -24,7 +24,7 @@ Notification, IssueAssignee, ) -from plane.api.serializers import IssueActivitySerializer, IssueFlatSerializer +from plane.api.serializers import IssueActivitySerializer # Track Chnages in name From 6c3be6faba9b5dc6aa802a0201756098d4c03d57 Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Mon, 10 Jul 2023 13:51:36 +0530 Subject: [PATCH 14/31] dev: notification filtering --- apiserver/plane/api/views/notification.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apiserver/plane/api/views/notification.py b/apiserver/plane/api/views/notification.py index 83758484b25..318c3b1f7f4 100644 --- a/apiserver/plane/api/views/notification.py +++ b/apiserver/plane/api/views/notification.py @@ -45,21 +45,21 @@ def list(self, request, slug): issue_ids = IssueSubscriber.objects.filter( workspace__slug=slug, subsriber_id=request.user.id ).values_list("issue_id", flat=True) - notifications = notifications.filter(entity_id__in=issue_ids) + notifications = notifications.filter(entity_identifier__in=issue_ids) # Assigned Issues if type == "assigned": issue_ids = IssueAssignee.objects.filter( workspace__slug=slug, assignee_id=request.user.id ).values_list("issue_id", flat=True) - notifications = notifications.filter(entity_id__in=issue_ids) + notifications = notifications.filter(entity_identifier__in=issue_ids) # Created issues if type == "created": issue_ids = Issue.objects.filter( workspace__slug=slug, created_by=request.user ).values_list("pk", flat=True) - notifications = notifications.filter(entity_id__in=issue_ids) + notifications = notifications.filter(entity_identifier__in=issue_ids) if snoozed == "false": notifications = notifications.filter( @@ -69,7 +69,7 @@ def list(self, request, slug): serializer = NotificationSerializer(notifications, many=True) return Response(serializer.data, status=status.HTTP_200_OK) except Exception as e: - capture_exception(e) + print(e) return Response( {"error": "Something went wrong please try again later"}, status=status.HTTP_400_BAD_REQUEST, From 624dfdb2b60ab55efb9f4012e5e7668c723fcd20 Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Mon, 10 Jul 2023 17:52:02 +0530 Subject: [PATCH 15/31] dev: add issue name in notifications --- apiserver/plane/bgtasks/issue_activites_task.py | 1 + 1 file changed, 1 insertion(+) diff --git a/apiserver/plane/bgtasks/issue_activites_task.py b/apiserver/plane/bgtasks/issue_activites_task.py index bf3d0c91cbb..7bb6010dd55 100644 --- a/apiserver/plane/bgtasks/issue_activites_task.py +++ b/apiserver/plane/bgtasks/issue_activites_task.py @@ -1053,6 +1053,7 @@ def issue_activity( data={ "issue": { "id": str(issue_id), + "name": str(issue.name), "identifier": str(project.identifier), "sequence_id": issue.sequence_id, "state_name": issue.state.name, From cc513b9030662d881f3fc768cbdf622ab53a0336 Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Tue, 11 Jul 2023 12:53:06 +0530 Subject: [PATCH 16/31] dev: notification new endpoints --- apiserver/plane/api/urls.py | 20 ++++ apiserver/plane/api/views/notification.py | 113 ++++++++++++++++++++-- apiserver/plane/db/models/notification.py | 1 + 3 files changed, 127 insertions(+), 7 deletions(-) diff --git a/apiserver/plane/api/urls.py b/apiserver/plane/api/urls.py index 7ce79855b8c..34e711be6b7 100644 --- a/apiserver/plane/api/urls.py +++ b/apiserver/plane/api/urls.py @@ -1326,5 +1326,25 @@ ), name="notifications", ), + path( + "workspaces//users/notifications//read/", + NotificationViewSet.as_view( + { + "post": "mark_read", + "delete": "mark_unread", + } + ), + name="notifications", + ), + path( + "workspaces//users/notifications//archive/", + NotificationViewSet.as_view( + { + "post": "archive", + "delete": "unarchive", + } + ), + name="notifications", + ), ## End Notification ] diff --git a/apiserver/plane/api/views/notification.py b/apiserver/plane/api/views/notification.py index 318c3b1f7f4..fa1e280d6c2 100644 --- a/apiserver/plane/api/views/notification.py +++ b/apiserver/plane/api/views/notification.py @@ -32,6 +32,7 @@ def list(self, request, slug): try: order_by = request.GET.get("order_by", "-created_at") snoozed = request.GET.get("snoozed", "false") + archived = request.GET.get("archived", "false") # Filter type type = request.GET.get("type", "all") @@ -40,6 +41,24 @@ def list(self, request, slug): workspace__slug=slug, receiver_id=request.user.id ).order_by(order_by) + # Filter for snoozed notifications + if snoozed == "false": + notifications = notifications.filter( + Q(snoozed_till__gte=timezone.now()) | Q(snoozed_till__isnull=True), + ) + + if snoozed == "true": + notifications = notifications.filter( + snoozed_till__lt=timezone.now(), + ) + + # Filter for archived or unarchive + if archived == "true": + notifications = notifications.filter(archived_at__isnull=True) + + if archived == "false": + notifications = notifications.filter(archived_at__isnull=False) + # Subscribed issues if type == "watching": issue_ids = IssueSubscriber.objects.filter( @@ -61,15 +80,10 @@ def list(self, request, slug): ).values_list("pk", flat=True) notifications = notifications.filter(entity_identifier__in=issue_ids) - if snoozed == "false": - notifications = notifications.filter( - Q(snoozed_till__gte=timezone.now()) | Q(snoozed_till__isnull=True), - ) - serializer = NotificationSerializer(notifications, many=True) return Response(serializer.data, status=status.HTTP_200_OK) except Exception as e: - print(e) + capture_exception(e) return Response( {"error": "Something went wrong please try again later"}, status=status.HTTP_400_BAD_REQUEST, @@ -82,7 +96,6 @@ def partial_update(self, request, slug, pk): ) # Only read_at and snoozed_till can be updated notification_data = { - "read_at": request.data.get("read_at", None), "snoozed_till": request.data.get("snoozed_till", None), } serializer = NotificationSerializer( @@ -104,3 +117,89 @@ def partial_update(self, request, slug, pk): {"error": "Something went wrong please try again later"}, status=status.HTTP_400_BAD_REQUEST, ) + + def mark_read(self, request, slug, pk): + try: + notification = Notification.objects.get( + receiver=request.user, workspace__slug=slug, pk=pk + ) + notification.read_at = timezone.now() + notification.save() + serializer = NotificationSerializer(notification) + return Response(serializer.data, status=status.HTTP_200_OK) + except Notification.DoesNotExist: + return Response( + {"error": "Notification does not exists"}, + 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 mark_unread(self, request, slug, pk): + try: + notification = Notification.objects.get( + receiver=request.user, workspace__slug=slug, pk=pk + ) + notification.read_at = None + notification.save() + serializer = NotificationSerializer(notification) + return Response(serializer.data, status=status.HTTP_200_OK) + except Notification.DoesNotExist: + return Response( + {"error": "Notification does not exists"}, + 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 archive(self, request, slug, pk): + try: + notification = Notification.objects.get( + receiver=request.user, workspace__slug=slug, pk=pk + ) + notification.archived_at = timezone.now() + notification.save() + serializer = NotificationSerializer(notification) + return Response(serializer.data, status=status.HTTP_200_OK) + except Notification.DoesNotExist: + return Response( + {"error": "Notification does not exists"}, + 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 unarchive(self, request, slug, pk): + try: + notification = Notification.objects.get( + receiver=request.user, workspace__slug=slug, pk=pk + ) + notification.archived_at = None + notification.save() + serializer = NotificationSerializer(notification) + return Response(serializer.data, status=status.HTTP_200_OK) + except Notification.DoesNotExist: + return Response( + {"error": "Notification does not exists"}, + 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, + ) + diff --git a/apiserver/plane/db/models/notification.py b/apiserver/plane/db/models/notification.py index 6c4777bcd7d..3df93571802 100644 --- a/apiserver/plane/db/models/notification.py +++ b/apiserver/plane/db/models/notification.py @@ -24,6 +24,7 @@ class Notification(BaseModel): receiver = models.ForeignKey("db.User", related_name="received_notifications", on_delete=models.CASCADE) read_at = models.DateTimeField(null=True) snoozed_till = models.DateTimeField(null=True) + archived_at = models.DateTimeField(null=True) class Meta: verbose_name = "Notification" From 53487eaadd9fc2ccc21568dc0e149a85a4da2f46 Mon Sep 17 00:00:00 2001 From: NarayanBavisetti Date: Wed, 12 Jul 2023 12:18:33 +0530 Subject: [PATCH 17/31] fix: pushing local settings --- apiserver/plane/settings/local.py | 36 +++++++++++++++++++++++-------- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/apiserver/plane/settings/local.py b/apiserver/plane/settings/local.py index e6f5f8e3959..194b2629f5c 100644 --- a/apiserver/plane/settings/local.py +++ b/apiserver/plane/settings/local.py @@ -10,9 +10,7 @@ from .common import * # noqa -DEBUG = int(os.environ.get( - "DEBUG", 1 -)) == 1 +DEBUG = int(os.environ.get("DEBUG", 1)) == 1 EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" @@ -27,13 +25,11 @@ } } -DOCKERIZED = int(os.environ.get( - "DOCKERIZED", 0 -)) == 1 +DOCKERIZED = int(os.environ.get("DOCKERIZED", 0)) == 1 USE_MINIO = int(os.environ.get("USE_MINIO", 0)) == 1 -FILE_SIZE_LIMIT = int(os.environ.get("FILE_SIZE_LIMIT", 5242880)) +FILE_SIZE_LIMIT = int(os.environ.get("FILE_SIZE_LIMIT", 5242880)) if DOCKERIZED: DATABASES["default"] = dj_database_url.config() @@ -65,6 +61,27 @@ traces_sample_rate=0.7, profiles_sample_rate=1.0, ) +else: + LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "handlers": { + "console": { + "class": "logging.StreamHandler", + }, + }, + "root": { + "handlers": ["console"], + "level": "DEBUG", + }, + "loggers": { + "*": { + "handlers": ["console"], + "level": "DEBUG", + "propagate": True, + }, + }, + } REDIS_HOST = "localhost" REDIS_PORT = 6379 @@ -83,8 +100,9 @@ ANALYTICS_SECRET_KEY = os.environ.get("ANALYTICS_SECRET_KEY", False) ANALYTICS_BASE_API = os.environ.get("ANALYTICS_BASE_API", False) +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", "text-davinci-003") +GPT_ENGINE = os.environ.get("GPT_ENGINE", "gpt-3.5-turbo") SLACK_BOT_TOKEN = os.environ.get("SLACK_BOT_TOKEN", False) @@ -95,4 +113,4 @@ GITHUB_ACCESS_TOKEN = os.environ.get("GITHUB_ACCESS_TOKEN", False) -ENABLE_SIGNUP = os.environ.get("ENABLE_SIGNUP", "1") == "1" \ No newline at end of file +ENABLE_SIGNUP = os.environ.get("ENABLE_SIGNUP", "1") == "1" From e802790581a65f12ef245216fba352edc962637b Mon Sep 17 00:00:00 2001 From: Dakshesh Jain Date: Thu, 13 Jul 2023 20:35:40 +0530 Subject: [PATCH 18/31] feat: notification workflow setup and made basic UI --- apps/app/components/icons/archive-icon.tsx | 19 ++ apps/app/components/icons/bell-icon.tsx | 24 ++ apps/app/components/icons/clock-icon.tsx | 19 ++ apps/app/components/icons/index.ts | 6 + .../components/icons/single-comment-icon.tsx | 24 ++ apps/app/components/icons/sort-icon.tsx | 19 ++ apps/app/components/icons/x-mark-icon.tsx | 19 ++ apps/app/components/issues/sidebar.tsx | 16 +- apps/app/components/notifications/index.ts | 3 + .../notifications/notification-card.tsx | 163 +++++++++ .../notifications/notification-popover.tsx | 245 ++++++++++++++ .../select-snooze-till-modal.tsx | 317 ++++++++++++++++++ .../app/components/workspace/sidebar-menu.tsx | 59 ++-- apps/app/constants/fetch-keys.ts | 20 +- apps/app/helpers/date-time.helper.ts | 88 ++++- apps/app/helpers/string.helper.ts | 16 + .../use-issue-notification-subscription.tsx | 76 +++++ apps/app/hooks/use-user-notifications.tsx | 142 ++++++++ .../public/empty-state/empty-notification.svg | 38 +++ apps/app/services/notifications.service.ts | 162 +++++++++ apps/app/tailwind.config.js | 2 +- apps/app/types/index.d.ts | 1 + apps/app/types/notifications.d.ts | 56 ++++ 23 files changed, 1500 insertions(+), 34 deletions(-) create mode 100644 apps/app/components/icons/archive-icon.tsx create mode 100644 apps/app/components/icons/bell-icon.tsx create mode 100644 apps/app/components/icons/clock-icon.tsx create mode 100644 apps/app/components/icons/single-comment-icon.tsx create mode 100644 apps/app/components/icons/sort-icon.tsx create mode 100644 apps/app/components/icons/x-mark-icon.tsx create mode 100644 apps/app/components/notifications/index.ts create mode 100644 apps/app/components/notifications/notification-card.tsx create mode 100644 apps/app/components/notifications/notification-popover.tsx create mode 100644 apps/app/components/notifications/select-snooze-till-modal.tsx create mode 100644 apps/app/hooks/use-issue-notification-subscription.tsx create mode 100644 apps/app/hooks/use-user-notifications.tsx create mode 100644 apps/app/public/empty-state/empty-notification.svg create mode 100644 apps/app/services/notifications.service.ts create mode 100644 apps/app/types/notifications.d.ts diff --git a/apps/app/components/icons/archive-icon.tsx b/apps/app/components/icons/archive-icon.tsx new file mode 100644 index 00000000000..6c9c791fbfe --- /dev/null +++ b/apps/app/components/icons/archive-icon.tsx @@ -0,0 +1,19 @@ +import React from "react"; + +import type { Props } from "./types"; + +export const ArchiveIcon: React.FC = ({ width = "24", height = "24", className, color }) => ( + + + +); diff --git a/apps/app/components/icons/bell-icon.tsx b/apps/app/components/icons/bell-icon.tsx new file mode 100644 index 00000000000..4aafb702e38 --- /dev/null +++ b/apps/app/components/icons/bell-icon.tsx @@ -0,0 +1,24 @@ +import React from "react"; + +import type { Props } from "./types"; + +export const BellNotificationIcon: React.FC = ({ + width = "24", + height = "24", + color = "rgb(var(--color-text-200))", + className, +}) => ( + + + +); diff --git a/apps/app/components/icons/clock-icon.tsx b/apps/app/components/icons/clock-icon.tsx new file mode 100644 index 00000000000..3d2273364e8 --- /dev/null +++ b/apps/app/components/icons/clock-icon.tsx @@ -0,0 +1,19 @@ +import React from "react"; + +import type { Props } from "./types"; + +export const ClockIcon: React.FC = ({ width = "24", height = "24", className, color }) => ( + + + +); diff --git a/apps/app/components/icons/index.ts b/apps/app/components/icons/index.ts index db7aad041ac..183b20c97c2 100644 --- a/apps/app/components/icons/index.ts +++ b/apps/app/components/icons/index.ts @@ -82,3 +82,9 @@ export * from "./command-icon"; export * from "./color-picker-icon"; export * from "./inbox-icon"; export * from "./stacked-layers-horizontal-icon"; +export * from "./sort-icon"; +export * from "./x-mark-icon"; +export * from "./archive-icon"; +export * from "./clock-icon"; +export * from "./bell-icon"; +export * from "./single-comment-icon"; diff --git a/apps/app/components/icons/single-comment-icon.tsx b/apps/app/components/icons/single-comment-icon.tsx new file mode 100644 index 00000000000..b770124a162 --- /dev/null +++ b/apps/app/components/icons/single-comment-icon.tsx @@ -0,0 +1,24 @@ +import React from "react"; + +import type { Props } from "./types"; + +export const SingleCommentCard: React.FC = ({ + width = "24", + height = "24", + className, + color, +}) => ( + + + +); diff --git a/apps/app/components/icons/sort-icon.tsx b/apps/app/components/icons/sort-icon.tsx new file mode 100644 index 00000000000..955cdadd591 --- /dev/null +++ b/apps/app/components/icons/sort-icon.tsx @@ -0,0 +1,19 @@ +import React from "react"; + +import type { Props } from "./types"; + +export const SortIcon: React.FC = ({ width = "24", height = "24", className, color }) => ( + + + +); diff --git a/apps/app/components/icons/x-mark-icon.tsx b/apps/app/components/icons/x-mark-icon.tsx new file mode 100644 index 00000000000..afebc827334 --- /dev/null +++ b/apps/app/components/icons/x-mark-icon.tsx @@ -0,0 +1,19 @@ +import React from "react"; + +import type { Props } from "./types"; + +export const XMarkIcon: React.FC = ({ width = "24", height = "24", className, color }) => ( + + + +); diff --git a/apps/app/components/issues/sidebar.tsx b/apps/app/components/issues/sidebar.tsx index 13a9c639564..8b1ab802314 100644 --- a/apps/app/components/issues/sidebar.tsx +++ b/apps/app/components/issues/sidebar.tsx @@ -5,7 +5,7 @@ import { useRouter } from "next/router"; import useSWR, { mutate } from "swr"; // react-hook-form -import { useForm, Controller, UseFormWatch, Control } from "react-hook-form"; +import { useForm, Controller, UseFormWatch } from "react-hook-form"; // react-color import { TwitterPicker } from "react-color"; // headless ui @@ -13,6 +13,7 @@ import { Popover, Listbox, Transition } from "@headlessui/react"; // hooks import useToast from "hooks/use-toast"; import useUserAuth from "hooks/use-user-auth"; +import useUserIssueNotificationSubscription from "hooks/use-issue-notification-subscription"; // services import issuesService from "services/issues.service"; import modulesService from "services/modules.service"; @@ -96,6 +97,9 @@ export const IssueDetailsSidebar: React.FC = ({ const { user } = useUserAuth(); + const { loading, handleSubscribe, handleUnsubscribe, subscribed } = + useUserIssueNotificationSubscription(workspaceSlug, projectId, issueId); + const { memberRole } = useProjectMyMembership(); const { setToastAlert } = useToast(); @@ -287,6 +291,16 @@ export const IssueDetailsSidebar: React.FC = ({ {issueDetail?.project_detail?.identifier}-{issueDetail?.sequence_id}
+ {(fieldsToShow.includes("all") || fieldsToShow.includes("link")) && ( + ))} +
+ + + ); +}; diff --git a/apps/app/components/notifications/notification-popover.tsx b/apps/app/components/notifications/notification-popover.tsx new file mode 100644 index 00000000000..334a3acda09 --- /dev/null +++ b/apps/app/components/notifications/notification-popover.tsx @@ -0,0 +1,245 @@ +import React, { Fragment } from "react"; + +import Image from "next/image"; + +// hooks +import useTheme from "hooks/use-theme"; +// icons +import { + XMarkIcon, + ArchiveIcon, + ClockIcon, + SortIcon, + BellNotificationIcon, +} from "components/icons"; + +import { Popover, Transition, Menu } from "@headlessui/react"; +import { ArrowLeftIcon } from "@heroicons/react/20/solid"; + +// hooks +import useUserNotification from "hooks/use-user-notifications"; + +// components +import { Spinner } from "components/ui"; +import { SnoozeNotificationModal, NotificationCard } from "components/notifications"; + +// type +import type { NotificationType } from "types"; + +const notificationTabs: Array<{ + label: string; + value: NotificationType; +}> = [ + { + label: "My Issues", + value: "assigned", + }, + { + label: "Created by me", + value: "created", + }, + { + label: "Subscribed", + value: "watching", + }, +]; + +export const NotificationPopover = () => { + const { + notifications, + archived, + readNotification, + selectedNotificationForSnooze, + selectedTab, + setArchived, + setReadNotification, + setSelectedNotificationForSnooze, + setSelectedTab, + setSnoozed, + snoozed, + notificationsMutate, + markNotificationArchivedStatus, + markNotificationReadStatus, + } = useUserNotification(); + + // theme context + const { collapsed: sidebarCollapse } = useTheme(); + + return ( + <> + setSelectedNotificationForSnooze(null)} + notificationId={selectedNotificationForSnooze} + onSuccess={() => { + notificationsMutate(); + setSelectedNotificationForSnooze(null); + }} + /> + + {({ open: isActive, close: closePopover }) => ( + <> + + + + {sidebarCollapse ? null : Notifications} + + + +
+

+ Notifications +

+
+ + + + +
+
+ +
+ {snoozed || archived || readNotification ? ( +
+
+ +
+
+ ) : ( +
+ +
+ )} +
+ +
+ {notifications ? ( + notifications.length > 0 ? ( + notifications.map((notification) => ( + + )) + ) : ( +
+ Empty +

+ You{"'"}re updated with all the notifications +

+

+ You have read all the notifications. +

+
+ ) + ) : ( +
+ +
+ )} +
+
+
+ + )} +
+ + ); +}; diff --git a/apps/app/components/notifications/select-snooze-till-modal.tsx b/apps/app/components/notifications/select-snooze-till-modal.tsx new file mode 100644 index 00000000000..4d50ebf6fd5 --- /dev/null +++ b/apps/app/components/notifications/select-snooze-till-modal.tsx @@ -0,0 +1,317 @@ +import React, { Fragment } from "react"; + +// next +import { useRouter } from "next/router"; + +// react hook form +import { useForm, Controller } from "react-hook-form"; + +import { Transition, Dialog, Listbox } from "@headlessui/react"; +import { ChevronDownIcon, CheckIcon } from "@heroicons/react/20/solid"; + +// date helper +import { getDatesAfterCurrentDate, getTimestampAfterCurrentTime } from "helpers/date-time.helper"; + +// services +import userNotificationServices from "services/notifications.service"; + +// hooks +import useToast from "hooks/use-toast"; + +// components +import { PrimaryButton, SecondaryButton } from "components/ui"; + +// icons +import { XMarkIcon } from "components/icons"; + +type SnoozeModalProps = { + isOpen: boolean; + onClose: () => void; + onSuccess: () => void; + notificationId: string | null; +}; + +const dates = getDatesAfterCurrentDate(); +const timeStamps = getTimestampAfterCurrentTime(); + +export const SnoozeNotificationModal: React.FC = (props) => { + const { isOpen, onClose, notificationId, onSuccess } = props; + + const router = useRouter(); + const { workspaceSlug } = router.query; + + const { setToastAlert } = useToast(); + + const { + formState: { isSubmitting }, + reset, + handleSubmit, + control, + } = useForm(); + + const onSubmit = async (formData: any) => { + if (!workspaceSlug || !notificationId) return; + + const dateTime = new Date( + `${formData.date.toLocaleDateString()} ${formData.time.toLocaleTimeString()}` + ); + + await userNotificationServices + .patchUserNotification(workspaceSlug.toString(), notificationId, { + snoozed_till: dateTime, + }) + .then(() => { + onClose(); + onSuccess(); + setToastAlert({ + title: "Notification snoozed", + message: "Notification snoozed successfully", + type: "success", + }); + }); + }; + + const handleClose = () => { + onClose(); + const timeout = setTimeout(() => { + reset(); + clearTimeout(timeout); + }, 500); + }; + + return ( + + + +
+ + +
+
+ + +
+
+ + Customize Snooze Time + + +
+ +
+
+ +
+
+ ( + + {({ open }) => ( + <> +
+ + + + {value?.toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + }) || "Select Time"} + + + + + + + + + {timeStamps.map((time, index) => ( + + `relative cursor-default select-none py-2 pl-3 pr-9 ${ + active + ? "bg-custom-primary-100 text-custom-text-100" + : "text-custom-text-700" + }` + } + value={time.value} + > + {({ selected, active }) => ( + <> +
+ + {time.label} + +
+ + {selected ? ( + + + ) : null} + + )} +
+ ))} +
+
+
+ + )} +
+ )} + /> +
+
+ ( + + {({ open }) => ( + <> +
+ + + + {value?.toLocaleDateString([], { + day: "numeric", + month: "long", + year: "numeric", + }) || "Select Date"} + + + + + + + + + {dates.map((date, index) => ( + + `relative cursor-default select-none py-2 pl-3 pr-9 ${ + active + ? "bg-custom-primary-100 text-custom-text-100" + : "text-custom-text-700" + }` + } + value={date.value} + > + {({ selected, active }) => ( + <> +
+ + {date.label} + +
+ + {selected ? ( + + + ) : null} + + )} +
+ ))} +
+
+
+ + )} +
+ )} + /> +
+
+ +
+
+ Cancel + + Submit + +
+
+
+
+
+
+
+
+
+ ); +}; diff --git a/apps/app/components/workspace/sidebar-menu.tsx b/apps/app/components/workspace/sidebar-menu.tsx index b86aa661028..2114f502d0a 100644 --- a/apps/app/components/workspace/sidebar-menu.tsx +++ b/apps/app/components/workspace/sidebar-menu.tsx @@ -1,13 +1,38 @@ import React from "react"; -import { useRouter } from "next/router"; import Link from "next/link"; +import { useRouter } from "next/router"; // hooks import useTheme from "hooks/use-theme"; // icons import { ChartBarIcon } from "@heroicons/react/24/outline"; -import { GridViewIcon, AssignmentClipboardIcon, TickMarkIcon, SettingIcon } from "components/icons"; +import { GridViewIcon, AssignmentClipboardIcon, TickMarkIcon } from "components/icons"; + +import { NotificationPopover } from "components/notifications"; + +const workspaceLinks = (workspaceSlug: string) => [ + { + icon: GridViewIcon, + name: "Dashboard", + href: `/${workspaceSlug}`, + }, + { + icon: ChartBarIcon, + name: "Analytics", + href: `/${workspaceSlug}/analytics`, + }, + { + icon: AssignmentClipboardIcon, + name: "Projects", + href: `/${workspaceSlug}/projects`, + }, + { + icon: TickMarkIcon, + name: "My Issues", + href: `/${workspaceSlug}/me/my-issues`, + }, +]; export const WorkspaceSidebarMenu = () => { const router = useRouter(); @@ -16,34 +41,6 @@ export const WorkspaceSidebarMenu = () => { // theme context const { collapsed: sidebarCollapse } = useTheme(); - const workspaceLinks = (workspaceSlug: string) => [ - { - icon: GridViewIcon, - name: "Dashboard", - href: `/${workspaceSlug}`, - }, - { - icon: ChartBarIcon, - name: "Analytics", - href: `/${workspaceSlug}/analytics`, - }, - { - icon: AssignmentClipboardIcon, - name: "Projects", - href: `/${workspaceSlug}/projects`, - }, - { - icon: TickMarkIcon, - name: "My Issues", - href: `/${workspaceSlug}/me/my-issues`, - }, - { - icon: SettingIcon, - name: "Settings", - href: `/${workspaceSlug}/settings`, - }, - ]; - return (
{workspaceLinks(workspaceSlug as string).map((link, index) => { @@ -80,6 +77,8 @@ export const WorkspaceSidebarMenu = () => { ); })} + +
); }; diff --git a/apps/app/constants/fetch-keys.ts b/apps/app/constants/fetch-keys.ts index 7e77e6dc28b..7e52616abe9 100644 --- a/apps/app/constants/fetch-keys.ts +++ b/apps/app/constants/fetch-keys.ts @@ -1,4 +1,4 @@ -import { IAnalyticsParams, IJiraMetadata } from "types"; +import { IAnalyticsParams, IJiraMetadata, INotificationParams } from "types"; const paramsToKey = (params: any) => { const { state, priority, assignees, created_by, labels, target_date, sub_issue } = params; @@ -206,3 +206,21 @@ export const DEFAULT_ANALYTICS = (workspaceSlug: string, params?: Partial { + const { type, snoozed, archived, read } = params; + + return `USER_WORKSPACE_NOTIFICATIONS_${workspaceSlug.toUpperCase()}_TYPE_${( + type ?? "assigned" + ).toUpperCase()}_SNOOZED_${snoozed}_ARCHIVED_${archived}_READ_${read}`; +}; + +export const USER_WORKSPACE_NOTIFICATIONS_DETAILS = ( + workspaceSlug: string, + notificationId: string +) => + `USER_WORKSPACE_NOTIFICATIONS_DETAILS_${workspaceSlug.toUpperCase()}_${notificationId.toUpperCase()}`; diff --git a/apps/app/helpers/date-time.helper.ts b/apps/app/helpers/date-time.helper.ts index b513b38dabe..c28d326ee94 100644 --- a/apps/app/helpers/date-time.helper.ts +++ b/apps/app/helpers/date-time.helper.ts @@ -92,7 +92,7 @@ export const timeAgo = (time: any) => { list_choice = 2; } var i = 0, - format; + format: any[]; while ((format = time_formats[i++])) if (seconds < format[0]) { if (typeof format[2] == "string") return format[list_choice]; @@ -101,6 +101,35 @@ export const timeAgo = (time: any) => { return time; }; +export const formatDateDistance = (date: string | Date) => { + const today = new Date(); + const eventDate = new Date(date); + const timeDiff = Math.abs(eventDate.getTime() - today.getTime()); + const days = Math.ceil(timeDiff / (1000 * 3600 * 24)); + + if (days < 1) { + const hours = Math.ceil(timeDiff / (1000 * 3600)); + if (hours < 1) { + const minutes = Math.ceil(timeDiff / (1000 * 60)); + if (minutes < 1) { + return "Just now"; + } else { + return `${minutes}m`; + } + } else { + return `${hours}h`; + } + } else if (days < 7) { + return `${days}d`; + } else if (days < 30) { + return `${Math.floor(days / 7)}w`; + } else if (days < 365) { + return `${Math.floor(days / 30)}m`; + } else { + return `${Math.floor(days / 365)}y`; + } +}; + export const getDateRangeStatus = ( startDate: string | null | undefined, endDate: string | null | undefined @@ -230,3 +259,60 @@ export const renderLongDateFormat = (dateString: string) => { } return `${day}${suffix} ${monthName} ${year}`; }; + +/** + * + * @returns {Array} Array of time objects with label and value as keys + */ + +export const getTimestampAfterCurrentTime = (): Array<{ + label: string; + value: Date; +}> => { + const current = new Date(); + const time = []; + for (let i = 0; i < 24; i++) { + const newTime = new Date(current.getTime() + i * 60 * 60 * 1000); + time.push({ + label: newTime.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }), + value: newTime, + }); + } + return time; +}; + +/** + * @returns {Array} Array of date objects with label and value as keys + * @description Returns an array of date objects starting from current date to 7 days after + */ + +export const getDatesAfterCurrentDate = (): Array<{ + label: string; + value: Date; +}> => { + const current = new Date(); + const date = []; + for (let i = 0; i < 7; i++) { + const newDate = new Date(current.getTime() + i * 24 * 60 * 60 * 1000); + date.push({ + label: newDate.toLocaleDateString([], { + day: "numeric", + month: "long", + year: "numeric", + }), + value: newDate, + }); + } + return date; +}; + +/** + * @returns {boolean} true if date is valid + * @description Returns true if date is valid + * @param {string} date + * @example checkIfStringIsDate("2021-01-01") // true + * @example checkIfStringIsDate("2021-01-32") // false + */ + +export const checkIfStringIsDate = (date: string): boolean => + new Date(date).toString() !== "Invalid Date"; diff --git a/apps/app/helpers/string.helper.ts b/apps/app/helpers/string.helper.ts index a5fc05e7812..2436a8d121d 100644 --- a/apps/app/helpers/string.helper.ts +++ b/apps/app/helpers/string.helper.ts @@ -118,3 +118,19 @@ export const getFirstCharacters = (str: string) => { return words[0].charAt(0) + words[1].charAt(0); } }; + +/** + * @description: This function will remove all the HTML tags from the string + * @param {string} html + * @return {string} + * @example: + * const html = "

Some text

"; + * const text = stripHTML(html); + * console.log(text); // Some text + */ + +export const stripHTML = (html: string) => { + const tmp = document.createElement("DIV"); + tmp.innerHTML = html; + return tmp.textContent || tmp.innerText || ""; +}; diff --git a/apps/app/hooks/use-issue-notification-subscription.tsx b/apps/app/hooks/use-issue-notification-subscription.tsx new file mode 100644 index 00000000000..38bf8bf229f --- /dev/null +++ b/apps/app/hooks/use-issue-notification-subscription.tsx @@ -0,0 +1,76 @@ +import { useCallback } from "react"; + +import useSWR from "swr"; + +// hooks +import useUserAuth from "hooks/use-user-auth"; +// services +import userNotificationServices from "services/notifications.service"; + +const useUserIssueNotificationSubscription = ( + workspaceSlug?: string | string[] | null, + projectId?: string | string[] | null, + issueId?: string | string[] | null +) => { + const { user } = useUserAuth(); + + const { data, error, mutate } = useSWR( + workspaceSlug && projectId && issueId + ? `SUBSCRIPTION_STATUE_${workspaceSlug}_${projectId}_${issueId}` + : null, + workspaceSlug && projectId && issueId + ? () => + userNotificationServices.getIssueNotificationSubscriptionStatus( + workspaceSlug.toString(), + projectId.toString(), + issueId.toString() + ) + : null + ); + + const handleUnsubscribe = useCallback(() => { + if (!workspaceSlug || !projectId || !issueId) return; + + userNotificationServices + .unsubscribeFromIssueNotifications( + workspaceSlug as string, + projectId as string, + issueId as string + ) + .then(() => { + mutate({ + subscribed: false, + }); + }); + }, [workspaceSlug, projectId, issueId, mutate]); + + const handleSubscribe = useCallback(() => { + console.log(workspaceSlug, projectId, issueId, user); + + if (!workspaceSlug || !projectId || !issueId || !user) return; + + userNotificationServices + .subscribeToIssueNotifications( + workspaceSlug as string, + projectId as string, + issueId as string, + { + subscriber: user.id, + } + ) + .then(() => { + mutate({ + subscribed: true, + }); + }); + }, [workspaceSlug, projectId, issueId, mutate, user]); + + return { + loading: !data && !error, + subscribed: data?.subscribed, + handleSubscribe, + handleUnsubscribe, + } as const; +}; + +export default useUserIssueNotificationSubscription; diff --git a/apps/app/hooks/use-user-notifications.tsx b/apps/app/hooks/use-user-notifications.tsx new file mode 100644 index 00000000000..8aaeadf11c9 --- /dev/null +++ b/apps/app/hooks/use-user-notifications.tsx @@ -0,0 +1,142 @@ +import { useCallback, useState } from "react"; + +import { useRouter } from "next/router"; + +// swr +import useSWR from "swr"; + +// services +import userNotificationServices from "services/notifications.service"; + +// fetch-keys +import { USER_WORKSPACE_NOTIFICATIONS } from "constants/fetch-keys"; + +// type +import type { NotificationType } from "types"; + +const useUserNotification = () => { + const router = useRouter(); + const { workspaceSlug } = router.query; + + const [snoozed, setSnoozed] = useState(false); + const [archived, setArchived] = useState(false); + const [readNotification, setReadNotification] = useState(false); + const [selectedNotificationForSnooze, setSelectedNotificationForSnooze] = useState( + null + ); + const [selectedTab, setSelectedTab] = useState("assigned"); + + const { data: notifications, mutate: notificationsMutate } = useSWR( + workspaceSlug + ? USER_WORKSPACE_NOTIFICATIONS(workspaceSlug.toString(), { + type: selectedTab, + snoozed, + archived, + read: readNotification, + }) + : null, + workspaceSlug + ? () => + userNotificationServices.getUserNotifications(workspaceSlug.toString(), { + type: selectedTab, + snoozed, + archived, + read: readNotification, + }) + : null + ); + + const markNotificationReadStatus = async (notificationId: string) => { + if (!workspaceSlug) return; + const isRead = + notifications?.find((notification) => notification.id === notificationId)?.read_at !== null; + + if (isRead) { + await userNotificationServices + .markUserNotificationAsUnread(workspaceSlug.toString(), notificationId) + .then(() => { + notificationsMutate((prev) => + prev?.map((prevNotification) => { + if (prevNotification.id === notificationId) { + return { + ...prevNotification, + read_at: null, + }; + } + return prevNotification; + }) + ); + }) + .catch(() => { + throw new Error("Something went wrong"); + }); + } else { + await userNotificationServices + .markUserNotificationAsRead(workspaceSlug.toString(), notificationId) + .then(() => { + notificationsMutate((prev) => + prev?.map((prevNotification) => { + if (prevNotification.id === notificationId) { + return { + ...prevNotification, + read_at: new Date(), + }; + } + return prevNotification; + }) + ); + }) + .catch(() => { + throw new Error("Something went wrong"); + }); + } + }; + + const markNotificationArchivedStatus = async (notificationId: string) => { + if (!workspaceSlug) return; + const isArchived = + notifications?.find((notification) => notification.id === notificationId)?.archived_at !== + null; + + if (isArchived) { + await userNotificationServices + .markUserNotificationAsUnarchived(workspaceSlug.toString(), notificationId) + .then(() => { + notificationsMutate(); + }) + .catch(() => { + throw new Error("Something went wrong"); + }); + } else { + await userNotificationServices + .markUserNotificationAsArchived(workspaceSlug.toString(), notificationId) + .then(() => { + notificationsMutate((prev) => + prev?.filter((prevNotification) => prevNotification.id !== notificationId) + ); + }) + .catch(() => { + throw new Error("Something went wrong"); + }); + } + }; + + return { + notifications, + notificationsMutate, + markNotificationReadStatus, + markNotificationArchivedStatus, + snoozed, + setSnoozed, + archived, + setArchived, + readNotification, + setReadNotification, + selectedNotificationForSnooze, + setSelectedNotificationForSnooze, + selectedTab, + setSelectedTab, + }; +}; + +export default useUserNotification; diff --git a/apps/app/public/empty-state/empty-notification.svg b/apps/app/public/empty-state/empty-notification.svg new file mode 100644 index 00000000000..700a1552f56 --- /dev/null +++ b/apps/app/public/empty-state/empty-notification.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/apps/app/services/notifications.service.ts b/apps/app/services/notifications.service.ts new file mode 100644 index 00000000000..8a9cc8e4c49 --- /dev/null +++ b/apps/app/services/notifications.service.ts @@ -0,0 +1,162 @@ +// services +import APIService from "services/api.service"; + +const { NEXT_PUBLIC_API_BASE_URL } = process.env; + +// types +import { IUserNotification, INotificationParams } from "types"; + +class UserNotificationsServices extends APIService { + constructor() { + super(NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000"); + } + + async getUserNotifications( + workspaceSlug: string, + params: INotificationParams + ): Promise { + return this.get(`/api/workspaces/${workspaceSlug}/users/notifications`, { + params, + }) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async getUserNotificationDetailById( + workspaceSlug: string, + notificationId: string + ): Promise { + return this.get(`/api/workspaces/${workspaceSlug}/users/notifications/${notificationId}/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async markUserNotificationAsRead( + workspaceSlug: string, + notificationId: string + ): Promise { + return this.post(`/api/workspaces/${workspaceSlug}/users/notifications/${notificationId}/read/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async markUserNotificationAsUnread( + workspaceSlug: string, + notificationId: string + ): Promise { + return this.delete( + `/api/workspaces/${workspaceSlug}/users/notifications/${notificationId}/read/` + ) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async markUserNotificationAsArchived( + workspaceSlug: string, + notificationId: string + ): Promise { + return this.post( + `/api/workspaces/${workspaceSlug}/users/notifications/${notificationId}/archive/` + ) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async markUserNotificationAsUnarchived( + workspaceSlug: string, + notificationId: string + ): Promise { + return this.delete( + `/api/workspaces/${workspaceSlug}/users/notifications/${notificationId}/archive/` + ) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async patchUserNotification( + workspaceSlug: string, + notificationId: string, + data: Partial + ): Promise { + return this.patch( + `/api/workspaces/${workspaceSlug}/users/notifications/${notificationId}/`, + data + ) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async deleteUserNotification(workspaceSlug: string, notificationId: string): Promise { + return this.delete(`/api/workspaces/${workspaceSlug}/users/notifications/${notificationId}/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async subscribeToIssueNotifications( + workspaceSlug: string, + projectId: string, + issueId: string, + data: { + subscriber: string; + } + ): Promise { + return this.post( + `/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}/issue-subscribers/`, + data + ) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async getIssueNotificationSubscriptionStatus( + workspaceSlug: string, + projectId: string, + issueId: string + ): Promise<{ + subscribed: boolean; + }> { + return this.get( + `/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}/subscribe/` + ) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async unsubscribeFromIssueNotifications( + workspaceSlug: string, + projectId: string, + issueId: string + ): Promise { + return this.delete( + `/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}/subscribe/` + ) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } +} + +const userNotificationServices = new UserNotificationsServices(); + +export default userNotificationServices; diff --git a/apps/app/tailwind.config.js b/apps/app/tailwind.config.js index d9897cee964..dbb4b940c37 100644 --- a/apps/app/tailwind.config.js +++ b/apps/app/tailwind.config.js @@ -1,4 +1,4 @@ -const convertToRGB = (variableName) => `rgb(var(${variableName}))`; +const convertToRGB = (variableName) => `rgba(var(${variableName}))`; module.exports = { darkMode: "class", diff --git a/apps/app/types/index.d.ts b/apps/app/types/index.d.ts index a8dcce3bc6a..fdb612604b6 100644 --- a/apps/app/types/index.d.ts +++ b/apps/app/types/index.d.ts @@ -15,6 +15,7 @@ export * from "./importer"; export * from "./inbox"; export * from "./analytics"; export * from "./calendar"; +export * from "./notifications"; export type NestedKeyOf = { [Key in keyof ObjectType & (string | number)]: ObjectType[Key] extends object diff --git a/apps/app/types/notifications.d.ts b/apps/app/types/notifications.d.ts new file mode 100644 index 00000000000..cc7b6f1ede9 --- /dev/null +++ b/apps/app/types/notifications.d.ts @@ -0,0 +1,56 @@ +import type { IUserLite } from "./users"; + +export interface IUserNotification { + id: string; + created_at: Date; + updated_at: Date; + data: Data; + entity_identifier: string; + entity_name: string; + title: string; + message: null; + message_html: string; + message_stripped: null; + sender: string; + read_at: Date | null; + archived_at: Date | null; + snoozed_till: Date | null; + created_by: null; + updated_by: null; + workspace: string; + project: string; + triggered_by: string; + triggered_by_details: IUserLite; + receiver: string; +} + +export interface Data { + issue: IIssueLite; + issue_activity: { + actor: string; + field: string; + id: string; + issue_comment: string | null; + new_value: string; + old_value: string; + verb: "created" | "updated"; + }; +} + +export interface IIssueLite { + id: string; + name: string; + identifier: string; + state_name: string; + sequence_id: number; + state_group: string; +} + +export type NotificationType = "created" | "assigned" | "watching"; + +export interface INotificationParams { + snoozed?: boolean; + type?: NotificationType; + archived?: boolean; + read?: boolean; +} From 7045f80ec61f0ca8f2de5bdaa6f0cd436ac260cf Mon Sep 17 00:00:00 2001 From: Dakshesh Jain Date: Fri, 14 Jul 2023 19:23:56 +0530 Subject: [PATCH 19/31] style: improved UX with toast alerts and other interactions refactor: changed classnames according to new theme structure, changed all icons to material icons --- apps/app/components/issues/sidebar.tsx | 25 +- .../notifications/notification-card.tsx | 56 +- .../notifications/notification-popover.tsx | 70 +- .../select-snooze-till-modal.tsx | 102 +- apps/app/components/ui/icon-name-type.d.ts | 2991 +++++++++++++++++ .../app/components/workspace/sidebar-menu.tsx | 12 +- apps/app/helpers/date-time.helper.ts | 2 +- apps/app/hooks/use-user-notifications.tsx | 34 +- apps/app/types/notifications.d.ts | 2 +- 9 files changed, 3176 insertions(+), 118 deletions(-) create mode 100644 apps/app/components/ui/icon-name-type.d.ts diff --git a/apps/app/components/issues/sidebar.tsx b/apps/app/components/issues/sidebar.tsx index 2ccccea162e..19d56baa1e6 100644 --- a/apps/app/components/issues/sidebar.tsx +++ b/apps/app/components/issues/sidebar.tsx @@ -34,7 +34,7 @@ import { SidebarEstimateSelect, } from "components/issues"; // ui -import { Input, Spinner, CustomDatePicker } from "components/ui"; +import { Input, Spinner, CustomDatePicker, Icon } from "components/ui"; // icons import { TagIcon, @@ -293,16 +293,19 @@ export const IssueDetailsSidebar: React.FC = ({ {issueDetail?.project_detail?.identifier}-{issueDetail?.sequence_id}
- + {(fieldsToShow.includes("all") || fieldsToShow.includes("link")) && ( + + )} {(fieldsToShow.includes("all") || fieldsToShow.includes("link")) && ( ))}
diff --git a/apps/app/components/notifications/notification-popover.tsx b/apps/app/components/notifications/notification-popover.tsx index 334a3acda09..fb79b5a5d15 100644 --- a/apps/app/components/notifications/notification-popover.tsx +++ b/apps/app/components/notifications/notification-popover.tsx @@ -4,23 +4,14 @@ import Image from "next/image"; // hooks import useTheme from "hooks/use-theme"; -// icons -import { - XMarkIcon, - ArchiveIcon, - ClockIcon, - SortIcon, - BellNotificationIcon, -} from "components/icons"; import { Popover, Transition, Menu } from "@headlessui/react"; -import { ArrowLeftIcon } from "@heroicons/react/20/solid"; // hooks import useUserNotification from "hooks/use-user-notifications"; // components -import { Spinner } from "components/ui"; +import { Spinner, Icon } from "components/ui"; import { SnoozeNotificationModal, NotificationCard } from "components/notifications"; // type @@ -60,6 +51,7 @@ export const NotificationPopover = () => { notificationsMutate, markNotificationArchivedStatus, markNotificationReadStatus, + markSnoozeNotification, } = useUserNotification(); // theme context @@ -70,7 +62,12 @@ export const NotificationPopover = () => { setSelectedNotificationForSnooze(null)} - notificationId={selectedNotificationForSnooze} + onSubmit={markSnoozeNotification} + notification={ + notifications?.find( + (notification) => notification.id === selectedNotificationForSnooze + ) || null + } onSuccess={() => { notificationsMutate(); setSelectedNotificationForSnooze(null); @@ -80,26 +77,13 @@ export const NotificationPopover = () => { {({ open: isActive, close: closePopover }) => ( <> - - + {sidebarCollapse ? null : Notifications} { Notifications
+
@@ -166,7 +163,7 @@ export const NotificationPopover = () => { }} >

- + {snoozed ? "Snoozed Notifications" @@ -202,7 +199,9 @@ export const NotificationPopover = () => {
{notifications ? ( - notifications.length > 0 ? ( + notifications.filter( + (notification) => notification.data.issue_activity.field !== "None" + ).length > 0 ? ( notifications.map((notification) => ( { markNotificationArchivedStatus={markNotificationArchivedStatus} markNotificationReadStatus={markNotificationReadStatus} setSelectedNotificationForSnooze={setSelectedNotificationForSnooze} + markSnoozeNotification={markSnoozeNotification} /> )) ) : ( diff --git a/apps/app/components/notifications/select-snooze-till-modal.tsx b/apps/app/components/notifications/select-snooze-till-modal.tsx index 4d50ebf6fd5..7bd78862b74 100644 --- a/apps/app/components/notifications/select-snooze-till-modal.tsx +++ b/apps/app/components/notifications/select-snooze-till-modal.tsx @@ -7,35 +7,37 @@ import { useRouter } from "next/router"; import { useForm, Controller } from "react-hook-form"; import { Transition, Dialog, Listbox } from "@headlessui/react"; -import { ChevronDownIcon, CheckIcon } from "@heroicons/react/20/solid"; // date helper import { getDatesAfterCurrentDate, getTimestampAfterCurrentTime } from "helpers/date-time.helper"; -// services -import userNotificationServices from "services/notifications.service"; - // hooks import useToast from "hooks/use-toast"; // components -import { PrimaryButton, SecondaryButton } from "components/ui"; +import { PrimaryButton, SecondaryButton, Icon } from "components/ui"; -// icons -import { XMarkIcon } from "components/icons"; +// types +import type { IUserNotification } from "types"; type SnoozeModalProps = { isOpen: boolean; onClose: () => void; onSuccess: () => void; - notificationId: string | null; + notification: IUserNotification | null; + onSubmit: (notificationId: string, dateTime?: Date | undefined) => Promise; }; const dates = getDatesAfterCurrentDate(); const timeStamps = getTimestampAfterCurrentTime(); +const defaultValues = { + time: null, + date: null, +}; + export const SnoozeNotificationModal: React.FC = (props) => { - const { isOpen, onClose, notificationId, onSuccess } = props; + const { isOpen, onClose, notification, onSuccess, onSubmit: handleSubmitSnooze } = props; const router = useRouter(); const { workspaceSlug } = router.query; @@ -47,28 +49,26 @@ export const SnoozeNotificationModal: React.FC = (props) => { reset, handleSubmit, control, - } = useForm(); + } = useForm({ + defaultValues, + }); const onSubmit = async (formData: any) => { - if (!workspaceSlug || !notificationId) return; + if (!workspaceSlug || !notification) return; const dateTime = new Date( `${formData.date.toLocaleDateString()} ${formData.time.toLocaleTimeString()}` ); - await userNotificationServices - .patchUserNotification(workspaceSlug.toString(), notificationId, { - snoozed_till: dateTime, - }) - .then(() => { - onClose(); - onSuccess(); - setToastAlert({ - title: "Notification snoozed", - message: "Notification snoozed successfully", - type: "success", - }); + await handleSubmitSnooze(notification.id, dateTime).then(() => { + onClose(); + onSuccess(); + setToastAlert({ + title: "Notification snoozed", + message: "Notification snoozed successfully", + type: "success", }); + }); }; const handleClose = () => { @@ -91,7 +91,7 @@ export const SnoozeNotificationModal: React.FC = (props) => { leaveFrom="opacity-100" leaveTo="opacity-0" > -
+
@@ -117,7 +117,7 @@ export const SnoozeNotificationModal: React.FC = (props) => {
@@ -133,18 +133,21 @@ export const SnoozeNotificationModal: React.FC = (props) => { {({ open }) => ( <>
- + - {value?.toLocaleTimeString([], { - hour: "2-digit", - minute: "2-digit", - }) || "Select Time"} + {value + ? new Date(value)?.toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + }) + : "Select Time"} - @@ -157,14 +160,14 @@ export const SnoozeNotificationModal: React.FC = (props) => { leaveFrom="opacity-100" leaveTo="opacity-0" > - + {timeStamps.map((time, index) => ( `relative cursor-default select-none py-2 pl-3 pr-9 ${ active - ? "bg-custom-primary-100 text-custom-text-100" + ? "bg-custom-primary-100/80 text-custom-text-100" : "text-custom-text-700" }` } @@ -190,7 +193,8 @@ export const SnoozeNotificationModal: React.FC = (props) => { : "text-custom-primary-100" }`} > -

+ {issueDetail?.created_by !== user?.id && + !issueDetail?.assignees.includes(user?.id ?? "") && + (fieldsToShow.includes("all") || fieldsToShow.includes("link")) && ( + + )} {(fieldsToShow.includes("all") || fieldsToShow.includes("link")) && ( - ))} + {notificationTabs.map((tab) => + tab.value === "created" ? ( + isMember || isOwner ? ( + + ) : null + ) : ( + + ) + )}
)} From e3d489ae5268b73918bcdd7b7f950b4ee3d7512c Mon Sep 17 00:00:00 2001 From: Dakshesh Jain Date: Tue, 18 Jul 2023 16:47:04 +0530 Subject: [PATCH 22/31] fix: 'read' -> 'unread' heading, my issue wrong filter --- apps/app/components/notifications/notification-popover.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/apps/app/components/notifications/notification-popover.tsx b/apps/app/components/notifications/notification-popover.tsx index 486a091e413..51b760bbb85 100644 --- a/apps/app/components/notifications/notification-popover.tsx +++ b/apps/app/components/notifications/notification-popover.tsx @@ -189,7 +189,7 @@ export const NotificationPopover = () => { {snoozed ? "Snoozed Notifications" : readNotification - ? "Read Notifications" + ? "Unread Notifications" : "Archived Notifications"} @@ -247,9 +247,7 @@ export const NotificationPopover = () => {
{notifications ? ( - notifications.filter( - (notification) => notification.data.issue_activity.field !== "None" - ).length > 0 ? ( + notifications.length > 0 ? ( notifications.map((notification) => ( Date: Tue, 18 Jul 2023 20:26:00 +0530 Subject: [PATCH 23/31] feat: made snooze dropdown & modal feat: switched to calendar --- .../notifications/notification-card.tsx | 152 +++++++++++++--- .../notifications/notification-popover.tsx | 9 +- .../select-snooze-till-modal.tsx | 170 ++++++++---------- apps/app/components/ui/datepicker.tsx | 3 + apps/app/helpers/date-time.helper.ts | 13 +- 5 files changed, 221 insertions(+), 126 deletions(-) diff --git a/apps/app/components/notifications/notification-card.tsx b/apps/app/components/notifications/notification-card.tsx index 787ad50c0cb..421705fcae3 100644 --- a/apps/app/components/notifications/notification-card.tsx +++ b/apps/app/components/notifications/notification-card.tsx @@ -4,15 +4,22 @@ import React from "react"; import Image from "next/image"; import { useRouter } from "next/router"; +// headless ui +import { Menu, Transition } from "@headlessui/react"; + // hooks import useToast from "hooks/use-toast"; // icons -import { Icon } from "components/ui"; +import { Icon, Tooltip } from "components/ui"; // helper import { stripHTML, replaceUnderscoreIfSnakeCase } from "helpers/string.helper"; -import { formatDateDistance, renderShortDateWithYearFormat } from "helpers/date-time.helper"; +import { + formatDateDistance, + renderLongDateFormat, + renderShortDateWithYearFormat, +} from "helpers/date-time.helper"; // type import type { IUserNotification } from "types"; @@ -168,31 +175,126 @@ export const NotificationCard: React.FC = (props) => { }); }, }, - { - id: 3, - name: notification.snoozed_till ? "Unsnooze Notification" : "Snooze Notification", - icon: "schedule", - onClick: () => { - if (notification.snoozed_till) - markSnoozeNotification(notification.id).then(() => { - setToastAlert({ title: "Notification un-snoozed", type: "success" }); - }); - else setSelectedNotificationForSnooze(notification.id); - }, - }, + // { + // id: 3, + // name: notification.snoozed_till ? "Unsnooze Notification" : "Snooze Notification", + // icon: "schedule", + // onClick: () => { + // if (notification.snoozed_till) + // markSnoozeNotification(notification.id).then(() => { + // setToastAlert({ title: "Notification un-snoozed", type: "success" }); + // }); + // else setSelectedNotificationForSnooze(notification.id); + // }, + // }, ].map((item) => ( - + + + ))} + + + +
+ { + e.stopPropagation(); + }} + className="text-sm flex w-full items-center gap-x-2 bg-custom-background-80 hover:bg-custom-background-100 p-0.5 rounded" + > + + +
+ + + +
+ {[ + { + label: "1 days", + value: new Date(new Date().getTime() + 24 * 60 * 60 * 1000), + }, + { + label: "3 days", + value: new Date(new Date().getTime() + 3 * 24 * 60 * 60 * 1000), + }, + { + label: "5 days", + value: new Date(new Date().getTime() + 5 * 24 * 60 * 60 * 1000), + }, + { + label: "1 week", + value: new Date(new Date().getTime() + 7 * 24 * 60 * 60 * 1000), + }, + { + label: "2 weeks", + value: new Date(new Date().getTime() + 14 * 24 * 60 * 60 * 1000), + }, + { + label: "Custom", + value: null, + }, + ].map((item) => ( + { + e.stopPropagation(); + + if (!item.value) { + setSelectedNotificationForSnooze(notification.id); + return; + } + + markSnoozeNotification(notification.id, item.value).then(() => { + setToastAlert({ + title: `Notification snoozed till ${renderLongDateFormat( + item.value + )}`, + + type: "success", + }); + }); + }} + key={item.label} + > + {({ active }) => ( + + {item.label} + + )} + + ))} +
+
+
+
+
diff --git a/apps/app/components/notifications/notification-popover.tsx b/apps/app/components/notifications/notification-popover.tsx index 51b760bbb85..df035a7f4c2 100644 --- a/apps/app/components/notifications/notification-popover.tsx +++ b/apps/app/components/notifications/notification-popover.tsx @@ -212,9 +212,14 @@ export const NotificationPopover = () => { : "border-transparent text-custom-text-500 hover:border-custom-border-300 hover:text-custom-text-200" }`} > - {tab.label} {tab.unreadCount && tab.unreadCount > 0 ? ( - + {getNumberCount(tab.unreadCount)} ) : null} diff --git a/apps/app/components/notifications/select-snooze-till-modal.tsx b/apps/app/components/notifications/select-snooze-till-modal.tsx index 7bd78862b74..d40d5a19560 100644 --- a/apps/app/components/notifications/select-snooze-till-modal.tsx +++ b/apps/app/components/notifications/select-snooze-till-modal.tsx @@ -9,13 +9,17 @@ import { useForm, Controller } from "react-hook-form"; import { Transition, Dialog, Listbox } from "@headlessui/react"; // date helper -import { getDatesAfterCurrentDate, getTimestampAfterCurrentTime } from "helpers/date-time.helper"; +import { + getDatesAfterCurrentDate, + getTimestampAfterCurrentTime, + getDatesWith30MinutesInterval, +} from "helpers/date-time.helper"; // hooks import useToast from "hooks/use-toast"; // components -import { PrimaryButton, SecondaryButton, Icon } from "components/ui"; +import { PrimaryButton, SecondaryButton, Icon, CustomDatePicker } from "components/ui"; // types import type { IUserNotification } from "types"; @@ -28,12 +32,12 @@ type SnoozeModalProps = { onSubmit: (notificationId: string, dateTime?: Date | undefined) => Promise; }; -const dates = getDatesAfterCurrentDate(); -const timeStamps = getTimestampAfterCurrentTime(); +const timeStamps = getDatesWith30MinutesInterval(); const defaultValues = { time: null, date: null, + amPm: "AM", }; export const SnoozeNotificationModal: React.FC = (props) => { @@ -49,6 +53,8 @@ export const SnoozeNotificationModal: React.FC = (props) => { reset, handleSubmit, control, + watch, + setValue, } = useForm({ defaultValues, }); @@ -122,7 +128,7 @@ export const SnoozeNotificationModal: React.FC = (props) => { -
+
= (props) => { {({ open }) => ( <>
- + + + Pick a time + + + {value @@ -161,9 +172,35 @@ export const SnoozeNotificationModal: React.FC = (props) => { leaveTo="opacity-0" > +
+
{ + setValue("amPm", "AM"); + }} + className={`w-1/2 h-full cursor-pointer flex justify-center items-center text-center ${ + watch("amPm") === "AM" + ? "bg-custom-primary-100/90 text-custom-primary-0" + : "bg-custom-background-90" + }`} + > + AM +
+
{ + setValue("amPm", "PM"); + }} + className={`w-1/2 h-full cursor-pointer flex justify-center items-center text-center ${ + watch("amPm") === "PM" + ? "bg-custom-primary-100/90 text-custom-primary-0" + : "bg-custom-background-90" + }`} + > + PM +
+
{timeStamps.map((time, index) => ( `relative cursor-default select-none py-2 pl-3 pr-9 ${ active @@ -171,7 +208,7 @@ export const SnoozeNotificationModal: React.FC = (props) => { : "text-custom-text-700" }` } - value={time.value} + value={time} > {({ selected, active }) => ( <> @@ -181,7 +218,13 @@ export const SnoozeNotificationModal: React.FC = (props) => { selected ? "font-semibold" : "font-normal" }`} > - {time.label} + { + // TODO: remove AM/PM from time + new Date(time)?.toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + }) + }
@@ -214,95 +257,26 @@ export const SnoozeNotificationModal: React.FC = (props) => { />
- ( - - {({ open }) => ( - <> -
- - - - {value - ? new Date(value)?.toLocaleDateString([], { - day: "numeric", - month: "long", - year: "numeric", - }) - : "Select Date"} - - - - - - - - - {dates.map((date, index) => ( - - `relative cursor-default select-none py-2 pl-3 pr-9 ${ - active - ? "bg-custom-primary-100/80 text-custom-text-100" - : "text-custom-text-700" - }` - } - value={date.value} - > - {({ selected, active }) => ( - <> -
- - {date.label} - -
- - {selected ? ( - - - ) : null} - - )} -
- ))} -
-
-
- - )} -
- )} - /> + + Pick a date + +
+ ( + + )} + /> +
diff --git a/apps/app/components/ui/datepicker.tsx b/apps/app/components/ui/datepicker.tsx index b98642b28a8..56289a72734 100644 --- a/apps/app/components/ui/datepicker.tsx +++ b/apps/app/components/ui/datepicker.tsx @@ -15,6 +15,7 @@ type Props = { className?: string; isClearable?: boolean; disabled?: boolean; + minDate?: Date; }; export const CustomDatePicker: React.FC = ({ @@ -28,6 +29,7 @@ export const CustomDatePicker: React.FC = ({ className = "", isClearable = true, disabled = false, + minDate, }) => ( = ({ dateFormat="MMM dd, yyyy" isClearable={isClearable} disabled={disabled} + minDate={minDate} /> ); diff --git a/apps/app/helpers/date-time.helper.ts b/apps/app/helpers/date-time.helper.ts index 894bf317dfe..ae89cedee61 100644 --- a/apps/app/helpers/date-time.helper.ts +++ b/apps/app/helpers/date-time.helper.ts @@ -243,7 +243,7 @@ export const isDateGreaterThanToday = (dateStr: string) => { return date > today; }; -export const renderLongDateFormat = (dateString: string) => { +export const renderLongDateFormat = (dateString: string | Date) => { const date = new Date(dateString); const day = date.getDate(); const year = date.getFullYear(); @@ -333,3 +333,14 @@ export const getDatesAfterCurrentDate = (): Array<{ export const checkIfStringIsDate = (date: string): boolean => new Date(date).toString() !== "Invalid Date"; + +// return an array of dates starting from 12:00 to 23:30 with 30 minutes interval as dates +export const getDatesWith30MinutesInterval = (): Array => { + const dates = []; + const current = new Date(); + for (let i = 0; i < 24; i++) { + const newDate = new Date(current.getTime() + i * 60 * 60 * 1000); + dates.push(newDate); + } + return dates; +}; From d04ac5d32eb19b1d36cade60326cf20e1918b3b1 Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal Date: Wed, 19 Jul 2023 12:57:27 +0530 Subject: [PATCH 24/31] fix: minor ui fixes --- apps/app/components/notifications/notification-popover.tsx | 6 +++--- apps/app/components/workspace/issues-pie-chart.tsx | 4 ++-- apps/app/pages/[workspaceSlug]/index.tsx | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/app/components/notifications/notification-popover.tsx b/apps/app/components/notifications/notification-popover.tsx index 9edbd52905a..1bd6092e769 100644 --- a/apps/app/components/notifications/notification-popover.tsx +++ b/apps/app/components/notifications/notification-popover.tsx @@ -191,7 +191,7 @@ export const NotificationPopover = () => { ) : ( -
) ) : ( - + diff --git a/apps/app/components/workspace/issues-pie-chart.tsx b/apps/app/components/workspace/issues-pie-chart.tsx index 2d055c2c69b..7d453d8a994 100644 --- a/apps/app/components/workspace/issues-pie-chart.tsx +++ b/apps/app/components/workspace/issues-pie-chart.tsx @@ -36,7 +36,7 @@ export const IssuesPieChart: React.FC = ({ groupedIssues }) => ( activeInnerRadiusOffset={5} colors={(datum) => datum.data.color} tooltip={(datum) => ( -
+
{datum.datum.label} issues:{" "} {datum.datum.value}
@@ -59,7 +59,7 @@ export const IssuesPieChart: React.FC = ({ groupedIssues }) => ( className="h-2 w-2" style={{ backgroundColor: STATE_GROUP_COLORS[cell.state_group] }} /> -
+
{cell.state_group}- {cell.state_count}
diff --git a/apps/app/pages/[workspaceSlug]/index.tsx b/apps/app/pages/[workspaceSlug]/index.tsx index a02a157a1c1..aca40f85db3 100644 --- a/apps/app/pages/[workspaceSlug]/index.tsx +++ b/apps/app/pages/[workspaceSlug]/index.tsx @@ -69,13 +69,13 @@ const WorkspacePage: NextPage = () => { return ( +
Dashboard
} right={ -
+
@@ -190,18 +202,6 @@ export const NotificationCard: React.FC = (props) => { }); }, }, - // { - // id: 3, - // name: notification.snoozed_till ? "Unsnooze Notification" : "Snooze Notification", - // icon: "schedule", - // onClick: () => { - // if (notification.snoozed_till) - // markSnoozeNotification(notification.id).then(() => { - // setToastAlert({ title: "Notification un-snoozed", type: "success" }); - // }); - // else setSelectedNotificationForSnooze(notification.id); - // }, - // }, ].map((item) => (
+
+ + Pick a date + +
+ ( + { + setValue("time", null); + onChange(val); + }} + className="px-3 py-[0.385rem] w-full rounded-md border border-custom-border-100 bg-custom-background-100 text-custom-text-100 shadow-sm focus:outline-none sm:text-sm sm:leading-6" + noBorder + minDate={new Date()} + /> + )} + /> +
+
= (props) => { - {value - ? new Date(value)?.toLocaleTimeString([], { - hour: "2-digit", - minute: "2-digit", - }) - : "Select Time"} + {value ? ( + + {value} {watch("period").toLowerCase()} + + ) : ( + "Select a time" + )} @@ -175,10 +239,10 @@ export const SnoozeNotificationModal: React.FC = (props) => {
{ - setValue("amPm", "AM"); + setValue("period", "AM"); }} className={`w-1/2 h-full cursor-pointer flex justify-center items-center text-center ${ - watch("amPm") === "AM" + watch("period") === "AM" ? "bg-custom-primary-100/90 text-custom-primary-0" : "bg-custom-background-90" }`} @@ -187,10 +251,10 @@ export const SnoozeNotificationModal: React.FC = (props) => {
{ - setValue("amPm", "PM"); + setValue("period", "PM"); }} className={`w-1/2 h-full cursor-pointer flex justify-center items-center text-center ${ - watch("amPm") === "PM" + watch("period") === "PM" ? "bg-custom-primary-100/90 text-custom-primary-0" : "bg-custom-background-90" }`} @@ -198,55 +262,55 @@ export const SnoozeNotificationModal: React.FC = (props) => { PM
- {timeStamps.map((time, index) => ( - - `relative cursor-default select-none py-2 pl-3 pr-9 ${ - active - ? "bg-custom-primary-100/80 text-custom-text-100" - : "text-custom-text-700" - }` - } - value={time} - > - {({ selected, active }) => ( - <> -
- - { - // TODO: remove AM/PM from time - new Date(time)?.toLocaleTimeString([], { - hour: "2-digit", - minute: "2-digit", - }) - } - -
- - {selected ? ( - - - ) : null} - - )} -
- ))} + {getTimeStamp().length > 0 ? ( + getTimeStamp().map((time, index) => ( + + `relative cursor-default select-none py-2 pl-3 pr-9 ${ + active + ? "bg-custom-primary-100/80 text-custom-text-100" + : "text-custom-text-700" + }` + } + value={time.value} + > + {({ selected, active }) => ( + <> +
+ + {time.label} + +
+ + {selected ? ( + + + ) : null} + + )} +
+ )) + ) : ( +

+ No available time for this date. +

+ )}
@@ -256,28 +320,6 @@ export const SnoozeNotificationModal: React.FC = (props) => { )} />
-
- - Pick a date - -
- ( - - )} - /> -
-
diff --git a/apps/app/helpers/date-time.helper.ts b/apps/app/helpers/date-time.helper.ts index ae89cedee61..ea2170afb27 100644 --- a/apps/app/helpers/date-time.helper.ts +++ b/apps/app/helpers/date-time.helper.ts @@ -344,3 +344,33 @@ export const getDatesWith30MinutesInterval = (): Array => { } return dates; }; + +export const getAllTimeIn30MinutesInterval = (): Array<{ + label: string; + value: string; +}> => [ + { label: "12:00", value: "12:00" }, + { label: "12:30", value: "12:30" }, + { label: "01:00", value: "01:00" }, + { label: "01:30", value: "01:30" }, + { label: "02:00", value: "02:00" }, + { label: "02:30", value: "02:30" }, + { label: "03:00", value: "03:00" }, + { label: "03:30", value: "03:30" }, + { label: "04:00", value: "04:00" }, + { label: "04:30", value: "04:30" }, + { label: "05:00", value: "05:00" }, + { label: "05:30", value: "05:30" }, + { label: "06:00", value: "06:00" }, + { label: "06:30", value: "06:30" }, + { label: "07:00", value: "07:00" }, + { label: "07:30", value: "07:30" }, + { label: "08:00", value: "08:00" }, + { label: "08:30", value: "08:30" }, + { label: "09:00", value: "09:00" }, + { label: "09:30", value: "09:30" }, + { label: "10:00", value: "10:00" }, + { label: "10:30", value: "10:30" }, + { label: "11:00", value: "11:00" }, + { label: "11:30", value: "11:30" }, +]; diff --git a/apps/app/hooks/use-issue-notification-subscription.tsx b/apps/app/hooks/use-issue-notification-subscription.tsx index 38bf8bf229f..2abe353b0e8 100644 --- a/apps/app/hooks/use-issue-notification-subscription.tsx +++ b/apps/app/hooks/use-issue-notification-subscription.tsx @@ -45,8 +45,6 @@ const useUserIssueNotificationSubscription = ( }, [workspaceSlug, projectId, issueId, mutate]); const handleSubscribe = useCallback(() => { - console.log(workspaceSlug, projectId, issueId, user); - if (!workspaceSlug || !projectId || !issueId || !user) return; userNotificationServices diff --git a/apps/app/hooks/use-user-notifications.tsx b/apps/app/hooks/use-user-notifications.tsx index 4de9d296cc7..77acf767b26 100644 --- a/apps/app/hooks/use-user-notifications.tsx +++ b/apps/app/hooks/use-user-notifications.tsx @@ -26,23 +26,17 @@ const useUserNotification = () => { ); const [selectedTab, setSelectedTab] = useState("assigned"); + const params = { + type: snoozed || archived || readNotification ? undefined : selectedTab, + snoozed, + archived, + read: !readNotification, + }; + const { data: notifications, mutate: notificationsMutate } = useSWR( + workspaceSlug ? USER_WORKSPACE_NOTIFICATIONS(workspaceSlug.toString(), params) : null, workspaceSlug - ? USER_WORKSPACE_NOTIFICATIONS(workspaceSlug.toString(), { - type: selectedTab, - snoozed, - archived, - read: selectedTab === null ? !readNotification : undefined, - }) - : null, - workspaceSlug - ? () => - userNotificationServices.getUserNotifications(workspaceSlug.toString(), { - type: selectedTab, - snoozed, - archived, - read: selectedTab === null ? !readNotification : undefined, - }) + ? () => userNotificationServices.getUserNotifications(workspaceSlug.toString(), params) : null ); From 31501f5c0895303e34a0b3c79a0168161463648d Mon Sep 17 00:00:00 2001 From: Dakshesh Jain Date: Wed, 19 Jul 2023 14:18:31 +0530 Subject: [PATCH 26/31] fix: params for read/un-read notification --- apps/app/hooks/use-user-notifications.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/app/hooks/use-user-notifications.tsx b/apps/app/hooks/use-user-notifications.tsx index 77acf767b26..3225e963976 100644 --- a/apps/app/hooks/use-user-notifications.tsx +++ b/apps/app/hooks/use-user-notifications.tsx @@ -30,7 +30,7 @@ const useUserNotification = () => { type: snoozed || archived || readNotification ? undefined : selectedTab, snoozed, archived, - read: !readNotification, + read: !readNotification ? undefined : false, }; const { data: notifications, mutate: notificationsMutate } = useSWR( From 40fa1b7cca3da77950469b5e8119a08407acdea8 Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal Date: Wed, 19 Jul 2023 14:27:29 +0530 Subject: [PATCH 27/31] style: snooze notification modal --- .../select-snooze-till-modal.tsx | 235 +++++++----------- 1 file changed, 91 insertions(+), 144 deletions(-) diff --git a/apps/app/components/notifications/select-snooze-till-modal.tsx b/apps/app/components/notifications/select-snooze-till-modal.tsx index 4b93cc0ffdb..99281350e32 100644 --- a/apps/app/components/notifications/select-snooze-till-modal.tsx +++ b/apps/app/components/notifications/select-snooze-till-modal.tsx @@ -15,7 +15,13 @@ import { getAllTimeIn30MinutesInterval } from "helpers/date-time.helper"; import useToast from "hooks/use-toast"; // components -import { PrimaryButton, SecondaryButton, Icon, CustomDatePicker } from "components/ui"; +import { + PrimaryButton, + SecondaryButton, + Icon, + CustomDatePicker, + CustomSelect, +} from "components/ui"; // types import type { IUserNotification } from "types"; @@ -105,7 +111,7 @@ export const SnoozeNotificationModal: React.FC = (props) => { dateTime.setMinutes(minutes); await handleSubmitSnooze(notification.id, dateTime).then(() => { - onClose(); + handleClose(); onSuccess(); setToastAlert({ title: "Notification snoozed", @@ -118,7 +124,7 @@ export const SnoozeNotificationModal: React.FC = (props) => { const handleClose = () => { onClose(); const timeout = setTimeout(() => { - reset(); + reset({ ...defaultValues }); clearTimeout(timeout); }, 500); }; @@ -149,7 +155,7 @@ export const SnoozeNotificationModal: React.FC = (props) => { leaveFrom="opacity-100 translate-y-0 sm:scale-100" leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" > - +
= (props) => {
- +
Pick a date - -
- ( - { - setValue("time", null); - onChange(val); - }} - className="px-3 py-[0.385rem] w-full rounded-md border border-custom-border-100 bg-custom-background-100 text-custom-text-100 shadow-sm focus:outline-none sm:text-sm sm:leading-6" - noBorder - minDate={new Date()} - /> - )} - /> -
+
+ ( + { + setValue("time", null); + onChange(val); + }} + className="px-3 py-2 w-full rounded-md border border-custom-border-300 bg-custom-background-100 text-custom-text-100 focus:outline-none !text-sm" + noBorder + minDate={new Date()} + /> + )} + />
+
+ Pick a time +
( - - {({ open }) => ( - <> -
- - - Pick a time - - - - - - {value ? ( - - {value} {watch("period").toLowerCase()} - - ) : ( - "Select a time" - )} - - - - - - - - -
-
{ - setValue("period", "AM"); - }} - className={`w-1/2 h-full cursor-pointer flex justify-center items-center text-center ${ - watch("period") === "AM" - ? "bg-custom-primary-100/90 text-custom-primary-0" - : "bg-custom-background-90" - }`} - > - AM -
-
{ - setValue("period", "PM"); - }} - className={`w-1/2 h-full cursor-pointer flex justify-center items-center text-center ${ - watch("period") === "PM" - ? "bg-custom-primary-100/90 text-custom-primary-0" - : "bg-custom-background-90" - }`} - > - PM -
-
- {getTimeStamp().length > 0 ? ( - getTimeStamp().map((time, index) => ( - - `relative cursor-default select-none py-2 pl-3 pr-9 ${ - active - ? "bg-custom-primary-100/80 text-custom-text-100" - : "text-custom-text-700" - }` - } - value={time.value} - > - {({ selected, active }) => ( - <> -
- - {time.label} - -
- - {selected ? ( - - - ) : null} - - )} -
- )) - ) : ( -

- No available time for this date. -

- )} -
-
-
- + + {value ? ( + + {value} {watch("period").toLowerCase()} + + ) : ( + + Select a time + + )} +
+ } + width="w-full" + input + > +
+
{ + setValue("period", "AM"); + }} + className={`w-1/2 h-full cursor-pointer flex justify-center items-center text-center ${ + watch("period") === "AM" + ? "bg-custom-primary-100/90 text-custom-primary-0" + : "bg-custom-background-80" + }`} + > + AM +
+
{ + setValue("period", "PM"); + }} + className={`w-1/2 h-full cursor-pointer flex justify-center items-center text-center ${ + watch("period") === "PM" + ? "bg-custom-primary-100/90 text-custom-primary-0" + : "bg-custom-background-80" + }`} + > + PM +
+
+ {getTimeStamp().length > 0 ? ( + getTimeStamp().map((time, index) => ( + +
+ {time.label} +
+
+ )) + ) : ( +

+ No available time for this date. +

)} - + )} />
From d91659d91592b416af477fec4540ee134f5037b3 Mon Sep 17 00:00:00 2001 From: Dakshesh Jain Date: Wed, 19 Jul 2023 16:14:22 +0530 Subject: [PATCH 28/31] fix: no label for 'Create by me' --- apps/app/components/notifications/notification-popover.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/app/components/notifications/notification-popover.tsx b/apps/app/components/notifications/notification-popover.tsx index 5ab2220f5b6..4ba219ad7a4 100644 --- a/apps/app/components/notifications/notification-popover.tsx +++ b/apps/app/components/notifications/notification-popover.tsx @@ -73,6 +73,8 @@ export const NotificationPopover = () => { }, ]; + console.log(isMember, isOwner); + return ( <> { : "border-transparent text-custom-text-200" }`} > + {tab.label} {tab.unreadCount && tab.unreadCount > 0 ? ( Date: Wed, 19 Jul 2023 16:16:18 +0530 Subject: [PATCH 29/31] fix: no label for 'Create by me' --- apps/app/components/notifications/notification-popover.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/app/components/notifications/notification-popover.tsx b/apps/app/components/notifications/notification-popover.tsx index 05ee459c47c..4ba219ad7a4 100644 --- a/apps/app/components/notifications/notification-popover.tsx +++ b/apps/app/components/notifications/notification-popover.tsx @@ -207,6 +207,7 @@ export const NotificationPopover = () => { : "border-transparent text-custom-text-200" }`} > + {tab.label} {tab.unreadCount && tab.unreadCount > 0 ? ( Date: Wed, 19 Jul 2023 16:17:48 +0530 Subject: [PATCH 30/31] fix: removed console log --- apps/app/components/notifications/notification-popover.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/app/components/notifications/notification-popover.tsx b/apps/app/components/notifications/notification-popover.tsx index 4ba219ad7a4..cb71177075a 100644 --- a/apps/app/components/notifications/notification-popover.tsx +++ b/apps/app/components/notifications/notification-popover.tsx @@ -73,8 +73,6 @@ export const NotificationPopover = () => { }, ]; - console.log(isMember, isOwner); - return ( <> Date: Wed, 19 Jul 2023 17:34:15 +0530 Subject: [PATCH 31/31] fix: tooltip going behind popover --- .../notifications/notification-card.tsx | 72 ++++++++++--------- apps/app/styles/globals.css | 4 ++ 2 files changed, 41 insertions(+), 35 deletions(-) diff --git a/apps/app/components/notifications/notification-card.tsx b/apps/app/components/notifications/notification-card.tsx index 72cf1bed817..53ced88250b 100644 --- a/apps/app/components/notifications/notification-card.tsx +++ b/apps/app/components/notifications/notification-card.tsx @@ -219,44 +219,46 @@ export const NotificationCard: React.FC = (props) => { ))} - { - e.stopPropagation(); - }} - customButton={ - - } - optionsClassName="!z-20" - > - {snoozeOptions.map((item) => ( - { - e.stopPropagation(); +
+ { + e.stopPropagation(); + }} + customButton={ + + } + optionsClassName="!z-20" + > + {snoozeOptions.map((item) => ( + { + e.stopPropagation(); - if (!item.value) { - setSelectedNotificationForSnooze(notification.id); - return; - } + if (!item.value) { + setSelectedNotificationForSnooze(notification.id); + return; + } - markSnoozeNotification(notification.id, item.value).then(() => { - setToastAlert({ - title: `Notification snoozed till ${renderLongDateFormat(item.value)}`, - type: "success", + markSnoozeNotification(notification.id, item.value).then(() => { + setToastAlert({ + title: `Notification snoozed till ${renderLongDateFormat(item.value)}`, + type: "success", + }); }); - }); - }} - > - {item.label} - - ))} - + }} + > + {item.label} + + ))} + +
diff --git a/apps/app/styles/globals.css b/apps/app/styles/globals.css index 4e68cd5035c..aba03a24883 100644 --- a/apps/app/styles/globals.css +++ b/apps/app/styles/globals.css @@ -295,3 +295,7 @@ body { :-ms-input-placeholder { color: rgb(var(--color-text-400)); } + +.bp4-overlay-content { + z-index: 555 !important; +}