mirror of
https://github.com/makeplane/plane
synced 2025-08-07 19:59:33 +00:00
Compare commits
4 Commits
fix-projec
...
chore-dash
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2340e6b2ae | ||
|
|
7b5772550f | ||
|
|
7ac49a62fd | ||
|
|
846349e99e |
@@ -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 (
|
||||
|
||||
@@ -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"]
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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):
|
||||
|
||||
@@ -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',
|
||||
),
|
||||
]
|
||||
@@ -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,
|
||||
|
||||
@@ -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",)
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./dashboard.service";
|
||||
@@ -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";
|
||||
|
||||
181
packages/types/src/dashboard.d.ts
vendored
181
packages/types/src/dashboard.d.ts
vendored
@@ -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[];
|
||||
};
|
||||
1
packages/types/src/index.d.ts
vendored
1
packages/types/src/index.d.ts
vendored
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -1,3 +0,0 @@
|
||||
export * from "./widgets";
|
||||
export * from "./home-dashboard-widgets";
|
||||
export * from "./project-empty-state";
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./duration-filter";
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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";
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./issues";
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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";
|
||||
@@ -1,3 +0,0 @@
|
||||
export * from "./issue-list-item";
|
||||
export * from "./issues-list";
|
||||
export * from "./tabs-list";
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./loader";
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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];
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./root";
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./issues-by-priority";
|
||||
@@ -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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./workspace-dashboard";
|
||||
@@ -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);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./priority-distribution";
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 "";
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user