diff --git a/apiserver/plane/app/serializers/__init__.py b/apiserver/plane/app/serializers/__init__.py index e1f322fde..094328fff 100644 --- a/apiserver/plane/app/serializers/__init__.py +++ b/apiserver/plane/app/serializers/__init__.py @@ -68,7 +68,6 @@ from .issue import ( IssueRelationSerializer, RelatedIssueSerializer, IssuePublicSerializer, - IssueRelationLiteSerializer, ) from .module import ( @@ -120,3 +119,5 @@ from .notification import NotificationSerializer from .exporter import ExporterHistorySerializer from .webhook import WebhookSerializer, WebhookLogSerializer + +from .dashboard import DashboardSerializer, WidgetSerializer diff --git a/apiserver/plane/app/serializers/base.py b/apiserver/plane/app/serializers/base.py index f617124bf..89683ffe5 100644 --- a/apiserver/plane/app/serializers/base.py +++ b/apiserver/plane/app/serializers/base.py @@ -59,6 +59,7 @@ class DynamicBaseSerializer(BaseSerializer): LabelSerializer, CycleIssueSerializer, IssueFlatSerializer, + IssueRelationSerializer, ) # Expansion mapper @@ -78,14 +79,10 @@ class DynamicBaseSerializer(BaseSerializer): "labels": LabelSerializer, "issue_cycle": CycleIssueSerializer, "parent": IssueSerializer, + "issue_relation": IssueRelationSerializer, } - - self.fields[field] = expansion[field]( - many=True - if field - in ["members", "assignees", "labels", "issue_cycle"] - else False - ) + + self.fields[field] = expansion[field](many=True if field in ["members", "assignees", "labels", "issue_cycle", "issue_relation"] else False) return self.fields @@ -105,6 +102,7 @@ class DynamicBaseSerializer(BaseSerializer): IssueSerializer, LabelSerializer, CycleIssueSerializer, + IssueRelationSerializer, ) # Expansion mapper @@ -124,6 +122,7 @@ class DynamicBaseSerializer(BaseSerializer): "labels": LabelSerializer, "issue_cycle": CycleIssueSerializer, "parent": IssueSerializer, + "issue_relation": IssueRelationSerializer } # Check if field in expansion then expand the field if expand in expansion: diff --git a/apiserver/plane/app/serializers/dashboard.py b/apiserver/plane/app/serializers/dashboard.py new file mode 100644 index 000000000..8fca3c906 --- /dev/null +++ b/apiserver/plane/app/serializers/dashboard.py @@ -0,0 +1,26 @@ +# Module imports +from .base import BaseSerializer +from plane.db.models import Dashboard, Widget + +# Third party frameworks +from rest_framework import serializers + + +class DashboardSerializer(BaseSerializer): + class Meta: + model = Dashboard + fields = "__all__" + + +class WidgetSerializer(BaseSerializer): + is_visible = serializers.BooleanField(read_only=True) + widget_filters = serializers.JSONField(read_only=True) + + class Meta: + model = Widget + fields = [ + "id", + "key", + "is_visible", + "widget_filters" + ] \ No newline at end of file diff --git a/apiserver/plane/app/serializers/issue.py b/apiserver/plane/app/serializers/issue.py index 322caa275..0b3b666ce 100644 --- a/apiserver/plane/app/serializers/issue.py +++ b/apiserver/plane/app/serializers/issue.py @@ -293,31 +293,19 @@ class IssueLabelSerializer(BaseSerializer): ] -class IssueRelationLiteSerializer(DynamicBaseSerializer): - project_id = serializers.PrimaryKeyRelatedField(read_only=True) - - class Meta: - model = Issue - fields = [ - "id", - "project_id", - "sequence_id", - ] - read_only_fields = [ - "workspace", - "project", - ] - - class IssueRelationSerializer(BaseSerializer): - issue_detail = IssueRelationLiteSerializer( - read_only=True, source="related_issue" - ) + id = serializers.UUIDField(source="related_issue.id", read_only=True) + project_id = serializers.PrimaryKeyRelatedField(source="related_issue.project_id", read_only=True) + sequence_id = serializers.IntegerField(source="related_issue.sequence_id", read_only=True) + relation_type = serializers.CharField(read_only=True) class Meta: model = IssueRelation fields = [ - "issue_detail", + "id", + "project_id", + "sequence_id", + "relation_type", ] read_only_fields = [ "workspace", @@ -326,12 +314,18 @@ class IssueRelationSerializer(BaseSerializer): class RelatedIssueSerializer(BaseSerializer): - issue_detail = IssueRelationLiteSerializer(read_only=True, source="issue") + id = serializers.UUIDField(source="issue.id", read_only=True) + project_id = serializers.PrimaryKeyRelatedField(source="issue.project_id", read_only=True) + sequence_id = serializers.IntegerField(source="issue.sequence_id", read_only=True) + relation_type = serializers.CharField(read_only=True) class Meta: model = IssueRelation fields = [ - "issue_detail", + "id", + "project_id", + "sequence_id", + "relation_type", ] read_only_fields = [ "workspace", diff --git a/apiserver/plane/app/urls/__init__.py b/apiserver/plane/app/urls/__init__.py index 845660807..f2b11f127 100644 --- a/apiserver/plane/app/urls/__init__.py +++ b/apiserver/plane/app/urls/__init__.py @@ -3,6 +3,7 @@ from .asset import urlpatterns as asset_urls from .authentication import urlpatterns as authentication_urls from .config import urlpatterns as configuration_urls from .cycle import urlpatterns as cycle_urls +from .dashboard import urlpatterns as dashboard_urls from .estimate import urlpatterns as estimate_urls from .external import urlpatterns as external_urls from .importer import urlpatterns as importer_urls @@ -28,6 +29,7 @@ urlpatterns = [ *authentication_urls, *configuration_urls, *cycle_urls, + *dashboard_urls, *estimate_urls, *external_urls, *importer_urls, diff --git a/apiserver/plane/app/urls/dashboard.py b/apiserver/plane/app/urls/dashboard.py new file mode 100644 index 000000000..0dc24a808 --- /dev/null +++ b/apiserver/plane/app/urls/dashboard.py @@ -0,0 +1,23 @@ +from django.urls import path + + +from plane.app.views import DashboardEndpoint, WidgetsEndpoint + + +urlpatterns = [ + path( + "workspaces//dashboard/", + DashboardEndpoint.as_view(), + name="dashboard", + ), + path( + "workspaces//dashboard//", + DashboardEndpoint.as_view(), + name="dashboard", + ), + path( + "dashboard//widgets//", + WidgetsEndpoint.as_view(), + name="widgets", + ), +] diff --git a/apiserver/plane/app/views/__init__.py b/apiserver/plane/app/views/__init__.py index dccf2bb79..d3c4f4baf 100644 --- a/apiserver/plane/app/views/__init__.py +++ b/apiserver/plane/app/views/__init__.py @@ -176,3 +176,8 @@ from .webhook import ( WebhookLogsEndpoint, WebhookSecretRegenerateEndpoint, ) + +from .dashboard import ( + DashboardEndpoint, + WidgetsEndpoint +) \ No newline at end of file diff --git a/apiserver/plane/app/views/dashboard.py b/apiserver/plane/app/views/dashboard.py new file mode 100644 index 000000000..af476a130 --- /dev/null +++ b/apiserver/plane/app/views/dashboard.py @@ -0,0 +1,658 @@ +# Django imports +from django.db.models import ( + Q, + Case, + When, + Value, + CharField, + Count, + F, + Exists, + OuterRef, + Max, + Subquery, + JSONField, + Func, + Prefetch, +) +from django.utils import timezone + +# Third Party imports +from rest_framework.response import Response +from rest_framework import status + +# Module imports +from . import BaseAPIView +from plane.db.models import ( + Issue, + IssueActivity, + ProjectMember, + Widget, + DashboardWidget, + Dashboard, + Project, + IssueLink, + IssueAttachment, + IssueRelation, +) +from plane.app.serializers import ( + IssueActivitySerializer, + IssueSerializer, + DashboardSerializer, + WidgetSerializer, +) +from plane.utils.issue_filters import issue_filters + + +def dashboard_overview_stats(self, request, slug): + assigned_issues = Issue.issue_objects.filter( + project__project_projectmember__is_active=True, + project__project_projectmember__member=request.user, + workspace__slug=slug, + assignees__in=[request.user], + ).count() + + pending_issues_count = Issue.issue_objects.filter( + ~Q(state__group__in=["completed", "cancelled"]), + project__project_projectmember__is_active=True, + project__project_projectmember__member=request.user, + workspace__slug=slug, + assignees__in=[request.user], + ).count() + + created_issues_count = Issue.issue_objects.filter( + workspace__slug=slug, + project__project_projectmember__is_active=True, + project__project_projectmember__member=request.user, + created_by_id=request.user.id, + ).count() + + completed_issues_count = Issue.issue_objects.filter( + workspace__slug=slug, + project__project_projectmember__is_active=True, + project__project_projectmember__member=request.user, + assignees__in=[request.user], + state__group="completed", + ).count() + + return Response( + { + "assigned_issues_count": assigned_issues, + "pending_issues_count": pending_issues_count, + "completed_issues_count": completed_issues_count, + "created_issues_count": created_issues_count, + }, + status=status.HTTP_200_OK, + ) + + +def dashboard_assigned_issues(self, request, slug): + filters = issue_filters(request.query_params, "GET") + issue_type = request.GET.get("issue_type", None) + + # get all the assigned issues + assigned_issues = ( + Issue.issue_objects.filter( + workspace__slug=slug, + project__project_projectmember__member=request.user, + project__project_projectmember__is_active=True, + assignees__in=[request.user], + ) + .filter(**filters) + .select_related("workspace", "project", "state", "parent") + .prefetch_related("assignees", "labels") + .prefetch_related( + Prefetch( + "issue_relation", + queryset=IssueRelation.objects.select_related( + "related_issue" + ).select_related("issue"), + ) + ) + .annotate(cycle_id=F("issue_cycle__cycle_id")) + .annotate(module_id=F("issue_module__module_id")) + .annotate( + link_count=IssueLink.objects.filter(issue=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + attachment_count=IssueAttachment.objects.filter( + issue=OuterRef("id") + ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .order_by("created_at") + ) + + # Priority Ordering + priority_order = ["urgent", "high", "medium", "low", "none"] + assigned_issues = assigned_issues.annotate( + priority_order=Case( + *[ + When(priority=p, then=Value(i)) + for i, p in enumerate(priority_order) + ], + output_field=CharField(), + ) + ).order_by("priority_order") + + if issue_type == "completed": + completed_issues_count = assigned_issues.filter( + state__group__in=["completed"] + ).count() + completed_issues = assigned_issues.filter( + state__group__in=["completed"] + )[:5] + return Response( + { + "issues": IssueSerializer( + completed_issues, many=True, expand=self.expand + ).data, + "count": completed_issues_count, + }, + status=status.HTTP_200_OK, + ) + + if issue_type == "overdue": + overdue_issues_count = assigned_issues.filter( + state__group__in=["backlog", "unstarted", "started"], + target_date__lt=timezone.now() + ).count() + overdue_issues = assigned_issues.filter( + state__group__in=["backlog", "unstarted", "started"], + target_date__lt=timezone.now() + )[:5] + return Response( + { + "issues": IssueSerializer( + overdue_issues, many=True, expand=self.expand + ).data, + "count": overdue_issues_count, + }, + status=status.HTTP_200_OK, + ) + + if issue_type == "upcoming": + upcoming_issues_count = assigned_issues.filter( + state__group__in=["backlog", "unstarted", "started"], + target_date__gte=timezone.now() + ).count() + upcoming_issues = assigned_issues.filter( + state__group__in=["backlog", "unstarted", "started"], + target_date__gte=timezone.now() + )[:5] + return Response( + { + "issues": IssueSerializer( + upcoming_issues, many=True, expand=self.expand + ).data, + "count": upcoming_issues_count, + }, + status=status.HTTP_200_OK, + ) + + return Response( + {"error": "Please specify a valid issue type"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + +def dashboard_created_issues(self, request, slug): + filters = issue_filters(request.query_params, "GET") + issue_type = request.GET.get("issue_type", None) + + # get all the assigned issues + created_issues = ( + Issue.issue_objects.filter( + workspace__slug=slug, + project__project_projectmember__member=request.user, + project__project_projectmember__is_active=True, + created_by=request.user, + ) + .filter(**filters) + .select_related("workspace", "project", "state", "parent") + .prefetch_related("assignees", "labels") + .annotate(cycle_id=F("issue_cycle__cycle_id")) + .annotate(module_id=F("issue_module__module_id")) + .annotate( + link_count=IssueLink.objects.filter(issue=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + attachment_count=IssueAttachment.objects.filter( + issue=OuterRef("id") + ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .order_by("created_at") + ) + + # Priority Ordering + priority_order = ["urgent", "high", "medium", "low", "none"] + created_issues = created_issues.annotate( + priority_order=Case( + *[ + When(priority=p, then=Value(i)) + for i, p in enumerate(priority_order) + ], + output_field=CharField(), + ) + ).order_by("priority_order") + + if issue_type == "completed": + completed_issues_count = created_issues.filter( + state__group__in=["completed"] + ).count() + completed_issues = created_issues.filter( + state__group__in=["completed"] + )[:5] + return Response( + { + "issues": IssueSerializer(completed_issues, many=True).data, + "count": completed_issues_count, + }, + status=status.HTTP_200_OK, + ) + + if issue_type == "overdue": + overdue_issues_count = created_issues.filter( + state__group__in=["backlog", "unstarted", "started"], + target_date__lt=timezone.now() + ).count() + overdue_issues = created_issues.filter( + state__group__in=["backlog", "unstarted", "started"], + target_date__lt=timezone.now() + )[:5] + return Response( + { + "issues": IssueSerializer(overdue_issues, many=True).data, + "count": overdue_issues_count, + }, + status=status.HTTP_200_OK, + ) + + if issue_type == "upcoming": + upcoming_issues_count = created_issues.filter( + state__group__in=["backlog", "unstarted", "started"], + target_date__gte=timezone.now() + ).count() + upcoming_issues = created_issues.filter( + state__group__in=["backlog", "unstarted", "started"], + target_date__gte=timezone.now() + )[:5] + return Response( + { + "issues": IssueSerializer(upcoming_issues, many=True).data, + "count": upcoming_issues_count, + }, + status=status.HTTP_200_OK, + ) + + return Response( + {"error": "Please specify a valid issue type"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + +def dashboard_issues_by_state_groups(self, request, slug): + filters = issue_filters(request.query_params, "GET") + state_order = ["backlog", "unstarted", "started", "completed", "cancelled"] + issues_by_state_groups = ( + Issue.issue_objects.filter( + workspace__slug=slug, + project__project_projectmember__is_active=True, + project__project_projectmember__member=request.user, + assignees__in=[request.user], + ) + .filter(**filters) + .values("state__group") + .annotate(count=Count("id")) + ) + + # default state + all_groups = {state: 0 for state in state_order} + + # Update counts for existing groups + for entry in issues_by_state_groups: + all_groups[entry["state__group"]] = entry["count"] + + # Prepare output including all groups with their counts + output_data = [ + {"state": group, "count": count} for group, count in all_groups.items() + ] + + return Response(output_data, status=status.HTTP_200_OK) + + +def dashboard_issues_by_priority(self, request, slug): + filters = issue_filters(request.query_params, "GET") + priority_order = ["urgent", "high", "medium", "low", "none"] + + issues_by_priority = ( + Issue.issue_objects.filter( + workspace__slug=slug, + project__project_projectmember__is_active=True, + project__project_projectmember__member=request.user, + assignees__in=[request.user], + ) + .filter(**filters) + .values("priority") + .annotate(count=Count("id")) + ) + + # default priority + all_groups = {priority: 0 for priority in priority_order} + + # Update counts for existing groups + for entry in issues_by_priority: + all_groups[entry["priority"]] = entry["count"] + + # Prepare output including all groups with their counts + output_data = [ + {"priority": group, "count": count} + for group, count in all_groups.items() + ] + + return Response(output_data, status=status.HTTP_200_OK) + + +def dashboard_recent_activity(self, request, slug): + queryset = IssueActivity.objects.filter( + ~Q(field__in=["comment", "vote", "reaction", "draft"]), + workspace__slug=slug, + project__project_projectmember__member=request.user, + project__project_projectmember__is_active=True, + actor=request.user, + ).select_related("actor", "workspace", "issue", "project")[:8] + + return Response( + IssueActivitySerializer(queryset, many=True).data, + status=status.HTTP_200_OK, + ) + + +def dashboard_recent_projects(self, request, slug): + project_ids = ( + IssueActivity.objects.filter( + workspace__slug=slug, + project__project_projectmember__member=request.user, + project__project_projectmember__is_active=True, + actor=request.user, + ) + .values_list("project_id", flat=True) + .distinct() + ) + + # Extract project IDs from the recent projects + unique_project_ids = set(project_id for project_id in project_ids) + + # Fetch additional projects only if needed + if len(unique_project_ids) < 4: + additional_projects = Project.objects.filter( + project_projectmember__member=request.user, + project_projectmember__is_active=True, + workspace__slug=slug, + ).exclude(id__in=unique_project_ids) + + # Append additional project IDs to the existing list + unique_project_ids.update(additional_projects.values_list("id", flat=True)) + + return Response( + list(unique_project_ids)[:4], + status=status.HTTP_200_OK, + ) + + +def dashboard_recent_collaborators(self, request, slug): + # Fetch all project IDs where the user belongs to + user_projects = Project.objects.filter( + project_projectmember__member=request.user, + project_projectmember__is_active=True, + workspace__slug=slug, + ).values_list("id", flat=True) + + # Fetch all users who have performed an activity in the projects where the user exists + users_with_activities = ( + IssueActivity.objects.filter( + workspace__slug=slug, + project_id__in=user_projects, + ) + .values("actor") + .exclude(actor=request.user) + .annotate(num_activities=Count("actor")) + .order_by("-num_activities") + )[:7] + + # Get the count of active issues for each user in users_with_activities + users_with_active_issues = [] + for user_activity in users_with_activities: + user_id = user_activity["actor"] + active_issue_count = Issue.objects.filter( + assignees__in=[user_id], + state__group__in=["unstarted", "started"], + ).count() + users_with_active_issues.append( + {"user_id": user_id, "active_issue_count": active_issue_count} + ) + + # Insert the logged-in user's ID and their active issue count at the beginning + active_issue_count = Issue.objects.filter( + assignees__in=[request.user], + state__group__in=["unstarted", "started"], + ).count() + + if users_with_activities.count() < 7: + # Calculate the additional collaborators needed + additional_collaborators_needed = 7 - users_with_activities.count() + + # Fetch additional collaborators from the project_member table + additional_collaborators = list( + set( + ProjectMember.objects.filter( + ~Q(member=request.user), + project_id__in=user_projects, + workspace__slug=slug, + ) + .exclude( + member__in=[ + user["actor"] for user in users_with_activities + ] + ) + .values_list("member", flat=True) + ) + ) + + additional_collaborators = additional_collaborators[ + :additional_collaborators_needed + ] + + # Append additional collaborators to the list + for collaborator_id in additional_collaborators: + active_issue_count = Issue.objects.filter( + assignees__in=[collaborator_id], + state__group__in=["unstarted", "started"], + ).count() + users_with_active_issues.append( + { + "user_id": str(collaborator_id), + "active_issue_count": active_issue_count, + } + ) + + users_with_active_issues.insert( + 0, + {"user_id": request.user.id, "active_issue_count": active_issue_count}, + ) + + return Response(users_with_active_issues, status=status.HTTP_200_OK) + + +class DashboardEndpoint(BaseAPIView): + def create(self, request, slug): + serializer = DashboardSerializer(data=request.data) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def patch(self, request, slug, pk): + serializer = DashboardSerializer(data=request.data, partial=True) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def delete(self, request, slug, pk): + serializer = DashboardSerializer(data=request.data, partial=True) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_204_NO_CONTENT) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def get(self, request, slug, dashboard_id=None): + if not dashboard_id: + dashboard_type = request.GET.get("dashboard_type", None) + if dashboard_type == "home": + dashboard, created = Dashboard.objects.get_or_create( + type_identifier=dashboard_type, owned_by=request.user, is_default=True + ) + + if created: + widgets_to_fetch = [ + "overview_stats", + "assigned_issues", + "created_issues", + "issues_by_state_groups", + "issues_by_priority", + "recent_activity", + "recent_projects", + "recent_collaborators", + ] + + updated_dashboard_widgets = [] + for widget_key in widgets_to_fetch: + widget = Widget.objects.filter(key=widget_key).values_list("id", flat=True) + if widget: + updated_dashboard_widgets.append( + DashboardWidget( + widget_id=widget, + dashboard_id=dashboard.id, + ) + ) + + DashboardWidget.objects.bulk_create( + updated_dashboard_widgets, batch_size=100 + ) + + widgets = ( + Widget.objects.annotate( + is_visible=Exists( + DashboardWidget.objects.filter( + widget_id=OuterRef("pk"), + dashboard_id=dashboard.id, + is_visible=True, + ) + ) + ) + .annotate( + dashboard_filters=Subquery( + DashboardWidget.objects.filter( + widget_id=OuterRef("pk"), + dashboard_id=dashboard.id, + filters__isnull=False, + ) + .exclude(filters={}) + .values("filters")[:1] + ) + ) + .annotate( + widget_filters=Case( + When( + dashboard_filters__isnull=False, + then=F("dashboard_filters"), + ), + default=F("filters"), + output_field=JSONField(), + ) + ) + ) + return Response( + { + "dashboard": DashboardSerializer(dashboard).data, + "widgets": WidgetSerializer(widgets, many=True).data, + }, + status=status.HTTP_200_OK, + ) + return Response( + {"error": "Please specify a valid dashboard type"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + widget_key = request.GET.get("widget_key", "overview_stats") + + WIDGETS_MAPPER = { + "overview_stats": dashboard_overview_stats, + "assigned_issues": dashboard_assigned_issues, + "created_issues": dashboard_created_issues, + "issues_by_state_groups": dashboard_issues_by_state_groups, + "issues_by_priority": dashboard_issues_by_priority, + "recent_activity": dashboard_recent_activity, + "recent_projects": dashboard_recent_projects, + "recent_collaborators": dashboard_recent_collaborators, + } + + func = WIDGETS_MAPPER.get(widget_key) + if func is not None: + response = func( + self, + request=request, + slug=slug, + ) + if isinstance(response, Response): + return response + + return Response( + {"error": "Please specify a valid widget key"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + +class WidgetsEndpoint(BaseAPIView): + def patch(self, request, dashboard_id, widget_id): + dashboard_widget = DashboardWidget.objects.filter( + widget_id=widget_id, + dashboard_id=dashboard_id, + ).first() + dashboard_widget.is_visible = request.data.get( + "is_visible", dashboard_widget.is_visible + ) + dashboard_widget.sort_order = request.data.get( + "sort_order", dashboard_widget.sort_order + ) + dashboard_widget.filters = request.data.get( + "filters", dashboard_widget.filters + ) + dashboard_widget.save() + return Response( + {"message": "successfully updated"}, status=status.HTTP_200_OK + ) diff --git a/apiserver/plane/app/views/issue.py b/apiserver/plane/app/views/issue.py index 967147aeb..ec8b4da5e 100644 --- a/apiserver/plane/app/views/issue.py +++ b/apiserver/plane/app/views/issue.py @@ -52,7 +52,6 @@ from plane.app.serializers import ( IssueRelationSerializer, RelatedIssueSerializer, IssuePublicSerializer, - IssueRelationLiteSerializer, ) from plane.app.permissions import ( ProjectEntityPermission, diff --git a/apiserver/plane/db/migrations/0054_dashboard_widget_dashboardwidget.py b/apiserver/plane/db/migrations/0054_dashboard_widget_dashboardwidget.py new file mode 100644 index 000000000..933c229a1 --- /dev/null +++ b/apiserver/plane/db/migrations/0054_dashboard_widget_dashboardwidget.py @@ -0,0 +1,77 @@ +# Generated by Django 4.2.7 on 2024-01-08 06:47 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0053_auto_20240102_1315'), + ] + + operations = [ + migrations.CreateModel( + name='Dashboard', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), + ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('name', models.CharField(max_length=255)), + ('description_html', models.TextField(blank=True, default='

')), + ('identifier', models.UUIDField(null=True)), + ('is_default', models.BooleanField(default=False)), + ('type_identifier', models.CharField(choices=[('workspace', 'Workspace'), ('project', 'Project'), ('home', 'Home'), ('team', 'Team'), ('user', 'User')], default='home', max_length=30, verbose_name='Dashboard Type')), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('owned_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='dashboards', to=settings.AUTH_USER_MODEL)), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ], + options={ + 'verbose_name': 'Dashboard', + 'verbose_name_plural': 'Dashboards', + 'db_table': 'dashboards', + 'ordering': ('-created_at',), + }, + ), + migrations.CreateModel( + name='Widget', + fields=[ + ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), + ('key', models.CharField(max_length=255)), + ('filters', models.JSONField(default=dict)), + ], + options={ + 'verbose_name': 'Widget', + 'verbose_name_plural': 'Widgets', + 'db_table': 'widgets', + 'ordering': ('-created_at',), + }, + ), + migrations.CreateModel( + name='DashboardWidget', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), + ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('is_visible', models.BooleanField(default=True)), + ('sort_order', models.FloatField(default=65535)), + ('filters', models.JSONField(default=dict)), + ('properties', models.JSONField(default=dict)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('dashboard', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='dashboard_widgets', to='db.dashboard')), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ('widget', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='dashboard_widgets', to='db.widget')), + ], + options={ + 'verbose_name': 'Dashboard Widget', + 'verbose_name_plural': 'Dashboard Widgets', + 'db_table': 'dashboard_widgets', + 'ordering': ('-created_at',), + 'unique_together': {('widget', 'dashboard')}, + }, + ), + ] diff --git a/apiserver/plane/db/migrations/0055_auto_20240108_0648.py b/apiserver/plane/db/migrations/0055_auto_20240108_0648.py new file mode 100644 index 000000000..e369c185d --- /dev/null +++ b/apiserver/plane/db/migrations/0055_auto_20240108_0648.py @@ -0,0 +1,97 @@ +# Generated by Django 4.2.7 on 2024-01-08 06:48 + +from django.db import migrations + + +def create_widgets(apps, schema_editor): + Widget = apps.get_model("db", "Widget") + widgets_list = [ + {"key": "overview_stats", "filters": {}}, + { + "key": "assigned_issues", + "filters": { + "duration": "this_week", + "tab": "upcoming", + }, + }, + { + "key": "created_issues", + "filters": { + "duration": "this_week", + "tab": "upcoming", + }, + }, + { + "key": "issues_by_state_groups", + "filters": { + "duration": "this_week", + }, + }, + { + "key": "issues_by_priority", + "filters": { + "duration": "this_week", + }, + }, + {"key": "recent_activity", "filters": {}}, + {"key": "recent_projects", "filters": {}}, + {"key": "recent_collaborators", "filters": {}}, + ] + Widget.objects.bulk_create( + [ + Widget( + key=widget["key"], + filters=widget["filters"], + ) + for widget in widgets_list + ], + batch_size=10, + ) + + +def create_dashboards(apps, schema_editor): + Dashboard = apps.get_model("db", "Dashboard") + User = apps.get_model("db", "User") + Dashboard.objects.bulk_create( + [ + Dashboard( + name="Home dashboard", + description_html="

", + identifier=None, + owned_by_id=user_id, + type_identifier="home", + is_default=True, + ) + for user_id in User.objects.values_list('id', flat=True) + ], + batch_size=2000, + ) + + +def create_dashboard_widgets(apps, schema_editor): + Widget = apps.get_model("db", "Widget") + Dashboard = apps.get_model("db", "Dashboard") + DashboardWidget = apps.get_model("db", "DashboardWidget") + + updated_dashboard_widget = [ + DashboardWidget( + widget_id=widget_id, + dashboard_id=dashboard_id, + ) + for widget_id in Widget.objects.values_list('id', flat=True) + for dashboard_id in Dashboard.objects.values_list('id', flat=True) + ] + + DashboardWidget.objects.bulk_create(updated_dashboard_widget, batch_size=2000) + + +class Migration(migrations.Migration): + dependencies = [ + ("db", "0054_dashboard_widget_dashboardwidget"), + ] + + operations = [ + migrations.RunPython(create_widgets), + migrations.RunPython(create_dashboards), + migrations.RunPython(create_dashboard_widgets), + ] diff --git a/apiserver/plane/db/models/__init__.py b/apiserver/plane/db/models/__init__.py index 9ae0d154d..3a07a33f3 100644 --- a/apiserver/plane/db/models/__init__.py +++ b/apiserver/plane/db/models/__init__.py @@ -90,3 +90,5 @@ from .notification import Notification from .exporter import ExporterHistory from .webhook import Webhook, WebhookLog + +from .dashboard import Dashboard, DashboardWidget, Widget \ No newline at end of file diff --git a/apiserver/plane/db/models/dashboard.py b/apiserver/plane/db/models/dashboard.py new file mode 100644 index 000000000..05c5a893f --- /dev/null +++ b/apiserver/plane/db/models/dashboard.py @@ -0,0 +1,89 @@ +import uuid + +# Django imports +from django.db import models +from django.conf import settings + +# Module imports +from . import BaseModel +from ..mixins import TimeAuditModel + +class Dashboard(BaseModel): + DASHBOARD_CHOICES = ( + ("workspace", "Workspace"), + ("project", "Project"), + ("home", "Home"), + ("team", "Team"), + ("user", "User"), + ) + name = models.CharField(max_length=255) + description_html = models.TextField(blank=True, default="

") + identifier = models.UUIDField(null=True) + owned_by = models.ForeignKey( + "db.User", + on_delete=models.CASCADE, + related_name="dashboards", + ) + is_default = models.BooleanField(default=False) + type_identifier = models.CharField( + max_length=30, + choices=DASHBOARD_CHOICES, + verbose_name="Dashboard Type", + default="home", + ) + + def __str__(self): + """Return name of the dashboard""" + return f"{self.name}" + + class Meta: + verbose_name = "Dashboard" + verbose_name_plural = "Dashboards" + db_table = "dashboards" + ordering = ("-created_at",) + + +class Widget(TimeAuditModel): + id = models.UUIDField( + default=uuid.uuid4, unique=True, editable=False, db_index=True, primary_key=True + ) + key = models.CharField(max_length=255) + filters = models.JSONField(default=dict) + + def __str__(self): + """Return name of the widget""" + return f"{self.key}" + + class Meta: + verbose_name = "Widget" + verbose_name_plural = "Widgets" + db_table = "widgets" + ordering = ("-created_at",) + + +class DashboardWidget(BaseModel): + widget = models.ForeignKey( + Widget, + on_delete=models.CASCADE, + related_name="dashboard_widgets", + ) + dashboard = models.ForeignKey( + Dashboard, + on_delete=models.CASCADE, + related_name="dashboard_widgets", + ) + is_visible = models.BooleanField(default=True) + sort_order = models.FloatField(default=65535) + filters = models.JSONField(default=dict) + properties = models.JSONField(default=dict) + + def __str__(self): + """Return name of the dashboard""" + return f"{self.dashboard.name} {self.widget.key}" + + class Meta: + unique_together = ("widget", "dashboard") + verbose_name = "Dashboard Widget" + verbose_name_plural = "Dashboard Widgets" + db_table = "dashboard_widgets" + ordering = ("-created_at",) diff --git a/apiserver/plane/utils/issue_filters.py b/apiserver/plane/utils/issue_filters.py index 477e54bd8..87284ff24 100644 --- a/apiserver/plane/utils/issue_filters.py +++ b/apiserver/plane/utils/issue_filters.py @@ -3,7 +3,6 @@ import uuid from datetime import timedelta from django.utils import timezone - # The date from pattern pattern = re.compile(r"\d+_(weeks|months)$") @@ -464,7 +463,7 @@ def filter_start_target_date_issues(params, filter, method): filter["target_date__isnull"] = False filter["start_date__isnull"] = False return filter - + def issue_filters(query_params, method): filter = {} diff --git a/packages/tailwind-config-custom/tailwind.config.js b/packages/tailwind-config-custom/tailwind.config.js index 97f7cab84..3465b8196 100644 --- a/packages/tailwind-config-custom/tailwind.config.js +++ b/packages/tailwind-config-custom/tailwind.config.js @@ -27,6 +27,7 @@ module.exports = { "custom-shadow-xl": "var(--color-shadow-xl)", "custom-shadow-2xl": "var(--color-shadow-2xl)", "custom-shadow-3xl": "var(--color-shadow-3xl)", + "custom-shadow-4xl": "var(--color-shadow-4xl)", "custom-sidebar-shadow-2xs": "var(--color-sidebar-shadow-2xs)", "custom-sidebar-shadow-xs": "var(--color-sidebar-shadow-xs)", "custom-sidebar-shadow-sm": "var(--color-sidebar-shadow-sm)", @@ -36,8 +37,8 @@ module.exports = { "custom-sidebar-shadow-xl": "var(--color-sidebar-shadow-xl)", "custom-sidebar-shadow-2xl": "var(--color-sidebar-shadow-2xl)", "custom-sidebar-shadow-3xl": "var(--color-sidebar-shadow-3xl)", - "onbording-shadow-sm": "var(--color-onboarding-shadow-sm)", - + "custom-sidebar-shadow-4xl": "var(--color-sidebar-shadow-4xl)", + "onboarding-shadow-sm": "var(--color-onboarding-shadow-sm)", }, colors: { custom: { @@ -212,7 +213,7 @@ module.exports = { to: { left: "100%" }, }, }, - typography: ({ theme }) => ({ + typography: () => ({ brand: { css: { "--tw-prose-body": convertToRGB("--color-text-100"), @@ -225,12 +226,12 @@ module.exports = { "--tw-prose-bullets": convertToRGB("--color-text-100"), "--tw-prose-hr": convertToRGB("--color-text-100"), "--tw-prose-quotes": convertToRGB("--color-text-100"), - "--tw-prose-quote-borders": convertToRGB("--color-border"), + "--tw-prose-quote-borders": convertToRGB("--color-border-200"), "--tw-prose-code": convertToRGB("--color-text-100"), "--tw-prose-pre-code": convertToRGB("--color-text-100"), "--tw-prose-pre-bg": convertToRGB("--color-background-100"), - "--tw-prose-th-borders": convertToRGB("--color-border"), - "--tw-prose-td-borders": convertToRGB("--color-border"), + "--tw-prose-th-borders": convertToRGB("--color-border-200"), + "--tw-prose-td-borders": convertToRGB("--color-border-200"), }, }, }), diff --git a/packages/types/src/dashboard.d.ts b/packages/types/src/dashboard.d.ts new file mode 100644 index 000000000..31751c0d0 --- /dev/null +++ b/packages/types/src/dashboard.d.ts @@ -0,0 +1,175 @@ +import { IIssueActivity, TIssuePriorities } from "./issues"; +import { TIssue } from "./issues/issue"; +import { TIssueRelationTypes } from "./issues/issue_relation"; +import { TStateGroups } from "./state"; + +export type TWidgetKeys = + | "overview_stats" + | "assigned_issues" + | "created_issues" + | "issues_by_state_groups" + | "issues_by_priority" + | "recent_activity" + | "recent_projects" + | "recent_collaborators"; + +export type TIssuesListTypes = "upcoming" | "overdue" | "completed"; + +export type TDurationFilterOptions = + | "today" + | "this_week" + | "this_month" + | "this_year"; + +// widget filters +export type TAssignedIssuesWidgetFilters = { + target_date?: TDurationFilterOptions; + tab?: TIssuesListTypes; +}; + +export type TCreatedIssuesWidgetFilters = { + target_date?: TDurationFilterOptions; + tab?: TIssuesListTypes; +}; + +export type TIssuesByStateGroupsWidgetFilters = { + target_date?: TDurationFilterOptions; +}; + +export type TIssuesByPriorityWidgetFilters = { + target_date?: TDurationFilterOptions; +}; + +export type TWidgetFiltersFormData = + | { + widgetKey: "assigned_issues"; + filters: Partial; + } + | { + widgetKey: "created_issues"; + filters: Partial; + } + | { + widgetKey: "issues_by_state_groups"; + filters: Partial; + } + | { + widgetKey: "issues_by_priority"; + filters: Partial; + }; + +export type TWidget = { + id: string; + is_visible: boolean; + key: TWidgetKeys; + readonly widget_filters: // only for read + TAssignedIssuesWidgetFilters & + TCreatedIssuesWidgetFilters & + TIssuesByStateGroupsWidgetFilters & + TIssuesByPriorityWidgetFilters; + filters: // only for write + TAssignedIssuesWidgetFilters & + TCreatedIssuesWidgetFilters & + TIssuesByStateGroupsWidgetFilters & + TIssuesByPriorityWidgetFilters; +}; + +export type TWidgetStatsRequestParams = + | { + widget_key: TWidgetKeys; + } + | { + target_date: string; + issue_type: TIssuesListTypes; + widget_key: "assigned_issues"; + expand?: "issue_relation"; + } + | { + target_date: string; + issue_type: TIssuesListTypes; + widget_key: "created_issues"; + } + | { + target_date: string; + widget_key: "issues_by_state_groups"; + } + | { + target_date: string; + widget_key: "issues_by_priority"; + }; + +export type TWidgetIssue = TIssue & { + issue_relation: { + id: string; + project_id: string; + relation_type: TIssueRelationTypes; + sequence_id: number; + }[]; +}; + +// widget stats responses +export type TOverviewStatsWidgetResponse = { + assigned_issues_count: number; + completed_issues_count: number; + created_issues_count: number; + pending_issues_count: number; +}; + +export type TAssignedIssuesWidgetResponse = { + issues: TWidgetIssue[]; + count: number; +}; + +export type TCreatedIssuesWidgetResponse = { + issues: TWidgetIssue[]; + count: number; +}; + +export type TIssuesByStateGroupsWidgetResponse = { + count: number; + state: TStateGroups; +}; + +export type TIssuesByPriorityWidgetResponse = { + count: number; + priority: TIssuePriorities; +}; + +export type TRecentActivityWidgetResponse = IIssueActivity; + +export type TRecentProjectsWidgetResponse = string[]; + +export type TRecentCollaboratorsWidgetResponse = { + active_issue_count: number; + user_id: string; +}; + +export type TWidgetStatsResponse = + | TOverviewStatsWidgetResponse + | TIssuesByStateGroupsWidgetResponse[] + | TIssuesByPriorityWidgetResponse[] + | TAssignedIssuesWidgetResponse + | TCreatedIssuesWidgetResponse + | TRecentActivityWidgetResponse[] + | TRecentProjectsWidgetResponse + | TRecentCollaboratorsWidgetResponse[]; + +// dashboard +export type TDashboard = { + created_at: string; + created_by: string | null; + description_html: string; + id: string; + identifier: string | null; + is_default: boolean; + name: string; + owned_by: string; + type: string; + updated_at: string; + updated_by: string | null; +}; + +export type THomeDashboardResponse = { + dashboard: TDashboard; + widgets: TWidget[]; +}; diff --git a/packages/types/src/index.d.ts b/packages/types/src/index.d.ts index 4bbed28d3..209aa6794 100644 --- a/packages/types/src/index.d.ts +++ b/packages/types/src/index.d.ts @@ -1,6 +1,7 @@ export * from "./users"; export * from "./workspace"; export * from "./cycles"; +export * from "./dashboard"; export * from "./projects"; export * from "./state"; export * from "./invitation"; diff --git a/packages/types/src/issues.d.ts b/packages/types/src/issues.d.ts index b6ab05f2c..da6db8062 100644 --- a/packages/types/src/issues.d.ts +++ b/packages/types/src/issues.d.ts @@ -9,7 +9,6 @@ import type { IStateLite, Properties, IIssueDisplayFilterOptions, - IIssueReaction, TIssue, } from "@plane/types"; diff --git a/packages/types/src/issues/issue_relation.d.ts b/packages/types/src/issues/issue_relation.d.ts index 0d959ff6b..0b1c5f7cd 100644 --- a/packages/types/src/issues/issue_relation.d.ts +++ b/packages/types/src/issues/issue_relation.d.ts @@ -6,12 +6,7 @@ export type TIssueRelationTypes = | "duplicate" | "relates_to"; -export type TIssueRelationObject = { issue_detail: TIssue }; - -export type TIssueRelation = Record< - TIssueRelationTypes, - TIssueRelationObject[] ->; +export type TIssueRelation = Record; export type TIssueRelationMap = { [issue_id: string]: Record; diff --git a/packages/ui/helpers.ts b/packages/ui/helpers.ts new file mode 100644 index 000000000..a500a7385 --- /dev/null +++ b/packages/ui/helpers.ts @@ -0,0 +1,4 @@ +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs)); diff --git a/packages/ui/package.json b/packages/ui/package.json index def464623..b8a669631 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -17,6 +17,17 @@ "lint": "eslint src/", "clean": "rm -rf .turbo && rm -rf node_modules && rm -rf dist" }, + "dependencies": { + "@blueprintjs/core": "^4.16.3", + "@blueprintjs/popover2": "^1.13.3", + "@headlessui/react": "^1.7.17", + "@popperjs/core": "^2.11.8", + "clsx": "^2.0.0", + "react-color": "^2.19.3", + "react-dom": "^18.2.0", + "react-popper": "^2.3.0", + "tailwind-merge": "^2.0.0" + }, "devDependencies": { "@types/node": "^20.5.2", "@types/react": "^18.2.42", @@ -29,14 +40,5 @@ "tsconfig": "*", "tsup": "^5.10.1", "typescript": "4.7.4" - }, - "dependencies": { - "@blueprintjs/core": "^4.16.3", - "@blueprintjs/popover2": "^1.13.3", - "@headlessui/react": "^1.7.17", - "@popperjs/core": "^2.11.8", - "react-color": "^2.19.3", - "react-dom": "^18.2.0", - "react-popper": "^2.3.0" } } diff --git a/packages/ui/src/avatar/avatar.tsx b/packages/ui/src/avatar/avatar.tsx index 4be345961..6344dce83 100644 --- a/packages/ui/src/avatar/avatar.tsx +++ b/packages/ui/src/avatar/avatar.tsx @@ -141,6 +141,7 @@ export const Avatar: React.FC = (props) => { } : {} } + tabIndex={-1} > {src ? ( {name} diff --git a/packages/ui/src/button/button.tsx b/packages/ui/src/button/button.tsx index d63d89eb2..10ee815f6 100644 --- a/packages/ui/src/button/button.tsx +++ b/packages/ui/src/button/button.tsx @@ -1,6 +1,7 @@ import * as React from "react"; import { getIconStyling, getButtonStyling, TButtonVariant, TButtonSizes } from "./helper"; +import { cn } from "../../helpers"; export interface ButtonProps extends React.ButtonHTMLAttributes { variant?: TButtonVariant; @@ -31,7 +32,7 @@ const Button = React.forwardRef((props, ref) => const buttonIconStyle = getIconStyling(size); return ( - + + )} + + ); +}); diff --git a/web/components/dashboard/widgets/assigned-issues.tsx b/web/components/dashboard/widgets/assigned-issues.tsx new file mode 100644 index 000000000..5ad24ee0f --- /dev/null +++ b/web/components/dashboard/widgets/assigned-issues.tsx @@ -0,0 +1,119 @@ +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { observer } from "mobx-react-lite"; +import { Tab } from "@headlessui/react"; +// hooks +import { useDashboard } from "hooks/store"; +// components +import { + DurationFilterDropdown, + TabsList, + WidgetIssuesList, + WidgetLoader, + WidgetProps, +} from "components/dashboard/widgets"; +// helpers +import { getCustomDates, getRedirectionFilters } from "helpers/dashboard.helper"; +// types +import { TAssignedIssuesWidgetFilters, TAssignedIssuesWidgetResponse } from "@plane/types"; +// constants +import { ISSUES_TABS_LIST } from "constants/dashboard"; + +const WIDGET_KEY = "assigned_issues"; + +export const AssignedIssuesWidget: React.FC = observer((props) => { + const { dashboardId, workspaceSlug } = props; + // states + const [fetching, setFetching] = useState(false); + // store hooks + const { + fetchWidgetStats, + widgetDetails: allWidgetDetails, + widgetStats: allWidgetStats, + updateDashboardWidgetFilters, + } = useDashboard(); + // derived values + const widgetDetails = allWidgetDetails?.[workspaceSlug]?.[dashboardId]?.find((w) => w.key === WIDGET_KEY); + const widgetStats = allWidgetStats?.[workspaceSlug]?.[dashboardId]?.[WIDGET_KEY] as TAssignedIssuesWidgetResponse; + + const handleUpdateFilters = async (filters: Partial) => { + if (!widgetDetails) return; + + setFetching(true); + + await updateDashboardWidgetFilters(workspaceSlug, dashboardId, widgetDetails.id, { + widgetKey: WIDGET_KEY, + filters, + }); + + fetchWidgetStats(workspaceSlug, dashboardId, { + widget_key: WIDGET_KEY, + issue_type: widgetDetails.widget_filters.tab ?? "upcoming", + target_date: getCustomDates(widgetDetails.widget_filters.target_date ?? "this_week"), + expand: "issue_relation", + }).finally(() => setFetching(false)); + }; + + useEffect(() => { + if (!widgetDetails) return; + + const filterDates = getCustomDates(widgetDetails.widget_filters.target_date ?? "this_week"); + + if (!widgetStats) + fetchWidgetStats(workspaceSlug, dashboardId, { + widget_key: WIDGET_KEY, + issue_type: widgetDetails.widget_filters.tab ?? "upcoming", + target_date: filterDates, + expand: "issue_relation", + }); + }, [dashboardId, fetchWidgetStats, widgetDetails, widgetStats, workspaceSlug]); + + const filterParams = getRedirectionFilters(widgetDetails?.widget_filters.tab ?? "upcoming"); + const redirectionLink = `/${workspaceSlug}/workspace-views/assigned/${filterParams}`; + + if (!widgetDetails || !widgetStats) return ; + + return ( +
+ +

All issues assigned

+ + handleUpdateFilters({ + target_date: val, + }) + } + /> + + t.key === widgetDetails.widget_filters.tab ?? "upcoming")} + onChange={(i) => { + const selectedTab = ISSUES_TABS_LIST[i]; + handleUpdateFilters({ tab: selectedTab.key ?? "upcoming" }); + }} + className="h-full flex flex-col" + > +
+ +
+ + {ISSUES_TABS_LIST.map((tab) => ( + + + + ))} + +
+
+ ); +}); diff --git a/web/components/dashboard/widgets/created-issues.tsx b/web/components/dashboard/widgets/created-issues.tsx new file mode 100644 index 000000000..c25623070 --- /dev/null +++ b/web/components/dashboard/widgets/created-issues.tsx @@ -0,0 +1,115 @@ +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { observer } from "mobx-react-lite"; +import { Tab } from "@headlessui/react"; +// hooks +import { useDashboard } from "hooks/store"; +// components +import { + DurationFilterDropdown, + TabsList, + WidgetIssuesList, + WidgetLoader, + WidgetProps, +} from "components/dashboard/widgets"; +// helpers +import { getCustomDates, getRedirectionFilters } from "helpers/dashboard.helper"; +// types +import { TCreatedIssuesWidgetFilters, TCreatedIssuesWidgetResponse } from "@plane/types"; +// constants +import { ISSUES_TABS_LIST } from "constants/dashboard"; + +const WIDGET_KEY = "created_issues"; + +export const CreatedIssuesWidget: React.FC = observer((props) => { + const { dashboardId, workspaceSlug } = props; + // states + const [fetching, setFetching] = useState(false); + // store hooks + const { + fetchWidgetStats, + widgetDetails: allWidgetDetails, + widgetStats: allWidgetStats, + updateDashboardWidgetFilters, + } = useDashboard(); + // derived values + const widgetDetails = allWidgetDetails?.[workspaceSlug]?.[dashboardId]?.find((w) => w.key === WIDGET_KEY); + const widgetStats = allWidgetStats?.[workspaceSlug]?.[dashboardId]?.[WIDGET_KEY] as TCreatedIssuesWidgetResponse; + + const handleUpdateFilters = async (filters: Partial) => { + if (!widgetDetails) return; + + setFetching(true); + + await updateDashboardWidgetFilters(workspaceSlug, dashboardId, widgetDetails.id, { + widgetKey: WIDGET_KEY, + filters, + }); + + fetchWidgetStats(workspaceSlug, dashboardId, { + widget_key: WIDGET_KEY, + issue_type: widgetDetails.widget_filters.tab ?? "upcoming", + target_date: getCustomDates(widgetDetails.widget_filters.target_date ?? "this_week"), + }).finally(() => setFetching(false)); + }; + + useEffect(() => { + if (!widgetDetails) return; + + if (!widgetStats) + fetchWidgetStats(workspaceSlug, dashboardId, { + widget_key: WIDGET_KEY, + issue_type: widgetDetails.widget_filters.tab ?? "upcoming", + target_date: getCustomDates(widgetDetails.widget_filters.target_date ?? "this_week"), + }); + }, [dashboardId, fetchWidgetStats, widgetDetails, widgetStats, workspaceSlug]); + + const filterParams = getRedirectionFilters(widgetDetails?.widget_filters.tab ?? "upcoming"); + const redirectionLink = `/${workspaceSlug}/workspace-views/created/${filterParams}`; + + if (!widgetDetails || !widgetStats) return ; + + return ( +
+ +

All issues created

+ + handleUpdateFilters({ + target_date: val, + }) + } + /> + + t.key === widgetDetails.widget_filters.tab ?? "upcoming")} + onChange={(i) => { + const selectedTab = ISSUES_TABS_LIST[i]; + handleUpdateFilters({ tab: selectedTab.key ?? "upcoming" }); + }} + className="h-full flex flex-col" + > +
+ +
+ + {ISSUES_TABS_LIST.map((tab) => ( + + + + ))} + +
+
+ ); +}); diff --git a/web/components/dashboard/widgets/dropdowns/duration-filter.tsx b/web/components/dashboard/widgets/dropdowns/duration-filter.tsx new file mode 100644 index 000000000..fedc92cbe --- /dev/null +++ b/web/components/dashboard/widgets/dropdowns/duration-filter.tsx @@ -0,0 +1,41 @@ +import { ChevronDown } from "lucide-react"; +// ui +import { CustomMenu } from "@plane/ui"; +// types +import { TDurationFilterOptions } from "@plane/types"; +// constants +import { DURATION_FILTER_OPTIONS } from "constants/dashboard"; + +type Props = { + onChange: (value: TDurationFilterOptions) => void; + value: TDurationFilterOptions; +}; + +export const DurationFilterDropdown: React.FC = (props) => { + const { onChange, value } = props; + + return ( + + {DURATION_FILTER_OPTIONS.find((option) => option.key === value)?.label} + + + } + placement="bottom-end" + > + {DURATION_FILTER_OPTIONS.map((option) => ( + { + e.preventDefault(); + e.stopPropagation(); + onChange(option.key); + }} + > + {option.label} + + ))} + + ); +}; diff --git a/web/components/dashboard/widgets/dropdowns/index.ts b/web/components/dashboard/widgets/dropdowns/index.ts new file mode 100644 index 000000000..cff4cdb44 --- /dev/null +++ b/web/components/dashboard/widgets/dropdowns/index.ts @@ -0,0 +1 @@ +export * from "./duration-filter"; diff --git a/web/components/dashboard/widgets/empty-states/assigned-issues.tsx b/web/components/dashboard/widgets/empty-states/assigned-issues.tsx new file mode 100644 index 000000000..ef85ff611 --- /dev/null +++ b/web/components/dashboard/widgets/empty-states/assigned-issues.tsx @@ -0,0 +1,42 @@ +import Image from "next/image"; +import { useTheme } from "next-themes"; +// helpers +import { cn } from "helpers/common.helper"; +// types +import { TDurationFilterOptions, TIssuesListTypes } from "@plane/types"; +// constants +import { ASSIGNED_ISSUES_EMPTY_STATES } from "constants/dashboard"; + +type Props = { + filter: TDurationFilterOptions; + type: TIssuesListTypes; +}; + +export const AssignedIssuesEmptyState: React.FC = (props) => { + const { filter, type } = props; + // next-themes + const { resolvedTheme } = useTheme(); + + const typeDetails = ASSIGNED_ISSUES_EMPTY_STATES[type]; + + const image = resolvedTheme === "dark" ? typeDetails.darkImage : typeDetails.lightImage; + + return ( +
+

{typeDetails.title(filter)}

+
+ Assigned issues +
+
+ ); +}; diff --git a/web/components/dashboard/widgets/empty-states/created-issues.tsx b/web/components/dashboard/widgets/empty-states/created-issues.tsx new file mode 100644 index 000000000..3b5a646bb --- /dev/null +++ b/web/components/dashboard/widgets/empty-states/created-issues.tsx @@ -0,0 +1,42 @@ +import Image from "next/image"; +import { useTheme } from "next-themes"; +// helpers +import { cn } from "helpers/common.helper"; +// types +import { TDurationFilterOptions, TIssuesListTypes } from "@plane/types"; +// constants +import { CREATED_ISSUES_EMPTY_STATES } from "constants/dashboard"; + +type Props = { + filter: TDurationFilterOptions; + type: TIssuesListTypes; +}; + +export const CreatedIssuesEmptyState: React.FC = (props) => { + const { filter, type } = props; + // next-themes + const { resolvedTheme } = useTheme(); + + const typeDetails = CREATED_ISSUES_EMPTY_STATES[type]; + + const image = resolvedTheme === "dark" ? typeDetails.darkImage : typeDetails.lightImage; + + return ( +
+

{typeDetails.title(filter)}

+
+ Created issues +
+
+ ); +}; diff --git a/web/components/dashboard/widgets/empty-states/index.ts b/web/components/dashboard/widgets/empty-states/index.ts new file mode 100644 index 000000000..72ca1dbb2 --- /dev/null +++ b/web/components/dashboard/widgets/empty-states/index.ts @@ -0,0 +1,6 @@ +export * from "./assigned-issues"; +export * from "./created-issues"; +export * from "./issues-by-priority"; +export * from "./issues-by-state-group"; +export * from "./recent-activity"; +export * from "./recent-collaborators"; diff --git a/web/components/dashboard/widgets/empty-states/issues-by-priority.tsx b/web/components/dashboard/widgets/empty-states/issues-by-priority.tsx new file mode 100644 index 000000000..41e2754d5 --- /dev/null +++ b/web/components/dashboard/widgets/empty-states/issues-by-priority.tsx @@ -0,0 +1,45 @@ +import Image from "next/image"; +import { useTheme } from "next-themes"; +// assets +import DarkImage from "public/empty-state/dashboard/dark/issues-by-priority.svg"; +import LightImage from "public/empty-state/dashboard/light/issues-by-priority.svg"; +// helpers +import { cn } from "helpers/common.helper"; +import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper"; +// types +import { TDurationFilterOptions } from "@plane/types"; + +type Props = { + filter: TDurationFilterOptions; +}; + +export const IssuesByPriorityEmptyState: React.FC = (props) => { + const { filter } = props; + // next-themes + const { resolvedTheme } = useTheme(); + + return ( +
+

+ No assigned issues {replaceUnderscoreIfSnakeCase(filter)}. +

+
+ Issues by priority +
+
+ ); +}; diff --git a/web/components/dashboard/widgets/empty-states/issues-by-state-group.tsx b/web/components/dashboard/widgets/empty-states/issues-by-state-group.tsx new file mode 100644 index 000000000..166dbb36f --- /dev/null +++ b/web/components/dashboard/widgets/empty-states/issues-by-state-group.tsx @@ -0,0 +1,45 @@ +import Image from "next/image"; +import { useTheme } from "next-themes"; +// assets +import DarkImage from "public/empty-state/dashboard/dark/issues-by-state-group.svg"; +import LightImage from "public/empty-state/dashboard/light/issues-by-state-group.svg"; +// helpers +import { cn } from "helpers/common.helper"; +import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper"; +// types +import { TDurationFilterOptions } from "@plane/types"; + +type Props = { + filter: TDurationFilterOptions; +}; + +export const IssuesByStateGroupEmptyState: React.FC = (props) => { + const { filter } = props; + // next-themes + const { resolvedTheme } = useTheme(); + + return ( +
+

+ No assigned issues {replaceUnderscoreIfSnakeCase(filter)}. +

+
+ Issues by state group +
+
+ ); +}; diff --git a/web/components/dashboard/widgets/empty-states/recent-activity.tsx b/web/components/dashboard/widgets/empty-states/recent-activity.tsx new file mode 100644 index 000000000..ea62fbf08 --- /dev/null +++ b/web/components/dashboard/widgets/empty-states/recent-activity.tsx @@ -0,0 +1,42 @@ +import Image from "next/image"; +import { useTheme } from "next-themes"; +// assets +import DarkImage from "public/empty-state/dashboard/dark/recent-activity.svg"; +import LightImage from "public/empty-state/dashboard/light/recent-activity.svg"; +// helpers +import { cn } from "helpers/common.helper"; + +type Props = {}; + +export const RecentActivityEmptyState: React.FC = (props) => { + const {} = props; + // next-themes + const { resolvedTheme } = useTheme(); + + return ( +
+

+ Feels new, go and explore our tool in depth and come back +
+ to see your activity. +

+
+ Issues by priority +
+
+ ); +}; diff --git a/web/components/dashboard/widgets/empty-states/recent-collaborators.tsx b/web/components/dashboard/widgets/empty-states/recent-collaborators.tsx new file mode 100644 index 000000000..0ab0db1f9 --- /dev/null +++ b/web/components/dashboard/widgets/empty-states/recent-collaborators.tsx @@ -0,0 +1,40 @@ +import Image from "next/image"; +import { useTheme } from "next-themes"; +// assets +import DarkImage from "public/empty-state/dashboard/dark/recent-collaborators.svg"; +import LightImage from "public/empty-state/dashboard/light/recent-collaborators.svg"; +// helpers +import { cn } from "helpers/common.helper"; + +type Props = {}; + +export const RecentCollaboratorsEmptyState: React.FC = (props) => { + const {} = props; + // next-themes + const { resolvedTheme } = useTheme(); + + return ( +
+

+ People are excited to work with you, once they do you will find your frequent collaborators here. +

+
+ Recent collaborators +
+
+ ); +}; diff --git a/web/components/dashboard/widgets/index.ts b/web/components/dashboard/widgets/index.ts new file mode 100644 index 000000000..a481a8881 --- /dev/null +++ b/web/components/dashboard/widgets/index.ts @@ -0,0 +1,12 @@ +export * from "./dropdowns"; +export * from "./empty-states"; +export * from "./issue-panels"; +export * from "./loaders"; +export * from "./assigned-issues"; +export * from "./created-issues"; +export * from "./issues-by-priority"; +export * from "./issues-by-state-group"; +export * from "./overview-stats"; +export * from "./recent-activity"; +export * from "./recent-collaborators"; +export * from "./recent-projects"; diff --git a/web/components/dashboard/widgets/issue-panels/index.ts b/web/components/dashboard/widgets/issue-panels/index.ts new file mode 100644 index 000000000..f5b7d53d4 --- /dev/null +++ b/web/components/dashboard/widgets/issue-panels/index.ts @@ -0,0 +1,3 @@ +export * from "./issue-list-item"; +export * from "./issues-list"; +export * from "./tabs-list"; diff --git a/web/components/dashboard/widgets/issue-panels/issue-list-item.tsx b/web/components/dashboard/widgets/issue-panels/issue-list-item.tsx new file mode 100644 index 000000000..3da862d91 --- /dev/null +++ b/web/components/dashboard/widgets/issue-panels/issue-list-item.tsx @@ -0,0 +1,297 @@ +import { observer } from "mobx-react-lite"; +import isToday from "date-fns/isToday"; +// hooks +import { useIssueDetail, useMember, useProject } from "hooks/store"; +// ui +import { Avatar, AvatarGroup, ControlLink, PriorityIcon } from "@plane/ui"; +// helpers +import { findTotalDaysInRange, renderFormattedDate } from "helpers/date-time.helper"; +// types +import { TIssue, TWidgetIssue } from "@plane/types"; + +export type IssueListItemProps = { + issueId: string; + onClick: (issue: TIssue) => void; + workspaceSlug: string; +}; + +export const AssignedUpcomingIssueListItem: React.FC = observer((props) => { + const { issueId, onClick, workspaceSlug } = props; + // store hooks + const { getProjectById } = useProject(); + const { + issue: { getIssueById }, + } = useIssueDetail(); + // derived values + const issueDetails = getIssueById(issueId) as TWidgetIssue | undefined; + + if (!issueDetails) return null; + + const projectDetails = getProjectById(issueDetails.project_id); + + const blockedByIssues = issueDetails.issue_relation?.filter((issue) => issue.relation_type === "blocked_by") ?? []; + + const blockedByIssueProjectDetails = + blockedByIssues.length === 1 ? getProjectById(blockedByIssues[0]?.project_id ?? "") : null; + + return ( + onClick(issueDetails)} + className="py-2 px-3 hover:bg-custom-background-80 rounded grid grid-cols-6 gap-1" + > +
+ + + {projectDetails?.identifier} {issueDetails.sequence_id} + +
{issueDetails.name}
+
+
+ {issueDetails.target_date + ? isToday(new Date(issueDetails.target_date)) + ? "Today" + : renderFormattedDate(issueDetails.target_date) + : "-"} +
+
+ {blockedByIssues.length > 0 + ? blockedByIssues.length > 1 + ? `${blockedByIssues.length} blockers` + : `${blockedByIssueProjectDetails?.identifier} ${blockedByIssues[0]?.sequence_id}` + : "-"} +
+
+ ); +}); + +export const AssignedOverdueIssueListItem: React.FC = observer((props) => { + const { issueId, onClick, workspaceSlug } = props; + // store hooks + const { getProjectById } = useProject(); + const { + issue: { getIssueById }, + } = useIssueDetail(); + // derived values + const issueDetails = getIssueById(issueId) as TWidgetIssue | undefined; + + if (!issueDetails) return null; + + const projectDetails = getProjectById(issueDetails.project_id); + const blockedByIssues = issueDetails.issue_relation?.filter((issue) => issue.relation_type === "blocked_by") ?? []; + + const blockedByIssueProjectDetails = + blockedByIssues.length === 1 ? getProjectById(blockedByIssues[0]?.project_id ?? "") : null; + + const dueBy = findTotalDaysInRange(new Date(issueDetails.target_date ?? ""), new Date(), false); + + return ( + onClick(issueDetails)} + className="py-2 px-3 hover:bg-custom-background-80 rounded grid grid-cols-6 gap-1" + > +
+ + + {projectDetails?.identifier} {issueDetails.sequence_id} + +
{issueDetails.name}
+
+
+ {dueBy} {`day${dueBy > 1 ? "s" : ""}`} +
+
+ {blockedByIssues.length > 0 + ? blockedByIssues.length > 1 + ? `${blockedByIssues.length} blockers` + : `${blockedByIssueProjectDetails?.identifier} ${blockedByIssues[0]?.sequence_id}` + : "-"} +
+
+ ); +}); + +export const AssignedCompletedIssueListItem: React.FC = observer((props) => { + const { issueId, onClick, workspaceSlug } = props; + // store hooks + const { + issue: { getIssueById }, + } = useIssueDetail(); + const { getProjectById } = useProject(); + // derived values + const issueDetails = getIssueById(issueId); + + if (!issueDetails) return null; + + const projectDetails = getProjectById(issueDetails.project_id); + + return ( + onClick(issueDetails)} + className="py-2 px-3 hover:bg-custom-background-80 rounded grid grid-cols-6 gap-1" + > +
+ + + {projectDetails?.identifier} {issueDetails.sequence_id} + +
{issueDetails.name}
+
+
+ ); +}); + +export const CreatedUpcomingIssueListItem: React.FC = observer((props) => { + const { issueId, onClick, workspaceSlug } = props; + // store hooks + const { getUserDetails } = useMember(); + const { + issue: { getIssueById }, + } = useIssueDetail(); + const { getProjectById } = useProject(); + // derived values + const issue = getIssueById(issueId); + + if (!issue) return null; + + const projectDetails = getProjectById(issue.project_id); + + return ( + onClick(issue)} + className="py-2 px-3 hover:bg-custom-background-80 rounded grid grid-cols-6 gap-1" + > +
+ + + {projectDetails?.identifier} {issue.sequence_id} + +
{issue.name}
+
+
+ {issue.target_date + ? isToday(new Date(issue.target_date)) + ? "Today" + : renderFormattedDate(issue.target_date) + : "-"} +
+
+ {issue.assignee_ids.length > 0 ? ( + + {issue.assignee_ids?.map((assigneeId) => { + const userDetails = getUserDetails(assigneeId); + + if (!userDetails) return null; + + return ; + })} + + ) : ( + "-" + )} +
+
+ ); +}); + +export const CreatedOverdueIssueListItem: React.FC = observer((props) => { + const { issueId, onClick, workspaceSlug } = props; + // store hooks + const { getUserDetails } = useMember(); + const { + issue: { getIssueById }, + } = useIssueDetail(); + const { getProjectById } = useProject(); + // derived values + const issue = getIssueById(issueId); + + if (!issue) return null; + + const projectDetails = getProjectById(issue.project_id); + + const dueBy = findTotalDaysInRange(new Date(issue.target_date ?? ""), new Date(), false); + + return ( + onClick(issue)} + className="py-2 px-3 hover:bg-custom-background-80 rounded grid grid-cols-6 gap-1" + > +
+ + + {projectDetails?.identifier} {issue.sequence_id} + +
{issue.name}
+
+
+ {dueBy} {`day${dueBy > 1 ? "s" : ""}`} +
+
+ {issue.assignee_ids.length > 0 ? ( + + {issue.assignee_ids?.map((assigneeId) => { + const userDetails = getUserDetails(assigneeId); + + if (!userDetails) return null; + + return ; + })} + + ) : ( + "-" + )} +
+
+ ); +}); + +export const CreatedCompletedIssueListItem: React.FC = observer((props) => { + const { issueId, onClick, workspaceSlug } = props; + // store hooks + const { getUserDetails } = useMember(); + const { + issue: { getIssueById }, + } = useIssueDetail(); + const { getProjectById } = useProject(); + // derived values + const issue = getIssueById(issueId); + + if (!issue) return null; + + const projectDetails = getProjectById(issue.project_id); + + return ( + onClick(issue)} + className="py-2 px-3 hover:bg-custom-background-80 rounded grid grid-cols-6 gap-1" + > +
+ + + {projectDetails?.identifier} {issue.sequence_id} + +
{issue.name}
+
+
+ {issue.assignee_ids.length > 0 ? ( + + {issue.assignee_ids?.map((assigneeId) => { + const userDetails = getUserDetails(assigneeId); + + if (!userDetails) return null; + + return ; + })} + + ) : ( + "-" + )} +
+
+ ); +}); diff --git a/web/components/dashboard/widgets/issue-panels/issues-list.tsx b/web/components/dashboard/widgets/issue-panels/issues-list.tsx new file mode 100644 index 000000000..d104bbd05 --- /dev/null +++ b/web/components/dashboard/widgets/issue-panels/issues-list.tsx @@ -0,0 +1,124 @@ +import Link from "next/link"; +// hooks +import { useIssueDetail } from "hooks/store"; +// components +import { + AssignedCompletedIssueListItem, + AssignedIssuesEmptyState, + AssignedOverdueIssueListItem, + AssignedUpcomingIssueListItem, + CreatedCompletedIssueListItem, + CreatedIssuesEmptyState, + CreatedOverdueIssueListItem, + CreatedUpcomingIssueListItem, + IssueListItemProps, +} from "components/dashboard/widgets"; +// ui +import { Loader, getButtonStyling } from "@plane/ui"; +// helpers +import { cn } from "helpers/common.helper"; +import { getRedirectionFilters } from "helpers/dashboard.helper"; +// types +import { TDurationFilterOptions, TIssue, TIssuesListTypes } from "@plane/types"; + +export type WidgetIssuesListProps = { + filter: TDurationFilterOptions | undefined; + isLoading: boolean; + issues: TIssue[]; + tab: TIssuesListTypes; + totalIssues: number; + type: "assigned" | "created"; + workspaceSlug: string; +}; + +export const WidgetIssuesList: React.FC = (props) => { + const { filter, isLoading, issues, tab, totalIssues, type, workspaceSlug } = props; + // store hooks + const { setPeekIssue } = useIssueDetail(); + + const handleIssuePeekOverview = (issue: TIssue) => + setPeekIssue({ workspaceSlug, projectId: issue.project_id, issueId: issue.id }); + + const filterParams = getRedirectionFilters(tab); + + const ISSUE_LIST_ITEM: { + [key in string]: { + [key in TIssuesListTypes]: React.FC; + }; + } = { + assigned: { + upcoming: AssignedUpcomingIssueListItem, + overdue: AssignedOverdueIssueListItem, + completed: AssignedCompletedIssueListItem, + }, + created: { + upcoming: CreatedUpcomingIssueListItem, + overdue: CreatedOverdueIssueListItem, + completed: CreatedCompletedIssueListItem, + }, + }; + + return ( + <> +
+ {isLoading ? ( + + + + + + + ) : issues.length > 0 ? ( + <> +
+
+ Issues + + {totalIssues} + +
+ {tab === "upcoming" &&
Due date
} + {tab === "overdue" &&
Due by
} + {type === "assigned" && tab !== "completed" &&
Blocked by
} + {type === "created" &&
Assigned to
} +
+
+ {issues.map((issue) => { + const IssueListItem = ISSUE_LIST_ITEM[type][tab]; + + if (!IssueListItem) return null; + + return ( + + ); + })} +
+ + ) : ( +
+ {type === "assigned" && } + {type === "created" && } +
+ )} +
+ {totalIssues > issues.length && ( + + View all issues + + )} + + ); +}; diff --git a/web/components/dashboard/widgets/issue-panels/tabs-list.tsx b/web/components/dashboard/widgets/issue-panels/tabs-list.tsx new file mode 100644 index 000000000..6ef6ec0ee --- /dev/null +++ b/web/components/dashboard/widgets/issue-panels/tabs-list.tsx @@ -0,0 +1,26 @@ +import { Tab } from "@headlessui/react"; +// helpers +import { cn } from "helpers/common.helper"; +// constants +import { ISSUES_TABS_LIST } from "constants/dashboard"; + +export const TabsList = () => ( + + {ISSUES_TABS_LIST.map((tab) => ( + + cn("font-semibold text-xs rounded py-1.5 focus:outline-none", { + "bg-custom-background-100 text-custom-text-300 shadow-[2px_0_8px_rgba(167,169,174,0.15)]": selected, + "text-custom-text-400": !selected, + }) + } + > + {tab.label} + + ))} + +); diff --git a/web/components/dashboard/widgets/issues-by-priority.tsx b/web/components/dashboard/widgets/issues-by-priority.tsx new file mode 100644 index 000000000..1e0cd30da --- /dev/null +++ b/web/components/dashboard/widgets/issues-by-priority.tsx @@ -0,0 +1,208 @@ +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { observer } from "mobx-react-lite"; +// hooks +import { useDashboard } from "hooks/store"; +// components +import { MarimekkoGraph } from "components/ui"; +import { + DurationFilterDropdown, + IssuesByPriorityEmptyState, + WidgetLoader, + WidgetProps, +} from "components/dashboard/widgets"; +// ui +import { PriorityIcon } from "@plane/ui"; +// helpers +import { getCustomDates } from "helpers/dashboard.helper"; +// types +import { TIssuesByPriorityWidgetFilters, TIssuesByPriorityWidgetResponse } from "@plane/types"; +// constants +import { PRIORITY_GRAPH_GRADIENTS } from "constants/dashboard"; +import { ISSUE_PRIORITIES } from "constants/issue"; + +const TEXT_COLORS = { + urgent: "#F4A9AA", + high: "#AB4800", + medium: "#AB6400", + low: "#1F2D5C", + none: "#60646C", +}; + +const CustomBar = (props: any) => { + const { bar, workspaceSlug } = props; + // states + const [isMouseOver, setIsMouseOver] = useState(false); + + return ( + + setIsMouseOver(true)} + onMouseLeave={() => setIsMouseOver(false)} + > + + + {bar?.id} + + + + ); +}; + +const WIDGET_KEY = "issues_by_priority"; + +export const IssuesByPriorityWidget: React.FC = observer((props) => { + const { dashboardId, workspaceSlug } = props; + // store hooks + const { + fetchWidgetStats, + widgetDetails: allWidgetDetails, + widgetStats: allWidgetStats, + updateDashboardWidgetFilters, + } = useDashboard(); + const widgetDetails = allWidgetDetails?.[workspaceSlug]?.[dashboardId]?.find((w) => w.key === WIDGET_KEY); + const widgetStats = allWidgetStats?.[workspaceSlug]?.[dashboardId]?.[WIDGET_KEY] as TIssuesByPriorityWidgetResponse[]; + + const handleUpdateFilters = async (filters: Partial) => { + if (!widgetDetails) return; + + await updateDashboardWidgetFilters(workspaceSlug, dashboardId, widgetDetails.id, { + widgetKey: WIDGET_KEY, + filters, + }); + + fetchWidgetStats(workspaceSlug, dashboardId, { + widget_key: WIDGET_KEY, + target_date: getCustomDates(widgetDetails.widget_filters.target_date ?? "this_week"), + }); + }; + + useEffect(() => { + if (!widgetDetails) return; + + if (!widgetStats) + fetchWidgetStats(workspaceSlug, dashboardId, { + widget_key: WIDGET_KEY, + target_date: getCustomDates(widgetDetails.widget_filters.target_date ?? "this_week"), + }); + }, [dashboardId, fetchWidgetStats, widgetDetails, widgetStats, workspaceSlug]); + + if (!widgetDetails || !widgetStats) return ; + + const totalCount = widgetStats.reduce((acc, item) => acc + item?.count, 0); + const chartData = widgetStats + .filter((i) => i.count !== 0) + .map((item) => ({ + priority: item?.priority, + percentage: (item?.count / totalCount) * 100, + urgent: item?.priority === "urgent" ? 1 : 0, + high: item?.priority === "high" ? 1 : 0, + medium: item?.priority === "medium" ? 1 : 0, + low: item?.priority === "low" ? 1 : 0, + none: item?.priority === "none" ? 1 : 0, + })); + + const CustomBarsLayer = (props: any) => { + const { bars } = props; + + return ( + + {bars + ?.filter((b: any) => b?.value === 1) // render only bars with value 1 + .map((bar: any) => ( + + ))} + + ); + }; + + return ( + +
+

Priority of assigned issues

+ + handleUpdateFilters({ + target_date: val, + }) + } + /> +
+ {totalCount > 0 ? ( +
+
+ ({ + id: p.key, + value: p.key, + }))} + axisBottom={null} + axisLeft={null} + height="119px" + margin={{ + top: 11, + right: 0, + bottom: 0, + left: 0, + }} + defs={PRIORITY_GRAPH_GRADIENTS} + fill={ISSUE_PRIORITIES.map((p) => ({ + match: { + id: p.key, + }, + id: `gradient${p.title}`, + }))} + tooltip={() => <>} + enableGridX={false} + enableGridY={false} + layers={[CustomBarsLayer]} + /> +
+ {chartData.map((item) => ( +

+ + {item.percentage.toFixed(0)}% +

+ ))} +
+
+
+ ) : ( +
+ +
+ )} + + ); +}); diff --git a/web/components/dashboard/widgets/issues-by-state-group.tsx b/web/components/dashboard/widgets/issues-by-state-group.tsx new file mode 100644 index 000000000..d4478040a --- /dev/null +++ b/web/components/dashboard/widgets/issues-by-state-group.tsx @@ -0,0 +1,188 @@ +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/router"; +import { observer } from "mobx-react-lite"; +// hooks +import { useDashboard } from "hooks/store"; +// components +import { PieGraph } from "components/ui"; +import { + DurationFilterDropdown, + IssuesByStateGroupEmptyState, + WidgetLoader, + WidgetProps, +} from "components/dashboard/widgets"; +// helpers +import { getCustomDates } from "helpers/dashboard.helper"; +// types +import { TIssuesByStateGroupsWidgetFilters, TIssuesByStateGroupsWidgetResponse, TStateGroups } from "@plane/types"; +// constants +import { STATE_GROUP_GRAPH_COLORS, STATE_GROUP_GRAPH_GRADIENTS } from "constants/dashboard"; +import { STATE_GROUPS } from "constants/state"; + +const WIDGET_KEY = "issues_by_state_groups"; + +export const IssuesByStateGroupWidget: React.FC = observer((props) => { + const { dashboardId, workspaceSlug } = props; + // states + const [activeStateGroup, setActiveStateGroup] = useState("started"); + // router + const router = useRouter(); + // store hooks + const { + fetchWidgetStats, + widgetDetails: allWidgetDetails, + widgetStats: allWidgetStats, + updateDashboardWidgetFilters, + } = useDashboard(); + // derived values + const widgetDetails = allWidgetDetails?.[workspaceSlug]?.[dashboardId]?.find((w) => w.key === WIDGET_KEY); + const widgetStats = allWidgetStats?.[workspaceSlug]?.[dashboardId]?.[ + WIDGET_KEY + ] as TIssuesByStateGroupsWidgetResponse[]; + + const handleUpdateFilters = async (filters: Partial) => { + if (!widgetDetails) return; + + await updateDashboardWidgetFilters(workspaceSlug, dashboardId, widgetDetails.id, { + widgetKey: WIDGET_KEY, + filters, + }); + + fetchWidgetStats(workspaceSlug, dashboardId, { + widget_key: WIDGET_KEY, + target_date: getCustomDates(widgetDetails.widget_filters.target_date ?? "this_week"), + }); + }; + + useEffect(() => { + if (!widgetDetails) return; + + if (!widgetStats) + fetchWidgetStats(workspaceSlug, dashboardId, { + widget_key: WIDGET_KEY, + target_date: getCustomDates(widgetDetails.widget_filters.target_date ?? "this_week"), + }); + }, [dashboardId, fetchWidgetStats, widgetDetails, widgetStats, workspaceSlug]); + + if (!widgetDetails || !widgetStats) return ; + + const totalCount = widgetStats?.reduce((acc, item) => acc + item?.count, 0); + const chartData = widgetStats?.map((item) => ({ + color: STATE_GROUP_GRAPH_COLORS[item?.state as keyof typeof STATE_GROUP_GRAPH_COLORS], + id: item?.state, + label: item?.state, + value: (item?.count / totalCount) * 100, + })); + + const CenteredMetric = ({ dataWithArc, centerX, centerY }: any) => { + const data = dataWithArc?.find((datum: any) => datum?.id === activeStateGroup); + const percentage = chartData?.find((item) => item.id === activeStateGroup)?.value?.toFixed(0); + + return ( + + + {percentage}% + + + {data?.id} + + + ); + }; + + return ( + +
+

State of assigned issues

+ + handleUpdateFilters({ + target_date: val, + }) + } + /> +
+ {totalCount > 0 ? ( +
+
+
+ datum.data.color} + padAngle={1} + enableArcLinkLabels={false} + enableArcLabels={false} + activeOuterRadiusOffset={5} + tooltip={() => <>} + margin={{ + top: 0, + right: 5, + bottom: 0, + left: 5, + }} + defs={STATE_GROUP_GRAPH_GRADIENTS} + fill={Object.values(STATE_GROUPS).map((p) => ({ + match: { + id: p.key, + }, + id: `gradient${p.label}`, + }))} + onClick={(datum, e) => { + e.preventDefault(); + e.stopPropagation(); + router.push(`/${workspaceSlug}/workspace-views/assigned/?state_group=${datum.id}`); + }} + onMouseEnter={(datum) => setActiveStateGroup(datum.id as TStateGroups)} + layers={["arcs", CenteredMetric]} + /> +
+
+ {chartData.map((item) => ( +
+
+
+ {item.label} +
+ {item.value.toFixed(0)}% +
+ ))} +
+
+
+ ) : ( +
+ +
+ )} + + ); +}); diff --git a/web/components/dashboard/widgets/loaders/assigned-issues.tsx b/web/components/dashboard/widgets/loaders/assigned-issues.tsx new file mode 100644 index 000000000..4de381b29 --- /dev/null +++ b/web/components/dashboard/widgets/loaders/assigned-issues.tsx @@ -0,0 +1,22 @@ +// ui +import { Loader } from "@plane/ui"; + +export const AssignedIssuesWidgetLoader = () => ( + +
+ + +
+
+ + +
+
+ + + + + +
+
+); diff --git a/web/components/dashboard/widgets/loaders/index.ts b/web/components/dashboard/widgets/loaders/index.ts new file mode 100644 index 000000000..ee5286f0f --- /dev/null +++ b/web/components/dashboard/widgets/loaders/index.ts @@ -0,0 +1 @@ +export * from "./loader"; diff --git a/web/components/dashboard/widgets/loaders/issues-by-priority.tsx b/web/components/dashboard/widgets/loaders/issues-by-priority.tsx new file mode 100644 index 000000000..4051a2908 --- /dev/null +++ b/web/components/dashboard/widgets/loaders/issues-by-priority.tsx @@ -0,0 +1,15 @@ +// ui +import { Loader } from "@plane/ui"; + +export const IssuesByPriorityWidgetLoader = () => ( + + +
+ + + + + +
+
+); diff --git a/web/components/dashboard/widgets/loaders/issues-by-state-group.tsx b/web/components/dashboard/widgets/loaders/issues-by-state-group.tsx new file mode 100644 index 000000000..d2316802d --- /dev/null +++ b/web/components/dashboard/widgets/loaders/issues-by-state-group.tsx @@ -0,0 +1,21 @@ +// ui +import { Loader } from "@plane/ui"; + +export const IssuesByStateGroupWidgetLoader = () => ( + + +
+
+
+ +
+
+
+
+ {Array.from({ length: 5 }).map((_, index) => ( + + ))} +
+
+ +); diff --git a/web/components/dashboard/widgets/loaders/loader.tsx b/web/components/dashboard/widgets/loaders/loader.tsx new file mode 100644 index 000000000..141bb5533 --- /dev/null +++ b/web/components/dashboard/widgets/loaders/loader.tsx @@ -0,0 +1,31 @@ +// components +import { AssignedIssuesWidgetLoader } from "./assigned-issues"; +import { IssuesByPriorityWidgetLoader } from "./issues-by-priority"; +import { IssuesByStateGroupWidgetLoader } from "./issues-by-state-group"; +import { OverviewStatsWidgetLoader } from "./overview-stats"; +import { RecentActivityWidgetLoader } from "./recent-activity"; +import { RecentProjectsWidgetLoader } from "./recent-projects"; +import { RecentCollaboratorsWidgetLoader } from "./recent-collaborators"; +// types +import { TWidgetKeys } from "@plane/types"; + +type Props = { + widgetKey: TWidgetKeys; +}; + +export const WidgetLoader: React.FC = (props) => { + const { widgetKey } = props; + + const loaders = { + overview_stats: , + assigned_issues: , + created_issues: , + issues_by_state_groups: , + issues_by_priority: , + recent_activity: , + recent_projects: , + recent_collaborators: , + }; + + return loaders[widgetKey]; +}; diff --git a/web/components/dashboard/widgets/loaders/overview-stats.tsx b/web/components/dashboard/widgets/loaders/overview-stats.tsx new file mode 100644 index 000000000..f72d66ce4 --- /dev/null +++ b/web/components/dashboard/widgets/loaders/overview-stats.tsx @@ -0,0 +1,13 @@ +// ui +import { Loader } from "@plane/ui"; + +export const OverviewStatsWidgetLoader = () => ( + + {Array.from({ length: 4 }).map((_, index) => ( +
+ + +
+ ))} +
+); diff --git a/web/components/dashboard/widgets/loaders/recent-activity.tsx b/web/components/dashboard/widgets/loaders/recent-activity.tsx new file mode 100644 index 000000000..47e895a6e --- /dev/null +++ b/web/components/dashboard/widgets/loaders/recent-activity.tsx @@ -0,0 +1,19 @@ +// ui +import { Loader } from "@plane/ui"; + +export const RecentActivityWidgetLoader = () => ( + + + {Array.from({ length: 7 }).map((_, index) => ( +
+
+ +
+
+ + +
+
+ ))} +
+); diff --git a/web/components/dashboard/widgets/loaders/recent-collaborators.tsx b/web/components/dashboard/widgets/loaders/recent-collaborators.tsx new file mode 100644 index 000000000..d838967af --- /dev/null +++ b/web/components/dashboard/widgets/loaders/recent-collaborators.tsx @@ -0,0 +1,18 @@ +// ui +import { Loader } from "@plane/ui"; + +export const RecentCollaboratorsWidgetLoader = () => ( + + +
+ {Array.from({ length: 8 }).map((_, index) => ( +
+
+ +
+ +
+ ))} +
+
+); diff --git a/web/components/dashboard/widgets/loaders/recent-projects.tsx b/web/components/dashboard/widgets/loaders/recent-projects.tsx new file mode 100644 index 000000000..fc181ffab --- /dev/null +++ b/web/components/dashboard/widgets/loaders/recent-projects.tsx @@ -0,0 +1,19 @@ +// ui +import { Loader } from "@plane/ui"; + +export const RecentProjectsWidgetLoader = () => ( + + + {Array.from({ length: 5 }).map((_, index) => ( +
+
+ +
+
+ + +
+
+ ))} +
+); diff --git a/web/components/dashboard/widgets/overview-stats.tsx b/web/components/dashboard/widgets/overview-stats.tsx new file mode 100644 index 000000000..74630e1f8 --- /dev/null +++ b/web/components/dashboard/widgets/overview-stats.tsx @@ -0,0 +1,93 @@ +import { useEffect } from "react"; +import { observer } from "mobx-react-lite"; +import Link from "next/link"; +// hooks +import { useDashboard } from "hooks/store"; +// components +import { WidgetLoader } from "components/dashboard/widgets"; +// helpers +import { renderFormattedPayloadDate } from "helpers/date-time.helper"; +import { cn } from "helpers/common.helper"; +// types +import { TOverviewStatsWidgetResponse } from "@plane/types"; + +export type WidgetProps = { + dashboardId: string; + workspaceSlug: string; +}; + +const WIDGET_KEY = "overview_stats"; + +export const OverviewStatsWidget: React.FC = observer((props) => { + const { dashboardId, workspaceSlug } = props; + // store hooks + const { fetchWidgetStats, widgetStats: allWidgetStats } = useDashboard(); + // derived values + const widgetStats = allWidgetStats?.[workspaceSlug]?.[dashboardId]?.[WIDGET_KEY] as TOverviewStatsWidgetResponse; + + const today = renderFormattedPayloadDate(new Date()); + const STATS_LIST = [ + { + key: "assigned", + title: "Issues assigned", + count: widgetStats?.assigned_issues_count, + link: `/${workspaceSlug}/workspace-views/assigned`, + }, + { + key: "overdue", + title: "Issues overdue", + count: widgetStats?.pending_issues_count, + link: `/${workspaceSlug}/workspace-views/assigned/?target_date=${today};before`, + }, + { + key: "created", + title: "Issues created", + count: widgetStats?.created_issues_count, + link: `/${workspaceSlug}/workspace-views/created`, + }, + { + key: "completed", + title: "Issues completed", + count: widgetStats?.completed_issues_count, + link: `/${workspaceSlug}/workspace-views/assigned?state_group=completed`, + }, + ]; + + useEffect(() => { + if (!widgetStats) + fetchWidgetStats(workspaceSlug, dashboardId, { + widget_key: WIDGET_KEY, + }); + }, [dashboardId, fetchWidgetStats, widgetStats, workspaceSlug]); + + if (!widgetStats) return ; + + return ( +
+ {STATS_LIST.map((stat, index) => { + const isFirst = index === 0; + const isLast = index === STATS_LIST.length - 1; + const isMiddle = !isFirst && !isLast; + + return ( +
+ {!isLast && ( +
+ )} + +
{stat.count}
+

{stat.title}

+ +
+ ); + })} +
+ ); +}); diff --git a/web/components/dashboard/widgets/recent-activity.tsx b/web/components/dashboard/widgets/recent-activity.tsx new file mode 100644 index 000000000..52fca4600 --- /dev/null +++ b/web/components/dashboard/widgets/recent-activity.tsx @@ -0,0 +1,105 @@ +import { useEffect } from "react"; +import Link from "next/link"; +import { observer } from "mobx-react-lite"; +import { History } from "lucide-react"; +// hooks +import { useDashboard, useUser } from "hooks/store"; +// components +import { ActivityIcon, ActivityMessage } from "components/core"; +import { RecentActivityEmptyState, WidgetLoader, WidgetProps } from "components/dashboard/widgets"; +// ui +import { Avatar } from "@plane/ui"; +// helpers +import { calculateTimeAgo } from "helpers/date-time.helper"; +// types +import { TRecentActivityWidgetResponse } from "@plane/types"; + +const WIDGET_KEY = "recent_activity"; + +export const RecentActivityWidget: React.FC = observer((props) => { + const { dashboardId, workspaceSlug } = props; + // store hooks + const { currentUser } = useUser(); + // derived values + const { fetchWidgetStats, widgetStats: allWidgetStats } = useDashboard(); + const widgetStats = allWidgetStats?.[workspaceSlug]?.[dashboardId]?.[WIDGET_KEY] as TRecentActivityWidgetResponse[]; + + useEffect(() => { + if (!widgetStats) + fetchWidgetStats(workspaceSlug, dashboardId, { + widget_key: WIDGET_KEY, + }); + }, [dashboardId, fetchWidgetStats, widgetStats, workspaceSlug]); + + if (!widgetStats) return ; + + return ( + +
+

My activity

+
+ {widgetStats.length > 0 ? ( +
+ {widgetStats.map((activity) => ( +
+
+ {activity.field ? ( + activity.new_value === "restore" ? ( + + ) : ( +
+ +
+ ) + ) : activity.actor_detail.avatar && activity.actor_detail.avatar !== "" ? ( + + ) : ( +
+ {activity.actor_detail.is_bot + ? activity.actor_detail.first_name.charAt(0) + : activity.actor_detail.display_name.charAt(0)} +
+ )} +
+
+

+ + {currentUser?.id === activity.actor_detail.id ? "You" : activity.actor_detail.display_name}{" "} + + {activity.field ? ( + + ) : ( + + created this{" "} + + Issue. + + + )} +

+

{calculateTimeAgo(activity.created_at)}

+
+
+ ))} +
+ ) : ( +
+ +
+ )} + + ); +}); diff --git a/web/components/dashboard/widgets/recent-collaborators.tsx b/web/components/dashboard/widgets/recent-collaborators.tsx new file mode 100644 index 000000000..693f9808d --- /dev/null +++ b/web/components/dashboard/widgets/recent-collaborators.tsx @@ -0,0 +1,93 @@ +import { useEffect } from "react"; +import Link from "next/link"; +import { observer } from "mobx-react-lite"; +// hooks +import { useDashboard, useMember, useUser } from "hooks/store"; +// components +import { RecentCollaboratorsEmptyState, WidgetLoader, WidgetProps } from "components/dashboard/widgets"; +// ui +import { Avatar } from "@plane/ui"; +// types +import { TRecentCollaboratorsWidgetResponse } from "@plane/types"; + +type CollaboratorListItemProps = { + issueCount: number; + userId: string; + workspaceSlug: string; +}; + +const WIDGET_KEY = "recent_collaborators"; + +const CollaboratorListItem: React.FC = observer((props) => { + const { issueCount, userId, workspaceSlug } = props; + // store hooks + const { currentUser } = useUser(); + const { getUserDetails } = useMember(); + // derived values + const userDetails = getUserDetails(userId); + const isCurrentUser = userId === currentUser?.id; + + if (!userDetails) return null; + + return ( + +
+ +
+
+ {isCurrentUser ? "You" : userDetails?.display_name} +
+

+ {issueCount} active issue{issueCount > 1 ? "s" : ""} +

+ + ); +}); + +export const RecentCollaboratorsWidget: React.FC = observer((props) => { + const { dashboardId, workspaceSlug } = props; + // store hooks + const { fetchWidgetStats, widgetStats: allWidgetStats } = useDashboard(); + const widgetStats = allWidgetStats?.[workspaceSlug]?.[dashboardId]?.[ + WIDGET_KEY + ] as TRecentCollaboratorsWidgetResponse[]; + + useEffect(() => { + if (!widgetStats) + fetchWidgetStats(workspaceSlug, dashboardId, { + widget_key: WIDGET_KEY, + }); + }, [dashboardId, fetchWidgetStats, widgetStats, workspaceSlug]); + + if (!widgetStats) return ; + + return ( +
+
+

Collaborators

+
+ {widgetStats.length > 1 ? ( +
+ {widgetStats.map((user) => ( + + ))} +
+ ) : ( +
+ +
+ )} +
+ ); +}); diff --git a/web/components/dashboard/widgets/recent-projects.tsx b/web/components/dashboard/widgets/recent-projects.tsx new file mode 100644 index 000000000..d85cf52c8 --- /dev/null +++ b/web/components/dashboard/widgets/recent-projects.tsx @@ -0,0 +1,125 @@ +import { useEffect } from "react"; +import Link from "next/link"; +import { observer } from "mobx-react-lite"; +import { Plus } from "lucide-react"; +// hooks +import { useApplication, useDashboard, useProject, useUser } from "hooks/store"; +// components +import { WidgetLoader, WidgetProps } from "components/dashboard/widgets"; +// ui +import { Avatar, AvatarGroup } from "@plane/ui"; +// helpers +import { renderEmoji } from "helpers/emoji.helper"; +// types +import { TRecentProjectsWidgetResponse } from "@plane/types"; +// constants +import { EUserWorkspaceRoles } from "constants/workspace"; +import { PROJECT_BACKGROUND_COLORS } from "constants/dashboard"; + +const WIDGET_KEY = "recent_projects"; + +type ProjectListItemProps = { + projectId: string; + workspaceSlug: string; +}; + +const ProjectListItem: React.FC = observer((props) => { + const { projectId, workspaceSlug } = props; + // store hooks + const { getProjectById } = useProject(); + const projectDetails = getProjectById(projectId); + + const randomBgColor = PROJECT_BACKGROUND_COLORS[Math.floor(Math.random() * PROJECT_BACKGROUND_COLORS.length)]; + + if (!projectDetails) return null; + + return ( + +
+ {projectDetails.emoji ? ( + + {renderEmoji(projectDetails.emoji)} + + ) : projectDetails.icon_prop ? ( +
{renderEmoji(projectDetails.icon_prop)}
+ ) : ( + + {projectDetails.name.charAt(0)} + + )} +
+
+
+ {projectDetails.name} +
+
+ + {projectDetails.members?.map((member) => ( + + ))} + +
+
+ + ); +}); + +export const RecentProjectsWidget: React.FC = observer((props) => { + const { dashboardId, workspaceSlug } = props; + // store hooks + const { + commandPalette: { toggleCreateProjectModal }, + } = useApplication(); + const { + membership: { currentWorkspaceRole }, + } = useUser(); + const { fetchWidgetStats, widgetStats: allWidgetStats } = useDashboard(); + // derived values + const widgetStats = allWidgetStats?.[workspaceSlug]?.[dashboardId]?.[WIDGET_KEY] as TRecentProjectsWidgetResponse; + const canCreateProject = currentWorkspaceRole === EUserWorkspaceRoles.ADMIN; + + useEffect(() => { + if (!widgetStats) + fetchWidgetStats(workspaceSlug, dashboardId, { + widget_key: WIDGET_KEY, + }); + }, [dashboardId, fetchWidgetStats, widgetStats, workspaceSlug]); + + if (!widgetStats) return ; + + return ( + +
+

My projects

+
+
+ {canCreateProject && ( + + )} + {widgetStats.map((projectId) => ( + + ))} +
+ + ); +}); diff --git a/web/components/dropdowns/priority.tsx b/web/components/dropdowns/priority.tsx index 6712922ab..bd20c7965 100644 --- a/web/components/dropdowns/priority.tsx +++ b/web/components/dropdowns/priority.tsx @@ -258,7 +258,7 @@ export const PriorityDropdown: React.FC = (props) => { >