diff --git a/apiserver/plane/api/views/inbox.py b/apiserver/plane/api/views/inbox.py index 53248a21a..209b7b658 100644 --- a/apiserver/plane/api/views/inbox.py +++ b/apiserver/plane/api/views/inbox.py @@ -135,10 +135,11 @@ class InboxIssueAPIEndpoint(BaseAPIView): # Create or get state state, _ = State.objects.get_or_create( name="Triage", - group="backlog", + group="triage", description="Default state for managing all Inbox Issues", project_id=project_id, color="#ff7700", + is_triage=True, ) # create an issue @@ -299,7 +300,7 @@ class InboxIssueAPIEndpoint(BaseAPIView): ) # Update the issue state only if it is in triage state - if issue.state.name == "Triage": + if issue.state.is_triage: # Move to default state state = State.objects.filter( workspace__slug=slug, diff --git a/apiserver/plane/api/views/project.py b/apiserver/plane/api/views/project.py index 885b7dda5..fcb0cc4fb 100644 --- a/apiserver/plane/api/views/project.py +++ b/apiserver/plane/api/views/project.py @@ -291,10 +291,11 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView): # Create the triage state in Backlog group State.objects.get_or_create( name="Triage", - group="backlog", + group="triage", description="Default state for managing all Inbox Issues", project_id=pk, color="#ff7700", + is_triage=True, ) project = ( diff --git a/apiserver/plane/api/views/state.py b/apiserver/plane/api/views/state.py index 28181fffb..966c243e3 100644 --- a/apiserver/plane/api/views/state.py +++ b/apiserver/plane/api/views/state.py @@ -29,8 +29,8 @@ class StateAPIEndpoint(BaseAPIView): project__project_projectmember__member=self.request.user, project__project_projectmember__is_active=True, ) + .filter(is_triage=False) .filter(project__archived_at__isnull=True) - .filter(~Q(name="Triage")) .select_related("project") .select_related("workspace") .distinct() @@ -106,7 +106,7 @@ class StateAPIEndpoint(BaseAPIView): def delete(self, request, slug, project_id, state_id): state = State.objects.get( - ~Q(name="Triage"), + is_triage=False, pk=state_id, project_id=project_id, workspace__slug=slug, diff --git a/apiserver/plane/app/serializers/__init__.py b/apiserver/plane/app/serializers/__init__.py index 22673dabc..2f3c94450 100644 --- a/apiserver/plane/app/serializers/__init__.py +++ b/apiserver/plane/app/serializers/__init__.py @@ -59,6 +59,7 @@ from .issue import ( IssueFlatSerializer, IssueStateSerializer, IssueLinkSerializer, + IssueInboxSerializer, IssueLiteSerializer, IssueAttachmentSerializer, IssueSubscriberSerializer, @@ -107,6 +108,7 @@ from .inbox import ( InboxIssueSerializer, IssueStateInboxSerializer, InboxIssueLiteSerializer, + InboxIssueDetailSerializer, ) from .analytic import AnalyticViewSerializer diff --git a/apiserver/plane/app/serializers/inbox.py b/apiserver/plane/app/serializers/inbox.py index 1dc6f1f4a..25b3c8cb3 100644 --- a/apiserver/plane/app/serializers/inbox.py +++ b/apiserver/plane/app/serializers/inbox.py @@ -3,7 +3,11 @@ from rest_framework import serializers # Module imports from .base import BaseSerializer -from .issue import IssueFlatSerializer, LabelLiteSerializer +from .issue import ( + IssueInboxSerializer, + LabelLiteSerializer, + IssueDetailSerializer, +) from .project import ProjectLiteSerializer from .state import StateLiteSerializer from .user import UserLiteSerializer @@ -24,17 +28,58 @@ class InboxSerializer(BaseSerializer): class InboxIssueSerializer(BaseSerializer): - issue_detail = IssueFlatSerializer(source="issue", read_only=True) - project_detail = ProjectLiteSerializer(source="project", read_only=True) + issue = IssueInboxSerializer(read_only=True) class Meta: model = InboxIssue - fields = "__all__" + fields = [ + "id", + "status", + "duplicate_to", + "snoozed_till", + "source", + "issue", + "created_by", + ] read_only_fields = [ "project", "workspace", ] + def to_representation(self, instance): + # Pass the annotated fields to the Issue instance if they exist + if hasattr(instance, "label_ids"): + instance.issue.label_ids = instance.label_ids + return super().to_representation(instance) + + +class InboxIssueDetailSerializer(BaseSerializer): + issue = IssueDetailSerializer(read_only=True) + + class Meta: + model = InboxIssue + fields = [ + "id", + "status", + "duplicate_to", + "snoozed_till", + "source", + "issue", + ] + read_only_fields = [ + "project", + "workspace", + ] + + def to_representation(self, instance): + # Pass the annotated fields to the Issue instance if they exist + if hasattr(instance, "assignee_ids"): + instance.issue.assignee_ids = instance.assignee_ids + if hasattr(instance, "label_ids"): + instance.issue.label_ids = instance.label_ids + + return super().to_representation(instance) + class InboxIssueLiteSerializer(BaseSerializer): class Meta: diff --git a/apiserver/plane/app/serializers/issue.py b/apiserver/plane/app/serializers/issue.py index fc0e6f838..8c641b720 100644 --- a/apiserver/plane/app/serializers/issue.py +++ b/apiserver/plane/app/serializers/issue.py @@ -620,6 +620,26 @@ class IssueStateSerializer(DynamicBaseSerializer): fields = "__all__" +class IssueInboxSerializer(DynamicBaseSerializer): + label_ids = serializers.ListField( + child=serializers.UUIDField(), + required=False, + ) + + class Meta: + model = Issue + fields = [ + "id", + "name", + "priority", + "sequence_id", + "project_id", + "created_at", + "label_ids", + ] + read_only_fields = fields + + class IssueSerializer(DynamicBaseSerializer): # ids cycle_id = serializers.PrimaryKeyRelatedField(read_only=True) @@ -688,7 +708,7 @@ class IssueLiteSerializer(DynamicBaseSerializer): class IssueDetailSerializer(IssueSerializer): description_html = serializers.CharField() - is_subscribed = serializers.BooleanField() + is_subscribed = serializers.BooleanField(read_only=True) class Meta(IssueSerializer.Meta): fields = IssueSerializer.Meta.fields + [ diff --git a/apiserver/plane/app/urls/inbox.py b/apiserver/plane/app/urls/inbox.py index e9ec4e335..b6848244b 100644 --- a/apiserver/plane/app/urls/inbox.py +++ b/apiserver/plane/app/urls/inbox.py @@ -30,7 +30,7 @@ urlpatterns = [ name="inbox", ), path( - "workspaces//projects//inboxes//inbox-issues/", + "workspaces//projects//inbox-issues/", InboxIssueViewSet.as_view( { "get": "list", @@ -40,7 +40,7 @@ urlpatterns = [ name="inbox-issue", ), path( - "workspaces//projects//inboxes//inbox-issues//", + "workspaces//projects//inbox-issues//", InboxIssueViewSet.as_view( { "get": "retrieve", diff --git a/apiserver/plane/app/views/inbox/base.py b/apiserver/plane/app/views/inbox/base.py index 710aa10a2..e486052a3 100644 --- a/apiserver/plane/app/views/inbox/base.py +++ b/apiserver/plane/app/views/inbox/base.py @@ -22,18 +22,17 @@ from plane.db.models import ( InboxIssue, Issue, State, + Workspace, IssueLink, IssueAttachment, ProjectMember, - IssueReaction, - IssueSubscriber, ) from plane.app.serializers import ( IssueCreateSerializer, IssueSerializer, InboxSerializer, InboxIssueSerializer, - IssueDetailSerializer, + InboxIssueDetailSerializer, ) from plane.utils.issue_filters import issue_filters from plane.bgtasks.issue_activites_task import issue_activity @@ -64,13 +63,20 @@ class InboxViewSet(BaseViewSet): .select_related("workspace", "project") ) + def list(self, request, slug, project_id): + inbox = self.get_queryset().first() + return Response( + InboxSerializer(inbox).data, + status=status.HTTP_200_OK, + ) + def perform_create(self, serializer): serializer.save(project_id=self.kwargs.get("project_id")) def destroy(self, request, slug, project_id, pk): - inbox = Inbox.objects.get( + inbox = Inbox.objects.filter( workspace__slug=slug, project_id=project_id, pk=pk - ) + ).first() # Handle default inbox delete if inbox.is_default: return Response( @@ -98,7 +104,6 @@ class InboxIssueViewSet(BaseViewSet): Issue.objects.filter( project_id=self.kwargs.get("project_id"), workspace__slug=self.kwargs.get("slug"), - issue_inbox__inbox_id=self.kwargs.get("inbox_id"), ) .select_related("workspace", "project", "state", "parent") .prefetch_related("assignees", "labels", "issue_module__module") @@ -162,51 +167,50 @@ class InboxIssueViewSet(BaseViewSet): ) ).distinct() - def list(self, request, slug, project_id, inbox_id): - filters = issue_filters(request.query_params, "GET") - issue_queryset = ( - self.get_queryset() - .filter(**filters) - .order_by("issue_inbox__snoozed_till", "issue_inbox__status") - ) - if self.expand: - issues = IssueSerializer( - issue_queryset, expand=self.expand, many=True - ).data - else: - issues = issue_queryset.values( - "id", - "name", - "state_id", - "sort_order", - "completed_at", - "estimate_point", - "priority", - "start_date", - "target_date", - "sequence_id", - "project_id", - "parent_id", - "cycle_id", - "module_ids", - "label_ids", - "assignee_ids", - "sub_issues_count", - "created_at", - "updated_at", - "created_by", - "updated_by", - "attachment_count", - "link_count", - "is_draft", - "archived_at", + def list(self, request, slug, project_id): + workspace = Workspace.objects.filter(slug=slug).first() + inbox_id = Inbox.objects.filter( + workspace_id=workspace.id, project_id=project_id + ).first() + filters = issue_filters(request.GET, "GET", "issue__") + inbox_issue = ( + InboxIssue.objects.filter( + inbox_id=inbox_id.id, project_id=project_id, **filters ) - return Response( - issues, - status=status.HTTP_200_OK, + .select_related("issue") + .prefetch_related( + "issue__labels", + ) + .annotate( + label_ids=Coalesce( + ArrayAgg( + "issue__labels__id", + distinct=True, + filter=~Q(issue__labels__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ) + ) + ).order_by(request.GET.get("order_by", "-issue__created_at")) + # inbox status filter + inbox_status = [ + item + for item in request.GET.get("status", "-2").split(",") + if item != "null" + ] + if inbox_status: + inbox_issue = inbox_issue.filter(status__in=inbox_status) + + return self.paginate( + request=request, + queryset=(inbox_issue), + on_results=lambda inbox_issues: InboxIssueSerializer( + inbox_issues, + many=True, + ).data, ) - def create(self, request, slug, project_id, inbox_id): + def create(self, request, slug, project_id): if not request.data.get("issue", {}).get("name", False): return Response( {"error": "Name is required"}, @@ -229,10 +233,11 @@ class InboxIssueViewSet(BaseViewSet): # Create or get state state, _ = State.objects.get_or_create( name="Triage", - group="backlog", + group="triage", description="Default state for managing all Inbox Issues", project_id=project_id, color="#ff7700", + is_triage=True, ) # create an issue @@ -259,19 +264,25 @@ class InboxIssueViewSet(BaseViewSet): notification=True, origin=request.META.get("HTTP_ORIGIN"), ) + workspace = Workspace.objects.filter(slug=slug).first() + inbox_id = Inbox.objects.filter( + workspace_id=workspace.id, project_id=project_id + ).first() # create an inbox issue - InboxIssue.objects.create( - inbox_id=inbox_id, + inbox_issue = InboxIssue.objects.create( + inbox_id=inbox_id.id, project_id=project_id, issue=issue, source=request.data.get("source", "in-app"), ) - - issue = self.get_queryset().filter(pk=issue.id).first() - serializer = IssueSerializer(issue, expand=self.expand) + serializer = InboxIssueDetailSerializer(inbox_issue) return Response(serializer.data, status=status.HTTP_200_OK) - def partial_update(self, request, slug, project_id, inbox_id, issue_id): + def partial_update(self, request, slug, project_id, issue_id): + workspace = Workspace.objects.filter(slug=slug).first() + inbox_id = Inbox.objects.filter( + workspace_id=workspace.id, project_id=project_id + ).first() inbox_issue = InboxIssue.objects.get( issue_id=issue_id, workspace__slug=slug, @@ -374,7 +385,7 @@ class InboxIssueViewSet(BaseViewSet): ) # Update the issue state only if it is in triage state - if issue.state.name == "Triage": + if issue.state.is_triage: # Move to default state state = State.objects.filter( workspace__slug=slug, @@ -384,60 +395,60 @@ class InboxIssueViewSet(BaseViewSet): if state is not None: issue.state = state issue.save() - return Response(status=status.HTTP_204_NO_CONTENT) + + serializer = InboxIssueDetailSerializer(inbox_issue).data + return Response(serializer, status=status.HTTP_200_OK) return Response( serializer.errors, status=status.HTTP_400_BAD_REQUEST ) else: - issue = self.get_queryset().filter(pk=issue_id).first() - serializer = IssueSerializer(issue, expand=self.expand) - return Response(serializer.data, status=status.HTTP_200_OK) + serializer = InboxIssueDetailSerializer(inbox_issue).data + return Response(serializer, status=status.HTTP_200_OK) - def retrieve(self, request, slug, project_id, inbox_id, issue_id): - issue = ( - self.get_queryset() - .filter(pk=issue_id) + def retrieve(self, request, slug, project_id, issue_id): + workspace = Workspace.objects.filter(slug=slug).first() + inbox_id = Inbox.objects.filter( + workspace_id=workspace.id, project_id=project_id + ).first() + inbox_issue = ( + InboxIssue.objects.select_related("issue") .prefetch_related( - Prefetch( - "issue_reactions", - queryset=IssueReaction.objects.select_related( - "issue", "actor" - ), - ) - ) - .prefetch_related( - Prefetch( - "issue_attachment", - queryset=IssueAttachment.objects.select_related("issue"), - ) - ) - .prefetch_related( - Prefetch( - "issue_link", - queryset=IssueLink.objects.select_related("created_by"), - ) + "issue__labels", + "issue__assignees", ) .annotate( - is_subscribed=Exists( - IssueSubscriber.objects.filter( - workspace__slug=slug, - project_id=project_id, - issue_id=OuterRef("pk"), - subscriber=request.user, - ) - ) + label_ids=Coalesce( + ArrayAgg( + "issue__labels__id", + distinct=True, + filter=~Q(issue__labels__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + assignee_ids=Coalesce( + ArrayAgg( + "issue__assignees__id", + distinct=True, + filter=~Q(issue__assignees__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), ) + .get( + inbox_id=inbox_id.id, issue_id=issue_id, project_id=project_id + ) + ) + issue = InboxIssueDetailSerializer(inbox_issue).data + return Response( + issue, + status=status.HTTP_200_OK, + ) + + def destroy(self, request, slug, project_id, issue_id): + workspace = Workspace.objects.filter(slug=slug).first() + inbox_id = Inbox.objects.filter( + workspace_id=workspace.id, project_id=project_id ).first() - if issue is None: - return Response( - {"error": "Requested object was not found"}, - status=status.HTTP_404_NOT_FOUND, - ) - - serializer = IssueDetailSerializer(issue) - return Response(serializer.data, status=status.HTTP_200_OK) - - def destroy(self, request, slug, project_id, inbox_id, issue_id): inbox_issue = InboxIssue.objects.get( issue_id=issue_id, workspace__slug=slug, diff --git a/apiserver/plane/app/views/project/base.py b/apiserver/plane/app/views/project/base.py index 1672cd47c..50435e3a8 100644 --- a/apiserver/plane/app/views/project/base.py +++ b/apiserver/plane/app/views/project/base.py @@ -393,10 +393,11 @@ class ProjectViewSet(WebhookMixin, BaseViewSet): # Create the triage state in Backlog group State.objects.get_or_create( name="Triage", - group="backlog", + group="triage", description="Default state for managing all Inbox Issues", project_id=pk, color="#ff7700", + is_triage=True, ) project = ( diff --git a/apiserver/plane/app/views/state/base.py b/apiserver/plane/app/views/state/base.py index 7b0904490..6d55b1977 100644 --- a/apiserver/plane/app/views/state/base.py +++ b/apiserver/plane/app/views/state/base.py @@ -35,7 +35,7 @@ class StateViewSet(BaseViewSet): project__project_projectmember__is_active=True, project__archived_at__isnull=True, ) - .filter(~Q(name="Triage")) + .filter(is_triage=False) .select_related("project") .select_related("workspace") .distinct() @@ -76,7 +76,7 @@ class StateViewSet(BaseViewSet): @invalidate_cache(path="workspaces/:slug/states/", url_params=True, user=False) def destroy(self, request, slug, project_id, pk): state = State.objects.get( - ~Q(name="Triage"), + is_triage=False, pk=pk, project_id=project_id, workspace__slug=slug, diff --git a/apiserver/plane/db/migrations/0063_state_is_triage_alter_state_group.py b/apiserver/plane/db/migrations/0063_state_is_triage_alter_state_group.py new file mode 100644 index 000000000..66303dfe6 --- /dev/null +++ b/apiserver/plane/db/migrations/0063_state_is_triage_alter_state_group.py @@ -0,0 +1,44 @@ +# Generated by Django 4.2.10 on 2024-04-02 12:18 + +from django.db import migrations, models + + +def update_project_state_group(apps, schema_editor): + State = apps.get_model("db", "State") + + # Update states in bulk + State.objects.filter(group="backlog", name="Triage").update( + is_triage=True, group="triage" + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ("db", "0062_cycle_archived_at_module_archived_at_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="state", + name="is_triage", + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name="state", + name="group", + field=models.CharField( + choices=[ + ("backlog", "Backlog"), + ("unstarted", "Unstarted"), + ("started", "Started"), + ("completed", "Completed"), + ("cancelled", "Cancelled"), + ("triage", "Triage"), + ], + default="backlog", + max_length=20, + ), + ), + migrations.RunPython(update_project_state_group), + ] diff --git a/apiserver/plane/db/models/issue.py b/apiserver/plane/db/models/issue.py index 0a59acb93..01a43abca 100644 --- a/apiserver/plane/db/models/issue.py +++ b/apiserver/plane/db/models/issue.py @@ -171,14 +171,14 @@ class Issue(ProjectBaseModel): from plane.db.models import State default_state = State.objects.filter( - ~models.Q(name="Triage"), + ~models.Q(is_triage=True), project=self.project, default=True, ).first() # if there is no default state assign any random state if default_state is None: random_state = State.objects.filter( - ~models.Q(name="Triage"), project=self.project + ~models.Q(is_triage=True), project=self.project ).first() self.state = random_state else: diff --git a/apiserver/plane/db/models/state.py b/apiserver/plane/db/models/state.py index ab9b780c8..28e3b25a1 100644 --- a/apiserver/plane/db/models/state.py +++ b/apiserver/plane/db/models/state.py @@ -21,10 +21,12 @@ class State(ProjectBaseModel): ("started", "Started"), ("completed", "Completed"), ("cancelled", "Cancelled"), + ("triage", "Triage") ), default="backlog", max_length=20, ) + is_triage = models.BooleanField(default=False) default = models.BooleanField(default=False) external_source = models.CharField(max_length=255, null=True, blank=True) external_id = models.CharField(max_length=255, blank=True, null=True) diff --git a/apiserver/plane/utils/issue_filters.py b/apiserver/plane/utils/issue_filters.py index 2c4cbd471..18ef51937 100644 --- a/apiserver/plane/utils/issue_filters.py +++ b/apiserver/plane/utils/issue_filters.py @@ -83,25 +83,25 @@ def date_filter(filter, date_term, queries): filter[f"{date_term}__lte"] = date_query[0] -def filter_state(params, filter, method): +def filter_state(params, filter, method, prefix=""): if method == "GET": states = [ item for item in params.get("state").split(",") if item != "null" ] states = filter_valid_uuids(states) if len(states) and "" not in states: - filter["state__in"] = states + filter[f"{prefix}state__in"] = states else: if ( params.get("state", None) and len(params.get("state")) and params.get("state") != "null" ): - filter["state__in"] = params.get("state") + filter[f"{prefix}state__in"] = params.get("state") return filter -def filter_state_group(params, filter, method): +def filter_state_group(params, filter, method, prefix=""): if method == "GET": state_group = [ item @@ -109,18 +109,18 @@ def filter_state_group(params, filter, method): if item != "null" ] if len(state_group) and "" not in state_group: - filter["state__group__in"] = state_group + filter[f"{prefix}state__group__in"] = state_group else: if ( params.get("state_group", None) and len(params.get("state_group")) and params.get("state_group") != "null" ): - filter["state__group__in"] = params.get("state_group") + filter[f"{prefix}state__group__in"] = params.get("state_group") return filter -def filter_estimate_point(params, filter, method): +def filter_estimate_point(params, filter, method, prefix=""): if method == "GET": estimate_points = [ item @@ -128,18 +128,20 @@ def filter_estimate_point(params, filter, method): if item != "null" ] if len(estimate_points) and "" not in estimate_points: - filter["estimate_point__in"] = estimate_points + filter[f"{prefix}estimate_point__in"] = estimate_points else: if ( params.get("estimate_point", None) and len(params.get("estimate_point")) and params.get("estimate_point") != "null" ): - filter["estimate_point__in"] = params.get("estimate_point") + filter[f"{prefix}estimate_point__in"] = params.get( + "estimate_point" + ) return filter -def filter_priority(params, filter, method): +def filter_priority(params, filter, method, prefix=""): if method == "GET": priorities = [ item @@ -147,47 +149,47 @@ def filter_priority(params, filter, method): if item != "null" ] if len(priorities) and "" not in priorities: - filter["priority__in"] = priorities + filter[f"{prefix}priority__in"] = priorities return filter -def filter_parent(params, filter, method): +def filter_parent(params, filter, method, prefix=""): if method == "GET": parents = [ item for item in params.get("parent").split(",") if item != "null" ] parents = filter_valid_uuids(parents) if len(parents) and "" not in parents: - filter["parent__in"] = parents + filter[f"{prefix}parent__in"] = parents else: if ( params.get("parent", None) and len(params.get("parent")) and params.get("parent") != "null" ): - filter["parent__in"] = params.get("parent") + filter[f"{prefix}parent__in"] = params.get("parent") return filter -def filter_labels(params, filter, method): +def filter_labels(params, filter, method, prefix=""): if method == "GET": labels = [ item for item in params.get("labels").split(",") if item != "null" ] labels = filter_valid_uuids(labels) if len(labels) and "" not in labels: - filter["labels__in"] = labels + filter[f"{prefix}labels__in"] = labels else: if ( params.get("labels", None) and len(params.get("labels")) and params.get("labels") != "null" ): - filter["labels__in"] = params.get("labels") + filter[f"{prefix}labels__in"] = params.get("labels") return filter -def filter_assignees(params, filter, method): +def filter_assignees(params, filter, method, prefix=""): if method == "GET": assignees = [ item @@ -196,18 +198,18 @@ def filter_assignees(params, filter, method): ] assignees = filter_valid_uuids(assignees) if len(assignees) and "" not in assignees: - filter["assignees__in"] = assignees + filter[f"{prefix}assignees__in"] = assignees else: if ( params.get("assignees", None) and len(params.get("assignees")) and params.get("assignees") != "null" ): - filter["assignees__in"] = params.get("assignees") + filter[f"{prefix}assignees__in"] = params.get("assignees") return filter -def filter_mentions(params, filter, method): +def filter_mentions(params, filter, method, prefix=""): if method == "GET": mentions = [ item @@ -216,18 +218,20 @@ def filter_mentions(params, filter, method): ] mentions = filter_valid_uuids(mentions) if len(mentions) and "" not in mentions: - filter["issue_mention__mention__id__in"] = mentions + filter[f"{prefix}issue_mention__mention__id__in"] = mentions else: if ( params.get("mentions", None) and len(params.get("mentions")) and params.get("mentions") != "null" ): - filter["issue_mention__mention__id__in"] = params.get("mentions") + filter[f"{prefix}issue_mention__mention__id__in"] = params.get( + "mentions" + ) return filter -def filter_created_by(params, filter, method): +def filter_created_by(params, filter, method, prefix=""): if method == "GET": created_bys = [ item @@ -236,94 +240,98 @@ def filter_created_by(params, filter, method): ] created_bys = filter_valid_uuids(created_bys) if len(created_bys) and "" not in created_bys: - filter["created_by__in"] = created_bys + filter[f"{prefix}created_by__in"] = created_bys else: if ( params.get("created_by", None) and len(params.get("created_by")) and params.get("created_by") != "null" ): - filter["created_by__in"] = params.get("created_by") + filter[f"{prefix}created_by__in"] = params.get("created_by") return filter -def filter_name(params, filter, method): +def filter_name(params, filter, method, prefix=""): if params.get("name", "") != "": - filter["name__icontains"] = params.get("name") + filter[f"{prefix}name__icontains"] = params.get("name") return filter -def filter_created_at(params, filter, method): +def filter_created_at(params, filter, method, prefix=""): if method == "GET": created_ats = params.get("created_at").split(",") if len(created_ats) and "" not in created_ats: date_filter( filter=filter, - date_term="created_at__date", + date_term=f"{prefix}created_at__date", queries=created_ats, ) else: if params.get("created_at", None) and len(params.get("created_at")): date_filter( filter=filter, - date_term="created_at__date", + date_term=f"{prefix}created_at__date", queries=params.get("created_at", []), ) return filter -def filter_updated_at(params, filter, method): +def filter_updated_at(params, filter, method, prefix=""): if method == "GET": updated_ats = params.get("updated_at").split(",") if len(updated_ats) and "" not in updated_ats: date_filter( filter=filter, - date_term="created_at__date", + date_term=f"{prefix}created_at__date", queries=updated_ats, ) else: if params.get("updated_at", None) and len(params.get("updated_at")): date_filter( filter=filter, - date_term="created_at__date", + date_term=f"{prefix}created_at__date", queries=params.get("updated_at", []), ) return filter -def filter_start_date(params, filter, method): +def filter_start_date(params, filter, method, prefix=""): if method == "GET": start_dates = params.get("start_date").split(",") if len(start_dates) and "" not in start_dates: date_filter( - filter=filter, date_term="start_date", queries=start_dates + filter=filter, + date_term=f"{prefix}start_date", + queries=start_dates, ) else: if params.get("start_date", None) and len(params.get("start_date")): - filter["start_date"] = params.get("start_date") + filter[f"{prefix}start_date"] = params.get("start_date") return filter -def filter_target_date(params, filter, method): +def filter_target_date(params, filter, method, prefix=""): if method == "GET": target_dates = params.get("target_date").split(",") if len(target_dates) and "" not in target_dates: date_filter( - filter=filter, date_term="target_date", queries=target_dates + filter=filter, + date_term=f"{prefix}target_date", + queries=target_dates, ) else: if params.get("target_date", None) and len(params.get("target_date")): - filter["target_date"] = params.get("target_date") + filter[f"{prefix}target_date"] = params.get("target_date") return filter -def filter_completed_at(params, filter, method): +def filter_completed_at(params, filter, method, prefix=""): if method == "GET": completed_ats = params.get("completed_at").split(",") if len(completed_ats) and "" not in completed_ats: date_filter( filter=filter, - date_term="completed_at__date", + date_term=f"{prefix}completed_at__date", queries=completed_ats, ) else: @@ -332,13 +340,13 @@ def filter_completed_at(params, filter, method): ): date_filter( filter=filter, - date_term="completed_at__date", + date_term=f"{prefix}completed_at__date", queries=params.get("completed_at", []), ) return filter -def filter_issue_state_type(params, filter, method): +def filter_issue_state_type(params, filter, method, prefix=""): type = params.get("type", "all") group = ["backlog", "unstarted", "started", "completed", "cancelled"] if type == "backlog": @@ -346,65 +354,67 @@ def filter_issue_state_type(params, filter, method): if type == "active": group = ["unstarted", "started"] - filter["state__group__in"] = group + filter[f"{prefix}state__group__in"] = group return filter -def filter_project(params, filter, method): +def filter_project(params, filter, method, prefix=""): if method == "GET": projects = [ item for item in params.get("project").split(",") if item != "null" ] projects = filter_valid_uuids(projects) if len(projects) and "" not in projects: - filter["project__in"] = projects + filter[f"{prefix}project__in"] = projects else: if ( params.get("project", None) and len(params.get("project")) and params.get("project") != "null" ): - filter["project__in"] = params.get("project") + filter[f"{prefix}project__in"] = params.get("project") return filter -def filter_cycle(params, filter, method): +def filter_cycle(params, filter, method, prefix=""): if method == "GET": cycles = [ item for item in params.get("cycle").split(",") if item != "null" ] cycles = filter_valid_uuids(cycles) if len(cycles) and "" not in cycles: - filter["issue_cycle__cycle_id__in"] = cycles + filter[f"{prefix}issue_cycle__cycle_id__in"] = cycles else: if ( params.get("cycle", None) and len(params.get("cycle")) and params.get("cycle") != "null" ): - filter["issue_cycle__cycle_id__in"] = params.get("cycle") + filter[f"{prefix}issue_cycle__cycle_id__in"] = params.get("cycle") return filter -def filter_module(params, filter, method): +def filter_module(params, filter, method, prefix=""): if method == "GET": modules = [ item for item in params.get("module").split(",") if item != "null" ] modules = filter_valid_uuids(modules) if len(modules) and "" not in modules: - filter["issue_module__module_id__in"] = modules + filter[f"{prefix}issue_module__module_id__in"] = modules else: if ( params.get("module", None) and len(params.get("module")) and params.get("module") != "null" ): - filter["issue_module__module_id__in"] = params.get("module") + filter[f"{prefix}issue_module__module_id__in"] = params.get( + "module" + ) return filter -def filter_inbox_status(params, filter, method): +def filter_inbox_status(params, filter, method, prefix=""): if method == "GET": status = [ item @@ -412,30 +422,32 @@ def filter_inbox_status(params, filter, method): if item != "null" ] if len(status) and "" not in status: - filter["issue_inbox__status__in"] = status + filter[f"{prefix}issue_inbox__status__in"] = status else: if ( params.get("inbox_status", None) and len(params.get("inbox_status")) and params.get("inbox_status") != "null" ): - filter["issue_inbox__status__in"] = params.get("inbox_status") + filter[f"{prefix}issue_inbox__status__in"] = params.get( + "inbox_status" + ) return filter -def filter_sub_issue_toggle(params, filter, method): +def filter_sub_issue_toggle(params, filter, method, prefix=""): if method == "GET": sub_issue = params.get("sub_issue", "false") if sub_issue == "false": - filter["parent__isnull"] = True + filter[f"{prefix}parent__isnull"] = True else: sub_issue = params.get("sub_issue", "false") if sub_issue == "false": - filter["parent__isnull"] = True + filter[f"{prefix}parent__isnull"] = True return filter -def filter_subscribed_issues(params, filter, method): +def filter_subscribed_issues(params, filter, method, prefix=""): if method == "GET": subscribers = [ item @@ -444,28 +456,30 @@ def filter_subscribed_issues(params, filter, method): ] subscribers = filter_valid_uuids(subscribers) if len(subscribers) and "" not in subscribers: - filter["issue_subscribers__subscriber_id__in"] = subscribers + filter[f"{prefix}issue_subscribers__subscriber_id__in"] = ( + subscribers + ) else: if ( params.get("subscriber", None) and len(params.get("subscriber")) and params.get("subscriber") != "null" ): - filter["issue_subscribers__subscriber_id__in"] = params.get( - "subscriber" + filter[f"{prefix}issue_subscribers__subscriber_id__in"] = ( + params.get("subscriber") ) return filter -def filter_start_target_date_issues(params, filter, method): +def filter_start_target_date_issues(params, filter, method, prefix=""): start_target_date = params.get("start_target_date", "false") if start_target_date == "true": - filter["target_date__isnull"] = False - filter["start_date__isnull"] = False + filter[f"{prefix}target_date__isnull"] = False + filter[f"{prefix}start_date__isnull"] = False return filter -def issue_filters(query_params, method): +def issue_filters(query_params, method, prefix=""): filter = {} ISSUE_FILTER = { @@ -497,6 +511,5 @@ def issue_filters(query_params, method): for key, value in ISSUE_FILTER.items(): if key in query_params: func = value - func(query_params, filter, method) - + func(query_params, filter, method, prefix) return filter diff --git a/apiserver/plane/utils/paginator.py b/apiserver/plane/utils/paginator.py index db0ede6ad..8cc853370 100644 --- a/apiserver/plane/utils/paginator.py +++ b/apiserver/plane/utils/paginator.py @@ -134,7 +134,7 @@ class OffsetPaginator: results=results, next=next_cursor, prev=prev_cursor, - hits=None, + hits=count, max_hits=max_hits, ) @@ -217,6 +217,7 @@ class BasePaginator: "prev_page_results": cursor_result.prev.has_results, "count": cursor_result.__len__(), "total_pages": cursor_result.max_hits, + "total_results": cursor_result.hits, "extra_stats": extra_stats, "results": results, } diff --git a/packages/types/src/common.d.ts b/packages/types/src/common.d.ts new file mode 100644 index 000000000..d347ecef1 --- /dev/null +++ b/packages/types/src/common.d.ts @@ -0,0 +1,11 @@ +export type TPaginationInfo = { + count: number; + extra_stats: string | null; + next_cursor: string; + next_page_results: boolean; + prev_cursor: string; + prev_page_results: boolean; + total_pages: number; + per_page?: number; + total_results: number; +}; diff --git a/packages/types/src/inbox.d.ts b/packages/types/src/inbox.d.ts new file mode 100644 index 000000000..09c5353fd --- /dev/null +++ b/packages/types/src/inbox.d.ts @@ -0,0 +1,76 @@ +import { TPaginationInfo } from "./common"; +import { TIssuePriorities } from "./issues"; +import { TIssue } from "./issues/base"; + +export type TInboxIssueCurrentTab = "open" | "closed"; + +export type TInboxIssueStatus = -2 | -1 | 0 | 1 | 2; + +// filters +export type TInboxIssueFilterMemberKeys = "assignee" | "created_by"; + +export type TInboxIssueFilterDateKeys = "created_at" | "updated_at"; + +export type TInboxIssueFilter = { + [key in TInboxIssueFilterMemberKeys]: string[] | undefined; +} & { + [key in TInboxIssueFilterDateKeys]: string[] | undefined; +} & { + status: TInboxIssueStatus[] | undefined; + priority: TIssuePriorities[] | undefined; + label: string[] | undefined; +}; + +// sorting filters +export type TInboxIssueSortingKeys = "order_by" | "sort_by"; + +export type TInboxIssueSortingOrderByKeys = + | "issue__created_at" + | "issue__updated_at" + | "issue__sequence_id"; + +export type TInboxIssueSortingSortByKeys = "asc" | "desc"; + +export type TInboxIssueSorting = { + order_by: TInboxIssueSortingOrderByKeys | undefined; + sort_by: TInboxIssueSortingSortByKeys | undefined; +}; + +// filtering and sorting types for query params +export type TInboxIssueSortingOrderByQueryParamKeys = + | "issue__created_at" + | "-issue__created_at" + | "issue__updated_at" + | "-issue__updated_at" + | "issue__sequence_id" + | "-issue__sequence_id"; + +export type TInboxIssueSortingOrderByQueryParam = { + order_by: TInboxIssueSortingOrderByQueryParamKeys; +}; + +export type TInboxIssuesQueryParams = { + [key in TInboxIssueFilter]: string; +} & TInboxIssueSortingOrderByQueryParam & { + per_page: number; + cursor: string; + }; + +// inbox issue types +export type TInboxIssue = { + id: string; + status: TInboxIssueStatus; + snoozed_till: Date | null; + duplicate_to: string | null; + source: string; + issue: TIssue; + created_by: string; +}; + +export type TInboxIssuePaginationInfo = TPaginationInfo & { + total_results: number; +}; + +export type TInboxIssueWithPagination = TInboxIssuePaginationInfo & { + results: TInboxIssue[]; +}; diff --git a/packages/types/src/inbox/inbox-issue.d.ts b/packages/types/src/inbox/inbox-issue.d.ts deleted file mode 100644 index c7d33f75b..000000000 --- a/packages/types/src/inbox/inbox-issue.d.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { TIssue } from "../issues/base"; - -export enum EInboxStatus { - PENDING = -2, - REJECT = -1, - SNOOZED = 0, - ACCEPTED = 1, - DUPLICATE = 2, -} - -export type TInboxStatus = - | EInboxStatus.PENDING - | EInboxStatus.REJECT - | EInboxStatus.SNOOZED - | EInboxStatus.ACCEPTED - | EInboxStatus.DUPLICATE; - -export type TInboxIssueDetail = { - id?: string; - source: "in-app"; - status: TInboxStatus; - duplicate_to: string | undefined; - snoozed_till: Date | undefined; -}; - -export type TInboxIssueDetailMap = Record< - string, - Record ->; // inbox_id -> issue_id -> TInboxIssueDetail - -export type TInboxIssueDetailIdMap = Record; // inbox_id -> issue_id[] - -export type TInboxIssueExtendedDetail = TIssue & { - issue_inbox: TInboxIssueDetail[]; -}; - -// property type checks -export type TInboxPendingStatus = { - status: EInboxStatus.PENDING; -}; - -export type TInboxRejectStatus = { - status: EInboxStatus.REJECT; -}; - -export type TInboxSnoozedStatus = { - status: EInboxStatus.SNOOZED; - snoozed_till: Date; -}; - -export type TInboxAcceptedStatus = { - status: EInboxStatus.ACCEPTED; -}; - -export type TInboxDuplicateStatus = { - status: EInboxStatus.DUPLICATE; - duplicate_to: string; // issue_id -}; - -export type TInboxDetailedStatus = - | TInboxPendingStatus - | TInboxRejectStatus - | TInboxSnoozedStatus - | TInboxAcceptedStatus - | TInboxDuplicateStatus; diff --git a/packages/types/src/inbox/inbox-types.d.ts b/packages/types/src/inbox/inbox-types.d.ts deleted file mode 100644 index c3ec8461e..000000000 --- a/packages/types/src/inbox/inbox-types.d.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { TIssue } from "../issues/base"; -import type { IProjectLite } from "../project"; - -export type TInboxIssueExtended = { - completed_at: string | null; - start_date: string | null; - target_date: string | null; -}; - -export interface IInboxIssue extends TIssue, TInboxIssueExtended { - issue_inbox: { - duplicate_to: string | null; - id: string; - snoozed_till: Date | null; - source: string; - status: -2 | -1 | 0 | 1 | 2; - }[]; -} - -export interface IInbox { - id: string; - project_detail: IProjectLite; - pending_issue_count: number; - created_at: Date; - updated_at: Date; - name: string; - description: string; - is_default: boolean; - created_by: string; - updated_by: string; - project: string; - view_props: { filters: IInboxFilterOptions }; - workspace: string; -} - -export interface IInboxFilterOptions { - priority?: string[] | null; - inbox_status?: number[] | null; -} - -export interface IInboxQueryParams { - priority: string | null; - inbox_status: string | null; -} diff --git a/packages/types/src/inbox/inbox.d.ts b/packages/types/src/inbox/inbox.d.ts deleted file mode 100644 index 1b4e23e0f..000000000 --- a/packages/types/src/inbox/inbox.d.ts +++ /dev/null @@ -1,27 +0,0 @@ -export type TInboxIssueFilterOptions = { - priority: string[]; - inbox_status: number[]; -}; - -export type TInboxIssueQueryParams = "priority" | "inbox_status"; - -export type TInboxIssueFilters = { filters: TInboxIssueFilterOptions }; - -export type TInbox = { - id: string; - name: string; - description: string; - workspace: string; - project: string; - is_default: boolean; - view_props: TInboxIssueFilters; - created_by: string; - updated_by: string; - created_at: Date; - updated_at: Date; - pending_issue_count: number; -}; - -export type TInboxDetailMap = Record; // inbox_id -> TInbox - -export type TInboxDetailIdMap = Record; // project_id -> inbox_id[] diff --git a/packages/types/src/inbox/root.d.ts b/packages/types/src/inbox/root.d.ts deleted file mode 100644 index 6fd21a4fe..000000000 --- a/packages/types/src/inbox/root.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from "./inbox-issue"; -export * from "./inbox-types"; -export * from "./inbox"; diff --git a/packages/types/src/index.d.ts b/packages/types/src/index.d.ts index 48d0c1448..52ea3771e 100644 --- a/packages/types/src/index.d.ts +++ b/packages/types/src/index.d.ts @@ -12,10 +12,7 @@ export * from "./pages"; export * from "./ai"; export * from "./estimate"; export * from "./importer"; - -// FIXME: Remove this after development and the refactor/mobx-store-issue branch is stable -export * from "./inbox/root"; - +export * from "./inbox"; export * from "./analytics"; export * from "./calendar"; export * from "./notifications"; @@ -29,3 +26,4 @@ export * from "./auth"; export * from "./api_token"; export * from "./instance"; export * from "./app"; +export * from "./common"; diff --git a/web/components/headers/project-inbox.tsx b/web/components/headers/project-inbox.tsx index 7ddf92380..b35c7485d 100644 --- a/web/components/headers/project-inbox.tsx +++ b/web/components/headers/project-inbox.tsx @@ -1,16 +1,15 @@ import { FC, useState } from "react"; import { observer } from "mobx-react-lite"; import { useRouter } from "next/router"; -import { Plus } from "lucide-react"; -// hooks +import { Plus, RefreshCcw } from "lucide-react"; // ui import { Breadcrumbs, Button, LayersIcon } from "@plane/ui"; // components import { BreadcrumbLink } from "@/components/common"; import { CreateInboxIssueModal } from "@/components/inbox"; -// helper import { ProjectLogo } from "@/components/project"; -import { useProject } from "@/hooks/store"; +// hooks +import { useProject, useProjectInbox } from "@/hooks/store"; export const ProjectInboxHeader: FC = observer(() => { // states @@ -20,11 +19,12 @@ export const ProjectInboxHeader: FC = observer(() => { const { workspaceSlug } = router.query; // store hooks const { currentProjectDetails } = useProject(); + const { isLoading } = useProjectInbox(); return (
-
+
{ } /> + + {isLoading === "pagination-loading" && ( +
+ +

Syncing...

+
+ )}
diff --git a/web/components/inbox/content/inbox-issue-header.tsx b/web/components/inbox/content/inbox-issue-header.tsx new file mode 100644 index 000000000..4b1f7a114 --- /dev/null +++ b/web/components/inbox/content/inbox-issue-header.tsx @@ -0,0 +1,276 @@ +import { FC, useCallback, useEffect, useState } from "react"; +import { observer } from "mobx-react"; +import { useRouter } from "next/router"; +import { ChevronDown, ChevronUp, Clock, ExternalLink, FileStack, Link, Trash2 } from "lucide-react"; +import { Button, ControlLink, CustomMenu, TOAST_TYPE, setToast } from "@plane/ui"; +// components +import { + AcceptIssueModal, + DeclineIssueModal, + DeleteInboxIssueModal, + InboxIssueSnoozeModal, + InboxIssueStatus, + SelectDuplicateInboxIssueModal, +} from "@/components/inbox"; +import { IssueUpdateStatus } from "@/components/issues"; +// constants +import { EUserProjectRoles } from "@/constants/project"; +// helpers +import { copyUrlToClipboard } from "@/helpers/string.helper"; +// hooks +import { useUser, useProjectInbox, useProject } from "@/hooks/store"; +// store types +import type { IInboxIssueStore } from "@/store/inbox/inbox-issue.store"; + +type TInboxIssueActionsHeader = { + workspaceSlug: string; + projectId: string; + inboxIssue: IInboxIssueStore | undefined; + isSubmitting: "submitting" | "submitted" | "saved"; +}; + +export const InboxIssueActionsHeader: FC = observer((props) => { + const { workspaceSlug, projectId, inboxIssue, isSubmitting } = props; + // states + const [isSnoozeDateModalOpen, setIsSnoozeDateModalOpen] = useState(false); + const [selectDuplicateIssue, setSelectDuplicateIssue] = useState(false); + const [acceptIssueModal, setAcceptIssueModal] = useState(false); + const [declineIssueModal, setDeclineIssueModal] = useState(false); + const [deleteIssueModal, setDeleteIssueModal] = useState(false); + // store + const { deleteInboxIssue, inboxIssuesArray } = useProjectInbox(); + const { + currentUser, + membership: { currentProjectRole }, + } = useUser(); + const router = useRouter(); + const { getProjectById } = useProject(); + + const issue = inboxIssue?.issue; + // derived values + const isAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; + const canMarkAsDuplicate = isAllowed && inboxIssue?.status === -2; + const canMarkAsAccepted = isAllowed && (inboxIssue?.status === 0 || inboxIssue?.status === -2); + const canMarkAsDeclined = isAllowed && inboxIssue?.status === -2; + const canDelete = isAllowed || inboxIssue?.created_by === currentUser?.id; + const isCompleted = inboxIssue?.status === 1; + + const currentInboxIssueId = inboxIssue?.issue?.id; + + const issueLink = `${workspaceSlug}/projects/${issue?.project_id}/issues/${currentInboxIssueId}`; + + const handleInboxIssueAccept = async () => { + inboxIssue?.updateInboxIssueStatus(1); + setAcceptIssueModal(false); + }; + + const handleInboxIssueDecline = async () => { + inboxIssue?.updateInboxIssueStatus(-1); + setDeclineIssueModal(false); + }; + + const handleInboxIssueDuplicate = (issueId: string) => { + inboxIssue?.updateInboxIssueDuplicateTo(issueId); + }; + + const handleInboxSIssueSnooze = async (date: Date) => { + inboxIssue?.updateInboxIssueSnoozeTill(date); + setIsSnoozeDateModalOpen(false); + }; + + const handleInboxIssueDelete = async () => { + if (!inboxIssue || !currentInboxIssueId) return; + deleteInboxIssue(workspaceSlug, projectId, currentInboxIssueId).finally(() => { + router.push(`/${workspaceSlug}/projects/${projectId}/inbox`); + }); + }; + + const handleCopyIssueLink = () => + copyUrlToClipboard(issueLink).then(() => + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Link copied", + message: "Issue link copied to clipboard", + }) + ); + + const currentIssueIndex = inboxIssuesArray.findIndex((issue) => issue.issue.id === currentInboxIssueId) ?? 0; + + const handleInboxIssueNavigation = useCallback( + (direction: "next" | "prev") => { + if (!inboxIssuesArray || !currentInboxIssueId) return; + const activeElement = document.activeElement as HTMLElement; + if (activeElement && (activeElement.classList.contains("tiptap") || activeElement.id === "title-input")) return; + const nextIssueIndex = + direction === "next" + ? (currentIssueIndex + 1) % inboxIssuesArray.length + : (currentIssueIndex - 1 + inboxIssuesArray.length) % inboxIssuesArray.length; + const nextIssueId = inboxIssuesArray[nextIssueIndex].issue.id; + if (!nextIssueId) return; + router.push(`/${workspaceSlug}/projects/${projectId}/inbox?inboxIssueId=${nextIssueId}`); + }, + [currentInboxIssueId, currentIssueIndex, inboxIssuesArray, projectId, router, workspaceSlug] + ); + + const onKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === "ArrowUp") { + handleInboxIssueNavigation("prev"); + } else if (e.key === "ArrowDown") { + handleInboxIssueNavigation("next"); + } + }, + [handleInboxIssueNavigation] + ); + + useEffect(() => { + document.addEventListener("keydown", onKeyDown); + return () => { + document.removeEventListener("keydown", onKeyDown); + }; + }, [onKeyDown]); + + if (!inboxIssue) return null; + + return ( + <> + <> + setSelectDuplicateIssue(false)} + value={inboxIssue?.duplicate_to} + onSubmit={handleInboxIssueDuplicate} + /> + + setAcceptIssueModal(false)} + onSubmit={handleInboxIssueAccept} + /> + + setDeclineIssueModal(false)} + onSubmit={handleInboxIssueDecline} + /> + + setDeleteIssueModal(false)} + onSubmit={handleInboxIssueDelete} + /> + + setIsSnoozeDateModalOpen(false)} + value={inboxIssue?.snoozed_till} + onConfirm={handleInboxSIssueSnooze} + /> + + +
+
+ {issue?.project_id && issue.sequence_id && ( +

+ {getProjectById(issue.project_id)?.identifier}-{issue.sequence_id} +

+ )} + +
+ +
+
+ +
+
+ + +
+ +
+ {canMarkAsAccepted && ( +
+ +
+ )} + + {canMarkAsDeclined && ( +
+ +
+ )} + + {isCompleted ? ( +
+ + + router.push(`/${workspaceSlug}/projects/${issue?.project_id}/issues/${currentInboxIssueId}`) + } + > + + +
+ ) : ( + + {canMarkAsAccepted && ( + setIsSnoozeDateModalOpen(true)}> +
+ + Snooze +
+
+ )} + {canMarkAsDuplicate && ( + setSelectDuplicateIssue(true)}> +
+ + Mark as duplicate +
+
+ )} + {canDelete && ( + setDeleteIssueModal(true)}> +
+ + Delete +
+
+ )} +
+ )} +
+
+
+ + ); +}); diff --git a/web/components/inbox/content/index.ts b/web/components/inbox/content/index.ts new file mode 100644 index 000000000..029365f7a --- /dev/null +++ b/web/components/inbox/content/index.ts @@ -0,0 +1,4 @@ +export * from "./root"; +export * from "./inbox-issue-header"; +export * from "./issue-properties"; +export * from "./issue-root"; diff --git a/web/components/issues/issue-detail/inbox/sidebar.tsx b/web/components/inbox/content/issue-properties.tsx similarity index 60% rename from web/components/issues/issue-detail/inbox/sidebar.tsx rename to web/components/inbox/content/issue-properties.tsx index 6472eea27..038c73476 100644 --- a/web/components/issues/issue-detail/inbox/sidebar.tsx +++ b/web/components/inbox/content/issue-properties.tsx @@ -1,60 +1,32 @@ import React from "react"; - -import { observer } from "mobx-react-lite"; - +import { observer } from "mobx-react"; import { CalendarCheck2, Signal, Tag } from "lucide-react"; - -// hooks +import { TIssue } from "@plane/types"; +import { DoubleCircleIcon, UserGroupIcon } from "@plane/ui"; // components -import { DoubleCircleIcon, StateGroupIcon, UserGroupIcon } from "@plane/ui"; import { DateDropdown, PriorityDropdown, MemberDropdown, StateDropdown } from "@/components/dropdowns"; import { IssueLabel, TIssueOperations } from "@/components/issues"; -// icons // helper import { getDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper"; -import { useIssueDetail, useProject, useProjectState } from "@/hooks/store"; type Props = { workspaceSlug: string; projectId: string; - issueId: string; + issue: Partial; issueOperations: TIssueOperations; is_editable: boolean; }; -export const InboxIssueDetailsSidebar: React.FC = observer((props) => { - const { workspaceSlug, projectId, issueId, issueOperations, is_editable } = props; - // store hooks - const { getProjectById } = useProject(); - const { projectStates } = useProjectState(); - const { - issue: { getIssueById }, - } = useIssueDetail(); - - const issue = getIssueById(issueId); - if (!issue) return <>; - - const projectDetails = issue ? getProjectById(issue.project_id) : null; +export const InboxIssueProperties: React.FC = observer((props) => { + const { workspaceSlug, projectId, issue, issueOperations, is_editable } = props; const minDate = issue.start_date ? getDate(issue.start_date) : null; minDate?.setDate(minDate.getDate()); - const currentIssueState = projectStates?.find((s) => s.id === issue.state_id); - + if (!issue || !issue?.id) return <>; return ( -
-
-
- {currentIssueState && ( - - )} -

- {projectDetails?.identifier}-{issue?.sequence_id} -

-
-
- -
+
+
Properties
@@ -64,18 +36,22 @@ export const InboxIssueDetailsSidebar: React.FC = observer((props) => { State
- issueOperations.update(workspaceSlug, projectId, issueId, { state_id: val })} - projectId={projectId?.toString() ?? ""} - disabled={!is_editable} - buttonVariant="transparent-with-text" - className="w-3/5 flex-grow group" - buttonContainerClassName="w-full text-left" - buttonClassName="text-sm" - dropdownArrow - dropdownArrowClassName="h-3.5 w-3.5 hidden group-hover:inline" - /> + {issue?.state_id && ( + + issue?.id && issueOperations.update(workspaceSlug, projectId, issue?.id, { state_id: val }) + } + projectId={projectId?.toString() ?? ""} + disabled={!is_editable} + buttonVariant="transparent-with-text" + className="w-3/5 flex-grow group" + buttonContainerClassName="w-full text-left" + buttonClassName="text-sm" + dropdownArrow + dropdownArrowClassName="h-3.5 w-3.5 hidden group-hover:inline" + /> + )}
{/* Assignee */}
@@ -84,17 +60,21 @@ export const InboxIssueDetailsSidebar: React.FC = observer((props) => { Assignees
issueOperations.update(workspaceSlug, projectId, issueId, { assignee_ids: val })} + value={issue?.assignee_ids ?? []} + onChange={(val) => + issue?.id && issueOperations.update(workspaceSlug, projectId, issue?.id, { assignee_ids: val }) + } disabled={!is_editable} projectId={projectId?.toString() ?? ""} placeholder="Add assignees" multiple - buttonVariant={issue?.assignee_ids?.length > 0 ? "transparent-without-text" : "transparent-with-text"} + buttonVariant={ + (issue?.assignee_ids || [])?.length > 0 ? "transparent-without-text" : "transparent-with-text" + } className="w-3/5 flex-grow group" buttonContainerClassName="w-full text-left" buttonClassName={`text-sm justify-between ${ - issue?.assignee_ids.length > 0 ? "" : "text-custom-text-400" + (issue?.assignee_ids || [])?.length > 0 ? "" : "text-custom-text-400" }`} hideIcon={issue.assignee_ids?.length === 0} dropdownArrow @@ -108,8 +88,10 @@ export const InboxIssueDetailsSidebar: React.FC = observer((props) => { Priority
issueOperations.update(workspaceSlug, projectId, issueId, { priority: val })} + value={issue?.priority || "none"} + onChange={(val) => + issue?.id && issueOperations.update(workspaceSlug, projectId, issue?.id, { priority: val }) + } disabled={!is_editable} buttonVariant="border-with-text" className="w-3/5 flex-grow rounded px-2 hover:bg-custom-background-80" @@ -129,9 +111,10 @@ export const InboxIssueDetailsSidebar: React.FC = observer((props) => {
- issueOperations.update(workspaceSlug, projectId, issueId, { + issue?.id && + issueOperations.update(workspaceSlug, projectId, issue?.id, { target_date: val ? renderFormattedPayloadDate(val) : null, }) } @@ -152,16 +135,18 @@ export const InboxIssueDetailsSidebar: React.FC = observer((props) => { Labels
- - issueOperations.update(workspaceSlug, projectId, issueId, { label_ids: val }) - } - /> + {issue?.id && ( + + issue?.id && issueOperations.update(workspaceSlug, projectId, issue?.id, { label_ids: val }) + } + /> + )}
diff --git a/web/components/inbox/content/issue-root.tsx b/web/components/inbox/content/issue-root.tsx new file mode 100644 index 000000000..b493b573b --- /dev/null +++ b/web/components/inbox/content/issue-root.tsx @@ -0,0 +1,174 @@ +import { Dispatch, SetStateAction, useEffect, useMemo } from "react"; +import { observer } from "mobx-react"; +import { useRouter } from "next/router"; +import { TIssue } from "@plane/types"; +import { Loader, TOAST_TYPE, setToast } from "@plane/ui"; +// components +import { InboxIssueProperties } from "@/components/inbox/content"; +import { + IssueDescriptionInput, + IssueTitleInput, + IssueActivity, + IssueReaction, + TIssueOperations, +} from "@/components/issues"; +// hooks +import { useEventTracker, useProjectInbox, useUser } from "@/hooks/store"; +import useReloadConfirmations from "@/hooks/use-reload-confirmation"; +// store types +import { IInboxIssueStore } from "@/store/inbox/inbox-issue.store"; + +type Props = { + workspaceSlug: string; + projectId: string; + inboxIssue: IInboxIssueStore; + is_editable: boolean; + isSubmitting: "submitting" | "submitted" | "saved"; + setIsSubmitting: Dispatch>; +}; + +export const InboxIssueMainContent: React.FC = observer((props) => { + const router = useRouter(); + const { workspaceSlug, projectId, inboxIssue, is_editable, isSubmitting, setIsSubmitting } = props; + // hooks + const { currentUser } = useUser(); + const { isLoading } = useProjectInbox(); + const { setShowAlert } = useReloadConfirmations(isSubmitting === "submitting"); + const { captureIssueEvent } = useEventTracker(); + + useEffect(() => { + if (isSubmitting === "submitted") { + setShowAlert(false); + setTimeout(async () => { + setIsSubmitting("saved"); + }, 3000); + } else if (isSubmitting === "submitting") { + setShowAlert(true); + } + }, [isSubmitting, setShowAlert, setIsSubmitting]); + + const issue = inboxIssue.issue; + if (!issue) return <>; + + const issueDescription = + issue.description_html !== undefined || issue.description_html !== null + ? issue.description_html != "" + ? issue.description_html + : "

" + : undefined; + + const issueOperations: TIssueOperations = useMemo( + () => ({ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + fetch: async (workspaceSlug: string, projectId: string, issueId: string) => { + try { + return; + } catch (error) { + setToast({ + title: "Issue fetch failed", + type: TOAST_TYPE.ERROR, + message: "Issue fetch failed", + }); + } + }, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + remove: async (workspaceSlug: string, projectId: string, issueId: string) => { + try { + return; + } catch (error) { + setToast({ + title: "Issue remove failed", + type: TOAST_TYPE.ERROR, + message: "Issue remove failed", + }); + } + }, + update: async (workspaceSlug: string, projectId: string, issueId: string, data: Partial) => { + try { + await inboxIssue.updateIssue(data); + captureIssueEvent({ + eventName: "Inbox issue updated", + payload: { ...data, state: "SUCCESS", element: "Inbox" }, + updates: { + changed_property: Object.keys(data).join(","), + change_details: Object.values(data).join(","), + }, + path: router.asPath, + }); + } catch (error) { + setToast({ + title: "Issue update failed", + type: TOAST_TYPE.ERROR, + message: "Issue update failed", + }); + captureIssueEvent({ + eventName: "Inbox issue updated", + payload: { state: "SUCCESS", element: "Inbox" }, + updates: { + changed_property: Object.keys(data).join(","), + change_details: Object.values(data).join(","), + }, + path: router.asPath, + }); + } + }, + }), + [inboxIssue] + ); + + if (!issue?.project_id || !issue?.id) return <>; + return ( + <> +
+ setIsSubmitting(value)} + issueOperations={issueOperations} + disabled={!is_editable} + value={issue.name} + /> + + {isLoading ? ( + + + + ) : ( + setIsSubmitting(value)} + /> + )} + + {currentUser && ( + + )} +
+ + + +
+ +
+ + ); +}); diff --git a/web/components/inbox/content/root.tsx b/web/components/inbox/content/root.tsx index 389876812..12cfb836d 100644 --- a/web/components/inbox/content/root.tsx +++ b/web/components/inbox/content/root.tsx @@ -1,86 +1,62 @@ -import { FC } from "react"; +import { FC, useState } from "react"; import { observer } from "mobx-react"; -import { Inbox } from "lucide-react"; -// hooks -import { Loader } from "@plane/ui"; -import { InboxIssueActionsHeader } from "@/components/inbox"; -import { InboxIssueDetailRoot } from "@/components/issues/issue-detail/inbox"; -import { useInboxIssues } from "@/hooks/store"; -// components -// ui +import useSWR from "swr"; +import { InboxIssueActionsHeader, InboxIssueMainContent } from "@/components/inbox"; +import { EUserProjectRoles } from "@/constants/project"; +import { useProjectInbox, useUser } from "@/hooks/store"; type TInboxContentRoot = { workspaceSlug: string; projectId: string; - inboxId: string; - inboxIssueId: string | undefined; + inboxIssueId: string; }; export const InboxContentRoot: FC = observer((props) => { - const { workspaceSlug, projectId, inboxId, inboxIssueId } = props; + const { workspaceSlug, projectId, inboxIssueId } = props; + // states + const [isSubmitting, setIsSubmitting] = useState<"submitting" | "submitted" | "saved">("saved"); // hooks + const { fetchInboxIssueById, getIssueInboxByIssueId } = useProjectInbox(); + const inboxIssue = getIssueInboxByIssueId(inboxIssueId); const { - issues: { loader, getInboxIssuesByInboxId }, - } = useInboxIssues(); + membership: { currentProjectRole }, + } = useUser(); - const inboxIssuesList = inboxId ? getInboxIssuesByInboxId(inboxId) : undefined; + useSWR( + workspaceSlug && projectId && inboxIssueId + ? `PROJECT_INBOX_ISSUE_DETAIL_${workspaceSlug}_${projectId}_${inboxIssueId}` + : null, + () => { + workspaceSlug && projectId && inboxIssueId && fetchInboxIssueById(workspaceSlug, projectId, inboxIssueId); + }, + { revalidateOnFocus: false } + ); + const is_editable = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; + + if (!inboxIssue) return <>; return ( <> - {loader === "init-loader" ? ( - -
- - - - -
-
- - - - -
-
- ) : ( - <> - {!inboxIssueId ? ( -
-
-
- - {inboxIssuesList && inboxIssuesList.length > 0 ? ( - - {inboxIssuesList?.length} issues found. Select an issue from the sidebar to view its details. - - ) : ( - No issues found - )} -
-
-
- ) : ( -
-
- -
-
- -
-
- )} - - )} +
+
+ +
+
+ +
+
); }); diff --git a/web/components/inbox/inbox-filter/applied-filters/date.tsx b/web/components/inbox/inbox-filter/applied-filters/date.tsx new file mode 100644 index 000000000..bce7a0487 --- /dev/null +++ b/web/components/inbox/inbox-filter/applied-filters/date.tsx @@ -0,0 +1,66 @@ +import { FC } from "react"; +import { observer } from "mobx-react"; +import { X } from "lucide-react"; +import { TInboxIssueFilterDateKeys } from "@plane/types"; +// constants +import { DATE_BEFORE_FILTER_OPTIONS } from "@/constants/filters"; +// helpers +import { renderFormattedDate } from "@/helpers/date-time.helper"; +// hooks +import { useProjectInbox } from "@/hooks/store"; + +type InboxIssueAppliedFiltersDate = { + filterKey: TInboxIssueFilterDateKeys; + label: string; +}; + +export const InboxIssueAppliedFiltersDate: FC = observer((props) => { + const { filterKey, label } = props; + // hooks + const { inboxFilters, handleInboxIssueFilters } = useProjectInbox(); + // derived values + const filteredValues = inboxFilters?.[filterKey] || []; + const currentOptionDetail = (date: string) => { + const currentDate = DATE_BEFORE_FILTER_OPTIONS.find((d) => d.value === date); + if (currentDate) return currentDate; + const dateSplit = date.split(";"); + return { + name: `${dateSplit[1].charAt(0).toUpperCase() + dateSplit[1].slice(1)} ${renderFormattedDate(dateSplit[0])}`, + value: date, + }; + }; + + const handleFilterValue = (value: string): string[] => + filteredValues?.includes(value) ? filteredValues.filter((v) => v !== value) : [...filteredValues, value]; + + const clearFilter = () => handleInboxIssueFilters(filterKey, undefined); + + if (filteredValues.length === 0) return <>; + return ( +
+
{label}
+ {filteredValues.map((value) => { + const optionDetail = currentOptionDetail(value); + if (!optionDetail) return <>; + return ( +
+
{optionDetail?.name}
+
handleInboxIssueFilters(filterKey, handleFilterValue(optionDetail?.value))} + > + +
+
+ ); + })} + +
+ +
+
+ ); +}); diff --git a/web/components/inbox/inbox-filter/applied-filters/index.ts b/web/components/inbox/inbox-filter/applied-filters/index.ts new file mode 100644 index 000000000..35f0bc261 --- /dev/null +++ b/web/components/inbox/inbox-filter/applied-filters/index.ts @@ -0,0 +1,6 @@ +export * from "./root"; +export * from "./status"; +export * from "./priority"; +export * from "./member"; +export * from "./label"; +export * from "./date"; diff --git a/web/components/inbox/inbox-filter/applied-filters/label.tsx b/web/components/inbox/inbox-filter/applied-filters/label.tsx new file mode 100644 index 000000000..b028a1773 --- /dev/null +++ b/web/components/inbox/inbox-filter/applied-filters/label.tsx @@ -0,0 +1,55 @@ +import { FC } from "react"; +import { observer } from "mobx-react"; +import { X } from "lucide-react"; +// hooks +import { useLabel, useProjectInbox } from "@/hooks/store"; + +const LabelIcons = ({ color }: { color: string }) => ( + +); + +export const InboxIssueAppliedFiltersLabel: FC = observer(() => { + // hooks + const { inboxFilters, handleInboxIssueFilters } = useProjectInbox(); + const { getLabelById } = useLabel(); + // derived values + const filteredValues = inboxFilters?.label || []; + const currentOptionDetail = (labelId: string) => getLabelById(labelId) || undefined; + + const handleFilterValue = (value: string): string[] => + filteredValues?.includes(value) ? filteredValues.filter((v) => v !== value) : [...filteredValues, value]; + + const clearFilter = () => handleInboxIssueFilters("label", undefined); + + if (filteredValues.length === 0) return <>; + return ( +
+
Label
+ {filteredValues.map((value) => { + const optionDetail = currentOptionDetail(value); + if (!optionDetail) return <>; + return ( +
+
+ +
+
{optionDetail?.name}
+
handleInboxIssueFilters("label", handleFilterValue(value))} + > + +
+
+ ); + })} + +
+ +
+
+ ); +}); diff --git a/web/components/inbox/inbox-filter/applied-filters/member.tsx b/web/components/inbox/inbox-filter/applied-filters/member.tsx new file mode 100644 index 000000000..5d488a23c --- /dev/null +++ b/web/components/inbox/inbox-filter/applied-filters/member.tsx @@ -0,0 +1,59 @@ +import { FC } from "react"; +import { observer } from "mobx-react"; +import { X } from "lucide-react"; +import { TInboxIssueFilterMemberKeys } from "@plane/types"; +import { Avatar } from "@plane/ui"; +// hooks +import { useMember, useProjectInbox } from "@/hooks/store"; + +type InboxIssueAppliedFiltersMember = { + filterKey: TInboxIssueFilterMemberKeys; + label: string; +}; + +export const InboxIssueAppliedFiltersMember: FC = observer((props) => { + const { filterKey, label } = props; + // hooks + const { inboxFilters, handleInboxIssueFilters } = useProjectInbox(); + const { getUserDetails } = useMember(); + // derived values + const filteredValues = inboxFilters?.[filterKey] || []; + const currentOptionDetail = (memberId: string) => getUserDetails(memberId) || undefined; + + const handleFilterValue = (value: string): string[] => + filteredValues?.includes(value) ? filteredValues.filter((v) => v !== value) : [...filteredValues, value]; + + const clearFilter = () => handleInboxIssueFilters(filterKey, undefined); + + if (filteredValues.length === 0) return <>; + return ( +
+
{label}
+ {filteredValues.map((value) => { + const optionDetail = currentOptionDetail(value); + if (!optionDetail) return <>; + return ( +
+
+ +
+
{optionDetail?.display_name}
+
handleInboxIssueFilters(filterKey, handleFilterValue(value))} + > + +
+
+ ); + })} + +
+ +
+
+ ); +}); diff --git a/web/components/inbox/inbox-filter/applied-filters/priority.tsx b/web/components/inbox/inbox-filter/applied-filters/priority.tsx new file mode 100644 index 000000000..e5a51d188 --- /dev/null +++ b/web/components/inbox/inbox-filter/applied-filters/priority.tsx @@ -0,0 +1,55 @@ +import { FC } from "react"; +import { observer } from "mobx-react"; +import { X } from "lucide-react"; +import { TIssuePriorities } from "@plane/types"; +import { PriorityIcon } from "@plane/ui"; +// constants +import { ISSUE_PRIORITIES } from "@/constants/issue"; +// hooks +import { useProjectInbox } from "@/hooks/store"; + +export const InboxIssueAppliedFiltersPriority: FC = observer(() => { + // hooks + const { inboxFilters, handleInboxIssueFilters } = useProjectInbox(); + // derived values + const filteredValues = inboxFilters?.priority || []; + const currentOptionDetail = (priority: TIssuePriorities) => + ISSUE_PRIORITIES.find((p) => p.key === priority) || undefined; + + const handleFilterValue = (value: TIssuePriorities): TIssuePriorities[] => + filteredValues?.includes(value) ? filteredValues.filter((v) => v !== value) : [...filteredValues, value]; + + const clearFilter = () => handleInboxIssueFilters("priority", undefined); + + if (filteredValues.length === 0) return <>; + return ( +
+
Priority
+ {filteredValues.map((value) => { + const optionDetail = currentOptionDetail(value); + if (!optionDetail) return <>; + return ( +
+
+ +
+
{optionDetail?.title}
+
handleInboxIssueFilters("priority", handleFilterValue(optionDetail?.key))} + > + +
+
+ ); + })} + +
+ +
+
+ ); +}); diff --git a/web/components/inbox/inbox-filter/applied-filters/root.tsx b/web/components/inbox/inbox-filter/applied-filters/root.tsx new file mode 100644 index 000000000..8baf86df2 --- /dev/null +++ b/web/components/inbox/inbox-filter/applied-filters/root.tsx @@ -0,0 +1,36 @@ +import { FC } from "react"; +import { observer } from "mobx-react"; +// components +import { + InboxIssueAppliedFiltersStatus, + InboxIssueAppliedFiltersPriority, + InboxIssueAppliedFiltersMember, + InboxIssueAppliedFiltersLabel, + InboxIssueAppliedFiltersDate, +} from "@/components/inbox"; +// hooks +import { useProjectInbox } from "@/hooks/store"; + +export const InboxIssueAppliedFilters: FC = observer(() => { + const { getAppliedFiltersCount } = useProjectInbox(); + + if (getAppliedFiltersCount === 0) return <>; + return ( +
+ {/* status */} + + {/* priority */} + + {/* assignees */} + + {/* created_by */} + + {/* label */} + + {/* created_at */} + + {/* updated_at */} + +
+ ); +}); diff --git a/web/components/inbox/inbox-filter/applied-filters/status.tsx b/web/components/inbox/inbox-filter/applied-filters/status.tsx new file mode 100644 index 000000000..a4ec1a37e --- /dev/null +++ b/web/components/inbox/inbox-filter/applied-filters/status.tsx @@ -0,0 +1,57 @@ +import { FC } from "react"; +import { observer } from "mobx-react"; +import { X } from "lucide-react"; +import { TInboxIssueStatus } from "@plane/types"; +// constants +import { INBOX_STATUS } from "@/constants/inbox"; +// hooks +import { useProjectInbox } from "@/hooks/store"; + +export const InboxIssueAppliedFiltersStatus: FC = observer(() => { + // hooks + const { currentTab, inboxFilters, handleInboxIssueFilters } = useProjectInbox(); + // derived values + const filteredValues = inboxFilters?.status || []; + const currentOptionDetail = (status: TInboxIssueStatus) => INBOX_STATUS.find((s) => s.status === status) || undefined; + + const handleFilterValue = (value: TInboxIssueStatus): TInboxIssueStatus[] => + filteredValues?.includes(value) ? filteredValues.filter((v) => v !== value) : [...filteredValues, value]; + + const clearFilter = () => handleInboxIssueFilters("status", undefined); + + if (filteredValues.length === 0) return <>; + return ( +
+
Status
+ {filteredValues.map((value) => { + const optionDetail = currentOptionDetail(value); + if (!optionDetail) return <>; + return ( +
+
+ +
+
{optionDetail?.title}
+ {currentTab === "closed" && handleFilterValue(optionDetail?.status).length >= 1 && ( +
handleInboxIssueFilters("status", handleFilterValue(optionDetail?.status))} + > + +
+ )} +
+ ); + })} + + {currentTab === "closed" && filteredValues.length > 1 && ( +
+ +
+ )} +
+ ); +}); diff --git a/web/components/inbox/inbox-filter/filters/date.tsx b/web/components/inbox/inbox-filter/filters/date.tsx new file mode 100644 index 000000000..12aba01ef --- /dev/null +++ b/web/components/inbox/inbox-filter/filters/date.tsx @@ -0,0 +1,97 @@ +import { FC, useState } from "react"; +import concat from "lodash/concat"; +import pull from "lodash/pull"; +import uniq from "lodash/uniq"; +import { observer } from "mobx-react"; +import { TInboxIssueFilterDateKeys } from "@plane/types"; +// components +import { DateFilterModal } from "@/components/core"; +import { FilterHeader, FilterOption } from "@/components/issues"; +// constants +import { DATE_BEFORE_FILTER_OPTIONS } from "@/constants/filters"; +// hooks +import { useProjectInbox } from "@/hooks/store"; + +type Props = { + filterKey: TInboxIssueFilterDateKeys; + label?: string; + searchQuery: string; +}; + +const isDate = (date: string) => { + const datePattern = /^\d{4}-\d{2}-\d{2}$/; + return datePattern.test(date); +}; + +export const FilterDate: FC = observer((props) => { + const { filterKey, label, searchQuery } = props; + // hooks + const { inboxFilters, handleInboxIssueFilters } = useProjectInbox(); + // state + const [previewEnabled, setPreviewEnabled] = useState(true); + const [isDateFilterModalOpen, setIsDateFilterModalOpen] = useState(false); + // derived values + const filterValue: string[] = inboxFilters?.[filterKey] || []; + const appliedFiltersCount = filterValue?.length ?? 0; + const filteredOptions = DATE_BEFORE_FILTER_OPTIONS.filter((d) => + d.name.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + const handleFilterValue = (value: string): string[] => + filterValue?.includes(value) ? pull(filterValue, value) : uniq(concat(filterValue, value)); + + const handleCustomFilterValue = (value: string[]): string[] => { + const finalOptions: string[] = [...filterValue]; + value.forEach((v) => (finalOptions?.includes(v) ? pull(finalOptions, v) : finalOptions.push(v))); + return uniq(finalOptions); + }; + + const isCustomDateSelected = () => { + const isValidDateSelected = filterValue?.filter((f) => isDate(f.split(";")[0])) || []; + return isValidDateSelected.length > 0 ? true : false; + }; + + const handleCustomDate = () => { + if (isCustomDateSelected()) { + const updateAppliedFilters = filterValue?.filter((f) => isDate(f.split(";")[0])) || []; + handleInboxIssueFilters(filterKey, handleCustomFilterValue(updateAppliedFilters)); + } else setIsDateFilterModalOpen(true); + }; + return ( + <> + {isDateFilterModalOpen && ( + setIsDateFilterModalOpen(false)} + isOpen={isDateFilterModalOpen} + onSelect={(val) => handleInboxIssueFilters(filterKey, handleCustomFilterValue(val))} + title="Created date" + /> + )} + 0 ? ` (${appliedFiltersCount})` : ""}`} + isPreviewEnabled={previewEnabled} + handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)} + /> + {previewEnabled && ( +
+ {filteredOptions.length > 0 ? ( + <> + {filteredOptions.map((option) => ( + handleInboxIssueFilters(filterKey, handleFilterValue(option.value))} + title={option.name} + multiple + /> + ))} + + + ) : ( +

No matches found

+ )} +
+ )} + + ); +}); diff --git a/web/components/inbox/inbox-filter/filters/filter-selection.tsx b/web/components/inbox/inbox-filter/filters/filter-selection.tsx new file mode 100644 index 000000000..46f959a2d --- /dev/null +++ b/web/components/inbox/inbox-filter/filters/filter-selection.tsx @@ -0,0 +1,87 @@ +import { FC, useState } from "react"; +import { observer } from "mobx-react"; +import { Search, X } from "lucide-react"; +// components +import { + FilterStatus, + FilterPriority, + FilterMember, + FilterDate, + FilterLabels, +} from "@/components/inbox/inbox-filter/filters"; +// hooks +import { useMember, useLabel } from "@/hooks/store"; + +export const InboxIssueFilterSelection: FC = observer(() => { + // hooks + const { + project: { projectMemberIds }, + } = useMember(); + const { projectLabels } = useLabel(); + // states + const [filtersSearchQuery, setFiltersSearchQuery] = useState(""); + + return ( +
+
+
+ + setFiltersSearchQuery(e.target.value)} + autoFocus + /> + {filtersSearchQuery !== "" && ( + + )} +
+
+ +
+ {/* status */} +
+ +
+ {/* Priority */} +
+ +
+ {/* assignees */} +
+ +
+ {/* Created By */} +
+ +
+ {/* Labels */} +
+ +
+ {/* Created at */} +
+ +
+ {/* Updated at */} +
+ +
+
+
+ ); +}); diff --git a/web/components/inbox/inbox-filter/filters/index.ts b/web/components/inbox/inbox-filter/filters/index.ts new file mode 100644 index 000000000..a389dda9d --- /dev/null +++ b/web/components/inbox/inbox-filter/filters/index.ts @@ -0,0 +1,6 @@ +export * from "./filter-selection"; +export * from "./status"; +export * from "./priority"; +export * from "./labels"; +export * from "./members"; +export * from "./date"; diff --git a/web/components/inbox/inbox-filter/filters/labels.tsx b/web/components/inbox/inbox-filter/filters/labels.tsx new file mode 100644 index 000000000..cf55623c8 --- /dev/null +++ b/web/components/inbox/inbox-filter/filters/labels.tsx @@ -0,0 +1,88 @@ +import { FC, useState } from "react"; +import { observer } from "mobx-react"; +import { IIssueLabel } from "@plane/types"; +import { Loader } from "@plane/ui"; +// components +import { FilterHeader, FilterOption } from "@/components/issues"; +// hooks +import { useProjectInbox } from "@/hooks/store"; + +const LabelIcons = ({ color }: { color: string }) => ( + +); + +type Props = { + labels: IIssueLabel[] | undefined; + searchQuery: string; +}; + +export const FilterLabels: FC = observer((props) => { + const { labels, searchQuery } = props; + + const [itemsToRender, setItemsToRender] = useState(5); + const [previewEnabled, setPreviewEnabled] = useState(true); + + const { inboxFilters, handleInboxIssueFilters } = useProjectInbox(); + + const filterValue = inboxFilters?.label || []; + + const appliedFiltersCount = filterValue?.length ?? 0; + + const filteredOptions = labels?.filter((label) => label.name.toLowerCase().includes(searchQuery.toLowerCase())); + + const handleViewToggle = () => { + if (!filteredOptions) return; + + if (itemsToRender === filteredOptions.length) setItemsToRender(5); + else setItemsToRender(filteredOptions.length); + }; + + const handleFilterValue = (value: string): string[] => + filterValue?.includes(value) ? filterValue.filter((v) => v !== value) : [...filterValue, value]; + + return ( + <> + 0 ? ` (${appliedFiltersCount})` : ""}`} + isPreviewEnabled={previewEnabled} + handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)} + /> + {previewEnabled && ( +
+ {filteredOptions ? ( + filteredOptions.length > 0 ? ( + <> + {filteredOptions.slice(0, itemsToRender).map((label) => ( + handleInboxIssueFilters("label", handleFilterValue(label.id))} + icon={} + title={label.name} + /> + ))} + {filteredOptions.length > 5 && ( + + )} + + ) : ( +

No matches found

+ ) + ) : ( + + + + + + )} +
+ )} + + ); +}); diff --git a/web/components/inbox/inbox-filter/filters/members.tsx b/web/components/inbox/inbox-filter/filters/members.tsx new file mode 100644 index 000000000..f2776104b --- /dev/null +++ b/web/components/inbox/inbox-filter/filters/members.tsx @@ -0,0 +1,102 @@ +import { FC, useMemo, useState } from "react"; +import sortBy from "lodash/sortBy"; +import { observer } from "mobx-react"; +import { TInboxIssueFilterMemberKeys } from "@plane/types"; +import { Avatar, Loader } from "@plane/ui"; +// components +import { FilterHeader, FilterOption } from "@/components/issues"; +// hooks +import { useMember, useProjectInbox } from "@/hooks/store"; + +type Props = { + filterKey: TInboxIssueFilterMemberKeys; + label?: string; + memberIds: string[] | undefined; + searchQuery: string; +}; + +export const FilterMember: FC = observer((props: Props) => { + const { filterKey, label = "Members", memberIds, searchQuery } = props; + // hooks + const { inboxFilters, handleInboxIssueFilters } = useProjectInbox(); + const { getUserDetails } = useMember(); + // states + const [itemsToRender, setItemsToRender] = useState(5); + const [previewEnabled, setPreviewEnabled] = useState(true); + // derived values + const filterValue = inboxFilters?.[filterKey] || []; + const appliedFiltersCount = filterValue?.length ?? 0; + + const sortedOptions = useMemo(() => { + const filteredOptions = (memberIds || []).filter((memberId) => + getUserDetails(memberId)?.display_name.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + return sortBy(filteredOptions, [ + (memberId) => !filterValue.includes(memberId), + (memberId) => getUserDetails(memberId)?.display_name.toLowerCase(), + ]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchQuery]); + + const handleViewToggle = () => { + if (!sortedOptions) return; + + if (itemsToRender === sortedOptions.length) setItemsToRender(5); + else setItemsToRender(sortedOptions.length); + }; + + const handleFilterValue = (value: string): string[] => + filterValue?.includes(value) ? filterValue.filter((v) => v !== value) : [...filterValue, value]; + + return ( + <> + 0 ? ` (${appliedFiltersCount})` : ""}`} + isPreviewEnabled={previewEnabled} + handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)} + /> + {previewEnabled && ( +
+ {sortedOptions ? ( + sortedOptions.length > 0 ? ( + <> + {sortedOptions.slice(0, itemsToRender).map((memberId) => { + const member = getUserDetails(memberId); + + if (!member) return null; + return ( + handleInboxIssueFilters(filterKey, handleFilterValue(member.id))} + icon={} + title={member.display_name} + /> + ); + })} + {sortedOptions.length > 5 && ( + + )} + + ) : ( +

No matches found

+ ) + ) : ( + + + + + + )} +
+ )} + + ); +}); diff --git a/web/components/inbox/inbox-filter/filters/priority.tsx b/web/components/inbox/inbox-filter/filters/priority.tsx new file mode 100644 index 000000000..0639623e4 --- /dev/null +++ b/web/components/inbox/inbox-filter/filters/priority.tsx @@ -0,0 +1,56 @@ +import { FC, useState } from "react"; +import { observer } from "mobx-react"; +import { TIssuePriorities } from "@plane/types"; +import { PriorityIcon } from "@plane/ui"; +// components +import { FilterHeader, FilterOption } from "@/components/issues"; +// constants +import { ISSUE_PRIORITIES } from "@/constants/issue"; +// hooks +import { useProjectInbox } from "@/hooks/store/use-project-inbox"; + +type Props = { + searchQuery: string; +}; + +export const FilterPriority: FC = observer((props) => { + const { searchQuery } = props; + // hooks + const { inboxFilters, handleInboxIssueFilters } = useProjectInbox(); + // states + const [previewEnabled, setPreviewEnabled] = useState(true); + // derived values + const filterValue = inboxFilters?.priority || []; + const appliedFiltersCount = filterValue?.length ?? 0; + const filteredOptions = ISSUE_PRIORITIES.filter((p) => p.key.includes(searchQuery.toLowerCase())); + + const handleFilterValue = (value: TIssuePriorities): TIssuePriorities[] => + filterValue?.includes(value) ? filterValue.filter((v) => v !== value) : [...filterValue, value]; + + return ( + <> + 0 ? ` (${appliedFiltersCount})` : ""}`} + isPreviewEnabled={previewEnabled} + handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)} + /> + {previewEnabled && ( +
+ {filteredOptions.length > 0 ? ( + filteredOptions.map((priority) => ( + handleInboxIssueFilters("priority", handleFilterValue(priority.key))} + icon={} + title={priority.title} + /> + )) + ) : ( +

No matches found

+ )} +
+ )} + + ); +}); diff --git a/web/components/inbox/inbox-filter/filters/status.tsx b/web/components/inbox/inbox-filter/filters/status.tsx new file mode 100644 index 000000000..ca79d882f --- /dev/null +++ b/web/components/inbox/inbox-filter/filters/status.tsx @@ -0,0 +1,68 @@ +import { FC, useState } from "react"; +import { observer } from "mobx-react"; +// types +import { TInboxIssueStatus } from "@plane/types"; +// components +import { FilterHeader, FilterOption } from "@/components/issues"; +// constants +import { INBOX_STATUS } from "@/constants/inbox"; +// hooks +import { useProjectInbox } from "@/hooks/store/use-project-inbox"; + +type Props = { + searchQuery: string; +}; + +export const FilterStatus: FC = observer((props) => { + const { searchQuery } = props; + // hooks + const { currentTab, inboxFilters, handleInboxIssueFilters } = useProjectInbox(); + // states + const [previewEnabled, setPreviewEnabled] = useState(true); + // derived values + const filterValue = inboxFilters?.status || []; + const appliedFiltersCount = filterValue?.length ?? 0; + const filteredOptions = INBOX_STATUS.filter( + (s) => + ((currentTab === "open" && [-2].includes(s.status)) || + (currentTab === "closed" && [-1, 0, 1, 2].includes(s.status))) && + s.key.includes(searchQuery.toLowerCase()) + ); + + const handleFilterValue = (value: TInboxIssueStatus): TInboxIssueStatus[] => + filterValue?.includes(value) ? filterValue.filter((v) => v !== value) : [...filterValue, value]; + + const handleStatusFilterSelect = (status: TInboxIssueStatus) => { + if (currentTab === "closed") { + const selectedStatus = handleFilterValue(status); + if (selectedStatus.length >= 1) handleInboxIssueFilters("status", selectedStatus); + } + }; + + return ( + <> + 0 ? ` (${appliedFiltersCount})` : ""}`} + isPreviewEnabled={previewEnabled} + handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)} + /> + {previewEnabled && ( +
+ {filteredOptions.length > 0 ? ( + filteredOptions.map((status) => ( + handleStatusFilterSelect(status.status)} + icon={} + title={status.title} + /> + )) + ) : ( +

No matches found

+ )} +
+ )} + + ); +}); diff --git a/web/components/inbox/inbox-filter/index.ts b/web/components/inbox/inbox-filter/index.ts new file mode 100644 index 000000000..20ec09a35 --- /dev/null +++ b/web/components/inbox/inbox-filter/index.ts @@ -0,0 +1,4 @@ +export * from "./root"; +export * from "./filters"; +export * from "./sorting"; +export * from "./applied-filters"; diff --git a/web/components/inbox/inbox-filter/root.tsx b/web/components/inbox/inbox-filter/root.tsx new file mode 100644 index 000000000..b3a2dc2c4 --- /dev/null +++ b/web/components/inbox/inbox-filter/root.tsx @@ -0,0 +1,18 @@ +import { FC } from "react"; +import { ListFilter } from "lucide-react"; +// components +import { InboxIssueFilterSelection, InboxIssueOrderByDropdown } from "@/components/inbox/inbox-filter"; +import { FiltersDropdown } from "@/components/issues"; + +export const FiltersRoot: FC = () => ( +
+
+ } title="Filters" placement="bottom-end"> + + +
+
+ +
+
+); diff --git a/web/components/inbox/inbox-filter/sorting/index.ts b/web/components/inbox/inbox-filter/sorting/index.ts new file mode 100644 index 000000000..d6db844ce --- /dev/null +++ b/web/components/inbox/inbox-filter/sorting/index.ts @@ -0,0 +1 @@ +export * from "./order-by"; diff --git a/web/components/inbox/inbox-filter/sorting/order-by.tsx b/web/components/inbox/inbox-filter/sorting/order-by.tsx new file mode 100644 index 000000000..c01c9977e --- /dev/null +++ b/web/components/inbox/inbox-filter/sorting/order-by.tsx @@ -0,0 +1,58 @@ +import { FC } from "react"; +import { observer } from "mobx-react"; +import { ArrowDownWideNarrow, ArrowUpWideNarrow, Check, ChevronDown } from "lucide-react"; +import { CustomMenu, getButtonStyling } from "@plane/ui"; +// constants +import { INBOX_ISSUE_ORDER_BY_OPTIONS, INBOX_ISSUE_SORT_BY_OPTIONS } from "@/constants/inbox"; +// helpers +import { cn } from "@/helpers/common.helper"; +// hooks +import { useProjectInbox } from "@/hooks/store"; + +export const InboxIssueOrderByDropdown: FC = observer(() => { + // hooks + const { inboxSorting, handleInboxIssueSorting } = useProjectInbox(); + const orderByDetails = + INBOX_ISSUE_ORDER_BY_OPTIONS.find((option) => inboxSorting?.order_by?.includes(option.key)) || undefined; + + return ( + + {inboxSorting?.sort_by === "asc" ? ( + + ) : ( + + )} + {orderByDetails?.label || "Order By"} + +
+ } + placement="bottom-end" + maxHeight="lg" + closeOnSelect + > + {INBOX_ISSUE_ORDER_BY_OPTIONS.map((option) => ( + handleInboxIssueSorting("order_by", option.key)} + > + {option.label} + {inboxSorting?.order_by?.includes(option.key) && } + + ))} +
+ {INBOX_ISSUE_SORT_BY_OPTIONS.map((option) => ( + handleInboxIssueSorting("sort_by", option.key)} + > + {option.label} + {inboxSorting?.sort_by?.includes(option.key) && } + + ))} + + ); +}); diff --git a/web/components/inbox/inbox-issue-actions.tsx b/web/components/inbox/inbox-issue-actions.tsx deleted file mode 100644 index 4c8a21592..000000000 --- a/web/components/inbox/inbox-issue-actions.tsx +++ /dev/null @@ -1,364 +0,0 @@ -import { FC, useCallback, useEffect, useMemo, useState } from "react"; -import { observer } from "mobx-react-lite"; -import { useRouter } from "next/router"; -import { DayPicker } from "react-day-picker"; -import { CheckCircle2, ChevronDown, ChevronUp, Clock, FileStack, Trash2, XCircle } from "lucide-react"; -import { Popover } from "@headlessui/react"; -// icons -import type { TInboxDetailedStatus } from "@plane/types"; -// ui -import { Button, TOAST_TYPE, setToast } from "@plane/ui"; -// components -import { - AcceptIssueModal, - DeclineIssueModal, - DeleteInboxIssueModal, - SelectDuplicateInboxIssueModal, -} from "@/components/inbox"; -import { ISSUE_DELETED } from "@/constants/event-tracker"; -import { EUserProjectRoles } from "@/constants/project"; -// hooks -import { getDate } from "@/helpers/date-time.helper"; -import { useUser, useInboxIssues, useIssueDetail, useWorkspace, useEventTracker } from "@/hooks/store"; -// types -//helpers - -type TInboxIssueActionsHeader = { - workspaceSlug: string; - projectId: string; - inboxId: string; - inboxIssueId: string | undefined; -}; - -type TInboxIssueOperations = { - updateInboxIssueStatus: (data: TInboxDetailedStatus) => Promise; - removeInboxIssue: () => Promise; -}; - -export const InboxIssueActionsHeader: FC = observer((props) => { - const { workspaceSlug, projectId, inboxId, inboxIssueId } = props; - // router - const router = useRouter(); - // hooks - const { captureIssueEvent } = useEventTracker(); - const { currentWorkspace } = useWorkspace(); - const { - issues: { getInboxIssuesByInboxId, getInboxIssueByIssueId, updateInboxIssueStatus, removeInboxIssue }, - } = useInboxIssues(); - const { - issue: { getIssueById }, - } = useIssueDetail(); - const { - currentUser, - membership: { currentProjectRole }, - } = useUser(); - - // states - const [date, setDate] = useState(new Date()); - const [selectDuplicateIssue, setSelectDuplicateIssue] = useState(false); - const [acceptIssueModal, setAcceptIssueModal] = useState(false); - const [declineIssueModal, setDeclineIssueModal] = useState(false); - const [deleteIssueModal, setDeleteIssueModal] = useState(false); - - // derived values - const inboxIssues = getInboxIssuesByInboxId(inboxId); - const issueStatus = (inboxIssueId && inboxId && getInboxIssueByIssueId(inboxId, inboxIssueId)) || undefined; - const issue = (inboxIssueId && getIssueById(inboxIssueId)) || undefined; - - const currentIssueIndex = inboxIssues?.findIndex((issue) => issue === inboxIssueId) ?? 0; - - const inboxIssueOperations: TInboxIssueOperations = useMemo( - () => ({ - updateInboxIssueStatus: async (data: TInboxDetailedStatus) => { - try { - if (!workspaceSlug || !projectId || !inboxId || !inboxIssueId) throw new Error("Missing required parameters"); - await updateInboxIssueStatus(workspaceSlug, projectId, inboxId, inboxIssueId, data); - } catch (error) { - setToast({ - type: TOAST_TYPE.ERROR, - title: "Error!", - message: "Something went wrong while updating inbox status. Please try again.", - }); - } - }, - removeInboxIssue: async () => { - try { - if (!workspaceSlug || !projectId || !inboxId || !inboxIssueId || !currentWorkspace) - throw new Error("Missing required parameters"); - await removeInboxIssue(workspaceSlug, projectId, inboxId, inboxIssueId); - captureIssueEvent({ - eventName: ISSUE_DELETED, - payload: { - id: inboxIssueId, - state: "SUCCESS", - element: "Inbox page", - }, - }); - router.push({ - pathname: `/${workspaceSlug}/projects/${projectId}/inbox/${inboxId}`, - }); - } catch (error) { - setToast({ - type: TOAST_TYPE.ERROR, - title: "Error!", - message: "Something went wrong while deleting inbox issue. Please try again.", - }); - captureIssueEvent({ - eventName: ISSUE_DELETED, - payload: { - id: inboxIssueId, - state: "FAILED", - element: "Inbox page", - }, - }); - } - }, - }), - [ - currentWorkspace, - workspaceSlug, - projectId, - inboxId, - inboxIssueId, - updateInboxIssueStatus, - removeInboxIssue, - captureIssueEvent, - router, - ] - ); - - const handleInboxIssueNavigation = useCallback( - (direction: "next" | "prev") => { - if (!inboxIssues || !inboxIssueId) return; - const activeElement = document.activeElement as HTMLElement; - if (activeElement && (activeElement.classList.contains("tiptap") || activeElement.id === "title-input")) return; - const nextIssueIndex = - direction === "next" - ? (currentIssueIndex + 1) % inboxIssues.length - : (currentIssueIndex - 1 + inboxIssues.length) % inboxIssues.length; - const nextIssueId = inboxIssues[nextIssueIndex]; - if (!nextIssueId) return; - router.push({ - pathname: `/${workspaceSlug}/projects/${projectId}/inbox/${inboxId}`, - query: { - inboxIssueId: nextIssueId, - }, - }); - }, - [workspaceSlug, projectId, inboxId, inboxIssues, inboxIssueId, currentIssueIndex, router] - ); - - const onKeyDown = useCallback( - (e: KeyboardEvent) => { - if (e.key === "ArrowUp") { - handleInboxIssueNavigation("prev"); - } else if (e.key === "ArrowDown") { - handleInboxIssueNavigation("next"); - } - }, - [handleInboxIssueNavigation] - ); - - useEffect(() => { - document.addEventListener("keydown", onKeyDown); - - return () => { - document.removeEventListener("keydown", onKeyDown); - }; - }, [onKeyDown]); - - const isAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; - - const today = new Date(); - const tomorrow = getDate(today); - tomorrow?.setDate(today.getDate() + 1); - useEffect(() => { - if (!issueStatus || !issueStatus.snoozed_till) return; - setDate(issueStatus.snoozed_till); - }, [issueStatus]); - - if (!issueStatus || !issue || !inboxIssues) return <>; - return ( - <> - {issue && ( - <> - setSelectDuplicateIssue(false)} - value={issueStatus.duplicate_to} - onSubmit={(dupIssueId) => { - inboxIssueOperations - .updateInboxIssueStatus({ - status: 2, - duplicate_to: dupIssueId, - }) - .finally(() => setSelectDuplicateIssue(false)); - }} - /> - - setAcceptIssueModal(false)} - onSubmit={async () => { - await inboxIssueOperations - .updateInboxIssueStatus({ - status: 1, - }) - .finally(() => setAcceptIssueModal(false)); - }} - /> - - setDeclineIssueModal(false)} - onSubmit={async () => { - await inboxIssueOperations - .updateInboxIssueStatus({ - status: -1, - }) - .finally(() => setDeclineIssueModal(false)); - }} - /> - - setDeleteIssueModal(false)} - onSubmit={async () => { - await inboxIssueOperations.removeInboxIssue().finally(() => setDeclineIssueModal(false)); - }} - /> - - )} - - {inboxIssueId && ( -
-
- - -
- {currentIssueIndex + 1}/{inboxIssues?.length ?? 0} -
-
- -
- {isAllowed && (issueStatus.status === 0 || issueStatus.status === -2) && ( -
- - - - - - {({ close }) => ( -
- { - if (!date) return; - setDate(date); - }} - mode="single" - className="border border-custom-border-200 rounded-md p-3" - disabled={ - tomorrow - ? [ - { - before: tomorrow, - }, - ] - : undefined - } - /> - -
- )} -
-
-
- )} - - {isAllowed && issueStatus.status === -2 && ( -
- -
- )} - - {isAllowed && (issueStatus.status === 0 || issueStatus.status === -2) && ( -
- -
- )} - - {isAllowed && issueStatus.status === -2 && ( -
- -
- )} - - {(isAllowed || currentUser?.id === issue?.created_by) && ( -
- -
- )} -
-
- )} - - ); -}); diff --git a/web/components/inbox/inbox-issue-status.tsx b/web/components/inbox/inbox-issue-status.tsx index 701feff6b..e230b02d5 100644 --- a/web/components/inbox/inbox-issue-status.tsx +++ b/web/components/inbox/inbox-issue-status.tsx @@ -1,56 +1,45 @@ import React from "react"; import { observer } from "mobx-react"; -// hooks -import { INBOX_STATUS } from "@/constants/inbox"; -import { useInboxIssues } from "@/hooks/store"; // constants +import { INBOX_STATUS } from "@/constants/inbox"; +// helpers +import { cn } from "@/helpers/common.helper"; +// store +import { IInboxIssueStore } from "@/store/inbox/inbox-issue.store"; type Props = { - workspaceSlug: string; - projectId: string; - inboxId: string; - issueId: string; + inboxIssue: IInboxIssueStore; iconSize?: number; showDescription?: boolean; }; export const InboxIssueStatus: React.FC = observer((props) => { - const { workspaceSlug, projectId, inboxId, issueId, iconSize = 18, showDescription = false } = props; - // hooks - const { - issues: { getInboxIssueByIssueId }, - } = useInboxIssues(); - - const inboxIssueDetail = getInboxIssueByIssueId(inboxId, issueId); - if (!inboxIssueDetail) return <>; - - const inboxIssueStatusDetail = INBOX_STATUS.find((s) => s.status === inboxIssueDetail.status); + const { inboxIssue, iconSize = 16, showDescription = false } = props; + // derived values + const inboxIssueStatusDetail = INBOX_STATUS.find((s) => s.status === inboxIssue.status); if (!inboxIssueStatusDetail) return <>; - const isSnoozedDatePassed = - inboxIssueDetail.status === 0 && !!inboxIssueDetail.snoozed_till && inboxIssueDetail.snoozed_till < new Date(); + const isSnoozedDatePassed = inboxIssue.status === 0 && new Date(inboxIssue.snoozed_till ?? "") < new Date(); + + const description = inboxIssueStatusDetail.description(new Date(inboxIssue.snoozed_till ?? "")); return (
- - {showDescription ? ( - inboxIssueStatusDetail.description( - workspaceSlug, - projectId, - inboxIssueDetail.duplicate_to ?? "", - inboxIssueDetail.snoozed_till - ) - ) : ( - {inboxIssueStatusDetail.title} + className={cn( + `relative flex flex-col gap-1 p-1.5 py-0.5 rounded ${inboxIssueStatusDetail.textColor( + isSnoozedDatePassed + )} ${inboxIssueStatusDetail.bgColor(isSnoozedDatePassed)}` )} + > +
+ +
+ {inboxIssue?.status === 0 && inboxIssue?.snoozed_till + ? inboxIssueStatusDetail.description(inboxIssue?.snoozed_till) + : inboxIssueStatusDetail.title} +
+
+ {showDescription &&
{description}
}
); }); diff --git a/web/components/inbox/index.ts b/web/components/inbox/index.ts index bc8be5506..8b05b565f 100644 --- a/web/components/inbox/index.ts +++ b/web/components/inbox/index.ts @@ -1,14 +1,6 @@ +export * from "./root"; export * from "./modals"; - -export * from "./inbox-issue-actions"; +export * from "./sidebar"; +export * from "./inbox-filter"; +export * from "./content"; export * from "./inbox-issue-status"; - -export * from "./content/root"; - -export * from "./sidebar/root"; - -export * from "./sidebar/filter/filter-selection"; -export * from "./sidebar/filter/applied-filters"; - -export * from "./sidebar/inbox-list"; -export * from "./sidebar/inbox-list-item"; diff --git a/web/components/inbox/modals/accept-issue-modal.tsx b/web/components/inbox/modals/accept-issue-modal.tsx index d8ccb68b6..080563505 100644 --- a/web/components/inbox/modals/accept-issue-modal.tsx +++ b/web/components/inbox/modals/accept-issue-modal.tsx @@ -5,11 +5,11 @@ import type { TIssue } from "@plane/types"; // icons // ui import { Button } from "@plane/ui"; -// types +// hooks import { useProject } from "@/hooks/store"; type Props = { - data: TIssue; + data: Partial; isOpen: boolean; onClose: () => void; onSubmit: () => Promise; @@ -70,7 +70,8 @@ export const AcceptIssueModal: React.FC = ({ isOpen, onClose, data, onSub

Are you sure you want to accept issue{" "} - {getProjectById(data?.project_id)?.identifier}-{data?.sequence_id} + {(data && data?.project_id && getProjectById(data?.project_id)?.identifier) || ""}- + {data?.sequence_id} {""}? Once accepted, this issue will be added to the project issues list.

diff --git a/web/components/inbox/modals/create-issue-modal.tsx b/web/components/inbox/modals/create-issue-modal.tsx index c32bcf8f4..4c22f8348 100644 --- a/web/components/inbox/modals/create-issue-modal.tsx +++ b/web/components/inbox/modals/create-issue-modal.tsx @@ -1,24 +1,24 @@ import { Fragment, useRef, useState } from "react"; -import { observer } from "mobx-react-lite"; +import { observer } from "mobx-react"; import { useRouter } from "next/router"; import { Controller, useForm } from "react-hook-form"; import { Sparkle } from "lucide-react"; -import { Dialog, Transition } from "@headlessui/react"; +import { Transition, Dialog } from "@headlessui/react"; import { RichTextEditorWithRef } from "@plane/rich-text-editor"; +// types import { TIssue } from "@plane/types"; -// hooks +// ui import { Button, Input, ToggleSwitch, TOAST_TYPE, setToast } from "@plane/ui"; +// components import { GptAssistantPopover } from "@/components/core"; import { PriorityDropdown } from "@/components/dropdowns"; +// constants import { ISSUE_CREATED } from "@/constants/event-tracker"; -import { useApplication, useEventTracker, useWorkspace, useInboxIssues, useMention } from "@/hooks/store"; +// hooks +import { useApplication, useEventTracker, useWorkspace, useMention, useProjectInbox } from "@/hooks/store"; // services import { AIService } from "@/services/ai.service"; import { FileService } from "@/services/file.service"; -// components -// ui -// types -// constants type Props = { isOpen: boolean; @@ -26,10 +26,8 @@ type Props = { }; const defaultValues: Partial = { - project_id: "", name: "", description_html: "

", - parent_id: null, priority: "none", }; @@ -39,33 +37,27 @@ const fileService = new FileService(); export const CreateInboxIssueModal: React.FC = observer((props) => { const { isOpen, onClose } = props; + // router + const router = useRouter(); + const { workspaceSlug, projectId } = router.query; + if (!workspaceSlug || !projectId) return null; // states const [createMore, setCreateMore] = useState(false); const [gptAssistantModal, setGptAssistantModal] = useState(false); const [iAmFeelingLucky, setIAmFeelingLucky] = useState(false); // refs const editorRef = useRef(null); - // router - const router = useRouter(); - const { workspaceSlug, projectId, inboxId } = router.query as { - workspaceSlug: string; - projectId: string; - inboxId: string; - }; // hooks const { mentionHighlights, mentionSuggestions } = useMention(); const workspaceStore = useWorkspace(); const workspaceId = workspaceStore.getWorkspaceBySlug(workspaceSlug as string)?.id as string; - // store hooks - const { - issues: { createInboxIssue }, - } = useInboxIssues(); + const { createInboxIssue } = useProjectInbox(); const { config: { envConfig }, } = useApplication(); const { captureIssueEvent } = useEventTracker(); - + // form info const { control, formState: { errors, isSubmitting }, @@ -73,24 +65,26 @@ export const CreateInboxIssueModal: React.FC = observer((props) => { reset, watch, getValues, - } = useForm({ defaultValues }); + } = useForm>({ defaultValues }); + const issueName = watch("name"); const handleClose = () => { onClose(); reset(defaultValues); + editorRef?.current?.clearEditor(); }; - const issueName = watch("name"); - const handleFormSubmit = async (formData: Partial) => { - if (!workspaceSlug || !projectId || !inboxId) return; - - await createInboxIssue(workspaceSlug.toString(), projectId.toString(), inboxId.toString(), formData) + if (!workspaceSlug || !projectId) return; + await createInboxIssue(workspaceSlug.toString(), projectId.toString(), formData) .then((res) => { if (!createMore) { - router.push(`/${workspaceSlug}/projects/${projectId}/inbox/${inboxId}?inboxIssueId=${res.id}`); + router.push(`/${workspaceSlug}/projects/${projectId}/inbox/?inboxIssueId=${res?.issue?.id}`); handleClose(); - } else reset(defaultValues); + } else { + reset(defaultValues); + editorRef?.current?.clearEditor(); + } captureIssueEvent({ eventName: ISSUE_CREATED, payload: { @@ -117,11 +111,11 @@ export const CreateInboxIssueModal: React.FC = observer((props) => { const handleAiAssistance = async (response: string) => { if (!workspaceSlug || !projectId) return; - editorRef.current?.setEditorValueAtCursorPosition(response); }; const handleAutoGenerateDescription = async () => { + const issueName = getValues("name"); if (!workspaceSlug || !projectId || !issueName) return; setIAmFeelingLucky(true); @@ -220,7 +214,7 @@ export const CreateInboxIssueModal: React.FC = observer((props) => {
- {issueName && issueName !== "" && ( + {watch("name") && issueName !== "" && ( +
+ + +
+ + + + ); +}; diff --git a/web/components/inbox/root.tsx b/web/components/inbox/root.tsx new file mode 100644 index 000000000..f74fd5780 --- /dev/null +++ b/web/components/inbox/root.tsx @@ -0,0 +1,68 @@ +import { FC } from "react"; +import { observer } from "mobx-react"; +import useSWR from "swr"; +import { Inbox } from "lucide-react"; +// components +import { EmptyState } from "@/components/empty-state"; +import { InboxSidebar, InboxContentRoot } from "@/components/inbox"; +import { InboxLayoutLoader } from "@/components/ui"; +// constants +import { EmptyStateType } from "@/constants/empty-state"; +// hooks +import { useProjectInbox } from "@/hooks/store"; + +type TInboxIssueRoot = { + workspaceSlug: string; + projectId: string; + inboxIssueId: string | undefined; + inboxAccessible: boolean; +}; + +export const InboxIssueRoot: FC = observer((props) => { + const { workspaceSlug, projectId, inboxIssueId, inboxAccessible } = props; + // hooks + const { isLoading, error, fetchInboxIssues } = useProjectInbox(); + + useSWR( + inboxAccessible && workspaceSlug && projectId ? `PROJECT_INBOX_ISSUES_${workspaceSlug}_${projectId}` : null, + () => { + inboxAccessible && workspaceSlug && projectId && fetchInboxIssues(workspaceSlug.toString(), projectId.toString()); + }, + { revalidateOnFocus: false } + ); + + // loader + if (isLoading === "init-loading") + return ( +
+ +
+ ); + + // error + if (error && error?.status === "init-error") + return ( +
+ +
{error?.message}
+
+ ); + + return ( +
+ + + {inboxIssueId ? ( + + ) : ( +
+ +
+ )} +
+ ); +}); diff --git a/web/components/inbox/sidebar/filter/applied-filters.tsx b/web/components/inbox/sidebar/filter/applied-filters.tsx deleted file mode 100644 index 4569cf577..000000000 --- a/web/components/inbox/sidebar/filter/applied-filters.tsx +++ /dev/null @@ -1,171 +0,0 @@ -import { FC } from "react"; -import { observer } from "mobx-react-lite"; -// mobx store -// icons -import { X } from "lucide-react"; -import { TInboxIssueFilterOptions, TIssuePriorities } from "@plane/types"; -import { PriorityIcon } from "@plane/ui"; -// helpers -import { INBOX_STATUS } from "@/constants/inbox"; -import { replaceUnderscoreIfSnakeCase } from "@/helpers/string.helper"; -// types -import { useInboxIssues } from "@/hooks/store"; -// constants - -type TInboxIssueAppliedFilter = { workspaceSlug: string; projectId: string; inboxId: string }; - -export const IssueStatusLabel = ({ status }: { status: number }) => { - const issueStatusDetail = INBOX_STATUS.find((s) => s.status === status); - - if (!issueStatusDetail) return <>; - - return ( -
-
- -
-
{issueStatusDetail.title}
-
- ); -}; - -export const InboxIssueAppliedFilter: FC = observer((props) => { - const { workspaceSlug, projectId, inboxId } = props; - // hooks - const { - filters: { inboxFilters, updateInboxFilters }, - } = useInboxIssues(); - - const filters = inboxFilters?.filters; - - const handleUpdateFilter = (filter: Partial) => { - if (!workspaceSlug || !projectId || !inboxId) return; - updateInboxFilters(workspaceSlug.toString(), projectId.toString(), inboxId.toString(), filter); - }; - - const handleClearAllFilters = () => { - const newFilters: TInboxIssueFilterOptions = { priority: [], inbox_status: [] }; - updateInboxFilters(workspaceSlug.toString(), projectId.toString(), inboxId.toString(), newFilters); - }; - - let filtersLength = 0; - Object.keys(filters ?? {}).forEach((key) => { - const filterKey = key as keyof TInboxIssueFilterOptions; - if (filters?.[filterKey] && Array.isArray(filters[filterKey])) filtersLength += (filters[filterKey] ?? []).length; - }); - - if (!filters || filtersLength <= 0) return <>; - return ( -
- {Object.keys(filters).map((key) => { - const filterKey = key as keyof TInboxIssueFilterOptions; - - if (filters[filterKey].length > 0) - return ( -
- {replaceUnderscoreIfSnakeCase(key)}: - {filters[filterKey]?.length < 0 ? ( - None - ) : ( -
- {filterKey === "priority" ? ( -
- {filters.priority?.map((priority) => ( -
-
-
- -
-
{priority}
-
- -
- ))} - -
- ) : filterKey === "inbox_status" ? ( -
- {filters.inbox_status?.map((status) => ( -
- - -
- ))} - -
- ) : ( - (filters[filterKey] as any)?.join(", ") - )} -
- )} -
- ); - })} - -
- ); -}); diff --git a/web/components/inbox/sidebar/filter/filter-selection.tsx b/web/components/inbox/sidebar/filter/filter-selection.tsx deleted file mode 100644 index fc9dc20eb..000000000 --- a/web/components/inbox/sidebar/filter/filter-selection.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import { FC } from "react"; -import { observer } from "mobx-react-lite"; -import { useRouter } from "next/router"; -import { TInboxIssueFilterOptions } from "@plane/types"; -// mobx store -// ui -// icons -import { PriorityIcon } from "@plane/ui"; -import { MultiLevelDropdown } from "@/components/ui"; -// types -// constants -import { INBOX_STATUS } from "@/constants/inbox"; -import { ISSUE_PRIORITIES } from "@/constants/issue"; -import { useInboxIssues } from "@/hooks/store"; - -type TInboxIssueFilterSelection = { workspaceSlug: string; projectId: string; inboxId: string }; - -export const InboxIssueFilterSelection: FC = observer((props) => { - const { workspaceSlug, projectId, inboxId } = props; - // router - const router = useRouter(); - const { inboxIssueId } = router.query; - // hooks - const { - filters: { inboxFilters, updateInboxFilters }, - } = useInboxIssues(); - - const filters = inboxFilters?.filters; - - let filtersLength = 0; - Object.keys(filters ?? {}).forEach((key) => { - const filterKey = key as keyof TInboxIssueFilterOptions; - if (filters?.[filterKey] && Array.isArray(filters[filterKey])) filtersLength += (filters[filterKey] ?? []).length; - }); - - return ( -
- { - if (!workspaceSlug || !projectId || !inboxId) return; - - const key = option.key as keyof TInboxIssueFilterOptions; - const currentValue: any[] = filters?.[key] ?? []; - - const valueExists = currentValue.includes(option.value); - - if (valueExists) - updateInboxFilters(workspaceSlug.toString(), projectId.toString(), inboxId.toString(), { - [option.key]: currentValue.filter((val) => val !== option.value), - }); - else - updateInboxFilters(workspaceSlug.toString(), projectId.toString(), inboxId.toString(), { - [option.key]: [...currentValue, option.value], - }); - - if (inboxIssueId) { - router.push({ - pathname: `/${workspaceSlug}/projects/${projectId}/inbox/${inboxId}`, - }); - } - }} - direction="right" - height="rg" - options={[ - { - id: "priority", - label: "Priority", - value: ISSUE_PRIORITIES.map((p) => p.key), - hasChildren: true, - children: ISSUE_PRIORITIES.map((priority) => ({ - id: priority.key, - label: ( -
- {priority.title ?? "None"} -
- ), - value: { - key: "priority", - value: priority.key, - }, - selected: filters?.priority?.includes(priority.key), - })), - }, - { - id: "inbox_status", - label: "Status", - value: INBOX_STATUS.map((status) => status.status), - hasChildren: true, - children: INBOX_STATUS.map((status) => ({ - id: status.status.toString(), - label: ( -
-
- -
-
{status.title}
-
- ), - value: { - key: "inbox_status", - value: status.status, - }, - selected: filters?.inbox_status?.includes(status.status), - })), - }, - ]} - /> - - {filtersLength > 0 && ( -
- {filtersLength} -
- )} -
- ); -}); diff --git a/web/components/inbox/sidebar/inbox-list-item.tsx b/web/components/inbox/sidebar/inbox-list-item.tsx index 792ade860..262d1e6c0 100644 --- a/web/components/inbox/sidebar/inbox-list-item.tsx +++ b/web/components/inbox/sidebar/inbox-list-item.tsx @@ -1,49 +1,40 @@ -import { FC, useEffect } from "react"; +import { FC, MouseEvent, useEffect } from "react"; import { observer } from "mobx-react"; import Link from "next/link"; import { useRouter } from "next/router"; -// icons -import { CalendarDays } from "lucide-react"; -// hooks -// ui import { Tooltip, PriorityIcon } from "@plane/ui"; -// helpers -import { InboxIssueStatus } from "@/components/inbox/inbox-issue-status"; -import { renderFormattedDate } from "@/helpers/date-time.helper"; // components -import { useInboxIssues, useIssueDetail, useProject } from "@/hooks/store"; +import { InboxIssueStatus } from "@/components/inbox"; +// helpers +import { cn } from "@/helpers/common.helper"; +import { renderFormattedDate } from "@/helpers/date-time.helper"; +// hooks +import { useLabel } from "@/hooks/store"; import { usePlatformOS } from "@/hooks/use-platform-os"; +// store +import { IInboxIssueStore } from "@/store/inbox/inbox-issue.store"; -type TInboxIssueListItem = { +type InboxIssueListItemProps = { workspaceSlug: string; projectId: string; - inboxId: string; - issueId: string; + projectIdentifier?: string; + inboxIssue: IInboxIssueStore; }; -export const InboxIssueListItem: FC = observer((props) => { - const { workspaceSlug, projectId, inboxId, issueId } = props; +export const InboxIssueListItem: FC = observer((props) => { + const { workspaceSlug, projectId, inboxIssue, projectIdentifier } = props; // router const router = useRouter(); const { inboxIssueId } = router.query; - // hooks - const { getProjectById } = useProject(); - const { - issues: { getInboxIssueByIssueId }, - } = useInboxIssues(); - const { - issue: { getIssueById }, - } = useIssueDetail(); + // store + const { projectLabels } = useLabel(); const { isMobile } = usePlatformOS(); - const inboxIssueDetail = getInboxIssueByIssueId(inboxId, issueId); - const issue = getIssueById(issueId); - - if (!issue || !inboxIssueDetail) return <>; + const issue = inboxIssue.issue; useEffect(() => { - if (issueId === inboxIssueId) { + if (issue.id === inboxIssueId) { setTimeout(() => { - const issueItemCard = document.getElementById(`inbox-issue-list-item-${issueId}`); + const issueItemCard = document.getElementById(`inbox-issue-list-item-${issue.id}`); if (issueItemCard) issueItemCard.scrollIntoView({ behavior: "smooth", @@ -51,52 +42,81 @@ export const InboxIssueListItem: FC = observer((props) => { }); }, 200); } - }, [issueId, inboxIssueId]); + }, [inboxIssueId, issue.id]); + const handleIssueRedirection = (event: MouseEvent, currentIssueId: string | undefined) => { + if (inboxIssueId === currentIssueId) event.preventDefault(); + }; + + if (!issue) return <>; return ( <> handleIssueRedirection(e, issue.id)} >
-
-
-

- {getProjectById(issue.project_id)?.identifier}-{issue.sequence_id} -

-
{issue.name}
-
-
- +
+
+
+ {projectIdentifier}-{issue.sequence_id} +
+ {inboxIssue.status !== -2 && }
+

{issue.name}

- - - -
- - {renderFormattedDate(issue.created_at ?? "")} -
+
{renderFormattedDate(issue.created_at ?? "")}
+ +
+ + {issue.priority && ( + + + + )} + + {issue.label_ids && issue.label_ids.length > 3 ? ( +
+ + {`${issue.label_ids.length} labels`} +
+ ) : ( + <> + {(issue.label_ids ?? []).map((labelId) => { + const labelDetails = projectLabels?.find((l) => l.id === labelId); + if (!labelDetails) return null; + return ( +
+ + {labelDetails.name} +
+ ); + })} + + )}
diff --git a/web/components/inbox/sidebar/inbox-list.tsx b/web/components/inbox/sidebar/inbox-list.tsx index 9d77f6baf..e6f536247 100644 --- a/web/components/inbox/sidebar/inbox-list.tsx +++ b/web/components/inbox/sidebar/inbox-list.tsx @@ -1,33 +1,33 @@ -import { FC } from "react"; +import { FC, Fragment } from "react"; import { observer } from "mobx-react"; -// hooks -import { useInboxIssues } from "@/hooks/store"; // components -import { InboxIssueListItem } from "../"; +import { InboxIssueListItem } from "@/components/inbox"; +// store +import { IInboxIssueStore } from "@/store/inbox/inbox-issue.store"; -type TInboxIssueList = { workspaceSlug: string; projectId: string; inboxId: string }; +export type InboxIssueListProps = { + workspaceSlug: string; + projectId: string; + projectIdentifier?: string; + inboxIssues: IInboxIssueStore[]; +}; -export const InboxIssueList: FC = observer((props) => { - const { workspaceSlug, projectId, inboxId } = props; - // hooks - const { - issues: { getInboxIssuesByInboxId }, - } = useInboxIssues(); +export const InboxIssueList: FC = observer((props) => { + const { workspaceSlug, projectId, projectIdentifier, inboxIssues } = props; - const inboxIssueIds = getInboxIssuesByInboxId(inboxId); - - if (!inboxIssueIds) return <>; return ( -
- {inboxIssueIds.map((issueId) => ( - + <> + {inboxIssues.map((inboxIssue) => ( + + + ))} -
+ ); }); diff --git a/web/components/inbox/sidebar/index.ts b/web/components/inbox/sidebar/index.ts new file mode 100644 index 000000000..8a7673423 --- /dev/null +++ b/web/components/inbox/sidebar/index.ts @@ -0,0 +1,3 @@ +export * from "./root"; +export * from "./inbox-list"; +export * from "./inbox-list-item"; diff --git a/web/components/inbox/sidebar/root.tsx b/web/components/inbox/sidebar/root.tsx index cedddba25..d1d08f017 100644 --- a/web/components/inbox/sidebar/root.tsx +++ b/web/components/inbox/sidebar/root.tsx @@ -1,49 +1,143 @@ -import { FC } from "react"; +import { FC, useCallback, useRef } from "react"; import { observer } from "mobx-react"; -import { Inbox } from "lucide-react"; -// hooks -import { InboxSidebarLoader } from "@/components/ui"; -import { useInboxIssues } from "@/hooks/store"; -// ui +import { useRouter } from "next/router"; +import { TInboxIssueCurrentTab } from "@plane/types"; +import { Loader } from "@plane/ui"; // components -import { InboxIssueList, InboxIssueFilterSelection, InboxIssueAppliedFilter } from "../"; +import { EmptyState } from "@/components/empty-state"; +import { FiltersRoot, InboxIssueAppliedFilters, InboxIssueList } from "@/components/inbox"; +import { InboxSidebarLoader } from "@/components/ui"; +// constants +import { EmptyStateType } from "@/constants/empty-state"; +// helpers +import { cn } from "@/helpers/common.helper"; +// hooks +import { useProject, useProjectInbox } from "@/hooks/store"; +import { useIntersectionObserver } from "@/hooks/use-intersection-observer"; -type TInboxSidebarRoot = { +type IInboxSidebarProps = { workspaceSlug: string; projectId: string; - inboxId: string; }; -export const InboxSidebarRoot: FC = observer((props) => { - const { workspaceSlug, projectId, inboxId } = props; - // store hooks - const { - issues: { loader }, - } = useInboxIssues(); +const tabNavigationOptions: { key: TInboxIssueCurrentTab; label: string }[] = [ + { + key: "open", + label: "Open", + }, + { + key: "closed", + label: "Closed", + }, +]; - if (loader === "init-loader") { - return ; - } +export const InboxSidebar: FC = observer((props) => { + const { workspaceSlug, projectId } = props; + // ref + const containerRef = useRef(null); + const elementRef = useRef(null); + // store + const { currentProjectDetails } = useProject(); + const { + currentTab, + handleCurrentTab, + isLoading, + inboxIssuesArray, + inboxIssuePaginationInfo, + fetchInboxPaginationIssues, + getAppliedFiltersCount, + } = useProjectInbox(); + + const router = useRouter(); + + const fetchNextPages = useCallback(() => { + if (!workspaceSlug || !projectId) return; + fetchInboxPaginationIssues(workspaceSlug.toString(), projectId.toString()); + }, [workspaceSlug, projectId, fetchInboxPaginationIssues]); + // page observer + useIntersectionObserver({ + containerRef, + elementRef, + callback: fetchNextPages, + rootMargin: "20%", + }); return ( -
-
-
-
- +
+
+
+ {tabNavigationOptions.map((option) => ( +
{ + if (currentTab != option?.key) handleCurrentTab(option?.key); + router.push(`/${workspaceSlug}/projects/${projectId}/inbox`); + }} + > +
{option?.label}
+ {option?.key === "open" && currentTab === option?.key && ( +
+ {inboxIssuePaginationInfo?.total_results || 0} +
+ )} +
+
+ ))} +
+
-
- -
-
-
- -
+ -
- + {isLoading && !inboxIssuePaginationInfo?.next_page_results ? ( + + ) : ( +
+ {inboxIssuesArray.length > 0 ? ( + + ) : ( +
+ 0 + ? EmptyStateType.INBOX_SIDEBAR_FILTER_EMPTY_STATE + : currentTab === "open" + ? EmptyStateType.INBOX_SIDEBAR_OPEN_TAB + : EmptyStateType.INBOX_SIDEBAR_CLOSED_TAB + } + layout="screen-simple" + /> +
+ )} + +
+ {inboxIssuePaginationInfo?.next_page_results && ( + + + + + )} +
+
+ )}
); diff --git a/web/components/issues/index.ts b/web/components/issues/index.ts index d1695b722..96136ccfe 100644 --- a/web/components/issues/index.ts +++ b/web/components/issues/index.ts @@ -3,7 +3,8 @@ export * from "./issue-modal"; export * from "./delete-issue-modal"; export * from "./description-form"; export * from "./issue-layouts"; - +export * from "./description-input"; +export * from "./title-input"; export * from "./parent-issues-list-modal"; export * from "./label"; export * from "./confirm-issue-discard"; diff --git a/web/components/issues/issue-detail/inbox/index.ts b/web/components/issues/issue-detail/inbox/index.ts deleted file mode 100644 index 0c4adc7d0..000000000 --- a/web/components/issues/issue-detail/inbox/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from "./root"; -export * from "./main-content"; -export * from "./sidebar"; diff --git a/web/components/issues/issue-detail/inbox/main-content.tsx b/web/components/issues/issue-detail/inbox/main-content.tsx deleted file mode 100644 index 2bea576c4..000000000 --- a/web/components/issues/issue-detail/inbox/main-content.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import { useEffect, useState } from "react"; -import { observer } from "mobx-react-lite"; -// hooks -import { StateGroupIcon } from "@plane/ui"; -import { IssueUpdateStatus, TIssueOperations } from "@/components/issues"; -import { useIssueDetail, useProjectState, useUser } from "@/hooks/store"; -import useReloadConfirmations from "@/hooks/use-reload-confirmation"; -// components -import { InboxIssueStatus } from "../../../inbox/inbox-issue-status"; -import { IssueDescriptionInput } from "../../description-input"; -import { IssueTitleInput } from "../../title-input"; -import { IssueActivity } from "../issue-activity"; -import { IssueReaction } from "../reactions"; -// ui - -type Props = { - workspaceSlug: string; - projectId: string; - inboxId: string; - issueId: string; - issueOperations: TIssueOperations; - is_editable: boolean; -}; - -export const InboxIssueMainContent: React.FC = observer((props) => { - const { workspaceSlug, projectId, inboxId, issueId, issueOperations, is_editable } = props; - // states - const [isSubmitting, setIsSubmitting] = useState<"submitting" | "submitted" | "saved">("saved"); - // hooks - const { currentUser } = useUser(); - const { projectStates } = useProjectState(); - const { - issue: { getIssueById }, - } = useIssueDetail(); - const { setShowAlert } = useReloadConfirmations(isSubmitting === "submitting"); - - useEffect(() => { - if (isSubmitting === "submitted") { - setShowAlert(false); - setTimeout(async () => { - setIsSubmitting("saved"); - }, 3000); - } else if (isSubmitting === "submitting") { - setShowAlert(true); - } - }, [isSubmitting, setShowAlert, setIsSubmitting]); - - const issue = issueId ? getIssueById(issueId) : undefined; - if (!issue) return <>; - - const currentIssueState = projectStates?.find((s) => s.id === issue.state_id); - - const issueDescription = - issue.description_html !== undefined || issue.description_html !== null - ? issue.description_html != "" - ? issue.description_html - : "

" - : undefined; - - return ( - <> -
- - -
- {currentIssueState && ( - - )} - -
- - setIsSubmitting(value)} - issueOperations={issueOperations} - disabled={!is_editable} - value={issue.name} - /> - - setIsSubmitting(value)} - /> - - {currentUser && ( - - )} -
- -
- -
- - ); -}); diff --git a/web/components/issues/issue-detail/inbox/root.tsx b/web/components/issues/issue-detail/inbox/root.tsx deleted file mode 100644 index bab406d1f..000000000 --- a/web/components/issues/issue-detail/inbox/root.tsx +++ /dev/null @@ -1,152 +0,0 @@ -import { FC, useMemo } from "react"; -import { useRouter } from "next/router"; -import useSWR from "swr"; -import { TIssue } from "@plane/types"; -// components -import { TOAST_TYPE, setToast } from "@plane/ui"; -import { EUserProjectRoles } from "@/constants/project"; -import { useEventTracker, useInboxIssues, useIssueDetail, useUser } from "@/hooks/store"; -// ui -// types -import { TIssueOperations } from "../root"; -import { InboxIssueMainContent } from "./main-content"; -import { InboxIssueDetailsSidebar } from "./sidebar"; -// constants - -export type TInboxIssueDetailRoot = { - workspaceSlug: string; - projectId: string; - inboxId: string; - issueId: string; -}; - -export const InboxIssueDetailRoot: FC = (props) => { - const { workspaceSlug, projectId, inboxId, issueId } = props; - // router - const router = useRouter(); - // hooks - const { - issues: { fetchInboxIssueById, updateInboxIssue, removeInboxIssue }, - } = useInboxIssues(); - const { - issue: { getIssueById }, - fetchActivities, - fetchComments, - } = useIssueDetail(); - const { captureIssueEvent } = useEventTracker(); - const { - membership: { currentProjectRole }, - } = useUser(); - - const issueOperations: TIssueOperations = useMemo( - () => ({ - fetch: async (workspaceSlug: string, projectId: string, issueId: string) => { - try { - await fetchInboxIssueById(workspaceSlug, projectId, inboxId, issueId); - } catch (error) { - console.error("Error fetching the parent issue"); - } - }, - update: async (workspaceSlug: string, projectId: string, issueId: string, data: Partial) => { - try { - await updateInboxIssue(workspaceSlug, projectId, inboxId, issueId, data); - captureIssueEvent({ - eventName: "Inbox issue updated", - payload: { ...data, state: "SUCCESS", element: "Inbox" }, - updates: { - changed_property: Object.keys(data).join(","), - change_details: Object.values(data).join(","), - }, - path: router.asPath, - }); - } catch (error) { - setToast({ - title: "Issue update failed", - type: TOAST_TYPE.ERROR, - message: "Issue update failed", - }); - captureIssueEvent({ - eventName: "Inbox issue updated", - payload: { state: "SUCCESS", element: "Inbox" }, - updates: { - changed_property: Object.keys(data).join(","), - change_details: Object.values(data).join(","), - }, - path: router.asPath, - }); - } - }, - remove: async (workspaceSlug: string, projectId: string, issueId: string) => { - try { - await removeInboxIssue(workspaceSlug, projectId, inboxId, issueId); - setToast({ - title: "Issue deleted successfully", - type: TOAST_TYPE.SUCCESS, - message: "Issue deleted successfully", - }); - captureIssueEvent({ - eventName: "Inbox issue deleted", - payload: { id: issueId, state: "SUCCESS", element: "Inbox" }, - path: router.asPath, - }); - } catch (error) { - captureIssueEvent({ - eventName: "Inbox issue deleted", - payload: { id: issueId, state: "FAILED", element: "Inbox" }, - path: router.asPath, - }); - setToast({ - title: "Issue delete failed", - type: TOAST_TYPE.ERROR, - message: "Issue delete failed", - }); - } - }, - }), - [inboxId, fetchInboxIssueById, updateInboxIssue, removeInboxIssue] - ); - - useSWR( - workspaceSlug && projectId && inboxId && issueId - ? `INBOX_ISSUE_DETAIL_${workspaceSlug}_${projectId}_${inboxId}_${issueId}` - : null, - async () => { - if (workspaceSlug && projectId && inboxId && issueId) { - await issueOperations.fetch(workspaceSlug, projectId, issueId); - await fetchActivities(workspaceSlug, projectId, issueId); - await fetchComments(workspaceSlug, projectId, issueId); - } - } - ); - - // checking if issue is editable, based on user role - const is_editable = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; - - // issue details - const issue = getIssueById(issueId); - - if (!issue) return <>; - return ( -
-
- -
-
- -
-
- ); -}; diff --git a/web/components/issues/issue-detail/index.ts b/web/components/issues/issue-detail/index.ts index 63ef560a1..dc36d97ab 100644 --- a/web/components/issues/issue-detail/index.ts +++ b/web/components/issues/issue-detail/index.ts @@ -1,14 +1,14 @@ export * from "./root"; - export * from "./main-content"; export * from "./sidebar"; - -// select -export * from "./cycle-select"; -export * from "./module-select"; -export * from "./parent-select"; -export * from "./relation-select"; export * from "./parent"; export * from "./label"; export * from "./subscription"; export * from "./links"; +export * from "./issue-activity"; +export * from "./reactions"; +// select components +export * from "./cycle-select"; +export * from "./module-select"; +export * from "./parent-select"; +export * from "./relation-select"; diff --git a/web/components/issues/issue-detail/label/create-label.tsx b/web/components/issues/issue-detail/label/create-label.tsx index de376ce79..cbdb441e4 100644 --- a/web/components/issues/issue-detail/label/create-label.tsx +++ b/web/components/issues/issue-detail/label/create-label.tsx @@ -7,7 +7,6 @@ import { Popover } from "@headlessui/react"; import { IIssueLabel } from "@plane/types"; // hooks import { Input, TOAST_TYPE, setToast } from "@plane/ui"; -import { useIssueDetail } from "@/hooks/store"; // ui // types import { TLabelOperations } from "./root"; @@ -16,6 +15,7 @@ type ILabelCreate = { workspaceSlug: string; projectId: string; issueId: string; + values: string[]; labelOperations: TLabelOperations; disabled?: boolean; }; @@ -26,11 +26,7 @@ const defaultValues: Partial = { }; export const LabelCreate: FC = (props) => { - const { workspaceSlug, projectId, issueId, labelOperations, disabled = false } = props; - // hooks - const { - issue: { getIssueById }, - } = useIssueDetail(); + const { workspaceSlug, projectId, issueId, values, labelOperations, disabled = false } = props; // state const [isCreateToggle, setIsCreateToggle] = useState(false); const handleIsCreateToggle = () => setIsCreateToggle(!isCreateToggle); @@ -70,9 +66,8 @@ export const LabelCreate: FC = (props) => { if (!workspaceSlug || !projectId || isSubmitting) return; try { - const issue = getIssueById(issueId); const labelResponse = await labelOperations.createLabel(workspaceSlug, projectId, formData); - const currentLabels = [...(issue?.label_ids || []), labelResponse.id]; + const currentLabels = [...(values || []), labelResponse.id]; await labelOperations.updateIssue(workspaceSlug, projectId, issueId, { label_ids: currentLabels }); reset(defaultValues); } catch (error) { diff --git a/web/components/issues/issue-detail/label/label-list-item.tsx b/web/components/issues/issue-detail/label/label-list-item.tsx index 2bb6d30d1..8dbd980c5 100644 --- a/web/components/issues/issue-detail/label/label-list-item.tsx +++ b/web/components/issues/issue-detail/label/label-list-item.tsx @@ -1,7 +1,7 @@ import { FC } from "react"; import { X } from "lucide-react"; // types -import { useIssueDetail, useLabel } from "@/hooks/store"; +import { useLabel } from "@/hooks/store"; import { TLabelOperations } from "./root"; type TLabelListItem = { @@ -9,24 +9,21 @@ type TLabelListItem = { projectId: string; issueId: string; labelId: string; + values: string[]; labelOperations: TLabelOperations; disabled: boolean; }; export const LabelListItem: FC = (props) => { - const { workspaceSlug, projectId, issueId, labelId, labelOperations, disabled } = props; + const { workspaceSlug, projectId, issueId, labelId, values, labelOperations, disabled } = props; // hooks - const { - issue: { getIssueById }, - } = useIssueDetail(); const { getLabelById } = useLabel(); - const issue = getIssueById(issueId); const label = getLabelById(labelId); const handleLabel = async () => { - if (issue && !disabled) { - const currentLabels = issue.label_ids.filter((_labelId) => _labelId !== labelId); + if (values && !disabled) { + const currentLabels = values.filter((_labelId) => _labelId !== labelId); await labelOperations.updateIssue(workspaceSlug, projectId, issueId, { label_ids: currentLabels }); } }; diff --git a/web/components/issues/issue-detail/label/label-list.tsx b/web/components/issues/issue-detail/label/label-list.tsx index 07ba72766..e6ec6e05c 100644 --- a/web/components/issues/issue-detail/label/label-list.tsx +++ b/web/components/issues/issue-detail/label/label-list.tsx @@ -1,9 +1,7 @@ import { FC } from "react"; import { observer } from "mobx-react"; // components -import { useIssueDetail } from "@/hooks/store"; import { LabelListItem } from "./label-list-item"; -// hooks // types import { TLabelOperations } from "./root"; @@ -11,21 +9,16 @@ type TLabelList = { workspaceSlug: string; projectId: string; issueId: string; + values: string[]; labelOperations: TLabelOperations; disabled: boolean; }; export const LabelList: FC = observer((props) => { - const { workspaceSlug, projectId, issueId, labelOperations, disabled } = props; - // hooks - const { - issue: { getIssueById }, - } = useIssueDetail(); + const { workspaceSlug, projectId, issueId, values, labelOperations, disabled } = props; + const issueLabels = values || undefined; - const issue = getIssueById(issueId); - const issueLabels = issue?.label_ids || undefined; - - if (!issue || !issueLabels) return <>; + if (!issueId || !issueLabels) return <>; return ( <> {issueLabels.map((labelId) => ( @@ -35,6 +28,7 @@ export const LabelList: FC = observer((props) => { projectId={projectId} issueId={issueId} labelId={labelId} + values={issueLabels} labelOperations={labelOperations} disabled={disabled} /> diff --git a/web/components/issues/issue-detail/label/root.tsx b/web/components/issues/issue-detail/label/root.tsx index 6309ada64..31030555a 100644 --- a/web/components/issues/issue-detail/label/root.tsx +++ b/web/components/issues/issue-detail/label/root.tsx @@ -4,7 +4,7 @@ import { IIssueLabel, TIssue } from "@plane/types"; // components import { TOAST_TYPE, setToast } from "@plane/ui"; // hooks -import { useIssueDetail, useLabel } from "@/hooks/store"; +import { useIssueDetail, useLabel, useProjectInbox } from "@/hooks/store"; // ui // types import { LabelList, LabelCreate, IssueLabelSelectRoot } from "./"; @@ -28,6 +28,12 @@ export const IssueLabel: FC = observer((props) => { // hooks const { updateIssue } = useIssueDetail(); const { createLabel } = useLabel(); + const { + issue: { getIssueById }, + } = useIssueDetail(); + const { getIssueInboxByIssueId } = useProjectInbox(); + + const issue = isInboxIssue ? getIssueInboxByIssueId(issueId)?.issue : getIssueById(issueId); const labelOperations: TLabelOperations = useMemo( () => ({ @@ -72,6 +78,7 @@ export const IssueLabel: FC = observer((props) => { workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} + values={issue?.label_ids || []} labelOperations={labelOperations} disabled={disabled} /> @@ -81,6 +88,7 @@ export const IssueLabel: FC = observer((props) => { workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} + values={issue?.label_ids || []} labelOperations={labelOperations} /> )} @@ -90,6 +98,7 @@ export const IssueLabel: FC = observer((props) => { workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} + values={issue?.label_ids || []} labelOperations={labelOperations} /> )} diff --git a/web/components/issues/issue-detail/label/select/label-select.tsx b/web/components/issues/issue-detail/label/select/label-select.tsx index 2882a1e0e..814758ebc 100644 --- a/web/components/issues/issue-detail/label/select/label-select.tsx +++ b/web/components/issues/issue-detail/label/select/label-select.tsx @@ -4,22 +4,20 @@ import { usePopper } from "react-popper"; import { Check, Search, Tag } from "lucide-react"; import { Combobox } from "@headlessui/react"; // hooks -import { useIssueDetail, useLabel } from "@/hooks/store"; +import { useLabel } from "@/hooks/store"; // components export interface IIssueLabelSelect { workspaceSlug: string; projectId: string; issueId: string; + values: string[]; onSelect: (_labelIds: string[]) => void; } export const IssueLabelSelect: React.FC = observer((props) => { - const { workspaceSlug, projectId, issueId, onSelect } = props; + const { workspaceSlug, projectId, issueId, values, onSelect } = props; // store hooks - const { - issue: { getIssueById }, - } = useIssueDetail(); const { fetchProjectLabels, getProjectLabels } = useLabel(); // states const [referenceElement, setReferenceElement] = useState(null); @@ -27,7 +25,6 @@ export const IssueLabelSelect: React.FC = observer((props) => const [isLoading, setIsLoading] = useState(false); const [query, setQuery] = useState(""); - const issue = getIssueById(issueId); const projectLabels = getProjectLabels(projectId); const fetchLabels = () => { @@ -67,7 +64,7 @@ export const IssueLabelSelect: React.FC = observer((props) => ], }); - const issueLabels = issue?.label_ids ?? []; + const issueLabels = values ?? []; const label = (
= observer((props) => } }; - if (!issue) return <>; + if (!issueId || !values) return <>; return ( <> diff --git a/web/components/issues/issue-detail/label/select/root.tsx b/web/components/issues/issue-detail/label/select/root.tsx index de0bcca90..00f96522b 100644 --- a/web/components/issues/issue-detail/label/select/root.tsx +++ b/web/components/issues/issue-detail/label/select/root.tsx @@ -8,17 +8,24 @@ type TIssueLabelSelectRoot = { workspaceSlug: string; projectId: string; issueId: string; + values: string[]; labelOperations: TLabelOperations; }; export const IssueLabelSelectRoot: FC = (props) => { - const { workspaceSlug, projectId, issueId, labelOperations } = props; + const { workspaceSlug, projectId, issueId, values, labelOperations } = props; const handleLabel = async (_labelIds: string[]) => { await labelOperations.updateIssue(workspaceSlug, projectId, issueId, { label_ids: _labelIds }); }; return ( - + ); }; diff --git a/web/components/project/sidebar-list-item.tsx b/web/components/project/sidebar-list-item.tsx index bb1d6ad17..5041e81c3 100644 --- a/web/components/project/sidebar-list-item.tsx +++ b/web/components/project/sidebar-list-item.tsx @@ -32,9 +32,8 @@ import { import { LeaveProjectModal, ProjectLogo, PublishProjectModal } from "@/components/project"; import { EUserProjectRoles } from "@/constants/project"; import { cn } from "@/helpers/common.helper"; -import { getNumberCount } from "@/helpers/string.helper"; // hooks -import { useApplication, useEventTracker, useInbox, useProject } from "@/hooks/store"; +import { useApplication, useEventTracker, useProject } from "@/hooks/store"; import useOutsideClickDetector from "@/hooks/use-outside-click-detector"; import { usePlatformOS } from "@/hooks/use-platform-os"; // helpers @@ -95,7 +94,6 @@ export const ProjectSidebarListItem: React.FC = observer((props) => { const { theme: themeStore } = useApplication(); const { setTrackElement } = useEventTracker(); const { addProjectToFavorites, removeProjectFromFavorites, getProjectById } = useProject(); - const { getInboxesByProjectId, getInboxById } = useInbox(); const { isMobile } = usePlatformOS(); // states const [leaveProjectModalOpen, setLeaveProjectModal] = useState(false); @@ -109,8 +107,6 @@ export const ProjectSidebarListItem: React.FC = observer((props) => { // derived values const project = getProjectById(projectId); const isCollapsed = themeStore.sidebarCollapsed; - const inboxesMap = project?.inbox_view ? getInboxesByProjectId(projectId) : undefined; - const inboxDetails = inboxesMap && inboxesMap.length > 0 ? getInboxById(inboxesMap[0]) : undefined; // auth const isAdmin = project?.member_role === EUserProjectRoles.ADMIN; const isViewerOrGuest = @@ -375,36 +371,8 @@ export const ProjectSidebarListItem: React.FC = observer((props) => { : "text-custom-sidebar-text-300 hover:bg-custom-sidebar-background-80 focus:bg-custom-sidebar-background-80" } ${isCollapsed ? "justify-center" : ""}`} > - {item.name === "Inbox" && inboxDetails ? ( - <> -
- {inboxDetails?.pending_issue_count > 0 && ( - = 100, - }, - { - "border-none bg-custom-primary-300 text-white": router.asPath.includes( - item.href - ), - } - )} - > - {getNumberCount(inboxDetails?.pending_issue_count)} - - )} - -
- {!isCollapsed && item.name} - - ) : ( - <> - - {!isCollapsed && item.name} - - )} + + {!isCollapsed && item.name}
diff --git a/web/components/ui/loader/layouts/project-inbox/inbox-layout-loader.tsx b/web/components/ui/loader/layouts/project-inbox/inbox-layout-loader.tsx index 3456e43ab..095c0d543 100644 --- a/web/components/ui/loader/layouts/project-inbox/inbox-layout-loader.tsx +++ b/web/components/ui/loader/layouts/project-inbox/inbox-layout-loader.tsx @@ -4,22 +4,19 @@ import { Loader } from "@plane/ui"; import { InboxSidebarLoader } from "./inbox-sidebar-loader"; export const InboxLayoutLoader = () => ( -
- -
- -
+
+
+ +
+
+ +
-
- - - - -
+
diff --git a/web/components/ui/loader/layouts/project-inbox/inbox-sidebar-loader.tsx b/web/components/ui/loader/layouts/project-inbox/inbox-sidebar-loader.tsx index 204c2fff6..01da38f6a 100644 --- a/web/components/ui/loader/layouts/project-inbox/inbox-sidebar-loader.tsx +++ b/web/components/ui/loader/layouts/project-inbox/inbox-sidebar-loader.tsx @@ -1,24 +1,20 @@ import React from "react"; export const InboxSidebarLoader = () => ( -
-
- - -
-
- {[...Array(6)].map((i) => ( -
-
- - -
-
- - -
+
+ {[...Array(6)].map((i, index) => ( +
+
+ +
- ))} -
+
+ + + + +
+
+ ))}
); diff --git a/web/constants/empty-state.ts b/web/constants/empty-state.ts index 363147775..808b5470d 100644 --- a/web/constants/empty-state.ts +++ b/web/constants/empty-state.ts @@ -90,6 +90,13 @@ export enum EmptyStateType { ACTIVE_CYCLE_PRIORITY_ISSUE_EMPTY_STATE = "active-cycle-priority-issue-empty-state", ACTIVE_CYCLE_ASSIGNEE_EMPTY_STATE = "active-cycle-assignee-empty-state", ACTIVE_CYCLE_LABEL_EMPTY_STATE = "active-cycle-label-empty-state", + + DISABLED_PROJECT_INBOX = "disabled-project-inbox", + + INBOX_SIDEBAR_OPEN_TAB = "inbox-sidebar-open-tab", + INBOX_SIDEBAR_CLOSED_TAB = "inbox-sidebar-closed-tab", + INBOX_SIDEBAR_FILTER_EMPTY_STATE = "inbox-sidebar-filter-empty-state", + INBOX_DETAIL_EMPTY_STATE = "inbox-detail-empty-state", } const emptyStateDetails = { @@ -615,6 +622,41 @@ const emptyStateDetails = { title: "Add labels to issues to see the \n breakdown of work by labels.", path: "/empty-state/active-cycle/label", }, + [EmptyStateType.DISABLED_PROJECT_INBOX]: { + key: EmptyStateType.DISABLED_PROJECT_INBOX, + title: "Inbox is not enabled for the project.", + description: + "Inbox helps you manage incoming requests to your project and add them as issues in your workflow. Enable inbox \n from project settings to manage requests.", + accessType: "project", + access: EUserProjectRoles.ADMIN, + path: "/empty-state/disabled-feature/inbox", + primaryButton: { + text: "Manage features", + }, + }, + [EmptyStateType.INBOX_SIDEBAR_OPEN_TAB]: { + key: EmptyStateType.INBOX_SIDEBAR_OPEN_TAB, + title: "No open issues", + description: "Find open issues here. Create new issue.", + path: "/empty-state/inbox/inbox-issue", + }, + [EmptyStateType.INBOX_SIDEBAR_CLOSED_TAB]: { + key: EmptyStateType.INBOX_SIDEBAR_CLOSED_TAB, + title: "No closed issues", + description: "All the issues whether accepted or \n declined can be found here.", + path: "/empty-state/inbox/inbox-issue", + }, + [EmptyStateType.INBOX_SIDEBAR_FILTER_EMPTY_STATE]: { + key: EmptyStateType.INBOX_SIDEBAR_FILTER_EMPTY_STATE, + title: "No matching issues", + description: "No issue matches filter applied in inbox. \n Create a new issue.", + path: "/empty-state/inbox/filter-issue", + }, + [EmptyStateType.INBOX_DETAIL_EMPTY_STATE]: { + key: EmptyStateType.INBOX_DETAIL_EMPTY_STATE, + title: "Select an issue to view its details.", + path: "/empty-state/inbox/issue-detail", + }, } as const; export const EMPTY_STATE_DETAILS: Record = emptyStateDetails; diff --git a/web/constants/inbox.tsx b/web/constants/inbox.tsx index b511b529e..6be3ad3fa 100644 --- a/web/constants/inbox.tsx +++ b/web/constants/inbox.tsx @@ -1,91 +1,90 @@ // icons -import { AlertTriangle, CheckCircle2, Clock, Copy, ExternalLink, LucideIcon, XCircle } from "lucide-react"; +import { AlertTriangle, CheckCircle2, Clock, Copy, LucideIcon, XCircle } from "lucide-react"; +// types +import { TInboxIssueSortingOrderByKeys, TInboxIssueSortingSortByKeys, TInboxIssueStatus } from "@plane/types"; // helpers -import { renderFormattedDate } from "@/helpers/date-time.helper"; +import { findHowManyDaysLeft } from "@/helpers/date-time.helper"; export const INBOX_STATUS: { key: string; - status: number; + status: TInboxIssueStatus; icon: LucideIcon; title: string; - description: ( - workspaceSlug: string, - projectId: string, - issueId: string, - snoozedTillDate: Date | undefined - ) => JSX.Element; + description: (snoozedTillDate: Date) => string; textColor: (snoozeDatePassed: boolean) => string; bgColor: (snoozeDatePassed: boolean) => string; - borderColor: (snoozeDatePassed: boolean) => string; }[] = [ { key: "pending", status: -2, icon: AlertTriangle, title: "Pending", - description: () =>

This issue is still pending.

, - textColor: (snoozeDatePassed: boolean = false) => (snoozeDatePassed ? "" : "text-yellow-500"), - bgColor: (snoozeDatePassed: boolean = false) => (snoozeDatePassed ? "" : "bg-yellow-500/10"), - borderColor: (snoozeDatePassed: boolean = false) => (snoozeDatePassed ? "" : "border-yellow-500"), + description: () => `Pending`, + textColor: (snoozeDatePassed: boolean = false) => (snoozeDatePassed ? "" : "text-[#AB6400]"), + bgColor: (snoozeDatePassed: boolean = false) => (snoozeDatePassed ? "" : "bg-[#FFF7C2]"), }, { key: "declined", status: -1, icon: XCircle, title: "Declined", - description: () =>

This issue has been declined.

, - textColor: (snoozeDatePassed: boolean = false) => (snoozeDatePassed ? "" : "text-red-500"), - bgColor: (snoozeDatePassed: boolean = false) => (snoozeDatePassed ? "" : "bg-red-500/10"), - borderColor: (snoozeDatePassed: boolean = false) => (snoozeDatePassed ? "" : "border-red-500"), + description: () => `Declined`, + textColor: (snoozeDatePassed: boolean = false) => (snoozeDatePassed ? "" : "text-[#CE2C31]"), + bgColor: (snoozeDatePassed: boolean = false) => (snoozeDatePassed ? "" : "bg-[#FEEBEC]"), }, { key: "snoozed", status: 0, icon: Clock, title: "Snoozed", - description: (workspaceSlug: string, projectId: string, issueId: string, snoozedTillDate: Date = new Date()) => - snoozedTillDate < new Date() ? ( -

This issue was snoozed till {renderFormattedDate(snoozedTillDate)}.

- ) : ( -

This issue has been snoozed till {renderFormattedDate(snoozedTillDate)}.

- ), - textColor: (snoozeDatePassed: boolean = false) => (snoozeDatePassed ? "text-red-500" : "text-custom-text-200"), - bgColor: (snoozeDatePassed: boolean = false) => (snoozeDatePassed ? "bg-red-500/10" : "bg-gray-500/10"), - borderColor: (snoozeDatePassed: boolean = false) => (snoozeDatePassed ? "border-red-500" : "border-gray-500"), + description: (snoozedTillDate: Date = new Date()) => `${findHowManyDaysLeft(snoozedTillDate)} days to go`, + textColor: (snoozeDatePassed: boolean = false) => (snoozeDatePassed ? "text-red-500" : "text-custom-text-400"), + bgColor: (snoozeDatePassed: boolean = false) => (snoozeDatePassed ? "bg-red-500/10" : "bg-[#E0E1E6]"), }, { key: "accepted", status: 1, icon: CheckCircle2, title: "Accepted", - description: () =>

This issue has been accepted.

, - textColor: (snoozeDatePassed: boolean = false) => (snoozeDatePassed ? "" : "text-green-500"), - bgColor: (snoozeDatePassed: boolean = false) => (snoozeDatePassed ? "" : "bg-green-500/10"), - borderColor: (snoozeDatePassed: boolean = false) => (snoozeDatePassed ? "" : "border-green-500"), + description: () => `Accepted`, + textColor: (snoozeDatePassed: boolean = false) => (snoozeDatePassed ? "" : "text-[#3E9B4F]"), + bgColor: (snoozeDatePassed: boolean = false) => (snoozeDatePassed ? "" : "bg-[#E9F6E9]"), }, { key: "duplicate", status: 2, icon: Copy, title: "Duplicate", - description: (workspaceSlug: string, projectId: string, issueId: string) => ( -

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

- ), + description: () => `Duplicate`, textColor: (snoozeDatePassed: boolean = false) => (snoozeDatePassed ? "" : "text-custom-text-200"), bgColor: (snoozeDatePassed: boolean = false) => (snoozeDatePassed ? "" : "bg-gray-500/10"), - borderColor: (snoozeDatePassed: boolean = false) => (snoozeDatePassed ? "" : "border-gray-500"), }, ]; export const INBOX_ISSUE_SOURCE = "in-app"; + +export const INBOX_ISSUE_ORDER_BY_OPTIONS: { key: TInboxIssueSortingOrderByKeys; label: string }[] = [ + { + key: "issue__created_at", + label: "Date created", + }, + { + key: "issue__updated_at", + label: "Date updated", + }, + { + key: "issue__sequence_id", + label: "ID", + }, +]; + +export const INBOX_ISSUE_SORT_BY_OPTIONS: { key: TInboxIssueSortingSortByKeys; label: string }[] = [ + { + key: "asc", + label: "Ascending", + }, + { + key: "desc", + label: "Descending", + }, +]; diff --git a/web/hooks/store/index.ts b/web/hooks/store/index.ts index 906b473ff..950ae00f1 100644 --- a/web/hooks/store/index.ts +++ b/web/hooks/store/index.ts @@ -23,5 +23,6 @@ export * from "./use-workspace"; export * from "./use-issues"; export * from "./use-kanban-view"; export * from "./use-issue-detail"; -export * from "./use-inbox"; +// project inbox +export * from "./use-project-inbox"; export * from "./use-inbox-issues"; diff --git a/web/hooks/store/use-inbox-issues.ts b/web/hooks/store/use-inbox-issues.ts index 000539e2c..f1afe751d 100644 --- a/web/hooks/store/use-inbox-issues.ts +++ b/web/hooks/store/use-inbox-issues.ts @@ -1,15 +1,9 @@ import { useContext } from "react"; // mobx store -import { StoreContext } from "@/contexts/store-context"; -// types -import { IInboxFilter } from "@/store/inbox/inbox_filter.store"; -import { IInboxIssue } from "@/store/inbox/inbox_issue.store"; +import { StoreContext } from "contexts/store-context"; -export const useInboxIssues = (): { - issues: IInboxIssue; - filters: IInboxFilter; -} => { +export const useInboxIssues = (inboxIssueId: string) => { const context = useContext(StoreContext); if (context === undefined) throw new Error("useInboxIssues must be used within StoreProvider"); - return { issues: context.inbox.inboxIssue, filters: context.inbox.inboxFilter }; + return context.projectInbox.getIssueInboxByIssueId(inboxIssueId) || {}; }; diff --git a/web/hooks/store/use-inbox.ts b/web/hooks/store/use-inbox.ts deleted file mode 100644 index d2a069834..000000000 --- a/web/hooks/store/use-inbox.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { useContext } from "react"; -// mobx store -import { StoreContext } from "@/contexts/store-context"; -// types -import { IInbox } from "@/store/inbox/inbox.store"; - -export const useInbox = (): IInbox => { - const context = useContext(StoreContext); - if (context === undefined) throw new Error("useInbox must be used within StoreProvider"); - return context.inbox.inbox; -}; diff --git a/web/hooks/store/use-project-inbox.ts b/web/hooks/store/use-project-inbox.ts new file mode 100644 index 000000000..5da2c8161 --- /dev/null +++ b/web/hooks/store/use-project-inbox.ts @@ -0,0 +1,11 @@ +import { useContext } from "react"; +// mobx store +import { StoreContext } from "contexts/store-context"; +// types +import { IProjectInboxStore } from "@/store/inbox/project-inbox.store"; + +export const useProjectInbox = (): IProjectInboxStore => { + const context = useContext(StoreContext); + if (context === undefined) throw new Error("useProjectInbox must be used within StoreProvider"); + return context.projectInbox; +}; diff --git a/web/hooks/use-intersection-observer.tsx b/web/hooks/use-intersection-observer.tsx new file mode 100644 index 000000000..eb57e58af --- /dev/null +++ b/web/hooks/use-intersection-observer.tsx @@ -0,0 +1,42 @@ +import { RefObject, useState, useEffect } from "react"; + +export type UseIntersectionObserverProps = { + containerRef: RefObject; + elementRef: RefObject; + callback: () => void; + rootMargin?: string; +}; + +export const useIntersectionObserver = (props: UseIntersectionObserverProps) => { + const { containerRef, elementRef, callback, rootMargin = "0px" } = props; + const [isVisible, setVisibility] = useState(false); + + useEffect(() => { + if (elementRef.current) { + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + callback(); + } + setVisibility(entry.isIntersecting); + }, + { + root: containerRef.current, + rootMargin, + } + ); + observer.observe(elementRef.current); + return () => { + if (elementRef.current) { + // eslint-disable-next-line react-hooks/exhaustive-deps + observer.unobserve(elementRef.current); + } + }; + } + // while removing the current from the refs, the observer is not not working as expected + // fix this eslint warning with caution + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [rootMargin, callback, elementRef.current, containerRef.current]); + + return isVisible; +}; diff --git a/web/layouts/auth-layout/project-wrapper.tsx b/web/layouts/auth-layout/project-wrapper.tsx index 58084b0d4..5f4573360 100644 --- a/web/layouts/auth-layout/project-wrapper.tsx +++ b/web/layouts/auth-layout/project-wrapper.tsx @@ -19,7 +19,7 @@ import { useProjectState, useProjectView, useUser, - useInbox, + // useInbox, } from "@/hooks/store"; // images import emptyProject from "public/empty-state/project.svg"; @@ -31,7 +31,7 @@ interface IProjectAuthWrapper { export const ProjectAuthWrapper: FC = observer((props) => { const { children } = props; // store - const { fetchInboxes } = useInbox(); + // const { fetchInboxes } = useInbox(); const { commandPalette: { toggleCreateProjectModal }, } = useApplication(); @@ -39,7 +39,7 @@ export const ProjectAuthWrapper: FC = observer((props) => { const { membership: { fetchUserProjectInfo, projectMemberInfo, hasPermissionToProject }, } = useUser(); - const { getProjectById, fetchProjectDetails, currentProjectDetails } = useProject(); + const { getProjectById, fetchProjectDetails } = useProject(); const { fetchAllCycles } = useCycle(); const { fetchModules } = useModule(); const { fetchViews } = useProjectView(); @@ -105,20 +105,6 @@ export const ProjectAuthWrapper: FC = observer((props) => { workspaceSlug && projectId ? () => fetchViews(workspaceSlug.toString(), projectId.toString()) : null, { revalidateIfStale: false, revalidateOnFocus: false } ); - // fetching project inboxes if inbox is enabled in project settings - useSWR( - workspaceSlug && projectId && currentProjectDetails && currentProjectDetails.inbox_view - ? `PROJECT_INBOXES_${workspaceSlug}_${projectId}` - : null, - workspaceSlug && projectId && currentProjectDetails && currentProjectDetails.inbox_view - ? () => fetchInboxes(workspaceSlug.toString(), projectId.toString()) - : null, - { - revalidateOnFocus: false, - revalidateOnReconnect: false, - } - ); - const projectExists = projectId ? getProjectById(projectId.toString()) : null; // check if the project member apis is loading diff --git a/web/next-env.d.ts b/web/next-env.d.ts index fd36f9494..4f11a03dc 100644 --- a/web/next-env.d.ts +++ b/web/next-env.d.ts @@ -1,6 +1,5 @@ /// /// -/// // NOTE: This file should not be edited // see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/inbox/[inboxId].tsx b/web/pages/[workspaceSlug]/projects/[projectId]/inbox/[inboxId].tsx deleted file mode 100644 index 03b1cc963..000000000 --- a/web/pages/[workspaceSlug]/projects/[projectId]/inbox/[inboxId].tsx +++ /dev/null @@ -1,80 +0,0 @@ -import { ReactElement } from "react"; -import { observer } from "mobx-react"; -import { useRouter } from "next/router"; -import useSWR from "swr"; -// hooks -import { PageHead } from "@/components/core"; -import { ProjectInboxHeader } from "@/components/headers"; -import { InboxSidebarRoot, InboxContentRoot } from "@/components/inbox"; -import { InboxLayoutLoader } from "@/components/ui"; -import { useProject, useInboxIssues } from "@/hooks/store"; -// layouts -import { AppLayout } from "@/layouts/app-layout"; -// components -// types -import { NextPageWithLayout } from "@/lib/types"; - -const ProjectInboxPage: NextPageWithLayout = observer(() => { - const router = useRouter(); - const { workspaceSlug, projectId, inboxId, inboxIssueId } = router.query; - // store hooks - const { currentProjectDetails } = useProject(); - const { - filters: { fetchInboxFilters }, - issues: { fetchInboxIssues }, - } = useInboxIssues(); - // fetching the Inbox filters and issues - const { isLoading } = useSWR( - workspaceSlug && projectId && currentProjectDetails && currentProjectDetails?.inbox_view - ? `INBOX_ISSUES_${workspaceSlug.toString()}_${projectId.toString()}` - : null, - async () => { - if (workspaceSlug && projectId && inboxId && currentProjectDetails && currentProjectDetails?.inbox_view) { - await fetchInboxFilters(workspaceSlug.toString(), projectId.toString(), inboxId.toString()); - await fetchInboxIssues(workspaceSlug.toString(), projectId.toString(), inboxId.toString()); - } - } - ); - // derived values - const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - Inbox` : undefined; - - if (!workspaceSlug || !projectId || !inboxId || !currentProjectDetails?.inbox_view || isLoading) - return ( -
- -
- ); - - return ( - <> - -
-
- -
-
- -
-
- - ); -}); - -ProjectInboxPage.getLayout = function getLayout(page: ReactElement) { - return ( - } withProjectWrapper> - {page} - - ); -}; - -export default ProjectInboxPage; diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/inbox/index.tsx b/web/pages/[workspaceSlug]/projects/[projectId]/inbox/index.tsx index 6f8bdda93..84205776f 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/inbox/index.tsx +++ b/web/pages/[workspaceSlug]/projects/[projectId]/inbox/index.tsx @@ -1,41 +1,54 @@ import { ReactElement } from "react"; import { observer } from "mobx-react"; import { useRouter } from "next/router"; -import useSWR from "swr"; -// hooks +// components +import { PageHead } from "@/components/core"; +import { EmptyState } from "@/components/empty-state"; import { ProjectInboxHeader } from "@/components/headers"; -import { InboxLayoutLoader } from "@/components/ui"; -import { useInbox, useProject } from "@/hooks/store"; +import { InboxIssueRoot } from "@/components/inbox"; +// constants +import { EmptyStateType } from "@/constants/empty-state"; +// hooks +import { useProject } from "@/hooks/store"; // layouts import { AppLayout } from "@/layouts/app-layout"; -// ui -// components // types import { NextPageWithLayout } from "@/lib/types"; const ProjectInboxPage: NextPageWithLayout = observer(() => { + /// router const router = useRouter(); - const { workspaceSlug, projectId } = router.query; - + const { workspaceSlug, projectId, inboxIssueId } = router.query; + // hooks const { currentProjectDetails } = useProject(); - const { fetchInboxes } = useInbox(); - useSWR( - workspaceSlug && projectId && currentProjectDetails && currentProjectDetails?.inbox_view - ? `INBOX_${workspaceSlug.toString()}_${projectId.toString()}` - : null, - async () => { - if (workspaceSlug && projectId && currentProjectDetails && currentProjectDetails?.inbox_view) { - const inboxes = await fetchInboxes(workspaceSlug.toString(), projectId.toString()); - if (inboxes && inboxes.length > 0) - router.push(`/${workspaceSlug}/projects/${projectId}/inbox/${inboxes[0].id}`); - } - } - ); + if (!workspaceSlug || !projectId) return <>; + + // No access to inbox + if (currentProjectDetails?.inbox_view === false) + return ( +
+ +
+ ); + + // derived values + const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - Inbox` : "Plane - Inbox"; return (
- {currentProjectDetails?.inbox_view ? :
You don{"'"}t have access to inbox
} + +
+ +
); }); diff --git a/web/public/empty-state/disabled-feature/inbox-dark.webp b/web/public/empty-state/disabled-feature/inbox-dark.webp new file mode 100644 index 000000000..d0e105519 Binary files /dev/null and b/web/public/empty-state/disabled-feature/inbox-dark.webp differ diff --git a/web/public/empty-state/disabled-feature/inbox-light.webp b/web/public/empty-state/disabled-feature/inbox-light.webp new file mode 100644 index 000000000..8a2999525 Binary files /dev/null and b/web/public/empty-state/disabled-feature/inbox-light.webp differ diff --git a/web/public/empty-state/inbox/filter-issue-dark.webp b/web/public/empty-state/inbox/filter-issue-dark.webp new file mode 100644 index 000000000..1e8e2dd74 Binary files /dev/null and b/web/public/empty-state/inbox/filter-issue-dark.webp differ diff --git a/web/public/empty-state/inbox/filter-issue-light.webp b/web/public/empty-state/inbox/filter-issue-light.webp new file mode 100644 index 000000000..ba27009a6 Binary files /dev/null and b/web/public/empty-state/inbox/filter-issue-light.webp differ diff --git a/web/public/empty-state/inbox/inbox-issue-dark.webp b/web/public/empty-state/inbox/inbox-issue-dark.webp new file mode 100644 index 000000000..4c15f9cdd Binary files /dev/null and b/web/public/empty-state/inbox/inbox-issue-dark.webp differ diff --git a/web/public/empty-state/inbox/inbox-issue-light.webp b/web/public/empty-state/inbox/inbox-issue-light.webp new file mode 100644 index 000000000..22a3d03f4 Binary files /dev/null and b/web/public/empty-state/inbox/inbox-issue-light.webp differ diff --git a/web/public/empty-state/inbox/issue-detail-dark.webp b/web/public/empty-state/inbox/issue-detail-dark.webp new file mode 100644 index 000000000..2a2c7063b Binary files /dev/null and b/web/public/empty-state/inbox/issue-detail-dark.webp differ diff --git a/web/public/empty-state/inbox/issue-detail-light.webp b/web/public/empty-state/inbox/issue-detail-light.webp new file mode 100644 index 000000000..a71306910 Binary files /dev/null and b/web/public/empty-state/inbox/issue-detail-light.webp differ diff --git a/web/services/inbox.service.ts b/web/services/inbox.service.ts deleted file mode 100644 index b55c02870..000000000 --- a/web/services/inbox.service.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { API_BASE_URL } from "@/helpers/common.helper"; -import { APIService } from "@/services/api.service"; -// helpers -// types -import type { IInboxIssue, IInbox, TInboxStatus, IInboxQueryParams } from "@plane/types"; - -export class InboxService extends APIService { - constructor() { - super(API_BASE_URL); - } - - async getInboxes(workspaceSlug: string, projectId: string): Promise { - return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/inboxes/`) - .then((response) => response?.data) - .catch((error) => { - throw error?.response?.data; - }); - } - - async getInboxById(workspaceSlug: string, projectId: string, inboxId: string): Promise { - return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/inboxes/${inboxId}/`) - .then((response) => response?.data) - .catch((error) => { - throw error?.response?.data; - }); - } - - async patchInbox(workspaceSlug: string, projectId: string, inboxId: string, data: Partial): Promise { - return this.patch(`/api/workspaces/${workspaceSlug}/projects/${projectId}/inboxes/${inboxId}/`, data) - .then((response) => response?.data) - .catch((error) => { - throw error?.response?.data; - }); - } - - async getInboxIssues( - workspaceSlug: string, - projectId: string, - inboxId: string, - params?: IInboxQueryParams - ): Promise { - return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/inboxes/${inboxId}/inbox-issues/`, { - params, - }) - .then((response) => response?.data) - .catch((error) => { - throw error?.response?.data; - }); - } - - async getInboxIssueById( - workspaceSlug: string, - projectId: string, - inboxId: string, - inboxIssueId: string - ): Promise { - return this.get( - `/api/workspaces/${workspaceSlug}/projects/${projectId}/inboxes/${inboxId}/inbox-issues/${inboxIssueId}/` - ) - .then((response) => response?.data) - .catch((error) => { - throw error?.response?.data; - }); - } - - async deleteInboxIssue( - workspaceSlug: string, - projectId: string, - inboxId: string, - inboxIssueId: string - ): Promise { - return this.delete( - `/api/workspaces/${workspaceSlug}/projects/${projectId}/inboxes/${inboxId}/inbox-issues/${inboxIssueId}/` - ) - .then((response) => response?.data) - .catch((error) => { - throw error?.response?.data; - }); - } - - async markInboxStatus( - workspaceSlug: string, - projectId: string, - inboxId: string, - inboxIssueId: string, - data: TInboxStatus - ): Promise { - return this.patch( - `/api/workspaces/${workspaceSlug}/projects/${projectId}/inboxes/${inboxId}/inbox-issues/${inboxIssueId}/`, - data - ) - .then((response) => response?.data) - .catch((error) => { - throw error?.response?.data; - }); - } - - async patchInboxIssue( - workspaceSlug: string, - projectId: string, - inboxId: string, - inboxIssueId: string, - data: { issue: Partial } - ): Promise { - return this.patch( - `/api/workspaces/${workspaceSlug}/projects/${projectId}/inboxes/${inboxId}/inbox-issues/${inboxIssueId}/`, - data - ) - .then((response) => response?.data) - .catch((error) => { - throw error?.response?.data; - }); - } - - async createInboxIssue(workspaceSlug: string, projectId: string, inboxId: string, data: any): Promise { - return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/inboxes/${inboxId}/inbox-issues/`, data) - .then((response) => response?.data) - .catch((error) => { - throw error?.response?.data; - }); - } -} diff --git a/web/services/inbox/inbox-issue.service.ts b/web/services/inbox/inbox-issue.service.ts index 6bedf1256..f8fcf72c4 100644 --- a/web/services/inbox/inbox-issue.service.ts +++ b/web/services/inbox/inbox-issue.service.ts @@ -1,25 +1,27 @@ +// types +import type { TInboxIssue, TIssue, TInboxIssueWithPagination } from "@plane/types"; import { API_BASE_URL } from "@/helpers/common.helper"; import { APIService } from "@/services/api.service"; // helpers -// types -import type { TInboxIssueFilterOptions, TInboxIssueExtendedDetail, TIssue, TInboxDetailedStatus } from "@plane/types"; export class InboxIssueService extends APIService { constructor() { super(API_BASE_URL); } - async fetchInboxIssues( - workspaceSlug: string, - projectId: string, - inboxId: string, - params?: TInboxIssueFilterOptions | {} - ): Promise { + async list(workspaceSlug: string, projectId: string, params = {}): Promise { + return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/inbox-issues/`, { + params, + }) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async retrieve(workspaceSlug: string, projectId: string, inboxIssueId: string): Promise { return this.get( - `/api/workspaces/${workspaceSlug}/projects/${projectId}/inboxes/${inboxId}/inbox-issues/?expand=issue_inbox`, - { - params, - } + `/api/workspaces/${workspaceSlug}/projects/${projectId}/inbox-issues/${inboxIssueId}/?expand=issue_inbox` ) .then((response) => response?.data) .catch((error) => { @@ -27,83 +29,47 @@ export class InboxIssueService extends APIService { }); } - async fetchInboxIssueById( - workspaceSlug: string, - projectId: string, - inboxId: string, - inboxIssueId: string - ): Promise { - return this.get( - `/api/workspaces/${workspaceSlug}/projects/${projectId}/inboxes/${inboxId}/inbox-issues/${inboxIssueId}/?expand=issue_inbox` - ) + async create(workspaceSlug: string, projectId: string, data: Partial): Promise { + return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/inbox-issues/`, { + source: "IN_APP", + issue: data, + }) .then((response) => response?.data) .catch((error) => { throw error?.response?.data; }); } - async createInboxIssue( + async update( workspaceSlug: string, projectId: string, - inboxId: string, - data: { - source: string; - issue: Partial; - } - ): Promise { - return this.post( - `/api/workspaces/${workspaceSlug}/projects/${projectId}/inboxes/${inboxId}/inbox-issues/?expand=issue_inbox`, - data - ) - .then((response) => response?.data) - .catch((error) => { - throw error?.response?.data; - }); - } - - async updateInboxIssue( - workspaceSlug: string, - projectId: string, - inboxId: string, inboxIssueId: string, - data: { issue: Partial } - ): Promise { - return this.patch( - `/api/workspaces/${workspaceSlug}/projects/${projectId}/inboxes/${inboxId}/inbox-issues/${inboxIssueId}/?expand=issue_inbox`, - data - ) + data: Partial + ): Promise { + return this.patch(`/api/workspaces/${workspaceSlug}/projects/${projectId}/inbox-issues/${inboxIssueId}/`, data) .then((response) => response?.data) .catch((error) => { throw error?.response?.data; }); } - async removeInboxIssue( + async updateIssue( workspaceSlug: string, projectId: string, - inboxId: string, - inboxIssueId: string - ): Promise { - return this.delete( - `/api/workspaces/${workspaceSlug}/projects/${projectId}/inboxes/${inboxId}/inbox-issues/${inboxIssueId}/` - ) - .then((response) => response?.data) - .catch((error) => { - throw error?.response?.data; - }); - } - - async updateInboxIssueStatus( - workspaceSlug: string, - projectId: string, - inboxId: string, inboxIssueId: string, - data: TInboxDetailedStatus - ): Promise { - return this.patch( - `/api/workspaces/${workspaceSlug}/projects/${projectId}/inboxes/${inboxId}/inbox-issues/${inboxIssueId}/?expand=issue_inbox`, - data - ) + data: Partial + ): Promise { + return this.patch(`/api/workspaces/${workspaceSlug}/projects/${projectId}/inbox-issues/${inboxIssueId}/`, { + issue: data, + }) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async destroy(workspaceSlug: string, projectId: string, inboxIssueId: string): Promise { + return this.delete(`/api/workspaces/${workspaceSlug}/projects/${projectId}/inbox-issues/${inboxIssueId}/`) .then((response) => response?.data) .catch((error) => { throw error?.response?.data; diff --git a/web/services/inbox/inbox.service.ts b/web/services/inbox/inbox.service.ts deleted file mode 100644 index b14fcae21..000000000 --- a/web/services/inbox/inbox.service.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { API_BASE_URL } from "@/helpers/common.helper"; -import { APIService } from "@/services/api.service"; -// helpers -// types -import type { TInbox } from "@plane/types"; - -export class InboxService extends APIService { - constructor() { - super(API_BASE_URL); - } - - async fetchInboxes(workspaceSlug: string, projectId: string): Promise { - return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/inboxes/`) - .then((response) => response?.data) - .catch((error) => { - throw error?.response?.data; - }); - } - - async fetchInboxById(workspaceSlug: string, projectId: string, inboxId: string): Promise { - return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/inboxes/${inboxId}/`) - .then((response) => response?.data) - .catch((error) => { - throw error?.response?.data; - }); - } - - async updateInbox(workspaceSlug: string, projectId: string, inboxId: string, data: Partial): Promise { - return this.patch(`/api/workspaces/${workspaceSlug}/projects/${projectId}/inboxes/${inboxId}/`, data) - .then((response) => response?.data) - .catch((error) => { - throw error?.response?.data; - }); - } -} diff --git a/web/services/inbox/index.ts b/web/services/inbox/index.ts index fe3f30fce..f4a25e361 100644 --- a/web/services/inbox/index.ts +++ b/web/services/inbox/index.ts @@ -1,2 +1 @@ -export * from "./inbox.service"; export * from "./inbox-issue.service"; diff --git a/web/store/inbox/inbox-issue.store.ts b/web/store/inbox/inbox-issue.store.ts new file mode 100644 index 000000000..1807645db --- /dev/null +++ b/web/store/inbox/inbox-issue.store.ts @@ -0,0 +1,139 @@ +import set from "lodash/set"; +import { makeObservable, observable, runInAction, action } from "mobx"; +// services +// types +import { TIssue, TInboxIssue, TInboxIssueStatus } from "@plane/types"; +import { InboxIssueService } from "@/services/inbox"; + +export interface IInboxIssueStore { + isLoading: boolean; + id: string; + status: TInboxIssueStatus; + issue: Partial; + snoozed_till: Date | undefined; + duplicate_to: string | undefined; + created_by: string | undefined; + // actions + updateInboxIssueStatus: (status: TInboxIssueStatus) => Promise; // accept, decline + updateInboxIssueDuplicateTo: (issueId: string) => Promise; // connecting the inbox issue to the project existing issue + updateInboxIssueSnoozeTill: (date: Date) => Promise; // snooze the issue + updateIssue: (issue: Partial) => Promise; // updating the issue +} + +export class InboxIssueStore implements IInboxIssueStore { + // observables + isLoading: boolean = false; + id: string; + status: TInboxIssueStatus = -2; + issue: Partial = {}; + snoozed_till: Date | undefined; + duplicate_to: string | undefined; + created_by: string | undefined; + workspaceSlug: string; + projectId: string; + // services + inboxIssueService; + + constructor(workspaceSlug: string, projectId: string, data: TInboxIssue) { + this.id = data.id; + this.status = data.status; + this.issue = data?.issue; + this.snoozed_till = data?.snoozed_till ? new Date(data.snoozed_till) : undefined; + this.duplicate_to = data?.duplicate_to || undefined; + this.created_by = data?.created_by || undefined; + this.workspaceSlug = workspaceSlug; + this.projectId = projectId; + // services + this.inboxIssueService = new InboxIssueService(); + // observable variables should be defined after the initialization of the values + makeObservable(this, { + id: observable, + status: observable, + issue: observable, + snoozed_till: observable, + duplicate_to: observable, + created_by: observable, + // actions + updateInboxIssueStatus: action, + updateInboxIssueDuplicateTo: action, + updateInboxIssueSnoozeTill: action, + updateIssue: action, + }); + } + + updateInboxIssueStatus = async (status: TInboxIssueStatus) => { + const previousData: Partial = { + status: this.status, + }; + try { + if (!this.issue.id) return; + set(this, "status", status); + await this.inboxIssueService.update(this.workspaceSlug, this.projectId, this.issue.id, { + status: status, + }); + } catch { + runInAction(() => set(this, "status", previousData.status)); + } + }; + + updateInboxIssueDuplicateTo = async (issueId: string) => { + const inboxStatus = 2; + const previousData: Partial = { + status: this.status, + duplicate_to: this.duplicate_to, + }; + try { + if (!this.issue.id) return; + set(this, "status", inboxStatus); + set(this, "duplicate_to", issueId); + await this.inboxIssueService.update(this.workspaceSlug, this.projectId, this.issue.id, { + status: inboxStatus, + duplicate_to: issueId, + }); + } catch { + runInAction(() => { + set(this, "status", previousData.status); + set(this, "duplicate_to", previousData.duplicate_to); + }); + } + }; + + updateInboxIssueSnoozeTill = async (date: Date) => { + const inboxStatus = 0; + const previousData: Partial = { + status: this.status, + snoozed_till: this.snoozed_till, + }; + try { + if (!this.issue.id) return; + set(this, "status", inboxStatus); + set(this, "snoozed_till", date); + await this.inboxIssueService.update(this.workspaceSlug, this.projectId, this.issue.id, { + status: inboxStatus, + snoozed_till: new Date(date), + }); + } catch { + runInAction(() => { + set(this, "status", previousData.status); + set(this, "snoozed_till", previousData.snoozed_till); + }); + } + }; + + updateIssue = async (issue: Partial) => { + const inboxIssue = this.issue; + try { + if (!this.issue.id) return; + Object.keys(issue).forEach((key) => { + const issueKey = key as keyof TIssue; + set(inboxIssue, issueKey, issue[issueKey]); + }); + await this.inboxIssueService.updateIssue(this.workspaceSlug, this.projectId, this.issue.id, issue); + } catch { + Object.keys(issue).forEach((key) => { + const issueKey = key as keyof TIssue; + set(inboxIssue, issueKey, inboxIssue[issueKey]); + }); + } + }; +} diff --git a/web/store/inbox/inbox.store.ts b/web/store/inbox/inbox.store.ts deleted file mode 100644 index 5d90b6d47..000000000 --- a/web/store/inbox/inbox.store.ts +++ /dev/null @@ -1,114 +0,0 @@ -import concat from "lodash/concat"; -import set from "lodash/set"; -import uniq from "lodash/uniq"; -import update from "lodash/update"; -import { observable, action, makeObservable, runInAction } from "mobx"; -import { computedFn } from "mobx-utils"; -// services -import { InboxService } from "@/services/inbox/inbox.service"; -// types -import { RootStore } from "@/store/root.store"; -import { TInboxDetailMap, TInboxDetailIdMap, TInbox } from "@plane/types"; - -export interface IInbox { - // observables - inboxes: TInboxDetailIdMap; - inboxMap: TInboxDetailMap; - // helper methods - getInboxesByProjectId: (projectId: string) => string[] | undefined; - getInboxById: (inboxId: string) => TInbox | undefined; - // fetch actions - fetchInboxes: (workspaceSlug: string, projectId: string) => Promise; - fetchInboxById: (workspaceSlug: string, projectId: string, inboxId: string) => Promise; - updateInbox: (workspaceSlug: string, projectId: string, inboxId: string, data: Partial) => Promise; -} - -export class Inbox implements IInbox { - // observables - inboxes: TInboxDetailIdMap = {}; - inboxMap: TInboxDetailMap = {}; - // root store - rootStore; - // services - inboxService; - - constructor(_rootStore: RootStore) { - makeObservable(this, { - // observables - inboxMap: observable, - inboxes: observable, - // actions - fetchInboxes: action, - fetchInboxById: action, - updateInbox: action, - }); - // root store - this.rootStore = _rootStore; - // services - this.inboxService = new InboxService(); - } - - // helper methods - getInboxesByProjectId = computedFn((projectId: string) => { - if (!projectId) return undefined; - return this.inboxes?.[projectId] ?? undefined; - }); - - getInboxById = computedFn((inboxId: string) => { - if (!inboxId) return undefined; - return this.inboxMap[inboxId] ?? undefined; - }); - - // actions - fetchInboxes = async (workspaceSlug: string, projectId: string) => { - try { - const response = await this.inboxService.fetchInboxes(workspaceSlug, projectId); - - const _inboxIds = response.map((inbox) => inbox.id); - runInAction(() => { - response.forEach((inbox) => { - set(this.inboxMap, inbox.id, inbox); - }); - set(this.inboxes, projectId, _inboxIds); - }); - - return response; - } catch (error) { - throw error; - } - }; - - fetchInboxById = async (workspaceSlug: string, projectId: string, inboxId: string) => { - try { - const response = await this.inboxService.fetchInboxById(workspaceSlug, projectId, inboxId); - - runInAction(() => { - set(this.inboxMap, inboxId, response); - update(this.inboxes, projectId, (inboxIds: string[] = []) => { - if (inboxIds.includes(inboxId)) return inboxIds; - return uniq(concat(inboxIds, inboxId)); - }); - }); - - return response; - } catch (error) { - throw error; - } - }; - - updateInbox = async (workspaceSlug: string, projectId: string, inboxId: string, data: Partial) => { - try { - const response = await this.inboxService.updateInbox(workspaceSlug, projectId, inboxId, data); - - runInAction(() => { - Object.keys(response).forEach((key) => { - set(this.inboxMap, [inboxId, key], response[key as keyof TInbox]); - }); - }); - - return response; - } catch (error) { - throw error; - } - }; -} diff --git a/web/store/inbox/inbox_filter.store.ts b/web/store/inbox/inbox_filter.store.ts deleted file mode 100644 index 603c75f9b..000000000 --- a/web/store/inbox/inbox_filter.store.ts +++ /dev/null @@ -1,129 +0,0 @@ -import isEmpty from "lodash/isEmpty"; -import set from "lodash/set"; -import { observable, action, makeObservable, runInAction, computed } from "mobx"; -// services -import { InboxService } from "@/services/inbox.service"; -// types -import { RootStore } from "@/store/root.store"; -import { TInboxIssueFilterOptions, TInboxIssueFilters, TInboxIssueQueryParams, TInbox } from "@plane/types"; - -export interface IInboxFilter { - // observables - filters: Record; // inbox_id -> TInboxIssueFilters - // computed - inboxFilters: TInboxIssueFilters | undefined; - inboxAppliedFilters: Partial> | undefined; - // actions - fetchInboxFilters: (workspaceSlug: string, projectId: string, inboxId: string) => Promise; - updateInboxFilters: ( - workspaceSlug: string, - projectId: string, - inboxId: string, - filters: Partial - ) => Promise; -} - -export class InboxFilter implements IInboxFilter { - // observables - filters: Record = {}; - // root store - rootStore; - // services - inboxService; - - constructor(_rootStore: RootStore) { - makeObservable(this, { - // observables - filters: observable, - // computed - inboxFilters: computed, - inboxAppliedFilters: computed, - // actions - fetchInboxFilters: action, - updateInboxFilters: action, - }); - // root store - this.rootStore = _rootStore; - // services - this.inboxService = new InboxService(); - } - - get inboxFilters() { - const inboxId = this.rootStore.app.router.inboxId; - if (!inboxId) return undefined; - - const displayFilters = this.filters[inboxId] || undefined; - if (isEmpty(displayFilters)) return undefined; - - const _filters: TInboxIssueFilters = { - filters: { - priority: isEmpty(displayFilters?.filters?.priority) ? [] : displayFilters?.filters?.priority, - inbox_status: isEmpty(displayFilters?.filters?.inbox_status) ? [] : displayFilters?.filters?.inbox_status, - }, - }; - return _filters; - } - - get inboxAppliedFilters() { - const userFilters = this.inboxFilters; - if (!userFilters) return undefined; - - const filteredParams = { - priority: userFilters?.filters?.priority?.join(",") || undefined, - inbox_status: userFilters?.filters?.inbox_status?.join(",") || undefined, - }; - return filteredParams; - } - - fetchInboxFilters = async (workspaceSlug: string, projectId: string, inboxId: string) => { - try { - const response = await this.rootStore.inbox.inbox.fetchInboxById(workspaceSlug, projectId, inboxId); - - const filters: TInboxIssueFilterOptions = { - priority: response?.view_props?.filters?.priority || [], - inbox_status: response?.view_props?.filters?.inbox_status || [], - }; - - runInAction(() => { - set(this.filters, [inboxId], { filters: filters }); - }); - - return response; - } catch (error) { - throw error; - } - }; - - updateInboxFilters = async ( - workspaceSlug: string, - projectId: string, - inboxId: string, - filters: Partial - ) => { - try { - runInAction(() => { - Object.keys(filters).forEach((_key) => { - const _filterKey = _key as keyof TInboxIssueFilterOptions; - set(this.filters, [inboxId, "filters", _key], filters[_filterKey]); - }); - }); - - const inboxFilters = this.inboxFilters; - let _filters: TInboxIssueFilterOptions = { - priority: inboxFilters?.filters?.priority || [], - inbox_status: inboxFilters?.filters?.inbox_status || [], - }; - _filters = { ..._filters, ...filters }; - - this.rootStore.inbox.inboxIssue.fetchInboxIssues(workspaceSlug, projectId, inboxId, "mutation"); - - const response = await this.rootStore.inbox.inbox.updateInbox(workspaceSlug, projectId, inboxId, { - view_props: { filters: _filters }, - }); - - return response; - } catch (error) { - throw error; - } - }; -} diff --git a/web/store/inbox/inbox_issue.store.ts b/web/store/inbox/inbox_issue.store.ts deleted file mode 100644 index 6e5eb94f0..000000000 --- a/web/store/inbox/inbox_issue.store.ts +++ /dev/null @@ -1,267 +0,0 @@ -import concat from "lodash/concat"; -import pull from "lodash/pull"; -import set from "lodash/set"; -import uniq from "lodash/uniq"; -import update from "lodash/update"; -import { observable, action, makeObservable, runInAction } from "mobx"; -import { computedFn } from "mobx-utils"; -// services -import { InboxIssueService } from "@/services/inbox/inbox-issue.service"; -// types -import { RootStore } from "@/store/root.store"; -import type { - TInboxIssueDetailIdMap, - TInboxIssueDetailMap, - TInboxIssueDetail, - TInboxIssueExtendedDetail, - TInboxDetailedStatus, - TIssue, -} from "@plane/types"; - -type TLoader = "init-loader" | "mutation" | undefined; - -export interface IInboxIssue { - // observables - loader: TLoader; - inboxIssues: TInboxIssueDetailIdMap; - inboxIssueMap: TInboxIssueDetailMap; - // helper methods - getInboxIssuesByInboxId: (inboxId: string) => string[] | undefined; - getInboxIssueByIssueId: (inboxId: string, issueId: string) => TInboxIssueDetail | undefined; - // actions - fetchInboxIssues: ( - workspaceSlug: string, - projectId: string, - inboxId: string, - loaderType?: TLoader - ) => Promise; - fetchInboxIssueById: ( - workspaceSlug: string, - projectId: string, - inboxId: string, - inboxIssueId: string - ) => Promise; - createInboxIssue: ( - workspaceSlug: string, - projectId: string, - inboxId: string, - data: Partial - ) => Promise; - updateInboxIssue: ( - workspaceSlug: string, - projectId: string, - inboxId: string, - inboxIssueId: string, - data: Partial - ) => Promise; - removeInboxIssue: (workspaceSlug: string, projectId: string, inboxId: string, issueId: string) => Promise; - updateInboxIssueStatus: ( - workspaceSlug: string, - projectId: string, - inboxId: string, - inboxIssueId: string, - data: TInboxDetailedStatus - ) => Promise; -} - -export class InboxIssue implements IInboxIssue { - // observables - loader: TLoader = "init-loader"; - inboxIssues: TInboxIssueDetailIdMap = {}; - inboxIssueMap: TInboxIssueDetailMap = {}; - // root store - rootStore; - // services - inboxIssueService; - - constructor(_rootStore: RootStore) { - makeObservable(this, { - // observables - loader: observable.ref, - inboxIssues: observable, - inboxIssueMap: observable, - // actions - fetchInboxIssues: action, - fetchInboxIssueById: action, - createInboxIssue: action, - updateInboxIssue: action, - removeInboxIssue: action, - updateInboxIssueStatus: action, - }); - - // root store - this.rootStore = _rootStore; - // services - this.inboxIssueService = new InboxIssueService(); - } - - // helper methods - getInboxIssuesByInboxId = computedFn((inboxId: string) => { - if (!inboxId) return undefined; - return this.inboxIssues?.[inboxId] ?? undefined; - }); - - getInboxIssueByIssueId = computedFn((inboxId: string, issueId: string) => { - if (!inboxId) return undefined; - return this.inboxIssueMap?.[inboxId]?.[issueId] ?? undefined; - }); - - // actions - fetchInboxIssues = async ( - workspaceSlug: string, - projectId: string, - inboxId: string, - loaderType: TLoader = "init-loader" - ) => { - try { - this.loader = loaderType; - const queryParams = this.rootStore.inbox.inboxFilter.inboxAppliedFilters ?? {}; - - const response = await this.inboxIssueService.fetchInboxIssues(workspaceSlug, projectId, inboxId, queryParams); - - runInAction(() => { - response.forEach((_inboxIssue) => { - const { ["issue_inbox"]: issueInboxDetail, ...issue } = _inboxIssue; - this.rootStore.inbox.rootStore.issue.issues.addIssue([issue]); - const { ["id"]: omittedId, ...inboxIssue } = issueInboxDetail[0]; - set(this.inboxIssueMap, [inboxId, _inboxIssue.id], inboxIssue); - }); - }); - - const _inboxIssueIds = response.map((inboxIssue) => inboxIssue.id); - runInAction(() => { - set(this.inboxIssues, inboxId, _inboxIssueIds); - this.loader = undefined; - }); - - return response; - } catch (error) { - this.loader = undefined; - throw error; - } - }; - - fetchInboxIssueById = async (workspaceSlug: string, projectId: string, inboxId: string, inboxIssueId: string) => { - try { - const response = await this.inboxIssueService.fetchInboxIssueById( - workspaceSlug, - projectId, - inboxId, - inboxIssueId - ); - - runInAction(() => { - const { ["issue_inbox"]: issueInboxDetail, ...issue } = response; - this.rootStore.inbox.rootStore.issue.issues.updateIssue(issue.id, issue); - const { ["id"]: omittedId, ...inboxIssue } = issueInboxDetail[0]; - set(this.inboxIssueMap, [inboxId, response.id], inboxIssue); - }); - - runInAction(() => { - update(this.inboxIssues, inboxId, (inboxIssueIds: string[] = []) => { - if (inboxIssueIds.includes(response.id)) return inboxIssueIds; - return uniq(concat(inboxIssueIds, response.id)); - }); - }); - - // fetching issue activity - await this.rootStore.issue.issueDetail.fetchActivities(workspaceSlug, projectId, inboxIssueId); - // fetching issue reaction - await this.rootStore.issue.issueDetail.fetchReactions(workspaceSlug, projectId, inboxIssueId); - return response as any; - } catch (error) { - throw error; - } - }; - - createInboxIssue = async (workspaceSlug: string, projectId: string, inboxId: string, data: Partial) => { - try { - const response = await this.inboxIssueService.createInboxIssue(workspaceSlug, projectId, inboxId, { - source: "in-app", - issue: data, - }); - - runInAction(() => { - const { ["issue_inbox"]: issueInboxDetail, ...issue } = response; - this.rootStore.inbox.rootStore.issue.issues.addIssue([issue]); - const { ["id"]: omittedId, ...inboxIssue } = issueInboxDetail[0]; - set(this.inboxIssueMap, [inboxId, response.id], inboxIssue); - update(this.rootStore.inbox.inbox.inboxMap, [inboxId, "pending_issue_count"], (count: number = 0) => count + 1); - }); - - runInAction(() => { - update(this.inboxIssues, inboxId, (inboxIssueIds: string[] = []) => { - if (inboxIssueIds.includes(response.id)) return inboxIssueIds; - return uniq(concat(inboxIssueIds, response.id)); - }); - }); - - await this.rootStore.issue.issueDetail.fetchActivities(workspaceSlug, projectId, response.id); - return response; - } catch (error) { - throw error; - } - }; - - updateInboxIssue = async ( - workspaceSlug: string, - projectId: string, - inboxId: string, - inboxIssueId: string, - data: Partial - ) => { - try { - const response = await this.inboxIssueService.updateInboxIssue(workspaceSlug, projectId, inboxId, inboxIssueId, { - issue: data, - }); - - this.rootStore.inbox.rootStore.issue.issues.updateIssue(inboxIssueId, data); - - await this.rootStore.issue.issueDetail.fetchActivities(workspaceSlug, projectId, inboxIssueId); - } catch (error) { - throw error; - } - }; - - removeInboxIssue = async (workspaceSlug: string, projectId: string, inboxId: string, inboxIssueId: string) => { - try { - await this.inboxIssueService.removeInboxIssue(workspaceSlug, projectId, inboxId, inboxIssueId); - - runInAction(() => { - pull(this.inboxIssues[inboxId], inboxIssueId); - delete this.inboxIssueMap[inboxId][inboxIssueId]; - this.rootStore.inbox.rootStore.issue.issues.removeIssue(inboxIssueId); - update(this.rootStore.inbox.inbox.inboxMap, [inboxId, "pending_issue_count"], (count: number = 0) => count - 1); - }); - - await this.rootStore.issue.issueDetail.fetchActivities(workspaceSlug, projectId, inboxIssueId); - } catch (error) { - throw error; - } - }; - - updateInboxIssueStatus = async ( - workspaceSlug: string, - projectId: string, - inboxId: string, - inboxIssueId: string, - data: TInboxDetailedStatus - ) => { - try { - await this.inboxIssueService.updateInboxIssueStatus(workspaceSlug, projectId, inboxId, inboxIssueId, data); - - const pendingStatus = -2; - runInAction(() => { - set(this.inboxIssueMap, [inboxId, inboxIssueId, "status"], data.status); - - update(this.rootStore.inbox.inbox.inboxMap, [inboxId, "pending_issue_count"], (count: number = 0) => - data.status === pendingStatus ? count + 1 : count - 1 - ); - }); - - await this.rootStore.issue.issueDetail.fetchActivities(workspaceSlug, projectId, inboxIssueId); - } catch (error) { - throw error; - } - }; -} diff --git a/web/store/inbox/project-inbox.store.ts b/web/store/inbox/project-inbox.store.ts new file mode 100644 index 000000000..b871ccb98 --- /dev/null +++ b/web/store/inbox/project-inbox.store.ts @@ -0,0 +1,353 @@ +import isEmpty from "lodash/isEmpty"; +import omit from "lodash/omit"; +import set from "lodash/set"; +import { action, computed, makeObservable, observable, runInAction } from "mobx"; +import { computedFn } from "mobx-utils"; +// types +import { + TInboxIssue, + TInboxIssueCurrentTab, + TInboxIssueFilter, + TInboxIssueSorting, + TInboxIssuePaginationInfo, + TInboxIssueSortingOrderByQueryParam, +} from "@plane/types"; +// services +import { InboxIssueService } from "@/services/inbox"; +// root store +import { IInboxIssueStore, InboxIssueStore } from "@/store/inbox/inbox-issue.store"; +import { RootStore } from "@/store/root.store"; + +type TLoader = "init-loading" | "filter-loading" | "pagination-loading" | "issue-loading" | undefined; + +export interface IProjectInboxStore { + currentTab: TInboxIssueCurrentTab; + isLoading: TLoader; + error: { message: string; status: "init-error" | "pagination-error" } | undefined; + inboxFilters: Partial; + inboxSorting: Partial; + inboxIssuePaginationInfo: TInboxIssuePaginationInfo | undefined; + inboxIssues: Record; + // computed + getAppliedFiltersCount: number; + inboxIssuesArray: IInboxIssueStore[]; + // helper actions + getIssueInboxByIssueId: (issueId: string) => IInboxIssueStore | undefined; + inboxIssueQueryParams: ( + inboxFilters: Partial, + inboxSorting: Partial, + pagePerCount: number, + paginationCursor: string + ) => Partial>; + // actions + handleCurrentTab: (tab: TInboxIssueCurrentTab) => void; + handleInboxIssueFilters: (key: T, value: TInboxIssueFilter[T]) => void; // if user sends me undefined, I will remove the value from the filter key + handleInboxIssueSorting: (key: T, value: TInboxIssueSorting[T]) => void; // if user sends me undefined, I will remove the value from the filter key + fetchInboxIssues: (workspaceSlug: string, projectId: string, loadingType?: TLoader) => Promise; + fetchInboxPaginationIssues: (workspaceSlug: string, projectId: string) => Promise; + fetchInboxIssueById: (workspaceSlug: string, projectId: string, inboxIssueId: string) => Promise; + createInboxIssue: ( + workspaceSlug: string, + projectId: string, + data: Partial + ) => Promise; + deleteInboxIssue: (workspaceSlug: string, projectId: string, inboxIssueId: string) => Promise; +} + +export class ProjectInboxStore implements IProjectInboxStore { + // constants + PER_PAGE_COUNT = 10; + // observables + currentTab: TInboxIssueCurrentTab = "open"; + isLoading: TLoader = undefined; + error: { message: string; status: "init-error" | "pagination-error" } | undefined = undefined; + inboxFilters: Partial = { + status: [-2], + }; + inboxSorting: Partial = { + order_by: "issue__created_at", + sort_by: "desc", + }; + inboxIssuePaginationInfo: TInboxIssuePaginationInfo | undefined = undefined; + inboxIssues: Record = {}; + // services + inboxIssueService; + + constructor(private store: RootStore) { + makeObservable(this, { + currentTab: observable.ref, + isLoading: observable.ref, + inboxFilters: observable, + inboxSorting: observable, + inboxIssuePaginationInfo: observable, + inboxIssues: observable, + // computed + getAppliedFiltersCount: computed, + inboxIssuesArray: computed, + // actions + handleInboxIssueFilters: action, + handleInboxIssueSorting: action, + fetchInboxIssues: action, + fetchInboxPaginationIssues: action, + fetchInboxIssueById: action, + createInboxIssue: action, + deleteInboxIssue: action, + }); + this.inboxIssueService = new InboxIssueService(); + } + + // computed + get getAppliedFiltersCount() { + let count = 0; + this.inboxFilters != undefined && + Object.keys(this.inboxFilters).forEach((key) => { + const filterKey = key as keyof TInboxIssueFilter; + if (this.inboxFilters[filterKey] && this.inboxFilters?.[filterKey]) + count = count + (this.inboxFilters?.[filterKey]?.length ?? 0); + }); + return count; + } + + get inboxIssuesArray() { + return Object.values(this.inboxIssues || {}).filter((inbox) => + (this.currentTab === "open" ? [-2] : [-1, 0, 1, 2]).includes(inbox.status) + ); + } + + getIssueInboxByIssueId = computedFn((issueId: string) => this.inboxIssues?.[issueId] || undefined); + + inboxIssueQueryParams = ( + inboxFilters: Partial, + inboxSorting: Partial, + pagePerCount: number, + paginationCursor: string + ) => { + const filters: Partial> = {}; + !isEmpty(inboxFilters) && + Object.keys(inboxFilters).forEach((key) => { + const filterKey = key as keyof TInboxIssueFilter; + if (inboxFilters[filterKey] && inboxFilters[filterKey]?.length) + filters[filterKey] = inboxFilters[filterKey]?.join(","); + }); + + const sorting: TInboxIssueSortingOrderByQueryParam = { + order_by: "-issue__created_at", + }; + if (inboxSorting?.order_by && inboxSorting?.sort_by) { + switch (inboxSorting.order_by) { + case "issue__created_at": + if (inboxSorting.sort_by === "desc") sorting.order_by = `-issue__created_at`; + else sorting.order_by = "issue__created_at"; + break; + case "issue__updated_at": + if (inboxSorting.sort_by === "desc") sorting.order_by = `-issue__updated_at`; + else sorting.order_by = "issue__updated_at"; + break; + case "issue__sequence_id": + if (inboxSorting.sort_by === "desc") sorting.order_by = `-issue__sequence_id`; + else sorting.order_by = "issue__sequence_id"; + break; + default: + sorting.order_by = "-issue__created_at"; + break; + } + } + + return { + ...filters, + ...sorting, + per_page: pagePerCount, + cursor: paginationCursor, + }; + }; + + // actions + handleCurrentTab = (tab: TInboxIssueCurrentTab) => { + set(this, "currentTab", tab); + set(this, "inboxFilters", undefined); + set(this, ["inboxSorting", "order_by"], "issue__created_at"); + set(this, ["inboxSorting", "sort_by"], "desc"); + if (tab === "closed") set(this, ["inboxFilters", "status"], [-1, 0, 1, 2]); + else set(this, ["inboxFilters", "status"], [-2]); + const { workspaceSlug, projectId } = this.store.app.router; + if (workspaceSlug && projectId) this.fetchInboxIssues(workspaceSlug, projectId, "filter-loading"); + }; + + handleInboxIssueFilters = (key: T, value: TInboxIssueFilter[T]) => { + set(this.inboxFilters, key, value); + const { workspaceSlug, projectId } = this.store.app.router; + if (workspaceSlug && projectId) this.fetchInboxIssues(workspaceSlug, projectId, "filter-loading"); + }; + + handleInboxIssueSorting = (key: T, value: TInboxIssueSorting[T]) => { + set(this.inboxSorting, key, value); + const { workspaceSlug, projectId } = this.store.app.router; + if (workspaceSlug && projectId) this.fetchInboxIssues(workspaceSlug, projectId, "filter-loading"); + }; + + /** + * @description fetch inbox issues with paginated data + * @param workspaceSlug + * @param projectId + */ + fetchInboxIssues = async (workspaceSlug: string, projectId: string, loadingType: TLoader = undefined) => { + try { + if (loadingType) this.isLoading = loadingType; + else this.isLoading = "init-loading"; + this.inboxIssuePaginationInfo = undefined; + this.inboxIssues = {}; + + const queryParams = this.inboxIssueQueryParams( + this.inboxFilters, + this.inboxSorting, + this.PER_PAGE_COUNT, + `${this.PER_PAGE_COUNT}:0:0` + ); + const { results, ...paginationInfo } = await this.inboxIssueService.list(workspaceSlug, projectId, queryParams); + + runInAction(() => { + this.isLoading = undefined; + set(this, "inboxIssuePaginationInfo", paginationInfo); + if (results && results.length > 0) + results.forEach((value: TInboxIssue) => { + if (this.getIssueInboxByIssueId(value?.issue?.id) === undefined) + set(this.inboxIssues, value?.issue?.id, new InboxIssueStore(workspaceSlug, projectId, value)); + }); + }); + } catch (error) { + console.error("Error fetching the inbox issues", error); + this.isLoading = undefined; + this.error = { + message: "Error fetching the inbox issues please try again later.", + status: "init-error", + }; + throw error; + } + }; + + /** + * @description fetch inbox issues with paginated data + * @param workspaceSlug + * @param projectId + */ + fetchInboxPaginationIssues = async (workspaceSlug: string, projectId: string) => { + try { + if ( + !this.inboxIssuePaginationInfo?.total_results || + (this.inboxIssuePaginationInfo?.total_results && + this.inboxIssuesArray.length < this.inboxIssuePaginationInfo?.total_results) + ) { + this.isLoading = "pagination-loading"; + + const queryParams = this.inboxIssueQueryParams( + this.inboxFilters, + this.inboxSorting, + this.PER_PAGE_COUNT, + this.inboxIssuePaginationInfo?.next_cursor || `${this.PER_PAGE_COUNT}:0:0` + ); + const { results, ...paginationInfo } = await this.inboxIssueService.list(workspaceSlug, projectId, queryParams); + + runInAction(() => { + this.isLoading = undefined; + set(this, "inboxIssuePaginationInfo", paginationInfo); + if (results && results.length > 0) + results.forEach((value: TInboxIssue) => { + if (this.getIssueInboxByIssueId(value?.issue?.id) === undefined) + set(this.inboxIssues, value?.issue?.id, new InboxIssueStore(workspaceSlug, projectId, value)); + }); + }); + } else set(this, ["inboxIssuePaginationInfo", "next_page_results"], false); + } catch (error) { + console.error("Error fetching the inbox issues", error); + this.isLoading = undefined; + this.error = { + message: "Error fetching the paginated inbox issues please try again later.", + status: "pagination-error", + }; + throw error; + } + }; + + /** + * @description fetch inbox issue with issue id + * @param workspaceSlug + * @param projectId + * @param inboxIssueId + */ + fetchInboxIssueById = async (workspaceSlug: string, projectId: string, inboxIssueId: string) => { + try { + this.isLoading = "issue-loading"; + const inboxIssue = await this.inboxIssueService.retrieve(workspaceSlug, projectId, inboxIssueId); + const issueId = inboxIssue?.issue?.id || undefined; + + if (inboxIssue && issueId) { + // fetching reactions + await this.store.issue.issueDetail.fetchReactions(workspaceSlug, projectId, issueId); + // fetching activity + await this.store.issue.issueDetail.fetchReactions(workspaceSlug, projectId, issueId); + // fetching comments + await this.store.issue.issueDetail.fetchReactions(workspaceSlug, projectId, issueId); + runInAction(() => { + set(this.inboxIssues, issueId, new InboxIssueStore(workspaceSlug, projectId, inboxIssue)); + }); + this.isLoading = undefined; + } + } catch { + console.error("Error fetching the inbox issue with inbox issue id"); + this.isLoading = undefined; + } + }; + + /** + * @description create inbox issue + * @param workspaceSlug + * @param projectId + * @param data + */ + createInboxIssue = async (workspaceSlug: string, projectId: string, data: Partial) => { + try { + const inboxIssueResponse = await this.inboxIssueService.create(workspaceSlug, projectId, data); + if (inboxIssueResponse) + runInAction(() => { + set( + this.inboxIssues, + inboxIssueResponse?.issue?.id, + new InboxIssueStore(workspaceSlug, projectId, inboxIssueResponse) + ); + set( + this, + ["inboxIssuePaginationInfo", "total_results"], + (this.inboxIssuePaginationInfo?.total_results || 0) + 1 + ); + }); + return inboxIssueResponse; + } catch { + console.error("Error creating the inbox issue"); + } + }; + + /** + * @description delete inbox issue + * @param workspaceSlug + * @param projectId + * @param inboxIssueId + */ + deleteInboxIssue = async (workspaceSlug: string, projectId: string, inboxIssueId: string) => { + const currentIssue = this.inboxIssues?.[inboxIssueId]; + try { + if (!currentIssue) return; + runInAction(() => { + set( + this, + ["inboxIssuePaginationInfo", "total_results"], + (this.inboxIssuePaginationInfo?.total_results || 0) - 1 + ); + set(this, "inboxIssues", omit(this.inboxIssues, inboxIssueId)); + }); + await this.inboxIssueService.destroy(workspaceSlug, projectId, inboxIssueId); + } catch { + console.error("Error removing the inbox issue"); + set(this.inboxIssues, [inboxIssueId], currentIssue); + } + }; +} diff --git a/web/store/inbox/root.store.ts b/web/store/inbox/root.store.ts deleted file mode 100644 index 0628e28ab..000000000 --- a/web/store/inbox/root.store.ts +++ /dev/null @@ -1,26 +0,0 @@ -// types -import { RootStore } from "@/store/root.store"; -import { IInbox, Inbox } from "./inbox.store"; -import { IInboxFilter, InboxFilter } from "./inbox_filter.store"; -import { IInboxIssue, InboxIssue } from "./inbox_issue.store"; - -export interface IInboxRootStore { - rootStore: RootStore; - inbox: IInbox; - inboxIssue: IInboxIssue; - inboxFilter: IInboxFilter; -} - -export class InboxRootStore implements IInboxRootStore { - rootStore: RootStore; - inbox: IInbox; - inboxIssue: IInboxIssue; - inboxFilter: IInboxFilter; - - constructor(_rootStore: RootStore) { - this.rootStore = _rootStore; - this.inbox = new Inbox(_rootStore); - this.inboxIssue = new InboxIssue(_rootStore); - this.inboxFilter = new InboxFilter(_rootStore); - } -} diff --git a/web/store/issue/project/filter.store.ts b/web/store/issue/project/filter.store.ts index 707f8e705..66ce98344 100644 --- a/web/store/issue/project/filter.store.ts +++ b/web/store/issue/project/filter.store.ts @@ -71,9 +71,7 @@ export class ProjectIssuesFilter extends IssueFilterHelperStore implements IProj const displayFilters = this.filters[projectId] || undefined; if (isEmpty(displayFilters)) return undefined; - const _filters: IIssueFilters = this.computedIssueFilters(displayFilters); - - return _filters; + return this.computedIssueFilters(displayFilters); } get appliedFilters() { diff --git a/web/store/root.store.ts b/web/store/root.store.ts index 5dc3c2574..d0be5b452 100644 --- a/web/store/root.store.ts +++ b/web/store/root.store.ts @@ -1,25 +1,25 @@ import { enableStaticRendering } from "mobx-react-lite"; // root stores +import { ProjectInboxStore, IProjectInboxStore } from "@/store/inbox/project-inbox.store"; import { AppRootStore, IAppRootStore } from "./application"; import { CycleStore, ICycleStore } from "./cycle.store"; +import { CycleFilterStore, ICycleFilterStore } from "./cycle_filter.store"; import { DashboardStore, IDashboardStore } from "./dashboard.store"; import { IEstimateStore, EstimateStore } from "./estimate.store"; import { EventTrackerStore, IEventTrackerStore } from "./event-tracker.store"; import { GlobalViewStore, IGlobalViewStore } from "./global-view.store"; -import { IInboxRootStore, InboxRootStore } from "./inbox/root.store"; import { IssueRootStore, IIssueRootStore } from "./issue/root.store"; import { ILabelStore, LabelStore } from "./label.store"; import { IMemberRootStore, MemberRootStore } from "./member"; import { IMentionStore, MentionStore } from "./mention.store"; import { IModuleStore, ModulesStore } from "./module.store"; +import { IModuleFilterStore, ModuleFilterStore } from "./module_filter.store"; import { IProjectRootStore, ProjectRootStore } from "./project"; +import { IProjectPageStore, ProjectPageStore } from "./project-page.store"; import { IProjectViewStore, ProjectViewStore } from "./project-view.store"; import { IStateStore, StateStore } from "./state.store"; import { IUserRootStore, UserRootStore } from "./user"; import { IWorkspaceRootStore, WorkspaceRootStore } from "./workspace"; -import { IProjectPageStore, ProjectPageStore } from "./project-page.store"; -import { CycleFilterStore, ICycleFilterStore } from "./cycle_filter.store"; -import { IModuleFilterStore, ModuleFilterStore } from "./module_filter.store"; enableStaticRendering(typeof window === "undefined"); @@ -37,13 +37,13 @@ export class RootStore { projectView: IProjectViewStore; globalView: IGlobalViewStore; issue: IIssueRootStore; - inbox: IInboxRootStore; state: IStateStore; label: ILabelStore; estimate: IEstimateStore; mention: IMentionStore; dashboard: IDashboardStore; projectPages: IProjectPageStore; + projectInbox: IProjectInboxStore; constructor() { this.app = new AppRootStore(this); @@ -60,12 +60,13 @@ export class RootStore { this.projectView = new ProjectViewStore(this); this.globalView = new GlobalViewStore(this); this.issue = new IssueRootStore(this); - this.inbox = new InboxRootStore(this); this.state = new StateStore(this); this.label = new LabelStore(this); this.estimate = new EstimateStore(this); this.mention = new MentionStore(this); this.dashboard = new DashboardStore(this); + // inbox + this.projectInbox = new ProjectInboxStore(this); this.projectPages = new ProjectPageStore(this); } @@ -81,12 +82,12 @@ export class RootStore { this.projectView = new ProjectViewStore(this); this.globalView = new GlobalViewStore(this); this.issue = new IssueRootStore(this); - this.inbox = new InboxRootStore(this); this.state = new StateStore(this); this.label = new LabelStore(this); this.estimate = new EstimateStore(this); this.mention = new MentionStore(this); this.dashboard = new DashboardStore(this); + this.projectInbox = new ProjectInboxStore(this); this.projectPages = new ProjectPageStore(this); } }