Compare commits

...

4 Commits

Author SHA1 Message Date
NarayanBavisetti
2340e6b2ae chore: changed the error message 2025-02-10 14:32:32 +05:30
NarayanBavisetti
7b5772550f chore: remove field from workspace 2025-02-07 15:31:45 +05:30
gakshita
7ac49a62fd chore: cleaned up dashboards from frontend 2025-02-07 13:55:39 +05:30
NarayanBavisetti
846349e99e chore: cleanup of code 2025-02-06 17:01:42 +05:30
68 changed files with 47 additions and 4495 deletions

View File

@@ -121,8 +121,6 @@ from .exporter import ExporterHistorySerializer
from .webhook import WebhookSerializer, WebhookLogSerializer
from .dashboard import DashboardSerializer, WidgetSerializer
from .favorite import UserFavoriteSerializer
from .draft import (

View File

@@ -1,21 +0,0 @@
# Module imports
from .base import BaseSerializer
from plane.db.models import DeprecatedDashboard, DeprecatedWidget
# Third party frameworks
from rest_framework import serializers
class DashboardSerializer(BaseSerializer):
class Meta:
model = DeprecatedDashboard
fields = "__all__"
class WidgetSerializer(BaseSerializer):
is_visible = serializers.BooleanField(read_only=True)
widget_filters = serializers.JSONField(read_only=True)
class Meta:
model = DeprecatedWidget
fields = ["id", "key", "is_visible", "widget_filters"]

View File

@@ -2,7 +2,6 @@ from .analytic import urlpatterns as analytic_urls
from .api import urlpatterns as api_urls
from .asset import urlpatterns as asset_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 .intake import urlpatterns as intake_urls
@@ -23,7 +22,6 @@ urlpatterns = [
*analytic_urls,
*asset_urls,
*cycle_urls,
*dashboard_urls,
*estimate_urls,
*external_urls,
*intake_urls,

View File

@@ -1,23 +0,0 @@
from django.urls import path
from plane.app.views import DashboardEndpoint, WidgetsEndpoint
urlpatterns = [
path(
"workspaces/<str:slug>/dashboard/",
DashboardEndpoint.as_view(),
name="dashboard",
),
path(
"workspaces/<str:slug>/dashboard/<uuid:dashboard_id>/",
DashboardEndpoint.as_view(),
name="dashboard",
),
path(
"dashboard/<uuid:dashboard_id>/widgets/<uuid:widget_id>/",
WidgetsEndpoint.as_view(),
name="widgets",
),
]

View File

@@ -208,8 +208,6 @@ from .webhook.base import (
WebhookSecretRegenerateEndpoint,
)
from .dashboard.base import DashboardEndpoint, WidgetsEndpoint
from .error_404 import custom_404_view
from .notification.base import MarkAllReadNotificationViewSet

View File

@@ -1,812 +0,0 @@
# Django imports
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.db.models import (
Case,
CharField,
Count,
Exists,
F,
Func,
IntegerField,
JSONField,
OuterRef,
Prefetch,
Q,
Subquery,
UUIDField,
Value,
When,
)
from django.db.models.functions import Coalesce
from django.utils import timezone
from rest_framework import status
# Third Party imports
from rest_framework.response import Response
from plane.app.serializers import (
DashboardSerializer,
IssueActivitySerializer,
IssueSerializer,
WidgetSerializer,
)
from plane.db.models import (
DeprecatedDashboard,
DeprecatedDashboardWidget,
Issue,
IssueActivity,
FileAsset,
IssueLink,
IssueRelation,
Project,
DeprecatedWidget,
WorkspaceMember,
CycleIssue,
)
from plane.utils.issue_filters import issue_filters
# Module imports
from .. import BaseAPIView
def dashboard_overview_stats(self, request, slug):
assigned_issues = (
Issue.issue_objects.filter(
(Q(assignees__in=[request.user]) & Q(issue_assignee__deleted_at__isnull=True)),
project__project_projectmember__is_active=True,
project__project_projectmember__member=request.user,
workspace__slug=slug,
)
.filter(
Q(
project__project_projectmember__role=5,
project__guest_view_all_features=True,
)
| Q(
project__project_projectmember__role=5,
project__guest_view_all_features=False,
created_by=self.request.user,
)
|
# For other roles (role < 5), show all issues
Q(project__project_projectmember__role__gt=5),
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
)
.count()
)
pending_issues_count = (
Issue.issue_objects.filter(
~Q(state__group__in=["completed", "cancelled"]),
target_date__lt=timezone.now().date(),
project__project_projectmember__is_active=True,
project__project_projectmember__member=request.user,
workspace__slug=slug,
assignees__in=[request.user],
)
.filter(
Q(
project__project_projectmember__role=5,
project__guest_view_all_features=True,
)
| Q(
project__project_projectmember__role=5,
project__guest_view_all_features=False,
created_by=self.request.user,
)
|
# For other roles (role < 5), show all issues
Q(project__project_projectmember__role__gt=5),
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
)
.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,
)
.filter(
Q(
project__project_projectmember__role=5,
project__guest_view_all_features=True,
)
| Q(
project__project_projectmember__role=5,
project__guest_view_all_features=False,
created_by=self.request.user,
)
|
# For other roles (role < 5), show all issues
Q(project__project_projectmember__role__gt=5),
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
)
.count()
)
completed_issues_count = (
Issue.issue_objects.filter(
(
Q(assignees__in=[request.user])
& Q(issue_assignee__deleted_at__isnull=True)
),
workspace__slug=slug,
project__project_projectmember__is_active=True,
project__project_projectmember__member=request.user,
state__group="completed",
)
.filter(
Q(
project__project_projectmember__role=5,
project__guest_view_all_features=True,
)
| Q(
project__project_projectmember__role=5,
project__guest_view_all_features=False,
created_by=self.request.user,
)
|
# For other roles (role < 5), show all issues
Q(project__project_projectmember__role__gt=5),
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
)
.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(
(
Q(assignees__in=[request.user])
& Q(issue_assignee__deleted_at__isnull=True)
),
workspace__slug=slug,
project__project_projectmember__member=request.user,
project__project_projectmember__is_active=True,
)
.filter(**filters)
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.prefetch_related(
Prefetch(
"issue_relation",
queryset=IssueRelation.objects.select_related(
"related_issue"
).select_related("issue"),
)
)
.annotate(
cycle_id=Subquery(
CycleIssue.objects.filter(
issue=OuterRef("id"), deleted_at__isnull=True
).values("cycle_id")[:1]
)
)
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=FileAsset.objects.filter(
issue_id=OuterRef("id"),
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
)
.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")
)
.annotate(
label_ids=Coalesce(
ArrayAgg(
"labels__id",
distinct=True,
filter=Q(
~Q(labels__id__isnull=True)
& Q(label_issue__deleted_at__isnull=True)
),
),
Value([], output_field=ArrayField(UUIDField())),
),
assignee_ids=Coalesce(
ArrayAgg(
"assignees__id",
distinct=True,
filter=Q(
~Q(assignees__id__isnull=True)
& Q(assignees__member_project__is_active=True)
& Q(issue_assignee__deleted_at__isnull=True)
),
),
Value([], output_field=ArrayField(UUIDField())),
),
module_ids=Coalesce(
ArrayAgg(
"issue_module__module_id",
distinct=True,
filter=Q(
~Q(issue_module__module_id__isnull=True)
& Q(issue_module__module__archived_at__isnull=True)
& Q(issue_module__deleted_at__isnull=True)
),
),
Value([], output_field=ArrayField(UUIDField())),
),
)
)
if WorkspaceMember.objects.filter(
workspace__slug=slug, member=request.user, role=5, is_active=True
).exists():
assigned_issues = assigned_issues.filter(created_by=request.user)
# 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 == "pending":
pending_issues_count = assigned_issues.filter(
state__group__in=["backlog", "started", "unstarted"]
).count()
pending_issues = assigned_issues.filter(
state__group__in=["backlog", "started", "unstarted"]
)[:5]
return Response(
{
"issues": IssueSerializer(
pending_issues, many=True, expand=self.expand
).data,
"count": pending_issues_count,
},
status=status.HTTP_200_OK,
)
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", "issue_module__module")
.annotate(
cycle_id=Subquery(
CycleIssue.objects.filter(
issue=OuterRef("id"), deleted_at__isnull=True
).values("cycle_id")[:1]
)
)
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=FileAsset.objects.filter(
issue_id=OuterRef("id"),
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
)
.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")
)
.annotate(
label_ids=Coalesce(
ArrayAgg(
"labels__id",
distinct=True,
filter=Q(
~Q(labels__id__isnull=True)
& Q(label_issue__deleted_at__isnull=True)
),
),
Value([], output_field=ArrayField(UUIDField())),
),
assignee_ids=Coalesce(
ArrayAgg(
"assignees__id",
distinct=True,
filter=Q(
~Q(assignees__id__isnull=True)
& Q(assignees__member_project__is_active=True)
& Q(issue_assignee__deleted_at__isnull=True)
),
),
Value([], output_field=ArrayField(UUIDField())),
),
module_ids=Coalesce(
ArrayAgg(
"issue_module__module_id",
distinct=True,
filter=Q(
~Q(issue_module__module_id__isnull=True)
& Q(issue_module__module__archived_at__isnull=True)
& Q(issue_module__deleted_at__isnull=True)
),
),
Value([], output_field=ArrayField(UUIDField())),
),
)
.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 == "pending":
pending_issues_count = created_issues.filter(
state__group__in=["backlog", "started", "unstarted"]
).count()
pending_issues = created_issues.filter(
state__group__in=["backlog", "started", "unstarted"]
)[:5]
return Response(
{
"issues": IssueSerializer(
pending_issues, many=True, expand=self.expand
).data,
"count": pending_issues_count,
},
status=status.HTTP_200_OK,
)
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"]
extra_filters = {}
if WorkspaceMember.objects.filter(
workspace__slug=slug, member=request.user, role=5, is_active=True
).exists():
extra_filters = {"created_by": request.user}
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, **extra_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"]
extra_filters = {}
if WorkspaceMember.objects.filter(
workspace__slug=slug, member=request.user, role=5, is_active=True
).exists():
extra_filters = {"created_by": request.user}
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, **extra_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,
project__archived_at__isnull=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,
project__archived_at__isnull=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,
archived_at__isnull=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):
project_members_with_activities = (
WorkspaceMember.objects.filter(workspace__slug=slug, is_active=True)
.annotate(
active_issue_count=Count(
Case(
When(
member__issue_assignee__issue__state__group__in=[
"unstarted",
"started",
],
member__issue_assignee__issue__workspace__slug=slug,
member__issue_assignee__issue__project__project_projectmember__member=request.user,
member__issue_assignee__issue__project__project_projectmember__is_active=True,
then=F("member__issue_assignee__issue__id"),
),
distinct=True,
output_field=IntegerField(),
),
distinct=True,
),
user_id=F("member_id"),
)
.values("user_id", "active_issue_count")
.order_by("-active_issue_count")
.distinct()
)
return Response((project_members_with_activities), 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 = DeprecatedDashboard.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 = DeprecatedWidget.objects.filter(
key=widget_key
).values_list("id", flat=True)
if widget:
updated_dashboard_widgets.append(
DeprecatedDashboardWidget(
widget_id=widget, dashboard_id=dashboard.id
)
)
DeprecatedDashboardWidget.objects.bulk_create(
updated_dashboard_widgets, batch_size=100
)
widgets = (
DeprecatedWidget.objects.annotate(
is_visible=Exists(
DeprecatedDashboardWidget.objects.filter(
widget_id=OuterRef("pk"),
dashboard_id=dashboard.id,
is_visible=True,
)
)
)
.annotate(
dashboard_filters=Subquery(
DeprecatedDashboardWidget.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 = DeprecatedDashboardWidget.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)

View File

@@ -135,6 +135,10 @@ class WorkSpaceViewSet(BaseViewSet):
{"slug": "The workspace with the slug already exists"},
status=status.HTTP_410_GONE,
)
return Response(
{"error": "The payload is not valid"},
status=status.HTTP_400_BAD_REQUEST,
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
def list(self, request, *args, **kwargs):

View File

@@ -0,0 +1,42 @@
# Generated by Django 4.2.17 on 2025-02-06 11:23
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('db', '0091_issuecomment_edited_at_and_more'),
]
operations = [
migrations.AlterUniqueTogether(
name='deprecateddashboardwidget',
unique_together=None,
),
migrations.RemoveField(
model_name='deprecateddashboardwidget',
name='created_by',
),
migrations.RemoveField(
model_name='deprecateddashboardwidget',
name='dashboard',
),
migrations.RemoveField(
model_name='deprecateddashboardwidget',
name='updated_by',
),
migrations.RemoveField(
model_name='deprecateddashboardwidget',
name='widget',
),
migrations.DeleteModel(
name='DeprecatedDashboard',
),
migrations.DeleteModel(
name='DeprecatedDashboardWidget',
),
migrations.DeleteModel(
name='DeprecatedWidget',
),
]

View File

@@ -3,7 +3,6 @@ from .api import APIActivityLog, APIToken
from .asset import FileAsset
from .base import BaseModel
from .cycle import Cycle, CycleIssue, CycleUserProperties
from .dashboard import DeprecatedDashboard, DeprecatedDashboardWidget, DeprecatedWidget
from .deploy_board import DeployBoard
from .draft import (
DraftIssue,

View File

@@ -1,92 +0,0 @@
import uuid
# Django imports
from django.db import models
# Module imports
from ..mixins import TimeAuditModel
from .base import BaseModel
class DeprecatedDashboard(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="<p></p>")
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",
)
logo_props = models.JSONField(default=dict)
def __str__(self):
"""Return name of the dashboard"""
return f"{self.name}"
class Meta:
verbose_name = "DeprecatedDashboard"
verbose_name_plural = "DeprecatedDashboards"
db_table = "deprecated_dashboards"
ordering = ("-created_at",)
class DeprecatedWidget(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)
logo_props = models.JSONField(default=dict)
def __str__(self):
"""Return name of the widget"""
return f"{self.key}"
class Meta:
verbose_name = "DeprecatedWidget"
verbose_name_plural = "DeprecatedWidgets"
db_table = "deprecated_widgets"
ordering = ("-created_at",)
class DeprecatedDashboardWidget(BaseModel):
widget = models.ForeignKey(
DeprecatedWidget, on_delete=models.CASCADE, related_name="dashboard_widgets"
)
dashboard = models.ForeignKey(
DeprecatedDashboard, 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", "deleted_at")
constraints = [
models.UniqueConstraint(
fields=["widget", "dashboard"],
condition=models.Q(deleted_at__isnull=True),
name="dashboard_widget_unique_widget_dashboard_when_deleted_at_null",
)
]
verbose_name = "Deprecated Dashboard Widget"
verbose_name_plural = "Deprecated Dashboard Widgets"
db_table = "deprecated_dashboard_widgets"
ordering = ("-created_at",)

View File

@@ -1,79 +0,0 @@
import { API_BASE_URL } from "@plane/constants";
import { THomeDashboardResponse, TWidget, TWidgetStatsResponse, TWidgetStatsRequestParams } from "@plane/types";
import { APIService } from "../api.service";
export default class DashboardService extends APIService {
constructor(BASE_URL?: string) {
super(BASE_URL || API_BASE_URL);
}
/**
* Retrieves home dashboard widgets for a specific workspace
* @param {string} workspaceSlug - The unique identifier for the workspace
* @returns {Promise<THomeDashboardResponse>} Promise resolving to dashboard widget data
* @throws {Error} If the API request fails
*/
async getHomeWidgets(workspaceSlug: string): Promise<THomeDashboardResponse> {
return this.get(`/api/workspaces/${workspaceSlug}/dashboard/`, {
params: {
dashboard_type: "home",
},
})
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
/**
* Fetches statistics for a specific dashboard widget
* @param {string} workspaceSlug - The unique identifier for the workspace
* @param {string} dashboardId - The unique identifier for the dashboard
* @param {TWidgetStatsRequestParams} params - Parameters for filtering widget statistics
* @returns {Promise<TWidgetStatsResponse>} Promise resolving to widget statistics data
* @throws {Error} If the API request fails
*/
async getWidgetStats(
workspaceSlug: string,
dashboardId: string,
params: TWidgetStatsRequestParams
): Promise<TWidgetStatsResponse> {
return this.get(`/api/workspaces/${workspaceSlug}/dashboard/${dashboardId}/`, {
params,
})
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
/**
* Retrieves detailed information about a specific dashboard
* @param {string} dashboardId - The unique identifier for the dashboard
* @returns {Promise<TWidgetStatsResponse>} Promise resolving to dashboard details
* @throws {Error} If the API request fails
*/
async retrieve(dashboardId: string): Promise<TWidgetStatsResponse> {
return this.get(`/api/dashboard/${dashboardId}/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
/**
* Updates a specific widget within a dashboard
* @param {string} dashboardId - The unique identifier for the dashboard
* @param {string} widgetId - The unique identifier for the widget
* @param {Partial<TWidget>} data - Partial widget data to update
* @returns {Promise<TWidget>} Promise resolving to the updated widget data
* @throws {Error} If the API request fails
*/
async updateWidget(dashboardId: string, widgetId: string, data: Partial<TWidget>): Promise<TWidget> {
return this.patch(`/api/dashboard/${dashboardId}/widgets/${widgetId}/`, data)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
}

View File

@@ -1 +0,0 @@
export * from "./dashboard.service";

View File

@@ -3,7 +3,6 @@ export * from "./analytics";
export * from "./developer";
export * from "./auth";
export * from "./cycle";
export * from "./dashboard";
export * from "./instance";
export * from "./intake";
export * from "./module";

View File

@@ -1,181 +0,0 @@
import { EDurationFilters } from "./enums";
import { IIssueActivity, TIssuePriorities } from "./issues";
import { TIssue } from "./issues/issue";
import { TStateGroups } from "./state";
import { TIssueRelationTypes } from "@/plane-web/types";
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 = "pending" | "upcoming" | "overdue" | "completed";
// widget filters
export type TAssignedIssuesWidgetFilters = {
custom_dates?: string[];
duration?: EDurationFilters;
tab?: TIssuesListTypes;
};
export type TCreatedIssuesWidgetFilters = {
custom_dates?: string[];
duration?: EDurationFilters;
tab?: TIssuesListTypes;
};
export type TIssuesByStateGroupsWidgetFilters = {
duration?: EDurationFilters;
custom_dates?: string[];
};
export type TIssuesByPriorityWidgetFilters = {
custom_dates?: string[];
duration?: EDurationFilters;
};
export type TWidgetFiltersFormData =
| {
widgetKey: "assigned_issues";
filters: Partial<TAssignedIssuesWidgetFilters>;
}
| {
widgetKey: "created_issues";
filters: Partial<TCreatedIssuesWidgetFilters>;
}
| {
widgetKey: "issues_by_state_groups";
filters: Partial<TIssuesByStateGroupsWidgetFilters>;
}
| {
widgetKey: "issues_by_priority";
filters: Partial<TIssuesByPriorityWidgetFilters>;
};
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";
}
| {
cursor: string;
per_page: number;
search?: string;
widget_key: "recent_collaborators";
};
export type TWidgetIssue = TIssue & {
issue_relation: {
id: string;
project_id: string;
relation_type: TIssueRelationTypes;
sequence_id: number;
type_id: string | null;
}[];
};
// 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[];
};

View File

@@ -1,7 +1,6 @@
export * from "./users";
export * from "./workspace";
export * from "./cycle";
export * from "./dashboard";
export * from "./de-dupe";
export * from "./project";
export * from "./state";

View File

@@ -1,62 +0,0 @@
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// types
import { TWidgetKeys } from "@plane/types";
// components
import {
AssignedIssuesWidget,
CreatedIssuesWidget,
IssuesByPriorityWidget,
IssuesByStateGroupWidget,
OverviewStatsWidget,
RecentActivityWidget,
RecentCollaboratorsWidget,
RecentProjectsWidget,
WidgetProps,
} from "@/components/dashboard";
// hooks
import { useDashboard } from "@/hooks/store";
const WIDGETS_LIST: {
[key in TWidgetKeys]: { component: React.FC<WidgetProps>; fullWidth: boolean };
} = {
overview_stats: { component: OverviewStatsWidget, fullWidth: true },
assigned_issues: { component: AssignedIssuesWidget, fullWidth: false },
created_issues: { component: CreatedIssuesWidget, fullWidth: false },
issues_by_state_groups: { component: IssuesByStateGroupWidget, fullWidth: false },
issues_by_priority: { component: IssuesByPriorityWidget, fullWidth: false },
recent_activity: { component: RecentActivityWidget, fullWidth: false },
recent_projects: { component: RecentProjectsWidget, fullWidth: false },
recent_collaborators: { component: RecentCollaboratorsWidget, fullWidth: true },
};
export const DashboardWidgets = observer(() => {
// router
const { workspaceSlug } = useParams();
// store hooks
const { homeDashboardId, homeDashboardWidgets } = useDashboard();
const doesWidgetExist = (widgetKey: TWidgetKeys) =>
Boolean(homeDashboardWidgets?.find((widget) => widget.key === widgetKey));
if (!workspaceSlug || !homeDashboardId) return null;
return (
<div className="relative flex flex-col lg:grid lg:grid-cols-2 gap-7">
{Object.entries(WIDGETS_LIST).map(([key, widget]) => {
const WidgetComponent = widget.component;
// if the widget doesn't exist, return null
if (!doesWidgetExist(key as TWidgetKeys)) return null;
// if the widget is full width, return it in a 2 column grid
if (widget.fullWidth)
return (
<div key={key} className="lg:col-span-2">
<WidgetComponent dashboardId={homeDashboardId} workspaceSlug={workspaceSlug.toString()} />
</div>
);
else
return <WidgetComponent key={key} dashboardId={homeDashboardId} workspaceSlug={workspaceSlug.toString()} />;
})}
</div>
);
});

View File

@@ -1,3 +0,0 @@
export * from "./widgets";
export * from "./home-dashboard-widgets";
export * from "./project-empty-state";

View File

@@ -1,46 +0,0 @@
"use client";
import { observer } from "mobx-react";
import Image from "next/image";
// ui
import { Button } from "@plane/ui";
// hooks
import { useCommandPalette, useEventTracker, useUserPermissions } from "@/hooks/store";
import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions";
// assets
import ProjectEmptyStateImage from "@/public/empty-state/onboarding/dashboard-light.webp";
export const DashboardProjectEmptyState = observer(() => {
// store hooks
const { toggleCreateProjectModal } = useCommandPalette();
const { setTrackElement } = useEventTracker();
const { allowPermissions } = useUserPermissions();
// derived values
const canCreateProject = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE);
return (
<div className="mx-auto flex h-full flex-col justify-center space-y-4 lg:w-3/5">
<h4 className="text-xl font-semibold">Overview of your projects, activity, and metrics</h4>
<p className="text-custom-text-300">
Welcome to Plane, we are excited to have you here. Create your first project and track your issues, and this
page will transform into a space that helps you progress. Admins will also see items which help their team
progress.
</p>
<Image src={ProjectEmptyStateImage} className="w-full" alt="Project empty state" />
{canCreateProject && (
<div className="flex justify-center">
<Button
variant="primary"
onClick={() => {
setTrackElement("Project empty state");
toggleCreateProjectModal(true);
}}
>
Build your first project
</Button>
</div>
)}
</div>
);
});

View File

@@ -1,165 +0,0 @@
import { useEffect, useState } from "react";
import { observer } from "mobx-react";
import Link from "next/link";
import { Tab } from "@headlessui/react";
import { TAssignedIssuesWidgetFilters, TAssignedIssuesWidgetResponse } from "@plane/types";
// hooks
import { Card } from "@plane/ui";
import {
DurationFilterDropdown,
IssuesErrorState,
TabsList,
WidgetIssuesList,
WidgetLoader,
WidgetProps,
} from "@/components/dashboard/widgets";
import { EDurationFilters, FILTERED_ISSUES_TABS_LIST, UNFILTERED_ISSUES_TABS_LIST } from "@/constants/dashboard";
import { getCustomDates, getRedirectionFilters, getTabKey } from "@/helpers/dashboard.helper";
import { useDashboard } from "@/hooks/store";
// components
// helpers
// types
// constants
const WIDGET_KEY = "assigned_issues";
export const AssignedIssuesWidget: React.FC<WidgetProps> = observer((props) => {
const { dashboardId, workspaceSlug } = props;
// states
const [fetching, setFetching] = useState(false);
// store hooks
const { fetchWidgetStats, getWidgetDetails, getWidgetStats, getWidgetStatsError, updateDashboardWidgetFilters } =
useDashboard();
// derived values
const widgetDetails = getWidgetDetails(workspaceSlug, dashboardId, WIDGET_KEY);
const widgetStats = getWidgetStats<TAssignedIssuesWidgetResponse>(workspaceSlug, dashboardId, WIDGET_KEY);
const widgetStatsError = getWidgetStatsError(workspaceSlug, dashboardId, WIDGET_KEY);
const selectedDurationFilter = widgetDetails?.widget_filters.duration ?? EDurationFilters.NONE;
const selectedTab = getTabKey(selectedDurationFilter, widgetDetails?.widget_filters.tab);
const selectedCustomDates = widgetDetails?.widget_filters.custom_dates ?? [];
const handleUpdateFilters = async (filters: Partial<TAssignedIssuesWidgetFilters>) => {
if (!widgetDetails) return;
setFetching(true);
await updateDashboardWidgetFilters(workspaceSlug, dashboardId, widgetDetails.id, {
widgetKey: WIDGET_KEY,
filters,
});
const filterDates = getCustomDates(
filters.duration ?? selectedDurationFilter,
filters.custom_dates ?? selectedCustomDates
);
fetchWidgetStats(workspaceSlug, dashboardId, {
widget_key: WIDGET_KEY,
issue_type: filters.tab ?? selectedTab,
...(filterDates.trim() !== "" ? { target_date: filterDates } : {}),
expand: "issue_relation",
}).finally(() => setFetching(false));
};
useEffect(() => {
const filterDates = getCustomDates(selectedDurationFilter, selectedCustomDates);
fetchWidgetStats(workspaceSlug, dashboardId, {
widget_key: WIDGET_KEY,
issue_type: selectedTab,
...(filterDates.trim() !== "" ? { target_date: filterDates } : {}),
expand: "issue_relation",
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const filterParams = getRedirectionFilters(selectedTab);
const tabsList = selectedDurationFilter === "none" ? UNFILTERED_ISSUES_TABS_LIST : FILTERED_ISSUES_TABS_LIST;
const selectedTabIndex = tabsList.findIndex((tab) => tab.key === selectedTab);
if ((!widgetDetails || !widgetStats) && !widgetStatsError) return <WidgetLoader widgetKey={WIDGET_KEY} />;
return (
<Card>
{widgetStatsError ? (
<IssuesErrorState
isRefreshing={fetching}
onClick={() =>
handleUpdateFilters({
duration: EDurationFilters.NONE,
tab: "pending",
})
}
/>
) : (
widgetStats && (
<>
<div className="flex items-center justify-between gap-2 mb-4">
<Link
href={`/${workspaceSlug}/workspace-views/assigned/${filterParams}`}
className="text-lg font-semibold text-custom-text-300 hover:underline"
>
Assigned to you
</Link>
<DurationFilterDropdown
customDates={selectedCustomDates}
value={selectedDurationFilter}
onChange={(val, customDates) => {
if (val === "custom" && customDates) {
handleUpdateFilters({
duration: val,
custom_dates: customDates,
});
return;
}
if (val === selectedDurationFilter) return;
let newTab = selectedTab;
// switch to pending tab if target date is changed to none
if (val === "none" && selectedTab !== "completed") newTab = "pending";
// switch to upcoming tab if target date is changed to other than none
if (val !== "none" && selectedDurationFilter === "none" && selectedTab !== "completed")
newTab = "upcoming";
handleUpdateFilters({
duration: val,
tab: newTab,
});
}}
/>
</div>
<Tab.Group
as="div"
selectedIndex={selectedTabIndex}
onChange={(i) => {
const newSelectedTab = tabsList[i];
handleUpdateFilters({ tab: newSelectedTab?.key ?? "completed" });
}}
className="h-full flex flex-col"
>
<TabsList durationFilter={selectedDurationFilter} selectedTab={selectedTab} />
<Tab.Panels as="div" className="h-full">
{tabsList.map((tab) => {
if (tab.key !== selectedTab) return null;
return (
<Tab.Panel key={tab.key} as="div" className="h-full flex flex-col" static>
<WidgetIssuesList
tab={tab.key}
type="assigned"
workspaceSlug={workspaceSlug}
widgetStats={widgetStats}
isLoading={fetching}
/>
</Tab.Panel>
);
})}
</Tab.Panels>
</Tab.Group>
</>
)
)}
</Card>
);
});

View File

@@ -1,162 +0,0 @@
import { useEffect, useState } from "react";
import { observer } from "mobx-react";
import Link from "next/link";
import { Tab } from "@headlessui/react";
import { TCreatedIssuesWidgetFilters, TCreatedIssuesWidgetResponse } from "@plane/types";
// hooks
import { Card } from "@plane/ui";
import {
DurationFilterDropdown,
IssuesErrorState,
TabsList,
WidgetIssuesList,
WidgetLoader,
WidgetProps,
} from "@/components/dashboard/widgets";
import { EDurationFilters, FILTERED_ISSUES_TABS_LIST, UNFILTERED_ISSUES_TABS_LIST } from "@/constants/dashboard";
import { getCustomDates, getRedirectionFilters, getTabKey } from "@/helpers/dashboard.helper";
import { useDashboard } from "@/hooks/store";
// components
// helpers
// types
// constants
const WIDGET_KEY = "created_issues";
export const CreatedIssuesWidget: React.FC<WidgetProps> = observer((props) => {
const { dashboardId, workspaceSlug } = props;
// states
const [fetching, setFetching] = useState(false);
// store hooks
const { fetchWidgetStats, getWidgetDetails, getWidgetStats, getWidgetStatsError, updateDashboardWidgetFilters } =
useDashboard();
// derived values
const widgetDetails = getWidgetDetails(workspaceSlug, dashboardId, WIDGET_KEY);
const widgetStats = getWidgetStats<TCreatedIssuesWidgetResponse>(workspaceSlug, dashboardId, WIDGET_KEY);
const widgetStatsError = getWidgetStatsError(workspaceSlug, dashboardId, WIDGET_KEY);
const selectedDurationFilter = widgetDetails?.widget_filters.duration ?? EDurationFilters.NONE;
const selectedTab = getTabKey(selectedDurationFilter, widgetDetails?.widget_filters.tab);
const selectedCustomDates = widgetDetails?.widget_filters.custom_dates ?? [];
const handleUpdateFilters = async (filters: Partial<TCreatedIssuesWidgetFilters>) => {
if (!widgetDetails) return;
setFetching(true);
await updateDashboardWidgetFilters(workspaceSlug, dashboardId, widgetDetails.id, {
widgetKey: WIDGET_KEY,
filters,
});
const filterDates = getCustomDates(
filters.duration ?? selectedDurationFilter,
filters.custom_dates ?? selectedCustomDates
);
fetchWidgetStats(workspaceSlug, dashboardId, {
widget_key: WIDGET_KEY,
issue_type: filters.tab ?? selectedTab,
...(filterDates.trim() !== "" ? { target_date: filterDates } : {}),
}).finally(() => setFetching(false));
};
useEffect(() => {
const filterDates = getCustomDates(selectedDurationFilter, selectedCustomDates);
fetchWidgetStats(workspaceSlug, dashboardId, {
widget_key: WIDGET_KEY,
issue_type: selectedTab,
...(filterDates.trim() !== "" ? { target_date: filterDates } : {}),
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const filterParams = getRedirectionFilters(selectedTab);
const tabsList = selectedDurationFilter === "none" ? UNFILTERED_ISSUES_TABS_LIST : FILTERED_ISSUES_TABS_LIST;
const selectedTabIndex = tabsList.findIndex((tab) => tab.key === selectedTab);
if ((!widgetDetails || !widgetStats) && !widgetStatsError) return <WidgetLoader widgetKey={WIDGET_KEY} />;
return (
<Card>
{widgetStatsError ? (
<IssuesErrorState
isRefreshing={fetching}
onClick={() =>
handleUpdateFilters({
duration: EDurationFilters.NONE,
tab: "pending",
})
}
/>
) : (
widgetStats && (
<>
<div className="flex items-center justify-between gap-2 mb-4">
<Link
href={`/${workspaceSlug}/workspace-views/created/${filterParams}`}
className="text-lg font-semibold text-custom-text-300 hover:underline"
>
Created by you
</Link>
<DurationFilterDropdown
customDates={selectedCustomDates}
value={selectedDurationFilter}
onChange={(val, customDates) => {
if (val === "custom" && customDates) {
handleUpdateFilters({
duration: val,
custom_dates: customDates,
});
return;
}
if (val === selectedDurationFilter) return;
let newTab = selectedTab;
// switch to pending tab if target date is changed to none
if (val === "none" && selectedTab !== "completed") newTab = "pending";
// switch to upcoming tab if target date is changed to other than none
if (val !== "none" && selectedDurationFilter === "none" && selectedTab !== "completed")
newTab = "upcoming";
handleUpdateFilters({
duration: val,
tab: newTab,
});
}}
/>
</div>
<Tab.Group
as="div"
selectedIndex={selectedTabIndex}
onChange={(i) => {
const newSelectedTab = tabsList[i];
handleUpdateFilters({ tab: newSelectedTab.key ?? "completed" });
}}
className="h-full flex flex-col"
>
<TabsList durationFilter={selectedDurationFilter} selectedTab={selectedTab} />
<Tab.Panels as="div" className="h-full">
{tabsList.map((tab) => {
if (tab.key !== selectedTab) return null;
return (
<Tab.Panel key={tab.key} as="div" className="h-full flex flex-col" static>
<WidgetIssuesList
tab={tab.key}
type="created"
workspaceSlug={workspaceSlug}
widgetStats={widgetStats}
isLoading={fetching}
/>
</Tab.Panel>
);
})}
</Tab.Panels>
</Tab.Group>
</>
)
)}
</Card>
);
});

View File

@@ -1,58 +0,0 @@
"use client";
import { useState } from "react";
import { ChevronDown } from "lucide-react";
// components
import { CustomMenu } from "@plane/ui";
import { DateFilterModal } from "@/components/core";
// ui
// helpers
import { DURATION_FILTER_OPTIONS, EDurationFilters } from "@/constants/dashboard";
import { getDurationFilterDropdownLabel } from "@/helpers/dashboard.helper";
// constants
type Props = {
customDates?: string[];
onChange: (value: EDurationFilters, customDates?: string[]) => void;
value: EDurationFilters;
};
export const DurationFilterDropdown: React.FC<Props> = (props) => {
const { customDates, onChange, value } = props;
// states
const [isDateFilterModalOpen, setIsDateFilterModalOpen] = useState(false);
return (
<>
<DateFilterModal
isOpen={isDateFilterModalOpen}
handleClose={() => setIsDateFilterModalOpen(false)}
onSelect={(val) => onChange(EDurationFilters.CUSTOM, val)}
title="Due date"
/>
<CustomMenu
className="flex-shrink-0"
customButton={
<div className="px-3 py-2 border-[0.5px] border-custom-border-300 hover:bg-custom-background-80 focus:bg-custom-background-80 text-xs font-medium whitespace-nowrap rounded-md outline-none flex items-center gap-2">
{getDurationFilterDropdownLabel(value, customDates ?? [])}
<ChevronDown className="h-3 w-3" />
</div>
}
placement="bottom-end"
closeOnSelect
>
{DURATION_FILTER_OPTIONS.map((option) => (
<CustomMenu.MenuItem
key={option.key}
onClick={() => {
if (option.key === "custom") setIsDateFilterModalOpen(true);
else onChange(option.key);
}}
>
{option.label}
</CustomMenu.MenuItem>
))}
</CustomMenu>
</>
);
};

View File

@@ -1 +0,0 @@
export * from "./duration-filter";

View File

@@ -1,30 +0,0 @@
import Image from "next/image";
import { useTheme } from "next-themes";
import { TIssuesListTypes } from "@plane/types";
// types
import { ASSIGNED_ISSUES_EMPTY_STATES } from "@/constants/dashboard";
// constants
type Props = {
type: TIssuesListTypes;
};
export const AssignedIssuesEmptyState: React.FC<Props> = (props) => {
const { type } = props;
// next-themes
const { resolvedTheme } = useTheme();
const typeDetails = ASSIGNED_ISSUES_EMPTY_STATES[type];
const image = resolvedTheme === "dark" ? typeDetails.darkImage : typeDetails.lightImage;
// TODO: update empty state logic to use a general component
return (
<div className="text-center space-y-6 flex flex-col items-center">
<div className="h-24 w-24">
<Image src={image} className="w-full h-full" alt="Assigned issues" />
</div>
<p className="text-sm font-medium text-custom-text-300 whitespace-pre-line">{typeDetails.title}</p>
</div>
);
};

View File

@@ -1,29 +0,0 @@
import Image from "next/image";
import { useTheme } from "next-themes";
import { TIssuesListTypes } from "@plane/types";
// types
import { CREATED_ISSUES_EMPTY_STATES } from "@/constants/dashboard";
// constants
type Props = {
type: TIssuesListTypes;
};
export const CreatedIssuesEmptyState: React.FC<Props> = (props) => {
const { type } = props;
// next-themes
const { resolvedTheme } = useTheme();
const typeDetails = CREATED_ISSUES_EMPTY_STATES[type];
const image = resolvedTheme === "dark" ? typeDetails.darkImage : typeDetails.lightImage;
return (
<div className="text-center space-y-6 flex flex-col items-center">
<div className="h-24 w-24">
<Image src={image} className="w-full h-full" alt="Assigned issues" />
</div>
<p className="text-sm font-medium text-custom-text-300 whitespace-pre-line">{typeDetails.title}</p>
</div>
);
};

View File

@@ -1,6 +0,0 @@
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";

View File

@@ -1,25 +0,0 @@
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";
export const IssuesByPriorityEmptyState = () => {
// next-themes
const { resolvedTheme } = useTheme();
const image = resolvedTheme === "dark" ? DarkImage : LightImage;
return (
<div className="text-center space-y-6 flex flex-col items-center">
<div className="h-24 w-24">
<Image src={image} className="w-full h-full" alt="Issues by state group" />
</div>
<p className="text-sm font-medium text-custom-text-300">
Issues assigned to you, broken down by
<br />
priority will show up here.
</p>
</div>
);
};

View File

@@ -1,25 +0,0 @@
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";
export const IssuesByStateGroupEmptyState = () => {
// next-themes
const { resolvedTheme } = useTheme();
const image = resolvedTheme === "dark" ? DarkImage : LightImage;
return (
<div className="text-center space-y-6 flex flex-col items-center">
<div className="h-24 w-24">
<Image src={image} className="w-full h-full" alt="Issues by state group" />
</div>
<p className="text-sm font-medium text-custom-text-300">
Issue assigned to you, broken down by state,
<br />
will show up here.
</p>
</div>
);
};

View File

@@ -1,25 +0,0 @@
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";
export const RecentActivityEmptyState = () => {
// next-themes
const { resolvedTheme } = useTheme();
const image = resolvedTheme === "dark" ? DarkImage : LightImage;
return (
<div className="text-center space-y-6 flex flex-col items-center">
<div className="h-24 w-24">
<Image src={image} className="w-full h-full" alt="Issues by state group" />
</div>
<p className="text-sm font-medium text-custom-text-300">
All your issue activities across
<br />
projects will show up here.
</p>
</div>
);
};

View File

@@ -1,39 +0,0 @@
import Image from "next/image";
import { useTheme } from "next-themes";
// assets
import DarkImage1 from "@/public/empty-state/dashboard/dark/recent-collaborators-1.svg";
import DarkImage2 from "@/public/empty-state/dashboard/dark/recent-collaborators-2.svg";
import DarkImage3 from "@/public/empty-state/dashboard/dark/recent-collaborators-3.svg";
import LightImage1 from "@/public/empty-state/dashboard/light/recent-collaborators-1.svg";
import LightImage2 from "@/public/empty-state/dashboard/light/recent-collaborators-2.svg";
import LightImage3 from "@/public/empty-state/dashboard/light/recent-collaborators-3.svg";
export const RecentCollaboratorsEmptyState = () => {
// next-themes
const { resolvedTheme } = useTheme();
const image1 = resolvedTheme === "dark" ? DarkImage1 : LightImage1;
const image2 = resolvedTheme === "dark" ? DarkImage2 : LightImage2;
const image3 = resolvedTheme === "dark" ? DarkImage3 : LightImage3;
return (
<div className="mt-7 mb-16 px-36 flex flex-col lg:flex-row items-center justify-between gap-x-24 gap-y-16">
<p className="text-sm font-medium text-custom-text-300 lg:w-2/5 flex-shrink-0 text-center lg:text-left">
Compare your activities with the top
<br />
seven in your project.
</p>
<div className="flex items-center justify-evenly gap-20 lg:w-3/5 flex-shrink-0">
<div className="h-24 w-24 flex-shrink-0">
<Image src={image1} className="w-full h-full" alt="Recent collaborators" />
</div>
<div className="h-24 w-24 flex-shrink-0">
<Image src={image2} className="w-full h-full" alt="Recent collaborators" />
</div>
<div className="h-24 w-24 flex-shrink-0 hidden xl:block">
<Image src={image3} className="w-full h-full" alt="Recent collaborators" />
</div>
</div>
</div>
);
};

View File

@@ -1 +0,0 @@
export * from "./issues";

View File

@@ -1,34 +0,0 @@
"use client";
import { AlertTriangle, RefreshCcw } from "lucide-react";
// ui
import { Button } from "@plane/ui";
type Props = {
isRefreshing: boolean;
onClick: () => void;
};
export const IssuesErrorState: React.FC<Props> = (props) => {
const { isRefreshing, onClick } = props;
return (
<div className="h-full w-full grid place-items-center">
<div className="text-center">
<div className="h-24 w-24 bg-red-500/20 rounded-full grid place-items-center mx-auto">
<AlertTriangle className="h-12 w-12 text-red-500" />
</div>
<p className="mt-7 text-custom-text-300 text-sm font-medium">There was an error in fetching widget details</p>
<Button
variant="neutral-primary"
prependIcon={<RefreshCcw className="h-3 w-3" />}
className="mt-2 mx-auto"
onClick={onClick}
loading={isRefreshing}
>
{isRefreshing ? "Retrying" : "Retry"}
</Button>
</div>
</div>
);
};

View File

@@ -1,13 +0,0 @@
export * from "./dropdowns";
export * from "./empty-states";
export * from "./error-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";

View File

@@ -1,3 +0,0 @@
export * from "./issue-list-item";
export * from "./issues-list";
export * from "./tabs-list";

View File

@@ -1,352 +0,0 @@
"use client";
import { isToday } from "date-fns/isToday";
import { observer } from "mobx-react";
// types
import { TIssue, TWidgetIssue } from "@plane/types";
// ui
import { Avatar, AvatarGroup, ControlLink, PriorityIcon } from "@plane/ui";
// helpers
import { findTotalDaysInRange, getDate, renderFormattedDate } from "@/helpers/date-time.helper";
import { getFileURL } from "@/helpers/file.helper";
// hooks
import { useIssueDetail, useMember, useProject } from "@/hooks/store";
// plane web components
import { IssueIdentifier } from "@/plane-web/components/issues";
export type IssueListItemProps = {
issueId: string;
onClick: (issue: TIssue) => void;
workspaceSlug: string;
};
export const AssignedUpcomingIssueListItem: React.FC<IssueListItemProps> = 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 || !issueDetails.project_id) 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 targetDate = getDate(issueDetails.target_date);
return (
<ControlLink
href={`/${workspaceSlug}/projects/${issueDetails.project_id}/issues/${issueDetails.id}`}
onClick={() => onClick(issueDetails)}
className="grid grid-cols-12 gap-1 rounded px-3 py-2 hover:bg-custom-background-80"
>
<div className="col-span-7 flex items-center gap-3">
{projectDetails && (
<IssueIdentifier
issueId={issueDetails.id}
projectId={projectDetails?.id}
textContainerClassName="text-xs text-custom-text-200 font-medium"
/>
)}
<h6 className="flex-grow truncate text-sm">{issueDetails.name}</h6>
</div>
<div className="flex justify-center col-span-1 items-center">
<PriorityIcon priority={issueDetails.priority} size={12} withContainer />
</div>
<div className="text-center text-xs col-span-2">
{targetDate ? (isToday(targetDate) ? "Today" : renderFormattedDate(targetDate)) : "-"}
</div>
<div className="flex justify-center text-xs col-span-2">
{blockedByIssues.length > 0
? blockedByIssues.length > 1
? `${blockedByIssues.length} blockers`
: blockedByIssueProjectDetails && (
<IssueIdentifier
projectIdentifier={blockedByIssueProjectDetails?.identifier}
projectId={blockedByIssueProjectDetails?.id}
issueSequenceId={blockedByIssues[0]?.sequence_id}
textContainerClassName="text-xs text-custom-text-200 font-medium"
/>
)
: "-"}
</div>
</ControlLink>
);
});
export const AssignedOverdueIssueListItem: React.FC<IssueListItemProps> = 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 || !issueDetails.project_id) 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(getDate(issueDetails.target_date), new Date(), false) ?? 0;
return (
<ControlLink
href={`/${workspaceSlug}/projects/${issueDetails.project_id}/issues/${issueDetails.id}`}
onClick={() => onClick(issueDetails)}
className="grid grid-cols-12 gap-1 rounded px-3 py-2 hover:bg-custom-background-80"
>
<div className="col-span-7 flex items-center gap-3">
{projectDetails && (
<IssueIdentifier
issueId={issueDetails.id}
projectId={projectDetails?.id}
textContainerClassName="text-xs text-custom-text-200 font-medium"
/>
)}
<h6 className="flex-grow truncate text-sm">{issueDetails.name}</h6>
</div>
<div className="flex justify-center col-span-1 items-center">
<PriorityIcon priority={issueDetails.priority} size={12} withContainer />
</div>
<div className="text-center text-xs col-span-2">
{dueBy} {`day${dueBy > 1 ? "s" : ""}`}
</div>
<div className="flex justify-center text-xs col-span-2">
{blockedByIssues.length > 0
? blockedByIssues.length > 1
? `${blockedByIssues.length} blockers`
: blockedByIssueProjectDetails && (
<IssueIdentifier
issueId={blockedByIssues[0]?.id}
projectId={blockedByIssueProjectDetails?.id}
textContainerClassName="text-xs text-custom-text-200 font-medium"
/>
)
: "-"}
</div>
</ControlLink>
);
});
export const AssignedCompletedIssueListItem: React.FC<IssueListItemProps> = observer((props) => {
const { issueId, onClick, workspaceSlug } = props;
// store hooks
const {
issue: { getIssueById },
} = useIssueDetail();
const { getProjectById } = useProject();
// derived values
const issueDetails = getIssueById(issueId);
if (!issueDetails || !issueDetails.project_id) return null;
const projectDetails = getProjectById(issueDetails.project_id);
return (
<ControlLink
href={`/${workspaceSlug}/projects/${issueDetails.project_id}/issues/${issueDetails.id}`}
onClick={() => onClick(issueDetails)}
className="grid grid-cols-12 gap-1 rounded px-3 py-2 hover:bg-custom-background-80"
>
<div className="col-span-11 flex items-center gap-3">
{projectDetails && (
<IssueIdentifier
issueId={issueDetails.id}
projectId={projectDetails?.id}
textContainerClassName="text-xs text-custom-text-200 font-medium"
/>
)}
<h6 className="flex-grow truncate text-sm">{issueDetails.name}</h6>
</div>
<div className="flex justify-center col-span-1 items-center">
<PriorityIcon priority={issueDetails.priority} size={12} withContainer />
</div>
</ControlLink>
);
});
export const CreatedUpcomingIssueListItem: React.FC<IssueListItemProps> = 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 || !issue.project_id) return null;
const projectDetails = getProjectById(issue.project_id);
const targetDate = getDate(issue.target_date);
return (
<ControlLink
href={`/${workspaceSlug}/projects/${issue.project_id}/issues/${issue.id}`}
onClick={() => onClick(issue)}
className="grid grid-cols-12 gap-1 rounded px-3 py-2 hover:bg-custom-background-80"
>
<div className="col-span-7 flex items-center gap-3">
{projectDetails && (
<IssueIdentifier
issueId={issue.id}
projectId={projectDetails?.id}
textContainerClassName="text-xs text-custom-text-200 font-medium"
/>
)}
<h6 className="flex-grow truncate text-sm">{issue.name}</h6>
</div>
<div className="flex justify-center col-span-1 items-center">
<PriorityIcon priority={issue.priority} size={12} withContainer />
</div>
<div className="text-center text-xs col-span-2">
{targetDate ? (isToday(targetDate) ? "Today" : renderFormattedDate(targetDate)) : "-"}
</div>
<div className="flex justify-center text-xs col-span-2">
{issue.assignee_ids && issue.assignee_ids?.length > 0 ? (
<AvatarGroup>
{issue.assignee_ids?.map((assigneeId) => {
const userDetails = getUserDetails(assigneeId);
if (!userDetails) return null;
return (
<Avatar key={assigneeId} src={getFileURL(userDetails.avatar_url)} name={userDetails.display_name} />
);
})}
</AvatarGroup>
) : (
"-"
)}
</div>
</ControlLink>
);
});
export const CreatedOverdueIssueListItem: React.FC<IssueListItemProps> = 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 || !issue.project_id) return null;
const projectDetails = getProjectById(issue.project_id);
const dueBy: number = findTotalDaysInRange(getDate(issue.target_date), new Date(), false) ?? 0;
return (
<ControlLink
href={`/${workspaceSlug}/projects/${issue.project_id}/issues/${issue.id}`}
onClick={() => onClick(issue)}
className="grid grid-cols-12 gap-1 rounded px-3 py-2 hover:bg-custom-background-80"
>
<div className="col-span-7 flex items-center gap-3">
{projectDetails && (
<IssueIdentifier
issueId={issue.id}
projectId={projectDetails?.id}
textContainerClassName="text-xs text-custom-text-200 font-medium"
/>
)}
<h6 className="flex-grow truncate text-sm">{issue.name}</h6>
</div>
<div className="flex justify-center col-span-1 items-center">
<PriorityIcon priority={issue.priority} size={12} withContainer />
</div>
<div className="text-center text-xs col-span-2">
{dueBy} {`day${dueBy > 1 ? "s" : ""}`}
</div>
<div className="flex justify-center text-xs col-span-2">
{issue.assignee_ids.length > 0 ? (
<AvatarGroup>
{issue.assignee_ids?.map((assigneeId) => {
const userDetails = getUserDetails(assigneeId);
if (!userDetails) return null;
return (
<Avatar key={assigneeId} src={getFileURL(userDetails.avatar_url)} name={userDetails.display_name} />
);
})}
</AvatarGroup>
) : (
"-"
)}
</div>
</ControlLink>
);
});
export const CreatedCompletedIssueListItem: React.FC<IssueListItemProps> = 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 || !issue.project_id) return null;
const projectDetails = getProjectById(issue.project_id);
return (
<ControlLink
href={`/${workspaceSlug}/projects/${issue.project_id}/issues/${issue.id}`}
onClick={() => onClick(issue)}
className="grid grid-cols-12 gap-1 rounded px-3 py-2 hover:bg-custom-background-80"
>
<div className="col-span-9 flex items-center gap-3">
{projectDetails && (
<IssueIdentifier
issueId={issue.id}
projectId={projectDetails?.id}
textContainerClassName="text-xs text-custom-text-200 font-medium"
/>
)}
<h6 className="flex-grow truncate text-sm">{issue.name}</h6>
</div>
<div className="flex justify-center col-span-1 items-center">
<PriorityIcon priority={issue.priority} size={12} withContainer />
</div>
<div className="flex justify-center text-xs col-span-2">
{issue.assignee_ids.length > 0 ? (
<AvatarGroup>
{issue.assignee_ids?.map((assigneeId) => {
const userDetails = getUserDetails(assigneeId);
if (!userDetails) return null;
return (
<Avatar key={assigneeId} src={getFileURL(userDetails.avatar_url)} name={userDetails.display_name} />
);
})}
</AvatarGroup>
) : (
"-"
)}
</div>
</ControlLink>
);
});

View File

@@ -1,134 +0,0 @@
"use client";
import Link from "next/link";
import { TAssignedIssuesWidgetResponse, TCreatedIssuesWidgetResponse, TIssue, TIssuesListTypes } from "@plane/types";
// hooks
// components
import { Loader, getButtonStyling } from "@plane/ui";
import {
AssignedCompletedIssueListItem,
AssignedIssuesEmptyState,
AssignedOverdueIssueListItem,
AssignedUpcomingIssueListItem,
CreatedCompletedIssueListItem,
CreatedIssuesEmptyState,
CreatedOverdueIssueListItem,
CreatedUpcomingIssueListItem,
IssueListItemProps,
} from "@/components/dashboard/widgets";
// ui
// helpers
import { cn } from "@/helpers/common.helper";
import { getRedirectionFilters } from "@/helpers/dashboard.helper";
// hooks
import useIssuePeekOverviewRedirection from "@/hooks/use-issue-peek-overview-redirection";
import { usePlatformOS } from "@/hooks/use-platform-os";
export type WidgetIssuesListProps = {
isLoading: boolean;
tab: TIssuesListTypes;
type: "assigned" | "created";
widgetStats: TAssignedIssuesWidgetResponse | TCreatedIssuesWidgetResponse;
workspaceSlug: string;
};
export const WidgetIssuesList: React.FC<WidgetIssuesListProps> = (props) => {
const { isLoading, tab, type, widgetStats, workspaceSlug } = props;
// hooks
const { isMobile } = usePlatformOS();
const { handleRedirection } = useIssuePeekOverviewRedirection();
// handlers
const handleIssuePeekOverview = (issue: TIssue) => handleRedirection(workspaceSlug, issue, isMobile);
const filterParams = getRedirectionFilters(tab);
const ISSUE_LIST_ITEM: {
[key: string]: {
[key in TIssuesListTypes]: React.FC<IssueListItemProps>;
};
} = {
assigned: {
pending: AssignedUpcomingIssueListItem,
upcoming: AssignedUpcomingIssueListItem,
overdue: AssignedOverdueIssueListItem,
completed: AssignedCompletedIssueListItem,
},
created: {
pending: CreatedUpcomingIssueListItem,
upcoming: CreatedUpcomingIssueListItem,
overdue: CreatedOverdueIssueListItem,
completed: CreatedCompletedIssueListItem,
},
};
const issuesList = widgetStats.issues;
return (
<>
<div className="h-full">
{isLoading ? (
<Loader className="space-y-4 mt-7">
<Loader.Item height="25px" />
<Loader.Item height="25px" />
<Loader.Item height="25px" />
<Loader.Item height="25px" />
</Loader>
) : issuesList.length > 0 ? (
<>
<div className="mt-7 border-b-[0.5px] border-custom-border-200 grid grid-cols-12 gap-1 text-xs text-custom-text-300 pb-1">
<h6
className={cn("pl-1 flex items-center gap-1 col-span-7", {
"col-span-11": type === "assigned" && tab === "completed",
"col-span-9": type === "created" && tab === "completed",
})}
>
Issues
<span className="flex-shrink-0 bg-custom-primary-100/20 text-custom-primary-100 text-xs font-medium rounded-xl px-2 flex items-center text-center justify-center">
{widgetStats.count}
</span>
</h6>
<h6 className="text-center col-span-1">Priority</h6>
{["upcoming", "pending"].includes(tab) && <h6 className="text-center col-span-2">Due date</h6>}
{tab === "overdue" && <h6 className="text-center col-span-2">Due by</h6>}
{type === "assigned" && tab !== "completed" && <h6 className="text-center col-span-2">Blocked by</h6>}
{type === "created" && <h6 className="text-center col-span-2">Assigned to</h6>}
</div>
<div className="pb-3 mt-2">
{issuesList.map((issue) => {
const IssueListItem = ISSUE_LIST_ITEM[type][tab];
if (!IssueListItem) return null;
return (
<IssueListItem
key={issue.id}
issueId={issue.id}
workspaceSlug={workspaceSlug}
onClick={handleIssuePeekOverview}
/>
);
})}
</div>
</>
) : (
<div className="h-full grid place-items-center my-6">
{type === "assigned" && <AssignedIssuesEmptyState type={tab} />}
{type === "created" && <CreatedIssuesEmptyState type={tab} />}
</div>
)}
</div>
{!isLoading && issuesList.length > 0 && (
<Link
href={`/${workspaceSlug}/workspace-views/${type}/${filterParams}`}
className={cn(
getButtonStyling("link-primary", "sm"),
"w-min my-3 mx-auto py-1 px-2 text-xs hover:bg-custom-primary-100/20"
)}
>
View all issues
</Link>
)}
</>
);
};

View File

@@ -1,61 +0,0 @@
import { observer } from "mobx-react";
import { Tab } from "@headlessui/react";
import { TIssuesListTypes } from "@plane/types";
// helpers
import { EDurationFilters, FILTERED_ISSUES_TABS_LIST, UNFILTERED_ISSUES_TABS_LIST } from "@/constants/dashboard";
import { cn } from "@/helpers/common.helper";
// types
// constants
type Props = {
durationFilter: EDurationFilters;
selectedTab: TIssuesListTypes;
};
export const TabsList: React.FC<Props> = observer((props) => {
const { durationFilter, selectedTab } = props;
const tabsList = durationFilter === "none" ? UNFILTERED_ISSUES_TABS_LIST : FILTERED_ISSUES_TABS_LIST;
const selectedTabIndex = tabsList.findIndex((tab) => tab.key === selectedTab);
return (
<Tab.List
as="div"
className="relative border-[0.5px] border-custom-border-200 rounded bg-custom-background-80 p-[1px] grid"
style={{
gridTemplateColumns: `repeat(${tabsList.length}, 1fr)`,
}}
>
<div
className={cn(
"absolute top-1/2 left-[1px] bg-custom-background-100 rounded-[3px] transition-all duration-500 ease-in-out",
{
// right shadow
"shadow-[2px_0_8px_rgba(167,169,174,0.15)]": selectedTabIndex !== tabsList.length - 1,
// left shadow
"shadow-[-2px_0_8px_rgba(167,169,174,0.15)]": selectedTabIndex !== 0,
}
)}
style={{
height: "calc(100% - 2px)",
width: `calc(${100 / tabsList.length}% - 1px)`,
transform: `translate(${selectedTabIndex * 100}%, -50%)`,
}}
/>
{tabsList.map((tab) => (
<Tab
key={tab.key}
className={cn(
"relative z-[1] font-semibold text-xs rounded-[3px] py-1.5 text-custom-text-400 focus:outline-none transition duration-500",
{
"text-custom-text-100": selectedTab === tab.key,
"hover:text-custom-text-300": selectedTab !== tab.key,
}
)}
>
<span className="scale-110">{tab.label}</span>
</Tab>
))}
</Tab.List>
);
});

View File

@@ -1,112 +0,0 @@
import { useEffect } from "react";
import { observer } from "mobx-react";
import Link from "next/link";
// types
import { TIssuesByPriorityWidgetFilters, TIssuesByPriorityWidgetResponse } from "@plane/types";
// components
import { Card } from "@plane/ui";
import {
DurationFilterDropdown,
IssuesByPriorityEmptyState,
WidgetLoader,
WidgetProps,
} from "@/components/dashboard/widgets";
import { IssuesByPriorityGraph } from "@/components/graphs";
// constants
import { EDurationFilters } from "@/constants/dashboard";
// helpers
import { getCustomDates } from "@/helpers/dashboard.helper";
// hooks
import { useDashboard } from "@/hooks/store";
import { useAppRouter } from "@/hooks/use-app-router";
const WIDGET_KEY = "issues_by_priority";
export const IssuesByPriorityWidget: React.FC<WidgetProps> = observer((props) => {
const { dashboardId, workspaceSlug } = props;
// router
const router = useAppRouter();
// store hooks
const { fetchWidgetStats, getWidgetDetails, getWidgetStats, updateDashboardWidgetFilters } = useDashboard();
// derived values
const widgetDetails = getWidgetDetails(workspaceSlug, dashboardId, WIDGET_KEY);
const widgetStats = getWidgetStats<TIssuesByPriorityWidgetResponse[]>(workspaceSlug, dashboardId, WIDGET_KEY);
const selectedDuration = widgetDetails?.widget_filters.duration ?? EDurationFilters.NONE;
const selectedCustomDates = widgetDetails?.widget_filters.custom_dates ?? [];
const handleUpdateFilters = async (filters: Partial<TIssuesByPriorityWidgetFilters>) => {
if (!widgetDetails) return;
await updateDashboardWidgetFilters(workspaceSlug, dashboardId, widgetDetails.id, {
widgetKey: WIDGET_KEY,
filters,
});
const filterDates = getCustomDates(
filters.duration ?? selectedDuration,
filters.custom_dates ?? selectedCustomDates
);
fetchWidgetStats(workspaceSlug, dashboardId, {
widget_key: WIDGET_KEY,
...(filterDates.trim() !== "" ? { target_date: filterDates } : {}),
});
};
useEffect(() => {
const filterDates = getCustomDates(selectedDuration, selectedCustomDates);
fetchWidgetStats(workspaceSlug, dashboardId, {
widget_key: WIDGET_KEY,
...(filterDates.trim() !== "" ? { target_date: filterDates } : {}),
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
if (!widgetDetails || !widgetStats) return <WidgetLoader widgetKey={WIDGET_KEY} />;
const totalCount = widgetStats.reduce((acc, item) => acc + item?.count, 0);
const chartData = widgetStats.map((item) => ({
priority: item?.priority,
priority_count: item?.count,
}));
return (
<Card>
<div className="flex items-center justify-between gap-2 mb-4">
<Link
href={`/${workspaceSlug}/workspace-views/assigned`}
className="text-lg font-semibold text-custom-text-300 hover:underline"
>
Assigned by priority
</Link>
<DurationFilterDropdown
customDates={selectedCustomDates}
value={selectedDuration}
onChange={(val, customDates) =>
handleUpdateFilters({
duration: val,
...(val === "custom" ? { custom_dates: customDates } : {}),
})
}
/>
</div>
{totalCount > 0 ? (
<div className="flex h-full items-center">
<div className="-mt-[11px] w-full">
<IssuesByPriorityGraph
data={chartData}
onBarClick={(datum) => {
router.push(
`/${workspaceSlug}/workspace-views/assigned?priority=${`${datum.data.priority}`.toLowerCase()}`
);
}}
/>
</div>
</div>
) : (
<div className="grid h-full place-items-center">
<IssuesByPriorityEmptyState />
</div>
)}
</Card>
);
});

View File

@@ -1,221 +0,0 @@
import { useEffect, useState } from "react";
import { observer } from "mobx-react";
import Link from "next/link";
// types
import { TIssuesByStateGroupsWidgetFilters, TIssuesByStateGroupsWidgetResponse, TStateGroups } from "@plane/types";
// components
import { Card } from "@plane/ui";
import {
DurationFilterDropdown,
IssuesByStateGroupEmptyState,
WidgetLoader,
WidgetProps,
} from "@/components/dashboard/widgets";
import { PieGraph } from "@/components/ui";
// constants
import { EDurationFilters, STATE_GROUP_GRAPH_COLORS, STATE_GROUP_GRAPH_GRADIENTS } from "@/constants/dashboard";
import { STATE_GROUPS } from "@/constants/state";
// helpers
import { getCustomDates } from "@/helpers/dashboard.helper";
// hooks
import { useDashboard } from "@/hooks/store";
import { useAppRouter } from "@/hooks/use-app-router";
const WIDGET_KEY = "issues_by_state_groups";
export const IssuesByStateGroupWidget: React.FC<WidgetProps> = observer((props) => {
const { dashboardId, workspaceSlug } = props;
// states
const [defaultStateGroup, setDefaultStateGroup] = useState<TStateGroups | null>(null);
const [activeStateGroup, setActiveStateGroup] = useState<TStateGroups | null>(null);
// router
const router = useAppRouter();
// store hooks
const { fetchWidgetStats, getWidgetDetails, getWidgetStats, updateDashboardWidgetFilters } = useDashboard();
// derived values
const widgetDetails = getWidgetDetails(workspaceSlug, dashboardId, WIDGET_KEY);
const widgetStats = getWidgetStats<TIssuesByStateGroupsWidgetResponse[]>(workspaceSlug, dashboardId, WIDGET_KEY);
const selectedDuration = widgetDetails?.widget_filters.duration ?? EDurationFilters.NONE;
const selectedCustomDates = widgetDetails?.widget_filters.custom_dates ?? [];
const handleUpdateFilters = async (filters: Partial<TIssuesByStateGroupsWidgetFilters>) => {
if (!widgetDetails) return;
await updateDashboardWidgetFilters(workspaceSlug, dashboardId, widgetDetails.id, {
widgetKey: WIDGET_KEY,
filters,
});
const filterDates = getCustomDates(
filters.duration ?? selectedDuration,
filters.custom_dates ?? selectedCustomDates
);
fetchWidgetStats(workspaceSlug, dashboardId, {
widget_key: WIDGET_KEY,
...(filterDates.trim() !== "" ? { target_date: filterDates } : {}),
});
};
// fetch widget stats
useEffect(() => {
const filterDates = getCustomDates(selectedDuration, selectedCustomDates);
fetchWidgetStats(workspaceSlug, dashboardId, {
widget_key: WIDGET_KEY,
...(filterDates.trim() !== "" ? { target_date: filterDates } : {}),
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// set active group for center metric
useEffect(() => {
if (!widgetStats) return;
const startedCount = widgetStats?.find((item) => item?.state === "started")?.count ?? 0;
const unStartedCount = widgetStats?.find((item) => item?.state === "unstarted")?.count ?? 0;
const backlogCount = widgetStats?.find((item) => item?.state === "backlog")?.count ?? 0;
const completedCount = widgetStats?.find((item) => item?.state === "completed")?.count ?? 0;
const canceledCount = widgetStats?.find((item) => item?.state === "cancelled")?.count ?? 0;
const stateGroup =
startedCount > 0
? "started"
: unStartedCount > 0
? "unstarted"
: backlogCount > 0
? "backlog"
: completedCount > 0
? "completed"
: canceledCount > 0
? "cancelled"
: null;
setActiveStateGroup(stateGroup);
setDefaultStateGroup(stateGroup);
}, [widgetStats]);
if (!widgetDetails || !widgetStats) return <WidgetLoader widgetKey={WIDGET_KEY} />;
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 (
<g>
<text
x={centerX}
y={centerY - 8}
textAnchor="middle"
dominantBaseline="central"
className="text-3xl font-bold"
style={{
fill: data?.color,
}}
>
{percentage}%
</text>
<text
x={centerX}
y={centerY + 20}
textAnchor="middle"
dominantBaseline="central"
className="text-sm font-medium fill-custom-text-300 capitalize"
>
{data?.id}
</text>
</g>
);
};
return (
<Card>
<div className="flex items-center justify-between gap-2 mb-4">
<Link
href={`/${workspaceSlug}/workspace-views/assigned`}
className="text-lg font-semibold text-custom-text-300 hover:underline"
>
Assigned by state
</Link>
<DurationFilterDropdown
customDates={selectedCustomDates}
value={selectedDuration}
onChange={(val, customDates) =>
handleUpdateFilters({
duration: val,
...(val === "custom" ? { custom_dates: customDates } : {}),
})
}
/>
</div>
{totalCount > 0 ? (
<div className="flex items-center mt-11">
<div className="flex flex-col sm:flex-row md:flex-row lg:flex-row items-center justify-evenly gap-x-10 gap-y-8 w-full">
<div>
<PieGraph
data={chartData}
height="220px"
width="200px"
innerRadius={0.6}
cornerRadius={5}
colors={(datum) => 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)}
onMouseLeave={() => setActiveStateGroup(defaultStateGroup)}
layers={["arcs", CenteredMetric]}
/>
</div>
<div className="space-y-6 w-min whitespace-nowrap">
{chartData.map((item) => (
<div key={item.id} className="flex items-center justify-between gap-6">
<div className="flex items-center gap-2.5 w-24">
<div
className="h-3 w-3 rounded-full"
style={{
backgroundColor: item.color,
}}
/>
<span className="text-custom-text-300 text-sm font-medium capitalize">{item.label}</span>
</div>
<span className="text-custom-text-400 text-sm">{item.value.toFixed(0)}%</span>
</div>
))}
</div>
</div>
</div>
) : (
<div className="h-full grid place-items-center">
<IssuesByStateGroupEmptyState />
</div>
)}
</Card>
);
});

View File

@@ -1,24 +0,0 @@
"use client";
// ui
import { Loader } from "@plane/ui";
export const AssignedIssuesWidgetLoader = () => (
<Loader className="bg-custom-background-100 p-6 rounded-xl">
<div className="flex items-center justify-between gap-2">
<Loader.Item height="17px" width="35%" />
<Loader.Item height="17px" width="10%" />
</div>
<div className="mt-6 space-y-7">
<Loader.Item height="29px" />
<Loader.Item height="17px" width="10%" />
</div>
<div className="mt-11 space-y-10">
<Loader.Item height="11px" width="35%" />
<Loader.Item height="11px" width="45%" />
<Loader.Item height="11px" width="55%" />
<Loader.Item height="11px" width="40%" />
<Loader.Item height="11px" width="60%" />
</div>
</Loader>
);

View File

@@ -1 +0,0 @@
export * from "./loader";

View File

@@ -1,17 +0,0 @@
"use client";
// ui
import { Loader } from "@plane/ui";
export const IssuesByPriorityWidgetLoader = () => (
<Loader className="bg-custom-background-100 rounded-xl p-6">
<Loader.Item height="17px" width="35%" />
<div className="flex items-center gap-1 h-full">
<Loader.Item height="119px" width="14%" />
<Loader.Item height="119px" width="26%" />
<Loader.Item height="119px" width="36%" />
<Loader.Item height="119px" width="18%" />
<Loader.Item height="119px" width="6%" />
</div>
</Loader>
);

View File

@@ -1,24 +0,0 @@
"use client";
import range from "lodash/range";
// ui
import { Loader } from "@plane/ui";
export const IssuesByStateGroupWidgetLoader = () => (
<Loader className="bg-custom-background-100 rounded-xl p-6">
<Loader.Item height="17px" width="35%" />
<div className="flex items-center justify-between gap-32 mt-12 pl-6">
<div className="w-1/2 grid place-items-center">
<div className="rounded-full overflow-hidden relative flex-shrink-0 h-[184px] w-[184px]">
<Loader.Item height="184px" width="184px" />
<div className="absolute h-[100px] w-[100px] top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-custom-background-100 rounded-full" />
</div>
</div>
<div className="w-1/2 space-y-7 flex-shrink-0">
{range(5).map((index) => (
<Loader.Item key={index} height="11px" width="100%" />
))}
</div>
</div>
</Loader>
);

View File

@@ -1,31 +0,0 @@
// components
import { TWidgetKeys } from "@plane/types";
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 { RecentCollaboratorsWidgetLoader } from "./recent-collaborators";
import { RecentProjectsWidgetLoader } from "./recent-projects";
// types
type Props = {
widgetKey: TWidgetKeys;
};
export const WidgetLoader: React.FC<Props> = (props) => {
const { widgetKey } = props;
const loaders = {
overview_stats: <OverviewStatsWidgetLoader />,
assigned_issues: <AssignedIssuesWidgetLoader />,
created_issues: <AssignedIssuesWidgetLoader />,
issues_by_state_groups: <IssuesByStateGroupWidgetLoader />,
issues_by_priority: <IssuesByPriorityWidgetLoader />,
recent_activity: <RecentActivityWidgetLoader />,
recent_projects: <RecentProjectsWidgetLoader />,
recent_collaborators: <RecentCollaboratorsWidgetLoader />,
};
return loaders[widgetKey];
};

View File

@@ -1,16 +0,0 @@
"use client";
import range from "lodash/range";
// ui
import { Loader } from "@plane/ui";
export const OverviewStatsWidgetLoader = () => (
<Loader className="bg-custom-background-100 rounded-xl py-6 grid grid-cols-4 gap-36 px-12">
{range(4).map((index) => (
<div key={index} className="space-y-3">
<Loader.Item height="11px" width="50%" />
<Loader.Item height="15px" />
</div>
))}
</Loader>
);

View File

@@ -1,22 +0,0 @@
"use client";
import range from "lodash/range";
// ui
import { Loader } from "@plane/ui";
export const RecentActivityWidgetLoader = () => (
<Loader className="bg-custom-background-100 rounded-xl p-6 space-y-6">
<Loader.Item height="17px" width="35%" />
{range(7).map((index) => (
<div key={index} className="flex items-start gap-3.5">
<div className="flex-shrink-0">
<Loader.Item height="16px" width="16px" />
</div>
<div className="space-y-3 flex-shrink-0 w-full">
<Loader.Item height="15px" width="70%" />
<Loader.Item height="11px" width="10%" />
</div>
</div>
))}
</Loader>
);

View File

@@ -1,20 +0,0 @@
"use client";
import range from "lodash/range";
// ui
import { Loader } from "@plane/ui";
export const RecentCollaboratorsWidgetLoader = () => (
<>
{range(8).map((index) => (
<Loader key={index} className="bg-custom-background-100 rounded-xl px-6 pb-12">
<div className="space-y-11 flex flex-col items-center">
<div className="rounded-full overflow-hidden h-[69px] w-[69px]">
<Loader.Item height="69px" width="69px" />
</div>
<Loader.Item height="11px" width="70%" />
</div>
</Loader>
))}
</>
);

View File

@@ -1,22 +0,0 @@
"use client";
import range from "lodash/range";
// ui
import { Loader } from "@plane/ui";
export const RecentProjectsWidgetLoader = () => (
<Loader className="bg-custom-background-100 rounded-xl p-6 space-y-6">
<Loader.Item height="17px" width="35%" />
{range(5).map((index) => (
<div key={index} className="flex items-center gap-6">
<div className="flex-shrink-0">
<Loader.Item height="60px" width="60px" />
</div>
<div className="space-y-3 flex-shrink-0 w-full">
<Loader.Item height="17px" width="42%" />
<Loader.Item height="23px" width="10%" />
</div>
</div>
))}
</Loader>
);

View File

@@ -1,100 +0,0 @@
import { useEffect } from "react";
import { observer } from "mobx-react";
import Link from "next/link";
import { TOverviewStatsWidgetResponse } from "@plane/types";
// hooks
import { Card, ECardSpacing } from "@plane/ui";
import { WidgetLoader } from "@/components/dashboard/widgets";
import { cn } from "@/helpers/common.helper";
import { renderFormattedPayloadDate } from "@/helpers/date-time.helper";
import { useDashboard } from "@/hooks/store";
// components
// helpers
// types
export type WidgetProps = {
dashboardId: string;
workspaceSlug: string;
};
const WIDGET_KEY = "overview_stats";
export const OverviewStatsWidget: React.FC<WidgetProps> = observer((props) => {
const { dashboardId, workspaceSlug } = props;
// store hooks
const { fetchWidgetStats, getWidgetStats } = useDashboard();
// derived values
const widgetStats = getWidgetStats<TOverviewStatsWidgetResponse>(workspaceSlug, dashboardId, WIDGET_KEY);
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/?state_group=backlog,unstarted,started&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(() => {
fetchWidgetStats(workspaceSlug, dashboardId, {
widget_key: WIDGET_KEY,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
if (!widgetStats) return <WidgetLoader widgetKey={WIDGET_KEY} />;
return (
<Card
spacing={ECardSpacing.SM}
className="flex-row grid lg:grid-cols-4 md:grid-cols-2 sm:grid-cols-2 grid-cols-2 space-y-0 p-0.5
[&>div>a>div]:border-r
[&>div:last-child>a>div]:border-0
[&>div>a>div]:border-custom-border-200
[&>div:nth-child(2)>a>div]:border-0
[&>div:nth-child(2)>a>div]:lg:border-r
"
>
{STATS_LIST.map((stat, index) => (
<div
key={stat.key}
className={cn(
`w-full flex flex-col gap-2 hover:bg-custom-background-80`,
index === 0 ? "rounded-l-md" : "",
index === STATS_LIST.length - 1 ? "rounded-r-md" : "",
index === 1 ? "rounded-tr-xl lg:rounded-[0px]" : "",
index == 2 ? "rounded-bl-xl lg:rounded-[0px]" : ""
)}
>
<Link href={stat.link} className="py-4 duration-300 rounded-[10px] w-full ">
<div className={`relative flex pl-10 sm:pl-20 md:pl-20 lg:pl-20 items-center`}>
<div>
<h5 className="font-semibold text-xl">{stat.count}</h5>
<p className="text-custom-text-300 text-sm xl:text-base">{stat.title}</p>
</div>
</div>
</Link>
</div>
))}
</Card>
);
});

View File

@@ -1,109 +0,0 @@
"use client";
import { useEffect } from "react";
import { observer } from "mobx-react";
import Link from "next/link";
import { History } from "lucide-react";
// types
import { TRecentActivityWidgetResponse } from "@plane/types";
// components
import { Card, Avatar, getButtonStyling } from "@plane/ui";
import { ActivityIcon, ActivityMessage, IssueLink } from "@/components/core";
import { RecentActivityEmptyState, WidgetLoader, WidgetProps } from "@/components/dashboard/widgets";
// helpers
import { cn } from "@/helpers/common.helper";
import { calculateTimeAgo } from "@/helpers/date-time.helper";
import { getFileURL } from "@/helpers/file.helper";
// hooks
import { useDashboard, useUser } from "@/hooks/store";
const WIDGET_KEY = "recent_activity";
export const RecentActivityWidget: React.FC<WidgetProps> = observer((props) => {
const { dashboardId, workspaceSlug } = props;
// store hooks
const { data: currentUser } = useUser();
// derived values
const { fetchWidgetStats, getWidgetStats } = useDashboard();
const widgetStats = getWidgetStats<TRecentActivityWidgetResponse[]>(workspaceSlug, dashboardId, WIDGET_KEY);
const redirectionLink = `/${workspaceSlug}/profile/${currentUser?.id}/activity`;
useEffect(() => {
fetchWidgetStats(workspaceSlug, dashboardId, {
widget_key: WIDGET_KEY,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
if (!widgetStats) return <WidgetLoader widgetKey={WIDGET_KEY} />;
return (
<Card>
<Link href={redirectionLink} className="text-lg font-semibold text-custom-text-300 hover:underline mb-4">
Your issue activities
</Link>
{widgetStats.length > 0 ? (
<div className="mt-4 space-y-6">
{widgetStats.map((activity) => (
<div key={activity.id} className="flex gap-5">
<div className="flex-shrink-0">
{activity.field ? (
activity.new_value === "restore" ? (
<History className="h-3.5 w-3.5 text-custom-text-200" />
) : (
<div className="flex h-6 w-6 justify-center">
<ActivityIcon activity={activity} />
</div>
)
) : activity.actor_detail.avatar_url && activity.actor_detail.avatar_url !== "" ? (
<Avatar
src={getFileURL(activity.actor_detail.avatar_url)}
name={activity.actor_detail.display_name}
size={24}
className="h-full w-full rounded-full object-cover"
/>
) : (
<div className="grid h-7 w-7 place-items-center rounded-full border-2 border-white bg-gray-700 text-xs text-white">
{activity.actor_detail.is_bot
? activity.actor_detail.first_name.charAt(0)
: activity.actor_detail.display_name.charAt(0)}
</div>
)}
</div>
<div className="-mt-2 break-words">
<p className="inline text-sm text-custom-text-200">
<span className="font-medium text-custom-text-100">
{currentUser?.id === activity.actor_detail.id ? "You" : activity.actor_detail?.display_name}{" "}
</span>
{activity.field ? (
<ActivityMessage activity={activity} showIssue />
) : (
<span>
created <IssueLink activity={activity} />
</span>
)}
</p>
<p className="text-xs text-custom-text-200 whitespace-nowrap">
{calculateTimeAgo(activity.created_at)}
</p>
</div>
</div>
))}
<Link
href={redirectionLink}
className={cn(
getButtonStyling("link-primary", "sm"),
"mx-auto w-min px-2 py-1 text-xs hover:bg-custom-primary-100/20"
)}
>
View all
</Link>
</div>
) : (
<div className="grid h-full place-items-center">
<RecentActivityEmptyState />
</div>
)}
</Card>
);
});

View File

@@ -1,154 +0,0 @@
"use client";
import { useState } from "react";
import sortBy from "lodash/sortBy";
import { observer } from "mobx-react";
import Link from "next/link";
import useSWR from "swr";
// types
import { TRecentCollaboratorsWidgetResponse } from "@plane/types";
// ui
import { Avatar } from "@plane/ui";
// helpers
import { getFileURL } from "@/helpers/file.helper";
// hooks
import { useDashboard, useMember, useUser } from "@/hooks/store";
// components
import { WidgetLoader } from "../loaders";
type CollaboratorListItemProps = {
issueCount: number;
userId: string;
workspaceSlug: string;
};
const CollaboratorListItem: React.FC<CollaboratorListItemProps> = observer((props) => {
const { issueCount, userId, workspaceSlug } = props;
// store hooks
const { data: currentUser } = useUser();
const { getUserDetails } = useMember();
// derived values
const userDetails = getUserDetails(userId);
const isCurrentUser = userId === currentUser?.id;
if (!userDetails || userDetails.is_bot) return null;
return (
<Link href={`/${workspaceSlug}/profile/${userId}`} className="group text-center">
<div className="flex justify-center">
<Avatar
src={getFileURL(userDetails.avatar_url)}
name={userDetails.display_name}
size={69}
className="!text-3xl !font-medium"
showTooltip={false}
/>
</div>
<h6 className="mt-6 truncate text-xs font-semibold group-hover:underline">
{isCurrentUser ? "You" : userDetails?.display_name}
</h6>
<p className="mt-2 text-sm">
{issueCount} active issue{issueCount > 1 ? "s" : ""}
</p>
</Link>
);
});
type CollaboratorsListProps = {
dashboardId: string;
searchQuery?: string;
workspaceSlug: string;
};
const WIDGET_KEY = "recent_collaborators";
export const CollaboratorsList: React.FC<CollaboratorsListProps> = (props) => {
const { dashboardId, searchQuery = "", workspaceSlug } = props;
// state
const [visibleItems, setVisibleItems] = useState(16);
const [isExpanded, setIsExpanded] = useState(false);
// store hooks
const { fetchWidgetStats } = useDashboard();
const { getUserDetails } = useMember();
const { data: currentUser } = useUser();
const { data: widgetStats } = useSWR(
workspaceSlug && dashboardId ? `WIDGET_STATS_${workspaceSlug}_${dashboardId}` : null,
workspaceSlug && dashboardId
? () =>
fetchWidgetStats(workspaceSlug, dashboardId, {
widget_key: WIDGET_KEY,
})
: null
) as {
data: TRecentCollaboratorsWidgetResponse[] | undefined;
};
if (!widgetStats)
return (
<div className="mt-7 mb-6 grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 xl:grid-cols-8 gap-2 gap-y-8">
<WidgetLoader widgetKey={WIDGET_KEY} />
</div>
);
const sortedStats = sortBy(widgetStats, [(user) => user?.user_id !== currentUser?.id]);
const filteredStats = sortedStats.filter((user) => {
if (!user) return false;
const userDetails = getUserDetails(user?.user_id);
if (!userDetails || userDetails.is_bot) return false;
const { display_name, first_name, last_name } = userDetails;
const searchLower = searchQuery.toLowerCase();
return (
display_name?.toLowerCase().includes(searchLower) ||
first_name?.toLowerCase().includes(searchLower) ||
last_name?.toLowerCase().includes(searchLower)
);
});
// Update the displayedStats to always use the visibleItems limit
const handleLoadMore = () => {
setVisibleItems((prev) => {
const newValue = prev + 16;
if (newValue >= filteredStats.length) {
setIsExpanded(true);
return filteredStats.length;
}
return newValue;
});
};
const handleHide = () => {
setVisibleItems(16);
setIsExpanded(false);
};
const displayedStats = filteredStats.slice(0, visibleItems);
return (
<>
<div className="mt-7 mb-6 grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 xl:grid-cols-8 gap-2 gap-y-8">
{displayedStats?.map((user) => (
<CollaboratorListItem
key={user?.user_id}
issueCount={user?.active_issue_count}
userId={user?.user_id}
workspaceSlug={workspaceSlug}
/>
))}
</div>
{filteredStats.length > visibleItems && !isExpanded && (
<div className="py-4 flex justify-center items-center text-sm font-medium" onClick={handleLoadMore}>
<div className="text-custom-primary-90 hover:text-custom-primary-100 transition-all cursor-pointer">
Load more
</div>
</div>
)}
{isExpanded && (
<div className="py-4 flex justify-center items-center text-sm font-medium" onClick={handleHide}>
<div className="text-custom-primary-90 hover:text-custom-primary-100 transition-all cursor-pointer">Hide</div>
</div>
)}
</>
);
};

View File

@@ -1 +0,0 @@
export * from "./root";

View File

@@ -1,36 +0,0 @@
import { useState } from "react";
import { Search } from "lucide-react";
// types
import { Card } from "@plane/ui";
import { WidgetProps } from "@/components/dashboard/widgets";
// components
import { CollaboratorsList } from "./collaborators-list";
export const RecentCollaboratorsWidget: React.FC<WidgetProps> = (props) => {
const { dashboardId, workspaceSlug } = props;
// states
const [searchQuery, setSearchQuery] = useState("");
return (
<Card>
<div className="flex flex-col sm:flex-row items-start justify-between mb-6">
<div>
<h4 className="text-lg font-semibold text-custom-text-300">Collaborators</h4>
<p className="mt-2 text-xs font-medium text-custom-text-300">
View and find all members you collaborate with across projects
</p>
</div>
<div className="mt-5 sm:mt-0 flex min-w-full md:min-w-72 items-center justify-start gap-2 rounded-md border border-custom-border-200 px-2.5 py-1.5 placeholder:text-custom-text-400">
<Search className="h-3.5 w-3.5 text-custom-text-400" />
<input
className="w-full border-none bg-transparent text-sm focus:outline-none"
placeholder="Search for collaborators"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
</div>
<CollaboratorsList dashboardId={dashboardId} searchQuery={searchQuery} workspaceSlug={workspaceSlug} />
</Card>
);
};

View File

@@ -1,135 +0,0 @@
"use client";
import { useEffect } from "react";
import { observer } from "mobx-react";
import Link from "next/link";
import { Plus } from "lucide-react";
// plane types
import { TRecentProjectsWidgetResponse } from "@plane/types";
// plane ui
import { Avatar, AvatarGroup, Card } from "@plane/ui";
// components
import { Logo } from "@/components/common";
import { WidgetLoader, WidgetProps } from "@/components/dashboard/widgets";
// constants
import { PROJECT_BACKGROUND_COLORS } from "@/constants/dashboard";
// helpers
import { getFileURL } from "@/helpers/file.helper";
// hooks
import {
useEventTracker,
useDashboard,
useProject,
useCommandPalette,
useUserPermissions,
useMember,
} from "@/hooks/store";
// plane web constants
import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions";
const WIDGET_KEY = "recent_projects";
type ProjectListItemProps = {
projectId: string;
workspaceSlug: string;
};
const ProjectListItem: React.FC<ProjectListItemProps> = observer((props) => {
const { projectId, workspaceSlug } = props;
// store hooks
const { getProjectById } = useProject();
const { getUserDetails } = useMember();
// derived values
const projectDetails = getProjectById(projectId);
const randomBgColor = PROJECT_BACKGROUND_COLORS[Math.floor(Math.random() * PROJECT_BACKGROUND_COLORS.length)];
if (!projectDetails) return null;
return (
<Link href={`/${workspaceSlug}/projects/${projectId}/issues`} className="group flex items-center gap-8">
<div
className={`grid h-[3.375rem] w-[3.375rem] flex-shrink-0 place-items-center rounded border border-transparent ${randomBgColor}`}
>
<div className="grid h-7 w-7 place-items-center">
<Logo logo={projectDetails.logo_props} size={20} />
</div>
</div>
<div className="flex-grow truncate">
<h6 className="truncate text-sm font-medium text-custom-text-300 group-hover:text-custom-text-100 group-hover:underline">
{projectDetails.name}
</h6>
<div className="mt-2">
<AvatarGroup>
{projectDetails.members?.map((memberId) => {
const userDetails = getUserDetails(memberId);
if (!userDetails) return null;
return (
<Avatar key={userDetails.id} src={getFileURL(userDetails.avatar_url)} name={userDetails.display_name} />
);
})}
</AvatarGroup>
</div>
</div>
</Link>
);
});
export const RecentProjectsWidget: React.FC<WidgetProps> = observer((props) => {
const { dashboardId, workspaceSlug } = props;
// store hooks
const { toggleCreateProjectModal } = useCommandPalette();
const { setTrackElement } = useEventTracker();
const { allowPermissions } = useUserPermissions();
const { fetchWidgetStats, getWidgetStats } = useDashboard();
// derived values
const widgetStats = getWidgetStats<TRecentProjectsWidgetResponse>(workspaceSlug, dashboardId, WIDGET_KEY);
const canCreateProject = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.WORKSPACE
);
useEffect(() => {
fetchWidgetStats(workspaceSlug, dashboardId, {
widget_key: WIDGET_KEY,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
if (!widgetStats) return <WidgetLoader widgetKey={WIDGET_KEY} />;
return (
<Card>
<Link
href={`/${workspaceSlug}/projects`}
className="text-lg font-semibold text-custom-text-300 hover:underline mb-4"
>
Recent projects
</Link>
<div className="mt-4 space-y-8">
{canCreateProject && (
<button
type="button"
className="group flex items-center gap-8"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setTrackElement("Sidebar");
toggleCreateProjectModal(true);
}}
>
<div className="grid h-[3.375rem] w-[3.375rem] flex-shrink-0 place-items-center rounded border border-dashed border-custom-primary-60 bg-custom-primary-100/20 text-custom-primary-100">
<Plus className="h-6 w-6" />
</div>
<p className="text-sm font-medium text-custom-text-300 group-hover:text-custom-text-100 group-hover:underline">
Create new project
</p>
</button>
)}
{widgetStats.map((projectId) => (
<ProjectListItem key={projectId} projectId={projectId} workspaceSlug={workspaceSlug} />
))}
</div>
</Card>
);
});

View File

@@ -1 +0,0 @@
export * from "./issues-by-priority";

View File

@@ -1,103 +0,0 @@
import { ComputedDatum } from "@nivo/bar";
import { Theme } from "@nivo/core";
// components
import { TIssuePriorities } from "@plane/types";
import { BarGraph } from "@/components/ui";
// helpers
import { PRIORITY_GRAPH_GRADIENTS } from "@/constants/dashboard";
import { ISSUE_PRIORITIES } from "@/constants/issue";
import { capitalizeFirstLetter } from "@/helpers/string.helper";
// types
// constants
type Props = {
borderRadius?: number;
data: {
priority: TIssuePriorities;
priority_count: number;
}[];
height?: number;
onBarClick?: (
datum: ComputedDatum<any> & {
color: string;
}
) => void;
padding?: number;
theme?: Theme;
};
const PRIORITY_TEXT_COLORS = {
urgent: "#CE2C31",
high: "#AB4800",
medium: "#AB6400",
low: "#1F2D5C",
none: "#60646C",
};
export const IssuesByPriorityGraph: React.FC<Props> = (props) => {
const { borderRadius = 8, data, height = 300, onBarClick, padding = 0.05, theme } = props;
const chartData = data.map((priority) => ({
priority: capitalizeFirstLetter(priority.priority),
value: priority.priority_count,
}));
return (
<BarGraph
data={chartData}
height={`${height}px`}
indexBy="priority"
keys={["value"]}
borderRadius={borderRadius}
padding={padding}
customYAxisTickValues={data.map((p) => p.priority_count)}
axisBottom={{
tickPadding: 8,
tickSize: 0,
}}
tooltip={(datum) => (
<div className="flex items-center gap-2 rounded-md border border-custom-border-200 bg-custom-background-80 p-2 text-xs">
<span
className="h-3 w-3 rounded"
style={{
backgroundColor: PRIORITY_TEXT_COLORS[`${datum.data.priority}`.toLowerCase() as TIssuePriorities],
}}
/>
<span className="font-medium text-custom-text-200">{datum.data.priority}:</span>
<span>{datum.value}</span>
</div>
)}
colors={({ data }) => `url(#gradient${data.priority})`}
defs={PRIORITY_GRAPH_GRADIENTS}
fill={ISSUE_PRIORITIES.map((p) => ({
match: {
id: p.key,
},
id: `gradient${p.title}`,
}))}
onClick={(datum) => {
if (onBarClick) onBarClick(datum);
}}
theme={{
axis: {
domain: {
line: {
stroke: "transparent",
},
},
ticks: {
text: {
fontSize: 13,
},
},
},
grid: {
line: {
stroke: "transparent",
},
},
...theme,
}}
/>
);
};

View File

@@ -1 +0,0 @@
export * from "./workspace-dashboard";

View File

@@ -1,88 +0,0 @@
import { useEffect } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// components
import { ContentWrapper } from "@plane/ui";
import { DashboardWidgets } from "@/components/dashboard";
import { EmptyState } from "@/components/empty-state";
import { IssuePeekOverview } from "@/components/issues";
import { TourRoot } from "@/components/onboarding";
import { UserGreetingsView } from "@/components/user";
// constants
import { EmptyStateType } from "@/constants/empty-state";
import { PRODUCT_TOUR_COMPLETED } from "@/constants/event-tracker";
// helpers
import { cn } from "@/helpers/common.helper";
// hooks
import { useCommandPalette, useUserProfile, useEventTracker, useDashboard, useProject, useUser } from "@/hooks/store";
import useSize from "@/hooks/use-window-size";
export const WorkspaceDashboardView = observer(() => {
// store hooks
const { captureEvent, setTrackElement } = useEventTracker();
const { toggleCreateProjectModal } = useCommandPalette();
const { workspaceSlug } = useParams();
const { data: currentUser } = useUser();
const { data: currentUserProfile, updateTourCompleted } = useUserProfile();
const { homeDashboardId, fetchHomeDashboardWidgets } = useDashboard();
const { joinedProjectIds, loader } = useProject();
const [windowWidth] = useSize();
const handleTourCompleted = () => {
updateTourCompleted()
.then(() => {
captureEvent(PRODUCT_TOUR_COMPLETED, {
user_id: currentUser?.id,
state: "SUCCESS",
});
})
.catch((error) => {
console.error(error);
});
};
// fetch home dashboard widgets on workspace change
useEffect(() => {
if (!workspaceSlug) return;
fetchHomeDashboardWidgets(workspaceSlug?.toString());
}, [fetchHomeDashboardWidgets, workspaceSlug]);
// TODO: refactor loader implementation
return (
<>
{currentUserProfile && !currentUserProfile.is_tour_completed && (
<div className="fixed left-0 top-0 z-20 grid h-full w-full place-items-center bg-custom-backdrop bg-opacity-50 transition-opacity">
<TourRoot onComplete={handleTourCompleted} />
</div>
)}
{homeDashboardId && joinedProjectIds && (
<>
{joinedProjectIds.length > 0 || loader === "init-loader" ? (
<>
<IssuePeekOverview />
<ContentWrapper
className={cn("gap-7 bg-custom-background-90/20", {
"vertical-scrollbar scrollbar-lg": windowWidth >= 768,
})}
>
{currentUser && <UserGreetingsView user={currentUser} />}
<DashboardWidgets />
</ContentWrapper>
</>
) : (
<EmptyState
type={EmptyStateType.WORKSPACE_DASHBOARD}
primaryButtonOnClick={() => {
setTrackElement("Dashboard empty state");
toggleCreateProjectModal(true);
}}
/>
)}
</>
)}
</>
);
});

View File

@@ -1 +0,0 @@
export * from "./priority-distribution";

View File

@@ -1,31 +0,0 @@
// components
import { IUserPriorityDistribution } from "@plane/types";
import { IssuesByPriorityGraph } from "@/components/graphs";
import { ProfileEmptyState } from "@/components/ui";
// assets
import emptyBarGraph from "@/public/empty-state/empty_bar_graph.svg";
// types
type Props = {
priorityDistribution: IUserPriorityDistribution[];
};
export const PriorityDistributionContent: React.FC<Props> = (props) => {
const { priorityDistribution } = props;
return (
<div className="flex-grow rounded border border-custom-border-100">
{priorityDistribution.length > 0 ? (
<IssuesByPriorityGraph data={priorityDistribution} />
) : (
<div className="flex-grow p-7">
<ProfileEmptyState
title="No Data yet"
description="Create issues to view the them by priority in the graph for better analysis."
image={emptyBarGraph}
/>
</div>
)}
</div>
);
};

View File

@@ -1,35 +0,0 @@
"use client";
// components
// ui
import { IUserPriorityDistribution } from "@plane/types";
import { Loader } from "@plane/ui";
// types
import { PriorityDistributionContent } from "./main-content";
type Props = {
priorityDistribution: IUserPriorityDistribution[] | undefined;
};
export const ProfilePriorityDistribution: React.FC<Props> = (props) => {
const { priorityDistribution } = props;
return (
<div className="flex flex-col space-y-2">
<h3 className="text-lg font-medium">Issues by priority</h3>
{priorityDistribution ? (
<PriorityDistributionContent priorityDistribution={priorityDistribution} />
) : (
<div className="grid place-items-center p-7">
<Loader className="flex items-end gap-12">
<Loader.Item width="30px" height="200px" />
<Loader.Item width="30px" height="150px" />
<Loader.Item width="30px" height="250px" />
<Loader.Item width="30px" height="150px" />
<Loader.Item width="30px" height="100px" />
</Loader>
</div>
)}
</div>
);
};

View File

@@ -1,249 +0,0 @@
"use client";
import { linearGradientDef } from "@nivo/core";
// types
import { TIssuesListTypes, TStateGroups } from "@plane/types";
// assets
import CompletedIssuesDark from "@/public/empty-state/dashboard/dark/completed-issues.svg";
import OverdueIssuesDark from "@/public/empty-state/dashboard/dark/overdue-issues.svg";
import UpcomingIssuesDark from "@/public/empty-state/dashboard/dark/upcoming-issues.svg";
import CompletedIssuesLight from "@/public/empty-state/dashboard/light/completed-issues.svg";
import OverdueIssuesLight from "@/public/empty-state/dashboard/light/overdue-issues.svg";
import UpcomingIssuesLight from "@/public/empty-state/dashboard/light/upcoming-issues.svg";
// gradients for issues by priority widget graph bars
export const PRIORITY_GRAPH_GRADIENTS = [
linearGradientDef(
"gradientUrgent",
[
{ offset: 0, color: "#A90408" },
{ offset: 100, color: "#DF4D51" },
],
{
x1: 1,
y1: 0,
x2: 0,
y2: 0,
}
),
linearGradientDef(
"gradientHigh",
[
{ offset: 0, color: "#FE6B00" },
{ offset: 100, color: "#FFAC88" },
],
{
x1: 1,
y1: 0,
x2: 0,
y2: 0,
}
),
linearGradientDef(
"gradientMedium",
[
{ offset: 0, color: "#F5AC00" },
{ offset: 100, color: "#FFD675" },
],
{
x1: 1,
y1: 0,
x2: 0,
y2: 0,
}
),
linearGradientDef(
"gradientLow",
[
{ offset: 0, color: "#1B46DE" },
{ offset: 100, color: "#4F9BF4" },
],
{
x1: 1,
y1: 0,
x2: 0,
y2: 0,
}
),
linearGradientDef(
"gradientNone",
[
{ offset: 0, color: "#A0A1A9" },
{ offset: 100, color: "#B9BBC6" },
],
{
x1: 1,
y1: 0,
x2: 0,
y2: 0,
}
),
];
// colors for issues by state group widget graph arcs
export const STATE_GROUP_GRAPH_GRADIENTS = [
linearGradientDef("gradientBacklog", [
{ offset: 0, color: "#DEDEDE" },
{ offset: 100, color: "#BABABE" },
]),
linearGradientDef("gradientUnstarted", [
{ offset: 0, color: "#D4D4D4" },
{ offset: 100, color: "#878796" },
]),
linearGradientDef("gradientStarted", [
{ offset: 0, color: "#FFD300" },
{ offset: 100, color: "#FAE270" },
]),
linearGradientDef("gradientCompleted", [
{ offset: 0, color: "#0E8B1B" },
{ offset: 100, color: "#37CB46" },
]),
linearGradientDef("gradientCanceled", [
{ offset: 0, color: "#C90004" },
{ offset: 100, color: "#FF7679" },
]),
];
export const STATE_GROUP_GRAPH_COLORS: Record<TStateGroups, string> = {
backlog: "#CDCED6",
unstarted: "#80838D",
started: "#FFC53D",
completed: "#3E9B4F",
cancelled: "#E5484D",
};
export enum EDurationFilters {
NONE = "none",
TODAY = "today",
THIS_WEEK = "this_week",
THIS_MONTH = "this_month",
THIS_YEAR = "this_year",
CUSTOM = "custom",
}
// filter duration options
export const DURATION_FILTER_OPTIONS: {
key: EDurationFilters;
label: string;
}[] = [
{
key: EDurationFilters.NONE,
label: "All time",
},
{
key: EDurationFilters.TODAY,
label: "Due today",
},
{
key: EDurationFilters.THIS_WEEK,
label: "Due this week",
},
{
key: EDurationFilters.THIS_MONTH,
label: "Due this month",
},
{
key: EDurationFilters.THIS_YEAR,
label: "Due this year",
},
{
key: EDurationFilters.CUSTOM,
label: "Custom",
},
];
// random background colors for project cards
export const PROJECT_BACKGROUND_COLORS = [
"bg-gray-500/20",
"bg-green-500/20",
"bg-red-500/20",
"bg-orange-500/20",
"bg-blue-500/20",
"bg-yellow-500/20",
"bg-pink-500/20",
"bg-purple-500/20",
];
// assigned and created issues widgets tabs list
export const FILTERED_ISSUES_TABS_LIST: {
key: TIssuesListTypes;
label: string;
}[] = [
{
key: "upcoming",
label: "Upcoming",
},
{
key: "overdue",
label: "Overdue",
},
{
key: "completed",
label: "Marked completed",
},
];
// assigned and created issues widgets tabs list
export const UNFILTERED_ISSUES_TABS_LIST: {
key: TIssuesListTypes;
label: string;
}[] = [
{
key: "pending",
label: "Pending",
},
{
key: "completed",
label: "Marked completed",
},
];
export const ASSIGNED_ISSUES_EMPTY_STATES = {
pending: {
title: "Issues assigned to you that are pending\nwill show up here.",
darkImage: UpcomingIssuesDark,
lightImage: UpcomingIssuesLight,
},
upcoming: {
title: "Upcoming issues assigned to\nyou will show up here.",
darkImage: UpcomingIssuesDark,
lightImage: UpcomingIssuesLight,
},
overdue: {
title: "Issues assigned to you that are past\ntheir due date will show up here.",
darkImage: OverdueIssuesDark,
lightImage: OverdueIssuesLight,
},
completed: {
title: "Issues assigned to you that you have\nmarked Completed will show up here.",
darkImage: CompletedIssuesDark,
lightImage: CompletedIssuesLight,
},
};
export const CREATED_ISSUES_EMPTY_STATES = {
pending: {
title: "Issues created by you that are pending\nwill show up here.",
darkImage: UpcomingIssuesDark,
lightImage: UpcomingIssuesLight,
},
upcoming: {
title: "Upcoming issues you created\nwill show up here.",
darkImage: UpcomingIssuesDark,
lightImage: UpcomingIssuesLight,
},
overdue: {
title: "Issues created by you that are past their\ndue date will show up here.",
darkImage: OverdueIssuesDark,
lightImage: OverdueIssuesLight,
},
completed: {
title: "Issues created by you that you have\nmarked completed will show up here.",
darkImage: CompletedIssuesDark,
lightImage: CompletedIssuesLight,
},
};
export type TLinkOptions = {
userId: string | undefined;
};

View File

@@ -1,5 +1,5 @@
import { EUserPermissions } from "ee/constants/user-permissions";
import { Plus, Shapes } from "lucide-react";
import { EUserPermissions } from "ee/constants/user-permissions";
export interface EmptyStateDetails {
key: EmptyStateType;

View File

@@ -6,7 +6,6 @@ export * from "./use-calendar-view";
export * from "./use-command-palette";
export * from "./use-cycle";
export * from "./use-cycle-filter";
export * from "./use-dashboard";
export * from "./use-event-tracker";
export * from "./use-global-view";
export * from "./use-inbox-issues";

View File

@@ -1,11 +0,0 @@
import { useContext } from "react";
// mobx store
import { StoreContext } from "@/lib/store-context";
// types
import { IDashboardStore } from "@/store/dashboard.store";
export const useDashboard = (): IDashboardStore => {
const context = useContext(StoreContext);
if (context === undefined) throw new Error("useDashboard must be used within StoreProvider");
return context.dashboard;
};

View File

@@ -1,53 +0,0 @@
import { THomeDashboardResponse, TWidget, TWidgetStatsResponse, TWidgetStatsRequestParams } from "@plane/types";
import { API_BASE_URL } from "@/helpers/common.helper";
import { APIService } from "@/services/api.service";
// helpers
// types
export class DashboardService extends APIService {
constructor() {
super(API_BASE_URL);
}
async getHomeDashboardWidgets(workspaceSlug: string): Promise<THomeDashboardResponse> {
return this.get(`/api/workspaces/${workspaceSlug}/dashboard/`, {
params: {
dashboard_type: "home",
},
})
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async getWidgetStats(
workspaceSlug: string,
dashboardId: string,
params: TWidgetStatsRequestParams
): Promise<TWidgetStatsResponse> {
return this.get(`/api/workspaces/${workspaceSlug}/dashboard/${dashboardId}/`, {
params,
})
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async getDashboardDetails(dashboardId: string): Promise<TWidgetStatsResponse> {
return this.get(`/api/dashboard/${dashboardId}/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async updateDashboardWidget(dashboardId: string, widgetId: string, data: Partial<TWidget>): Promise<TWidget> {
return this.patch(`/api/dashboard/${dashboardId}/widgets/${widgetId}/`, data)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
}

View File

@@ -1,285 +0,0 @@
import set from "lodash/set";
import { action, computed, makeObservable, observable, runInAction } from "mobx";
import { computedFn } from "mobx-utils";
// types
import {
THomeDashboardResponse,
TWidget,
TWidgetFiltersFormData,
TWidgetStatsResponse,
TWidgetKeys,
TWidgetStatsRequestParams,
} from "@plane/types";
// services
import { DashboardService } from "@/services/dashboard.service";
// plane web store
import { CoreRootStore } from "./root.store";
export interface IDashboardStore {
// error states
widgetStatsError: { [workspaceSlug: string]: Record<string, Record<TWidgetKeys, any | null>> };
// observables
homeDashboardId: string | null;
widgetDetails: { [workspaceSlug: string]: Record<string, TWidget[]> };
// {
// workspaceSlug: {
// dashboardId: TWidget[]
// }
// }
widgetStats: { [workspaceSlug: string]: Record<string, Record<TWidgetKeys, TWidgetStatsResponse>> };
// {
// workspaceSlug: {
// dashboardId: {
// widgetKey: TWidgetStatsResponse;
// }
// }
// }
// computed
homeDashboardWidgets: TWidget[] | undefined;
// computed actions
getWidgetDetails: (workspaceSlug: string, dashboardId: string, widgetKey: TWidgetKeys) => TWidget | undefined;
getWidgetStats: <T>(workspaceSlug: string, dashboardId: string, widgetKey: TWidgetKeys) => T | undefined;
getWidgetStatsError: (workspaceSlug: string, dashboardId: string, widgetKey: TWidgetKeys) => any | null;
// actions
fetchHomeDashboardWidgets: (workspaceSlug: string) => Promise<THomeDashboardResponse>;
fetchWidgetStats: (
workspaceSlug: string,
dashboardId: string,
params: TWidgetStatsRequestParams
) => Promise<TWidgetStatsResponse>;
updateDashboardWidget: (
workspaceSlug: string,
dashboardId: string,
widgetId: string,
data: Partial<TWidget>
) => Promise<any>;
updateDashboardWidgetFilters: (
workspaceSlug: string,
dashboardId: string,
widgetId: string,
data: TWidgetFiltersFormData
) => Promise<any>;
}
export class DashboardStore implements IDashboardStore {
// error states
widgetStatsError: { [workspaceSlug: string]: Record<string, Record<TWidgetKeys, any>> } = {};
// observables
homeDashboardId: string | null = null;
widgetDetails: { [workspaceSlug: string]: Record<string, TWidget[]> } = {};
widgetStats: { [workspaceSlug: string]: Record<string, Record<TWidgetKeys, TWidgetStatsResponse>> } = {};
// stores
routerStore;
issueStore;
// services
dashboardService;
constructor(_rootStore: CoreRootStore) {
makeObservable(this, {
// error states
widgetStatsError: observable,
// observables
homeDashboardId: observable.ref,
widgetDetails: observable,
widgetStats: observable,
// computed
homeDashboardWidgets: computed,
// fetch actions
fetchHomeDashboardWidgets: action,
fetchWidgetStats: action,
// update actions
updateDashboardWidget: action,
updateDashboardWidgetFilters: action,
});
// router store
this.routerStore = _rootStore.router;
this.issueStore = _rootStore.issue.issues;
// services
this.dashboardService = new DashboardService();
}
/**
* @description get home dashboard widgets
* @returns {TWidget[] | undefined}
*/
get homeDashboardWidgets() {
const workspaceSlug = this.routerStore.workspaceSlug;
if (!workspaceSlug) return undefined;
const { homeDashboardId, widgetDetails } = this;
return homeDashboardId ? widgetDetails?.[workspaceSlug]?.[homeDashboardId] : undefined;
}
/**
* @description get widget details
* @param {string} workspaceSlug
* @param {string} dashboardId
* @param {TWidgetKeys} widgetKey
* @returns {TWidget | undefined}
*/
getWidgetDetails = computedFn((workspaceSlug: string, dashboardId: string, widgetKey: TWidgetKeys) => {
const widgets = this.widgetDetails?.[workspaceSlug]?.[dashboardId];
if (!widgets) return undefined;
return widgets.find((widget) => widget.key === widgetKey);
});
/**
* @description get widget stats
* @param {string} workspaceSlug
* @param {string} dashboardId
* @param {TWidgetKeys} widgetKey
* @returns {T | undefined}
*/
getWidgetStats = <T>(workspaceSlug: string, dashboardId: string, widgetKey: TWidgetKeys): T | undefined =>
(this.widgetStats?.[workspaceSlug]?.[dashboardId]?.[widgetKey] as unknown as T) ?? undefined;
/**
* @description get widget stats error
* @param {string} workspaceSlug
* @param {string} dashboardId
* @param {TWidgetKeys} widgetKey
* @returns {any | null}
*/
getWidgetStatsError = (workspaceSlug: string, dashboardId: string, widgetKey: TWidgetKeys) =>
this.widgetStatsError?.[workspaceSlug]?.[dashboardId]?.[widgetKey] ?? null;
/**
* @description fetch home dashboard details and widgets
* @param {string} workspaceSlug
* @returns {Promise<THomeDashboardResponse>}
*/
fetchHomeDashboardWidgets = async (workspaceSlug: string): Promise<THomeDashboardResponse> => {
try {
const response = await this.dashboardService.getHomeDashboardWidgets(workspaceSlug);
runInAction(() => {
this.homeDashboardId = response.dashboard.id;
set(this.widgetDetails, [workspaceSlug, response.dashboard.id], response.widgets);
});
return response;
} catch (error) {
runInAction(() => {
this.homeDashboardId = null;
});
throw error;
}
};
/**
* @description fetch widget stats
* @param {string} workspaceSlug
* @param {string} dashboardId
* @param {TWidgetStatsRequestParams} widgetKey
* @returns widget stats
*/
fetchWidgetStats = async (workspaceSlug: string, dashboardId: string, params: TWidgetStatsRequestParams) =>
this.dashboardService
.getWidgetStats(workspaceSlug, dashboardId, params)
.then((res: any) => {
runInAction(() => {
if (res.issues) this.issueStore.addIssue(res.issues);
set(this.widgetStats, [workspaceSlug, dashboardId, params.widget_key], res);
set(this.widgetStatsError, [workspaceSlug, dashboardId, params.widget_key], null);
});
return res;
})
.catch((error) => {
runInAction(() => {
set(this.widgetStatsError, [workspaceSlug, dashboardId, params.widget_key], error);
});
throw error;
});
/**
* @description update dashboard widget
* @param {string} dashboardId
* @param {string} widgetId
* @param {Partial<TWidget>} data
* @returns updated widget
*/
updateDashboardWidget = async (
workspaceSlug: string,
dashboardId: string,
widgetId: string,
data: Partial<TWidget>
): Promise<any> => {
// find all widgets in dashboard
const widgets = this.widgetDetails?.[workspaceSlug]?.[dashboardId];
if (!widgets) throw new Error("Dashboard not found");
// find widget index
const widgetIndex = widgets.findIndex((widget) => widget.id === widgetId);
// get original widget
const originalWidget = { ...widgets[widgetIndex] };
if (widgetIndex === -1) throw new Error("Widget not found");
try {
runInAction(() => {
this.widgetDetails[workspaceSlug][dashboardId][widgetIndex] = {
...widgets[widgetIndex],
...data,
};
});
const response = await this.dashboardService.updateDashboardWidget(dashboardId, widgetId, data);
return response;
} catch (error) {
// revert changes
runInAction(() => {
this.widgetDetails[workspaceSlug][dashboardId][widgetIndex] = originalWidget;
});
throw error;
}
};
/**
* @description update dashboard widget filters
* @param {string} dashboardId
* @param {string} widgetId
* @param {TWidgetFiltersFormData} data
* @returns updated widget
*/
updateDashboardWidgetFilters = async (
workspaceSlug: string,
dashboardId: string,
widgetId: string,
data: TWidgetFiltersFormData
): Promise<TWidget> => {
const widgetDetails = this.getWidgetDetails(workspaceSlug, dashboardId, data.widgetKey);
if (!widgetDetails) throw new Error("Widget not found");
try {
const updatedWidget = {
...widgetDetails,
widget_filters: {
...widgetDetails.widget_filters,
...data.filters,
},
};
// update widget details optimistically
runInAction(() => {
set(
this.widgetDetails,
[workspaceSlug, dashboardId],
this.widgetDetails?.[workspaceSlug]?.[dashboardId]?.map((w) => (w.id === widgetId ? updatedWidget : w))
);
});
const response = await this.updateDashboardWidget(workspaceSlug, dashboardId, widgetId, {
filters: {
...widgetDetails.widget_filters,
...data.filters,
},
}).then((res) => res);
return response;
} catch (error) {
// revert changes
runInAction(() => {
this.widgetDetails[workspaceSlug][dashboardId] = this.widgetDetails?.[workspaceSlug]?.[dashboardId]?.map((w) =>
w.id === widgetId ? widgetDetails : w
);
});
throw error;
}
};
}

View File

@@ -1,5 +1,4 @@
import { enableStaticRendering } from "mobx-react";
import { EIssueServiceType } from "@plane/constants";
// plane web store
import { CommandPaletteStore, ICommandPaletteStore } from "@/plane-web/store/command-palette.store";
import { RootStore } from "@/plane-web/store/root.store";
@@ -7,7 +6,6 @@ import { IStateStore, StateStore } from "@/plane-web/store/state.store";
// stores
import { CycleStore, ICycleStore } from "./cycle.store";
import { CycleFilterStore, ICycleFilterStore } from "./cycle_filter.store";
import { DashboardStore, IDashboardStore } from "./dashboard.store";
import { IProjectEstimateStore, ProjectEstimateStore } from "./estimates/project-estimate.store";
import { EventTrackerStore, IEventTrackerStore } from "./event-tracker.store";
import { FavoriteStore, IFavoriteStore } from "./favorite.store";
@@ -46,7 +44,6 @@ export class CoreRootStore {
issue: IIssueRootStore;
state: IStateStore;
label: ILabelStore;
dashboard: IDashboardStore;
projectPages: IProjectPageStore;
router: IRouterStore;
commandPalette: ICommandPaletteStore;
@@ -80,7 +77,6 @@ export class CoreRootStore {
this.issue = new IssueRootStore(this as unknown as RootStore);
this.state = new StateStore(this as unknown as RootStore);
this.label = new LabelStore(this);
this.dashboard = new DashboardStore(this);
this.eventTracker = new EventTrackerStore(this);
this.multipleSelect = new MultipleSelectStore();
this.projectInbox = new ProjectInboxStore(this);
@@ -112,7 +108,6 @@ export class CoreRootStore {
this.issue = new IssueRootStore(this as unknown as RootStore);
this.state = new StateStore(this as unknown as RootStore);
this.label = new LabelStore(this);
this.dashboard = new DashboardStore(this);
this.eventTracker = new EventTrackerStore(this);
this.projectInbox = new ProjectInboxStore(this);
this.projectPages = new ProjectPageStore(this as unknown as RootStore);

View File

@@ -1,93 +0,0 @@
import { endOfMonth, endOfWeek, endOfYear, startOfMonth, startOfWeek, startOfYear } from "date-fns";
// helpers
// types
import { TIssuesListTypes } from "@plane/types";
// constants
import { DURATION_FILTER_OPTIONS, EDurationFilters } from "@/constants/dashboard";
import { renderFormattedDate, renderFormattedPayloadDate } from "./date-time.helper";
/**
* @description returns date range based on the duration filter
* @param duration
*/
export const getCustomDates = (duration: EDurationFilters, customDates: string[]): string => {
const today = new Date();
let firstDay, lastDay;
switch (duration) {
case EDurationFilters.NONE:
return "";
case EDurationFilters.TODAY:
firstDay = renderFormattedPayloadDate(today);
lastDay = renderFormattedPayloadDate(today);
return `${firstDay};after,${lastDay};before`;
case EDurationFilters.THIS_WEEK:
firstDay = renderFormattedPayloadDate(startOfWeek(today));
lastDay = renderFormattedPayloadDate(endOfWeek(today));
return `${firstDay};after,${lastDay};before`;
case EDurationFilters.THIS_MONTH:
firstDay = renderFormattedPayloadDate(startOfMonth(today));
lastDay = renderFormattedPayloadDate(endOfMonth(today));
return `${firstDay};after,${lastDay};before`;
case EDurationFilters.THIS_YEAR:
firstDay = renderFormattedPayloadDate(startOfYear(today));
lastDay = renderFormattedPayloadDate(endOfYear(today));
return `${firstDay};after,${lastDay};before`;
case EDurationFilters.CUSTOM:
return customDates.join(",");
}
};
/**
* @description returns redirection filters for the issues list
* @param type
*/
export const getRedirectionFilters = (type: TIssuesListTypes): string => {
const today = renderFormattedPayloadDate(new Date());
const filterParams =
type === "pending"
? "?state_group=backlog,unstarted,started"
: type === "upcoming"
? `?target_date=${today};after`
: type === "overdue"
? `?target_date=${today};before`
: "?state_group=completed";
return filterParams;
};
/**
* @description returns the tab key based on the duration filter
* @param duration
* @param tab
*/
export const getTabKey = (duration: EDurationFilters, tab: TIssuesListTypes | undefined): TIssuesListTypes => {
if (!tab) return "completed";
if (tab === "completed") return tab;
if (duration === "none") return "pending";
else {
if (["upcoming", "overdue"].includes(tab)) return tab;
else return "upcoming";
}
};
/**
* @description returns the label for the duration filter dropdown
* @param duration
* @param customDates
*/
export const getDurationFilterDropdownLabel = (duration: EDurationFilters, customDates: string[]): string => {
if (duration !== "custom") return DURATION_FILTER_OPTIONS.find((option) => option.key === duration)?.label ?? "";
else {
const afterDate = customDates.find((date) => date.includes("after"))?.split(";")[0];
const beforeDate = customDates.find((date) => date.includes("before"))?.split(";")[0];
if (afterDate && beforeDate) return `${renderFormattedDate(afterDate)} - ${renderFormattedDate(beforeDate)}`;
else if (afterDate) return `After ${renderFormattedDate(afterDate)}`;
else if (beforeDate) return `Before ${renderFormattedDate(beforeDate)}`;
else return "";
}
};