feat: dashboard widgets (#3362)
* fix: created dashboard, widgets and dashboard widget model * fix: new user home dashboard * chore: recent projects list * chore: recent collaborators * chore: priority order change * chore: payload changes * chore: collaborator's active issue count * chore: all dashboard widgets added with services and typs * chore: centered metric for pie chart * chore: widget filters * chore: created issue filter * fix: created and assigned issues payload change * chore: created issue payload change * fix: date filter change * chore: implement filters * fix: added expansion fields * fix: changed issue structure with relation * chore: new issues response * fix: project member fix * chore: updated issue_relation structure * chore: code cleanup * chore: update issues response and added empty states * fix: button text wrap * chore: update empty state messages * fix: filters * chore: update dark mode empty states * build-error: Type check in the issue relation service * fix: issues redirection * fix: project empty state * chore: project member active check * chore: project member check in state and priority * chore: remove console logs and replace harcoded values with constants * fix: code refactoring * fix: key name changed * refactor: mapping through similar components using an array * fix: build errors --------- Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com> Co-authored-by: gurusainath <gurusainath007@gmail.com>
@ -68,7 +68,6 @@ from .issue import (
|
|||||||
IssueRelationSerializer,
|
IssueRelationSerializer,
|
||||||
RelatedIssueSerializer,
|
RelatedIssueSerializer,
|
||||||
IssuePublicSerializer,
|
IssuePublicSerializer,
|
||||||
IssueRelationLiteSerializer,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
from .module import (
|
from .module import (
|
||||||
@ -120,3 +119,5 @@ from .notification import NotificationSerializer
|
|||||||
from .exporter import ExporterHistorySerializer
|
from .exporter import ExporterHistorySerializer
|
||||||
|
|
||||||
from .webhook import WebhookSerializer, WebhookLogSerializer
|
from .webhook import WebhookSerializer, WebhookLogSerializer
|
||||||
|
|
||||||
|
from .dashboard import DashboardSerializer, WidgetSerializer
|
||||||
|
@ -59,6 +59,7 @@ class DynamicBaseSerializer(BaseSerializer):
|
|||||||
LabelSerializer,
|
LabelSerializer,
|
||||||
CycleIssueSerializer,
|
CycleIssueSerializer,
|
||||||
IssueFlatSerializer,
|
IssueFlatSerializer,
|
||||||
|
IssueRelationSerializer,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Expansion mapper
|
# Expansion mapper
|
||||||
@ -78,14 +79,10 @@ class DynamicBaseSerializer(BaseSerializer):
|
|||||||
"labels": LabelSerializer,
|
"labels": LabelSerializer,
|
||||||
"issue_cycle": CycleIssueSerializer,
|
"issue_cycle": CycleIssueSerializer,
|
||||||
"parent": IssueSerializer,
|
"parent": IssueSerializer,
|
||||||
|
"issue_relation": IssueRelationSerializer,
|
||||||
}
|
}
|
||||||
|
|
||||||
self.fields[field] = expansion[field](
|
self.fields[field] = expansion[field](many=True if field in ["members", "assignees", "labels", "issue_cycle", "issue_relation"] else False)
|
||||||
many=True
|
|
||||||
if field
|
|
||||||
in ["members", "assignees", "labels", "issue_cycle"]
|
|
||||||
else False
|
|
||||||
)
|
|
||||||
|
|
||||||
return self.fields
|
return self.fields
|
||||||
|
|
||||||
@ -105,6 +102,7 @@ class DynamicBaseSerializer(BaseSerializer):
|
|||||||
IssueSerializer,
|
IssueSerializer,
|
||||||
LabelSerializer,
|
LabelSerializer,
|
||||||
CycleIssueSerializer,
|
CycleIssueSerializer,
|
||||||
|
IssueRelationSerializer,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Expansion mapper
|
# Expansion mapper
|
||||||
@ -124,6 +122,7 @@ class DynamicBaseSerializer(BaseSerializer):
|
|||||||
"labels": LabelSerializer,
|
"labels": LabelSerializer,
|
||||||
"issue_cycle": CycleIssueSerializer,
|
"issue_cycle": CycleIssueSerializer,
|
||||||
"parent": IssueSerializer,
|
"parent": IssueSerializer,
|
||||||
|
"issue_relation": IssueRelationSerializer
|
||||||
}
|
}
|
||||||
# Check if field in expansion then expand the field
|
# Check if field in expansion then expand the field
|
||||||
if expand in expansion:
|
if expand in expansion:
|
||||||
|
26
apiserver/plane/app/serializers/dashboard.py
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
# Module imports
|
||||||
|
from .base import BaseSerializer
|
||||||
|
from plane.db.models import Dashboard, Widget
|
||||||
|
|
||||||
|
# Third party frameworks
|
||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardSerializer(BaseSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = Dashboard
|
||||||
|
fields = "__all__"
|
||||||
|
|
||||||
|
|
||||||
|
class WidgetSerializer(BaseSerializer):
|
||||||
|
is_visible = serializers.BooleanField(read_only=True)
|
||||||
|
widget_filters = serializers.JSONField(read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Widget
|
||||||
|
fields = [
|
||||||
|
"id",
|
||||||
|
"key",
|
||||||
|
"is_visible",
|
||||||
|
"widget_filters"
|
||||||
|
]
|
@ -293,31 +293,19 @@ class IssueLabelSerializer(BaseSerializer):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
class IssueRelationLiteSerializer(DynamicBaseSerializer):
|
|
||||||
project_id = serializers.PrimaryKeyRelatedField(read_only=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = Issue
|
|
||||||
fields = [
|
|
||||||
"id",
|
|
||||||
"project_id",
|
|
||||||
"sequence_id",
|
|
||||||
]
|
|
||||||
read_only_fields = [
|
|
||||||
"workspace",
|
|
||||||
"project",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class IssueRelationSerializer(BaseSerializer):
|
class IssueRelationSerializer(BaseSerializer):
|
||||||
issue_detail = IssueRelationLiteSerializer(
|
id = serializers.UUIDField(source="related_issue.id", read_only=True)
|
||||||
read_only=True, source="related_issue"
|
project_id = serializers.PrimaryKeyRelatedField(source="related_issue.project_id", read_only=True)
|
||||||
)
|
sequence_id = serializers.IntegerField(source="related_issue.sequence_id", read_only=True)
|
||||||
|
relation_type = serializers.CharField(read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = IssueRelation
|
model = IssueRelation
|
||||||
fields = [
|
fields = [
|
||||||
"issue_detail",
|
"id",
|
||||||
|
"project_id",
|
||||||
|
"sequence_id",
|
||||||
|
"relation_type",
|
||||||
]
|
]
|
||||||
read_only_fields = [
|
read_only_fields = [
|
||||||
"workspace",
|
"workspace",
|
||||||
@ -326,12 +314,18 @@ class IssueRelationSerializer(BaseSerializer):
|
|||||||
|
|
||||||
|
|
||||||
class RelatedIssueSerializer(BaseSerializer):
|
class RelatedIssueSerializer(BaseSerializer):
|
||||||
issue_detail = IssueRelationLiteSerializer(read_only=True, source="issue")
|
id = serializers.UUIDField(source="issue.id", read_only=True)
|
||||||
|
project_id = serializers.PrimaryKeyRelatedField(source="issue.project_id", read_only=True)
|
||||||
|
sequence_id = serializers.IntegerField(source="issue.sequence_id", read_only=True)
|
||||||
|
relation_type = serializers.CharField(read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = IssueRelation
|
model = IssueRelation
|
||||||
fields = [
|
fields = [
|
||||||
"issue_detail",
|
"id",
|
||||||
|
"project_id",
|
||||||
|
"sequence_id",
|
||||||
|
"relation_type",
|
||||||
]
|
]
|
||||||
read_only_fields = [
|
read_only_fields = [
|
||||||
"workspace",
|
"workspace",
|
||||||
|
@ -3,6 +3,7 @@ from .asset import urlpatterns as asset_urls
|
|||||||
from .authentication import urlpatterns as authentication_urls
|
from .authentication import urlpatterns as authentication_urls
|
||||||
from .config import urlpatterns as configuration_urls
|
from .config import urlpatterns as configuration_urls
|
||||||
from .cycle import urlpatterns as cycle_urls
|
from .cycle import urlpatterns as cycle_urls
|
||||||
|
from .dashboard import urlpatterns as dashboard_urls
|
||||||
from .estimate import urlpatterns as estimate_urls
|
from .estimate import urlpatterns as estimate_urls
|
||||||
from .external import urlpatterns as external_urls
|
from .external import urlpatterns as external_urls
|
||||||
from .importer import urlpatterns as importer_urls
|
from .importer import urlpatterns as importer_urls
|
||||||
@ -28,6 +29,7 @@ urlpatterns = [
|
|||||||
*authentication_urls,
|
*authentication_urls,
|
||||||
*configuration_urls,
|
*configuration_urls,
|
||||||
*cycle_urls,
|
*cycle_urls,
|
||||||
|
*dashboard_urls,
|
||||||
*estimate_urls,
|
*estimate_urls,
|
||||||
*external_urls,
|
*external_urls,
|
||||||
*importer_urls,
|
*importer_urls,
|
||||||
|
23
apiserver/plane/app/urls/dashboard.py
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
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",
|
||||||
|
),
|
||||||
|
]
|
@ -176,3 +176,8 @@ from .webhook import (
|
|||||||
WebhookLogsEndpoint,
|
WebhookLogsEndpoint,
|
||||||
WebhookSecretRegenerateEndpoint,
|
WebhookSecretRegenerateEndpoint,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from .dashboard import (
|
||||||
|
DashboardEndpoint,
|
||||||
|
WidgetsEndpoint
|
||||||
|
)
|
658
apiserver/plane/app/views/dashboard.py
Normal file
@ -0,0 +1,658 @@
|
|||||||
|
# Django imports
|
||||||
|
from django.db.models import (
|
||||||
|
Q,
|
||||||
|
Case,
|
||||||
|
When,
|
||||||
|
Value,
|
||||||
|
CharField,
|
||||||
|
Count,
|
||||||
|
F,
|
||||||
|
Exists,
|
||||||
|
OuterRef,
|
||||||
|
Max,
|
||||||
|
Subquery,
|
||||||
|
JSONField,
|
||||||
|
Func,
|
||||||
|
Prefetch,
|
||||||
|
)
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
# Third Party imports
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework import status
|
||||||
|
|
||||||
|
# Module imports
|
||||||
|
from . import BaseAPIView
|
||||||
|
from plane.db.models import (
|
||||||
|
Issue,
|
||||||
|
IssueActivity,
|
||||||
|
ProjectMember,
|
||||||
|
Widget,
|
||||||
|
DashboardWidget,
|
||||||
|
Dashboard,
|
||||||
|
Project,
|
||||||
|
IssueLink,
|
||||||
|
IssueAttachment,
|
||||||
|
IssueRelation,
|
||||||
|
)
|
||||||
|
from plane.app.serializers import (
|
||||||
|
IssueActivitySerializer,
|
||||||
|
IssueSerializer,
|
||||||
|
DashboardSerializer,
|
||||||
|
WidgetSerializer,
|
||||||
|
)
|
||||||
|
from plane.utils.issue_filters import issue_filters
|
||||||
|
|
||||||
|
|
||||||
|
def dashboard_overview_stats(self, request, slug):
|
||||||
|
assigned_issues = Issue.issue_objects.filter(
|
||||||
|
project__project_projectmember__is_active=True,
|
||||||
|
project__project_projectmember__member=request.user,
|
||||||
|
workspace__slug=slug,
|
||||||
|
assignees__in=[request.user],
|
||||||
|
).count()
|
||||||
|
|
||||||
|
pending_issues_count = Issue.issue_objects.filter(
|
||||||
|
~Q(state__group__in=["completed", "cancelled"]),
|
||||||
|
project__project_projectmember__is_active=True,
|
||||||
|
project__project_projectmember__member=request.user,
|
||||||
|
workspace__slug=slug,
|
||||||
|
assignees__in=[request.user],
|
||||||
|
).count()
|
||||||
|
|
||||||
|
created_issues_count = Issue.issue_objects.filter(
|
||||||
|
workspace__slug=slug,
|
||||||
|
project__project_projectmember__is_active=True,
|
||||||
|
project__project_projectmember__member=request.user,
|
||||||
|
created_by_id=request.user.id,
|
||||||
|
).count()
|
||||||
|
|
||||||
|
completed_issues_count = Issue.issue_objects.filter(
|
||||||
|
workspace__slug=slug,
|
||||||
|
project__project_projectmember__is_active=True,
|
||||||
|
project__project_projectmember__member=request.user,
|
||||||
|
assignees__in=[request.user],
|
||||||
|
state__group="completed",
|
||||||
|
).count()
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"assigned_issues_count": assigned_issues,
|
||||||
|
"pending_issues_count": pending_issues_count,
|
||||||
|
"completed_issues_count": completed_issues_count,
|
||||||
|
"created_issues_count": created_issues_count,
|
||||||
|
},
|
||||||
|
status=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def dashboard_assigned_issues(self, request, slug):
|
||||||
|
filters = issue_filters(request.query_params, "GET")
|
||||||
|
issue_type = request.GET.get("issue_type", None)
|
||||||
|
|
||||||
|
# get all the assigned issues
|
||||||
|
assigned_issues = (
|
||||||
|
Issue.issue_objects.filter(
|
||||||
|
workspace__slug=slug,
|
||||||
|
project__project_projectmember__member=request.user,
|
||||||
|
project__project_projectmember__is_active=True,
|
||||||
|
assignees__in=[request.user],
|
||||||
|
)
|
||||||
|
.filter(**filters)
|
||||||
|
.select_related("workspace", "project", "state", "parent")
|
||||||
|
.prefetch_related("assignees", "labels")
|
||||||
|
.prefetch_related(
|
||||||
|
Prefetch(
|
||||||
|
"issue_relation",
|
||||||
|
queryset=IssueRelation.objects.select_related(
|
||||||
|
"related_issue"
|
||||||
|
).select_related("issue"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(cycle_id=F("issue_cycle__cycle_id"))
|
||||||
|
.annotate(module_id=F("issue_module__module_id"))
|
||||||
|
.annotate(
|
||||||
|
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
|
||||||
|
.order_by()
|
||||||
|
.annotate(count=Func(F("id"), function="Count"))
|
||||||
|
.values("count")
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
attachment_count=IssueAttachment.objects.filter(
|
||||||
|
issue=OuterRef("id")
|
||||||
|
)
|
||||||
|
.order_by()
|
||||||
|
.annotate(count=Func(F("id"), function="Count"))
|
||||||
|
.values("count")
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
|
||||||
|
.order_by()
|
||||||
|
.annotate(count=Func(F("id"), function="Count"))
|
||||||
|
.values("count")
|
||||||
|
)
|
||||||
|
.order_by("created_at")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Priority Ordering
|
||||||
|
priority_order = ["urgent", "high", "medium", "low", "none"]
|
||||||
|
assigned_issues = assigned_issues.annotate(
|
||||||
|
priority_order=Case(
|
||||||
|
*[
|
||||||
|
When(priority=p, then=Value(i))
|
||||||
|
for i, p in enumerate(priority_order)
|
||||||
|
],
|
||||||
|
output_field=CharField(),
|
||||||
|
)
|
||||||
|
).order_by("priority_order")
|
||||||
|
|
||||||
|
if issue_type == "completed":
|
||||||
|
completed_issues_count = assigned_issues.filter(
|
||||||
|
state__group__in=["completed"]
|
||||||
|
).count()
|
||||||
|
completed_issues = assigned_issues.filter(
|
||||||
|
state__group__in=["completed"]
|
||||||
|
)[:5]
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"issues": IssueSerializer(
|
||||||
|
completed_issues, many=True, expand=self.expand
|
||||||
|
).data,
|
||||||
|
"count": completed_issues_count,
|
||||||
|
},
|
||||||
|
status=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
|
||||||
|
if issue_type == "overdue":
|
||||||
|
overdue_issues_count = assigned_issues.filter(
|
||||||
|
state__group__in=["backlog", "unstarted", "started"],
|
||||||
|
target_date__lt=timezone.now()
|
||||||
|
).count()
|
||||||
|
overdue_issues = assigned_issues.filter(
|
||||||
|
state__group__in=["backlog", "unstarted", "started"],
|
||||||
|
target_date__lt=timezone.now()
|
||||||
|
)[:5]
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"issues": IssueSerializer(
|
||||||
|
overdue_issues, many=True, expand=self.expand
|
||||||
|
).data,
|
||||||
|
"count": overdue_issues_count,
|
||||||
|
},
|
||||||
|
status=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
|
||||||
|
if issue_type == "upcoming":
|
||||||
|
upcoming_issues_count = assigned_issues.filter(
|
||||||
|
state__group__in=["backlog", "unstarted", "started"],
|
||||||
|
target_date__gte=timezone.now()
|
||||||
|
).count()
|
||||||
|
upcoming_issues = assigned_issues.filter(
|
||||||
|
state__group__in=["backlog", "unstarted", "started"],
|
||||||
|
target_date__gte=timezone.now()
|
||||||
|
)[:5]
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"issues": IssueSerializer(
|
||||||
|
upcoming_issues, many=True, expand=self.expand
|
||||||
|
).data,
|
||||||
|
"count": upcoming_issues_count,
|
||||||
|
},
|
||||||
|
status=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
{"error": "Please specify a valid issue type"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def dashboard_created_issues(self, request, slug):
|
||||||
|
filters = issue_filters(request.query_params, "GET")
|
||||||
|
issue_type = request.GET.get("issue_type", None)
|
||||||
|
|
||||||
|
# get all the assigned issues
|
||||||
|
created_issues = (
|
||||||
|
Issue.issue_objects.filter(
|
||||||
|
workspace__slug=slug,
|
||||||
|
project__project_projectmember__member=request.user,
|
||||||
|
project__project_projectmember__is_active=True,
|
||||||
|
created_by=request.user,
|
||||||
|
)
|
||||||
|
.filter(**filters)
|
||||||
|
.select_related("workspace", "project", "state", "parent")
|
||||||
|
.prefetch_related("assignees", "labels")
|
||||||
|
.annotate(cycle_id=F("issue_cycle__cycle_id"))
|
||||||
|
.annotate(module_id=F("issue_module__module_id"))
|
||||||
|
.annotate(
|
||||||
|
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
|
||||||
|
.order_by()
|
||||||
|
.annotate(count=Func(F("id"), function="Count"))
|
||||||
|
.values("count")
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
attachment_count=IssueAttachment.objects.filter(
|
||||||
|
issue=OuterRef("id")
|
||||||
|
)
|
||||||
|
.order_by()
|
||||||
|
.annotate(count=Func(F("id"), function="Count"))
|
||||||
|
.values("count")
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
|
||||||
|
.order_by()
|
||||||
|
.annotate(count=Func(F("id"), function="Count"))
|
||||||
|
.values("count")
|
||||||
|
)
|
||||||
|
.order_by("created_at")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Priority Ordering
|
||||||
|
priority_order = ["urgent", "high", "medium", "low", "none"]
|
||||||
|
created_issues = created_issues.annotate(
|
||||||
|
priority_order=Case(
|
||||||
|
*[
|
||||||
|
When(priority=p, then=Value(i))
|
||||||
|
for i, p in enumerate(priority_order)
|
||||||
|
],
|
||||||
|
output_field=CharField(),
|
||||||
|
)
|
||||||
|
).order_by("priority_order")
|
||||||
|
|
||||||
|
if issue_type == "completed":
|
||||||
|
completed_issues_count = created_issues.filter(
|
||||||
|
state__group__in=["completed"]
|
||||||
|
).count()
|
||||||
|
completed_issues = created_issues.filter(
|
||||||
|
state__group__in=["completed"]
|
||||||
|
)[:5]
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"issues": IssueSerializer(completed_issues, many=True).data,
|
||||||
|
"count": completed_issues_count,
|
||||||
|
},
|
||||||
|
status=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
|
||||||
|
if issue_type == "overdue":
|
||||||
|
overdue_issues_count = created_issues.filter(
|
||||||
|
state__group__in=["backlog", "unstarted", "started"],
|
||||||
|
target_date__lt=timezone.now()
|
||||||
|
).count()
|
||||||
|
overdue_issues = created_issues.filter(
|
||||||
|
state__group__in=["backlog", "unstarted", "started"],
|
||||||
|
target_date__lt=timezone.now()
|
||||||
|
)[:5]
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"issues": IssueSerializer(overdue_issues, many=True).data,
|
||||||
|
"count": overdue_issues_count,
|
||||||
|
},
|
||||||
|
status=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
|
||||||
|
if issue_type == "upcoming":
|
||||||
|
upcoming_issues_count = created_issues.filter(
|
||||||
|
state__group__in=["backlog", "unstarted", "started"],
|
||||||
|
target_date__gte=timezone.now()
|
||||||
|
).count()
|
||||||
|
upcoming_issues = created_issues.filter(
|
||||||
|
state__group__in=["backlog", "unstarted", "started"],
|
||||||
|
target_date__gte=timezone.now()
|
||||||
|
)[:5]
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"issues": IssueSerializer(upcoming_issues, many=True).data,
|
||||||
|
"count": upcoming_issues_count,
|
||||||
|
},
|
||||||
|
status=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
{"error": "Please specify a valid issue type"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def dashboard_issues_by_state_groups(self, request, slug):
|
||||||
|
filters = issue_filters(request.query_params, "GET")
|
||||||
|
state_order = ["backlog", "unstarted", "started", "completed", "cancelled"]
|
||||||
|
issues_by_state_groups = (
|
||||||
|
Issue.issue_objects.filter(
|
||||||
|
workspace__slug=slug,
|
||||||
|
project__project_projectmember__is_active=True,
|
||||||
|
project__project_projectmember__member=request.user,
|
||||||
|
assignees__in=[request.user],
|
||||||
|
)
|
||||||
|
.filter(**filters)
|
||||||
|
.values("state__group")
|
||||||
|
.annotate(count=Count("id"))
|
||||||
|
)
|
||||||
|
|
||||||
|
# default state
|
||||||
|
all_groups = {state: 0 for state in state_order}
|
||||||
|
|
||||||
|
# Update counts for existing groups
|
||||||
|
for entry in issues_by_state_groups:
|
||||||
|
all_groups[entry["state__group"]] = entry["count"]
|
||||||
|
|
||||||
|
# Prepare output including all groups with their counts
|
||||||
|
output_data = [
|
||||||
|
{"state": group, "count": count} for group, count in all_groups.items()
|
||||||
|
]
|
||||||
|
|
||||||
|
return Response(output_data, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
||||||
|
def dashboard_issues_by_priority(self, request, slug):
|
||||||
|
filters = issue_filters(request.query_params, "GET")
|
||||||
|
priority_order = ["urgent", "high", "medium", "low", "none"]
|
||||||
|
|
||||||
|
issues_by_priority = (
|
||||||
|
Issue.issue_objects.filter(
|
||||||
|
workspace__slug=slug,
|
||||||
|
project__project_projectmember__is_active=True,
|
||||||
|
project__project_projectmember__member=request.user,
|
||||||
|
assignees__in=[request.user],
|
||||||
|
)
|
||||||
|
.filter(**filters)
|
||||||
|
.values("priority")
|
||||||
|
.annotate(count=Count("id"))
|
||||||
|
)
|
||||||
|
|
||||||
|
# default priority
|
||||||
|
all_groups = {priority: 0 for priority in priority_order}
|
||||||
|
|
||||||
|
# Update counts for existing groups
|
||||||
|
for entry in issues_by_priority:
|
||||||
|
all_groups[entry["priority"]] = entry["count"]
|
||||||
|
|
||||||
|
# Prepare output including all groups with their counts
|
||||||
|
output_data = [
|
||||||
|
{"priority": group, "count": count}
|
||||||
|
for group, count in all_groups.items()
|
||||||
|
]
|
||||||
|
|
||||||
|
return Response(output_data, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
||||||
|
def dashboard_recent_activity(self, request, slug):
|
||||||
|
queryset = IssueActivity.objects.filter(
|
||||||
|
~Q(field__in=["comment", "vote", "reaction", "draft"]),
|
||||||
|
workspace__slug=slug,
|
||||||
|
project__project_projectmember__member=request.user,
|
||||||
|
project__project_projectmember__is_active=True,
|
||||||
|
actor=request.user,
|
||||||
|
).select_related("actor", "workspace", "issue", "project")[:8]
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
IssueActivitySerializer(queryset, many=True).data,
|
||||||
|
status=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def dashboard_recent_projects(self, request, slug):
|
||||||
|
project_ids = (
|
||||||
|
IssueActivity.objects.filter(
|
||||||
|
workspace__slug=slug,
|
||||||
|
project__project_projectmember__member=request.user,
|
||||||
|
project__project_projectmember__is_active=True,
|
||||||
|
actor=request.user,
|
||||||
|
)
|
||||||
|
.values_list("project_id", flat=True)
|
||||||
|
.distinct()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Extract project IDs from the recent projects
|
||||||
|
unique_project_ids = set(project_id for project_id in project_ids)
|
||||||
|
|
||||||
|
# Fetch additional projects only if needed
|
||||||
|
if len(unique_project_ids) < 4:
|
||||||
|
additional_projects = Project.objects.filter(
|
||||||
|
project_projectmember__member=request.user,
|
||||||
|
project_projectmember__is_active=True,
|
||||||
|
workspace__slug=slug,
|
||||||
|
).exclude(id__in=unique_project_ids)
|
||||||
|
|
||||||
|
# Append additional project IDs to the existing list
|
||||||
|
unique_project_ids.update(additional_projects.values_list("id", flat=True))
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
list(unique_project_ids)[:4],
|
||||||
|
status=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def dashboard_recent_collaborators(self, request, slug):
|
||||||
|
# Fetch all project IDs where the user belongs to
|
||||||
|
user_projects = Project.objects.filter(
|
||||||
|
project_projectmember__member=request.user,
|
||||||
|
project_projectmember__is_active=True,
|
||||||
|
workspace__slug=slug,
|
||||||
|
).values_list("id", flat=True)
|
||||||
|
|
||||||
|
# Fetch all users who have performed an activity in the projects where the user exists
|
||||||
|
users_with_activities = (
|
||||||
|
IssueActivity.objects.filter(
|
||||||
|
workspace__slug=slug,
|
||||||
|
project_id__in=user_projects,
|
||||||
|
)
|
||||||
|
.values("actor")
|
||||||
|
.exclude(actor=request.user)
|
||||||
|
.annotate(num_activities=Count("actor"))
|
||||||
|
.order_by("-num_activities")
|
||||||
|
)[:7]
|
||||||
|
|
||||||
|
# Get the count of active issues for each user in users_with_activities
|
||||||
|
users_with_active_issues = []
|
||||||
|
for user_activity in users_with_activities:
|
||||||
|
user_id = user_activity["actor"]
|
||||||
|
active_issue_count = Issue.objects.filter(
|
||||||
|
assignees__in=[user_id],
|
||||||
|
state__group__in=["unstarted", "started"],
|
||||||
|
).count()
|
||||||
|
users_with_active_issues.append(
|
||||||
|
{"user_id": user_id, "active_issue_count": active_issue_count}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Insert the logged-in user's ID and their active issue count at the beginning
|
||||||
|
active_issue_count = Issue.objects.filter(
|
||||||
|
assignees__in=[request.user],
|
||||||
|
state__group__in=["unstarted", "started"],
|
||||||
|
).count()
|
||||||
|
|
||||||
|
if users_with_activities.count() < 7:
|
||||||
|
# Calculate the additional collaborators needed
|
||||||
|
additional_collaborators_needed = 7 - users_with_activities.count()
|
||||||
|
|
||||||
|
# Fetch additional collaborators from the project_member table
|
||||||
|
additional_collaborators = list(
|
||||||
|
set(
|
||||||
|
ProjectMember.objects.filter(
|
||||||
|
~Q(member=request.user),
|
||||||
|
project_id__in=user_projects,
|
||||||
|
workspace__slug=slug,
|
||||||
|
)
|
||||||
|
.exclude(
|
||||||
|
member__in=[
|
||||||
|
user["actor"] for user in users_with_activities
|
||||||
|
]
|
||||||
|
)
|
||||||
|
.values_list("member", flat=True)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
additional_collaborators = additional_collaborators[
|
||||||
|
:additional_collaborators_needed
|
||||||
|
]
|
||||||
|
|
||||||
|
# Append additional collaborators to the list
|
||||||
|
for collaborator_id in additional_collaborators:
|
||||||
|
active_issue_count = Issue.objects.filter(
|
||||||
|
assignees__in=[collaborator_id],
|
||||||
|
state__group__in=["unstarted", "started"],
|
||||||
|
).count()
|
||||||
|
users_with_active_issues.append(
|
||||||
|
{
|
||||||
|
"user_id": str(collaborator_id),
|
||||||
|
"active_issue_count": active_issue_count,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
users_with_active_issues.insert(
|
||||||
|
0,
|
||||||
|
{"user_id": request.user.id, "active_issue_count": active_issue_count},
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response(users_with_active_issues, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardEndpoint(BaseAPIView):
|
||||||
|
def create(self, request, slug):
|
||||||
|
serializer = DashboardSerializer(data=request.data)
|
||||||
|
if serializer.is_valid():
|
||||||
|
serializer.save()
|
||||||
|
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||||
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
def patch(self, request, slug, pk):
|
||||||
|
serializer = DashboardSerializer(data=request.data, partial=True)
|
||||||
|
if serializer.is_valid():
|
||||||
|
serializer.save()
|
||||||
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||||
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
def delete(self, request, slug, pk):
|
||||||
|
serializer = DashboardSerializer(data=request.data, partial=True)
|
||||||
|
if serializer.is_valid():
|
||||||
|
serializer.save()
|
||||||
|
return Response(serializer.data, status=status.HTTP_204_NO_CONTENT)
|
||||||
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
def get(self, request, slug, dashboard_id=None):
|
||||||
|
if not dashboard_id:
|
||||||
|
dashboard_type = request.GET.get("dashboard_type", None)
|
||||||
|
if dashboard_type == "home":
|
||||||
|
dashboard, created = Dashboard.objects.get_or_create(
|
||||||
|
type_identifier=dashboard_type, owned_by=request.user, is_default=True
|
||||||
|
)
|
||||||
|
|
||||||
|
if created:
|
||||||
|
widgets_to_fetch = [
|
||||||
|
"overview_stats",
|
||||||
|
"assigned_issues",
|
||||||
|
"created_issues",
|
||||||
|
"issues_by_state_groups",
|
||||||
|
"issues_by_priority",
|
||||||
|
"recent_activity",
|
||||||
|
"recent_projects",
|
||||||
|
"recent_collaborators",
|
||||||
|
]
|
||||||
|
|
||||||
|
updated_dashboard_widgets = []
|
||||||
|
for widget_key in widgets_to_fetch:
|
||||||
|
widget = Widget.objects.filter(key=widget_key).values_list("id", flat=True)
|
||||||
|
if widget:
|
||||||
|
updated_dashboard_widgets.append(
|
||||||
|
DashboardWidget(
|
||||||
|
widget_id=widget,
|
||||||
|
dashboard_id=dashboard.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
DashboardWidget.objects.bulk_create(
|
||||||
|
updated_dashboard_widgets, batch_size=100
|
||||||
|
)
|
||||||
|
|
||||||
|
widgets = (
|
||||||
|
Widget.objects.annotate(
|
||||||
|
is_visible=Exists(
|
||||||
|
DashboardWidget.objects.filter(
|
||||||
|
widget_id=OuterRef("pk"),
|
||||||
|
dashboard_id=dashboard.id,
|
||||||
|
is_visible=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
dashboard_filters=Subquery(
|
||||||
|
DashboardWidget.objects.filter(
|
||||||
|
widget_id=OuterRef("pk"),
|
||||||
|
dashboard_id=dashboard.id,
|
||||||
|
filters__isnull=False,
|
||||||
|
)
|
||||||
|
.exclude(filters={})
|
||||||
|
.values("filters")[:1]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
widget_filters=Case(
|
||||||
|
When(
|
||||||
|
dashboard_filters__isnull=False,
|
||||||
|
then=F("dashboard_filters"),
|
||||||
|
),
|
||||||
|
default=F("filters"),
|
||||||
|
output_field=JSONField(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"dashboard": DashboardSerializer(dashboard).data,
|
||||||
|
"widgets": WidgetSerializer(widgets, many=True).data,
|
||||||
|
},
|
||||||
|
status=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
return Response(
|
||||||
|
{"error": "Please specify a valid dashboard type"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
widget_key = request.GET.get("widget_key", "overview_stats")
|
||||||
|
|
||||||
|
WIDGETS_MAPPER = {
|
||||||
|
"overview_stats": dashboard_overview_stats,
|
||||||
|
"assigned_issues": dashboard_assigned_issues,
|
||||||
|
"created_issues": dashboard_created_issues,
|
||||||
|
"issues_by_state_groups": dashboard_issues_by_state_groups,
|
||||||
|
"issues_by_priority": dashboard_issues_by_priority,
|
||||||
|
"recent_activity": dashboard_recent_activity,
|
||||||
|
"recent_projects": dashboard_recent_projects,
|
||||||
|
"recent_collaborators": dashboard_recent_collaborators,
|
||||||
|
}
|
||||||
|
|
||||||
|
func = WIDGETS_MAPPER.get(widget_key)
|
||||||
|
if func is not None:
|
||||||
|
response = func(
|
||||||
|
self,
|
||||||
|
request=request,
|
||||||
|
slug=slug,
|
||||||
|
)
|
||||||
|
if isinstance(response, Response):
|
||||||
|
return response
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
{"error": "Please specify a valid widget key"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class WidgetsEndpoint(BaseAPIView):
|
||||||
|
def patch(self, request, dashboard_id, widget_id):
|
||||||
|
dashboard_widget = DashboardWidget.objects.filter(
|
||||||
|
widget_id=widget_id,
|
||||||
|
dashboard_id=dashboard_id,
|
||||||
|
).first()
|
||||||
|
dashboard_widget.is_visible = request.data.get(
|
||||||
|
"is_visible", dashboard_widget.is_visible
|
||||||
|
)
|
||||||
|
dashboard_widget.sort_order = request.data.get(
|
||||||
|
"sort_order", dashboard_widget.sort_order
|
||||||
|
)
|
||||||
|
dashboard_widget.filters = request.data.get(
|
||||||
|
"filters", dashboard_widget.filters
|
||||||
|
)
|
||||||
|
dashboard_widget.save()
|
||||||
|
return Response(
|
||||||
|
{"message": "successfully updated"}, status=status.HTTP_200_OK
|
||||||
|
)
|
@ -52,7 +52,6 @@ from plane.app.serializers import (
|
|||||||
IssueRelationSerializer,
|
IssueRelationSerializer,
|
||||||
RelatedIssueSerializer,
|
RelatedIssueSerializer,
|
||||||
IssuePublicSerializer,
|
IssuePublicSerializer,
|
||||||
IssueRelationLiteSerializer,
|
|
||||||
)
|
)
|
||||||
from plane.app.permissions import (
|
from plane.app.permissions import (
|
||||||
ProjectEntityPermission,
|
ProjectEntityPermission,
|
||||||
|
@ -0,0 +1,77 @@
|
|||||||
|
# Generated by Django 4.2.7 on 2024-01-08 06:47
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('db', '0053_auto_20240102_1315'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Dashboard',
|
||||||
|
fields=[
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
|
||||||
|
('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
|
||||||
|
('name', models.CharField(max_length=255)),
|
||||||
|
('description_html', models.TextField(blank=True, default='<p></p>')),
|
||||||
|
('identifier', models.UUIDField(null=True)),
|
||||||
|
('is_default', models.BooleanField(default=False)),
|
||||||
|
('type_identifier', models.CharField(choices=[('workspace', 'Workspace'), ('project', 'Project'), ('home', 'Home'), ('team', 'Team'), ('user', 'User')], default='home', max_length=30, verbose_name='Dashboard Type')),
|
||||||
|
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
|
||||||
|
('owned_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='dashboards', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Dashboard',
|
||||||
|
'verbose_name_plural': 'Dashboards',
|
||||||
|
'db_table': 'dashboards',
|
||||||
|
'ordering': ('-created_at',),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Widget',
|
||||||
|
fields=[
|
||||||
|
('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
|
||||||
|
('key', models.CharField(max_length=255)),
|
||||||
|
('filters', models.JSONField(default=dict)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Widget',
|
||||||
|
'verbose_name_plural': 'Widgets',
|
||||||
|
'db_table': 'widgets',
|
||||||
|
'ordering': ('-created_at',),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='DashboardWidget',
|
||||||
|
fields=[
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
|
||||||
|
('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
|
||||||
|
('is_visible', models.BooleanField(default=True)),
|
||||||
|
('sort_order', models.FloatField(default=65535)),
|
||||||
|
('filters', models.JSONField(default=dict)),
|
||||||
|
('properties', models.JSONField(default=dict)),
|
||||||
|
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
|
||||||
|
('dashboard', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='dashboard_widgets', to='db.dashboard')),
|
||||||
|
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
|
||||||
|
('widget', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='dashboard_widgets', to='db.widget')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Dashboard Widget',
|
||||||
|
'verbose_name_plural': 'Dashboard Widgets',
|
||||||
|
'db_table': 'dashboard_widgets',
|
||||||
|
'ordering': ('-created_at',),
|
||||||
|
'unique_together': {('widget', 'dashboard')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
97
apiserver/plane/db/migrations/0055_auto_20240108_0648.py
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
# Generated by Django 4.2.7 on 2024-01-08 06:48
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
def create_widgets(apps, schema_editor):
|
||||||
|
Widget = apps.get_model("db", "Widget")
|
||||||
|
widgets_list = [
|
||||||
|
{"key": "overview_stats", "filters": {}},
|
||||||
|
{
|
||||||
|
"key": "assigned_issues",
|
||||||
|
"filters": {
|
||||||
|
"duration": "this_week",
|
||||||
|
"tab": "upcoming",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "created_issues",
|
||||||
|
"filters": {
|
||||||
|
"duration": "this_week",
|
||||||
|
"tab": "upcoming",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "issues_by_state_groups",
|
||||||
|
"filters": {
|
||||||
|
"duration": "this_week",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "issues_by_priority",
|
||||||
|
"filters": {
|
||||||
|
"duration": "this_week",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{"key": "recent_activity", "filters": {}},
|
||||||
|
{"key": "recent_projects", "filters": {}},
|
||||||
|
{"key": "recent_collaborators", "filters": {}},
|
||||||
|
]
|
||||||
|
Widget.objects.bulk_create(
|
||||||
|
[
|
||||||
|
Widget(
|
||||||
|
key=widget["key"],
|
||||||
|
filters=widget["filters"],
|
||||||
|
)
|
||||||
|
for widget in widgets_list
|
||||||
|
],
|
||||||
|
batch_size=10,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_dashboards(apps, schema_editor):
|
||||||
|
Dashboard = apps.get_model("db", "Dashboard")
|
||||||
|
User = apps.get_model("db", "User")
|
||||||
|
Dashboard.objects.bulk_create(
|
||||||
|
[
|
||||||
|
Dashboard(
|
||||||
|
name="Home dashboard",
|
||||||
|
description_html="<p></p>",
|
||||||
|
identifier=None,
|
||||||
|
owned_by_id=user_id,
|
||||||
|
type_identifier="home",
|
||||||
|
is_default=True,
|
||||||
|
)
|
||||||
|
for user_id in User.objects.values_list('id', flat=True)
|
||||||
|
],
|
||||||
|
batch_size=2000,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_dashboard_widgets(apps, schema_editor):
|
||||||
|
Widget = apps.get_model("db", "Widget")
|
||||||
|
Dashboard = apps.get_model("db", "Dashboard")
|
||||||
|
DashboardWidget = apps.get_model("db", "DashboardWidget")
|
||||||
|
|
||||||
|
updated_dashboard_widget = [
|
||||||
|
DashboardWidget(
|
||||||
|
widget_id=widget_id,
|
||||||
|
dashboard_id=dashboard_id,
|
||||||
|
)
|
||||||
|
for widget_id in Widget.objects.values_list('id', flat=True)
|
||||||
|
for dashboard_id in Dashboard.objects.values_list('id', flat=True)
|
||||||
|
]
|
||||||
|
|
||||||
|
DashboardWidget.objects.bulk_create(updated_dashboard_widget, batch_size=2000)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("db", "0054_dashboard_widget_dashboardwidget"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(create_widgets),
|
||||||
|
migrations.RunPython(create_dashboards),
|
||||||
|
migrations.RunPython(create_dashboard_widgets),
|
||||||
|
]
|
@ -90,3 +90,5 @@ from .notification import Notification
|
|||||||
from .exporter import ExporterHistory
|
from .exporter import ExporterHistory
|
||||||
|
|
||||||
from .webhook import Webhook, WebhookLog
|
from .webhook import Webhook, WebhookLog
|
||||||
|
|
||||||
|
from .dashboard import Dashboard, DashboardWidget, Widget
|
89
apiserver/plane/db/models/dashboard.py
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
# Django imports
|
||||||
|
from django.db import models
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
# Module imports
|
||||||
|
from . import BaseModel
|
||||||
|
from ..mixins import TimeAuditModel
|
||||||
|
|
||||||
|
class Dashboard(BaseModel):
|
||||||
|
DASHBOARD_CHOICES = (
|
||||||
|
("workspace", "Workspace"),
|
||||||
|
("project", "Project"),
|
||||||
|
("home", "Home"),
|
||||||
|
("team", "Team"),
|
||||||
|
("user", "User"),
|
||||||
|
)
|
||||||
|
name = models.CharField(max_length=255)
|
||||||
|
description_html = models.TextField(blank=True, default="<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",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
"""Return name of the dashboard"""
|
||||||
|
return f"{self.name}"
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Dashboard"
|
||||||
|
verbose_name_plural = "Dashboards"
|
||||||
|
db_table = "dashboards"
|
||||||
|
ordering = ("-created_at",)
|
||||||
|
|
||||||
|
|
||||||
|
class Widget(TimeAuditModel):
|
||||||
|
id = models.UUIDField(
|
||||||
|
default=uuid.uuid4, unique=True, editable=False, db_index=True, primary_key=True
|
||||||
|
)
|
||||||
|
key = models.CharField(max_length=255)
|
||||||
|
filters = models.JSONField(default=dict)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
"""Return name of the widget"""
|
||||||
|
return f"{self.key}"
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Widget"
|
||||||
|
verbose_name_plural = "Widgets"
|
||||||
|
db_table = "widgets"
|
||||||
|
ordering = ("-created_at",)
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardWidget(BaseModel):
|
||||||
|
widget = models.ForeignKey(
|
||||||
|
Widget,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="dashboard_widgets",
|
||||||
|
)
|
||||||
|
dashboard = models.ForeignKey(
|
||||||
|
Dashboard,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="dashboard_widgets",
|
||||||
|
)
|
||||||
|
is_visible = models.BooleanField(default=True)
|
||||||
|
sort_order = models.FloatField(default=65535)
|
||||||
|
filters = models.JSONField(default=dict)
|
||||||
|
properties = models.JSONField(default=dict)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
"""Return name of the dashboard"""
|
||||||
|
return f"{self.dashboard.name} {self.widget.key}"
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unique_together = ("widget", "dashboard")
|
||||||
|
verbose_name = "Dashboard Widget"
|
||||||
|
verbose_name_plural = "Dashboard Widgets"
|
||||||
|
db_table = "dashboard_widgets"
|
||||||
|
ordering = ("-created_at",)
|
@ -3,7 +3,6 @@ import uuid
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
|
|
||||||
# The date from pattern
|
# The date from pattern
|
||||||
pattern = re.compile(r"\d+_(weeks|months)$")
|
pattern = re.compile(r"\d+_(weeks|months)$")
|
||||||
|
|
||||||
@ -464,7 +463,7 @@ def filter_start_target_date_issues(params, filter, method):
|
|||||||
filter["target_date__isnull"] = False
|
filter["target_date__isnull"] = False
|
||||||
filter["start_date__isnull"] = False
|
filter["start_date__isnull"] = False
|
||||||
return filter
|
return filter
|
||||||
|
|
||||||
|
|
||||||
def issue_filters(query_params, method):
|
def issue_filters(query_params, method):
|
||||||
filter = {}
|
filter = {}
|
||||||
|
@ -27,6 +27,7 @@ module.exports = {
|
|||||||
"custom-shadow-xl": "var(--color-shadow-xl)",
|
"custom-shadow-xl": "var(--color-shadow-xl)",
|
||||||
"custom-shadow-2xl": "var(--color-shadow-2xl)",
|
"custom-shadow-2xl": "var(--color-shadow-2xl)",
|
||||||
"custom-shadow-3xl": "var(--color-shadow-3xl)",
|
"custom-shadow-3xl": "var(--color-shadow-3xl)",
|
||||||
|
"custom-shadow-4xl": "var(--color-shadow-4xl)",
|
||||||
"custom-sidebar-shadow-2xs": "var(--color-sidebar-shadow-2xs)",
|
"custom-sidebar-shadow-2xs": "var(--color-sidebar-shadow-2xs)",
|
||||||
"custom-sidebar-shadow-xs": "var(--color-sidebar-shadow-xs)",
|
"custom-sidebar-shadow-xs": "var(--color-sidebar-shadow-xs)",
|
||||||
"custom-sidebar-shadow-sm": "var(--color-sidebar-shadow-sm)",
|
"custom-sidebar-shadow-sm": "var(--color-sidebar-shadow-sm)",
|
||||||
@ -36,8 +37,8 @@ module.exports = {
|
|||||||
"custom-sidebar-shadow-xl": "var(--color-sidebar-shadow-xl)",
|
"custom-sidebar-shadow-xl": "var(--color-sidebar-shadow-xl)",
|
||||||
"custom-sidebar-shadow-2xl": "var(--color-sidebar-shadow-2xl)",
|
"custom-sidebar-shadow-2xl": "var(--color-sidebar-shadow-2xl)",
|
||||||
"custom-sidebar-shadow-3xl": "var(--color-sidebar-shadow-3xl)",
|
"custom-sidebar-shadow-3xl": "var(--color-sidebar-shadow-3xl)",
|
||||||
"onbording-shadow-sm": "var(--color-onboarding-shadow-sm)",
|
"custom-sidebar-shadow-4xl": "var(--color-sidebar-shadow-4xl)",
|
||||||
|
"onboarding-shadow-sm": "var(--color-onboarding-shadow-sm)",
|
||||||
},
|
},
|
||||||
colors: {
|
colors: {
|
||||||
custom: {
|
custom: {
|
||||||
@ -212,7 +213,7 @@ module.exports = {
|
|||||||
to: { left: "100%" },
|
to: { left: "100%" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
typography: ({ theme }) => ({
|
typography: () => ({
|
||||||
brand: {
|
brand: {
|
||||||
css: {
|
css: {
|
||||||
"--tw-prose-body": convertToRGB("--color-text-100"),
|
"--tw-prose-body": convertToRGB("--color-text-100"),
|
||||||
@ -225,12 +226,12 @@ module.exports = {
|
|||||||
"--tw-prose-bullets": convertToRGB("--color-text-100"),
|
"--tw-prose-bullets": convertToRGB("--color-text-100"),
|
||||||
"--tw-prose-hr": convertToRGB("--color-text-100"),
|
"--tw-prose-hr": convertToRGB("--color-text-100"),
|
||||||
"--tw-prose-quotes": convertToRGB("--color-text-100"),
|
"--tw-prose-quotes": convertToRGB("--color-text-100"),
|
||||||
"--tw-prose-quote-borders": convertToRGB("--color-border"),
|
"--tw-prose-quote-borders": convertToRGB("--color-border-200"),
|
||||||
"--tw-prose-code": convertToRGB("--color-text-100"),
|
"--tw-prose-code": convertToRGB("--color-text-100"),
|
||||||
"--tw-prose-pre-code": convertToRGB("--color-text-100"),
|
"--tw-prose-pre-code": convertToRGB("--color-text-100"),
|
||||||
"--tw-prose-pre-bg": convertToRGB("--color-background-100"),
|
"--tw-prose-pre-bg": convertToRGB("--color-background-100"),
|
||||||
"--tw-prose-th-borders": convertToRGB("--color-border"),
|
"--tw-prose-th-borders": convertToRGB("--color-border-200"),
|
||||||
"--tw-prose-td-borders": convertToRGB("--color-border"),
|
"--tw-prose-td-borders": convertToRGB("--color-border-200"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
175
packages/types/src/dashboard.d.ts
vendored
Normal file
@ -0,0 +1,175 @@
|
|||||||
|
import { IIssueActivity, TIssuePriorities } from "./issues";
|
||||||
|
import { TIssue } from "./issues/issue";
|
||||||
|
import { TIssueRelationTypes } from "./issues/issue_relation";
|
||||||
|
import { TStateGroups } from "./state";
|
||||||
|
|
||||||
|
export type TWidgetKeys =
|
||||||
|
| "overview_stats"
|
||||||
|
| "assigned_issues"
|
||||||
|
| "created_issues"
|
||||||
|
| "issues_by_state_groups"
|
||||||
|
| "issues_by_priority"
|
||||||
|
| "recent_activity"
|
||||||
|
| "recent_projects"
|
||||||
|
| "recent_collaborators";
|
||||||
|
|
||||||
|
export type TIssuesListTypes = "upcoming" | "overdue" | "completed";
|
||||||
|
|
||||||
|
export type TDurationFilterOptions =
|
||||||
|
| "today"
|
||||||
|
| "this_week"
|
||||||
|
| "this_month"
|
||||||
|
| "this_year";
|
||||||
|
|
||||||
|
// widget filters
|
||||||
|
export type TAssignedIssuesWidgetFilters = {
|
||||||
|
target_date?: TDurationFilterOptions;
|
||||||
|
tab?: TIssuesListTypes;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TCreatedIssuesWidgetFilters = {
|
||||||
|
target_date?: TDurationFilterOptions;
|
||||||
|
tab?: TIssuesListTypes;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TIssuesByStateGroupsWidgetFilters = {
|
||||||
|
target_date?: TDurationFilterOptions;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TIssuesByPriorityWidgetFilters = {
|
||||||
|
target_date?: TDurationFilterOptions;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TWidgetFiltersFormData =
|
||||||
|
| {
|
||||||
|
widgetKey: "assigned_issues";
|
||||||
|
filters: Partial<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";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TWidgetIssue = TIssue & {
|
||||||
|
issue_relation: {
|
||||||
|
id: string;
|
||||||
|
project_id: string;
|
||||||
|
relation_type: TIssueRelationTypes;
|
||||||
|
sequence_id: number;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// widget stats responses
|
||||||
|
export type TOverviewStatsWidgetResponse = {
|
||||||
|
assigned_issues_count: number;
|
||||||
|
completed_issues_count: number;
|
||||||
|
created_issues_count: number;
|
||||||
|
pending_issues_count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TAssignedIssuesWidgetResponse = {
|
||||||
|
issues: TWidgetIssue[];
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TCreatedIssuesWidgetResponse = {
|
||||||
|
issues: TWidgetIssue[];
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TIssuesByStateGroupsWidgetResponse = {
|
||||||
|
count: number;
|
||||||
|
state: TStateGroups;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TIssuesByPriorityWidgetResponse = {
|
||||||
|
count: number;
|
||||||
|
priority: TIssuePriorities;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TRecentActivityWidgetResponse = IIssueActivity;
|
||||||
|
|
||||||
|
export type TRecentProjectsWidgetResponse = string[];
|
||||||
|
|
||||||
|
export type TRecentCollaboratorsWidgetResponse = {
|
||||||
|
active_issue_count: number;
|
||||||
|
user_id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TWidgetStatsResponse =
|
||||||
|
| TOverviewStatsWidgetResponse
|
||||||
|
| TIssuesByStateGroupsWidgetResponse[]
|
||||||
|
| TIssuesByPriorityWidgetResponse[]
|
||||||
|
| TAssignedIssuesWidgetResponse
|
||||||
|
| TCreatedIssuesWidgetResponse
|
||||||
|
| TRecentActivityWidgetResponse[]
|
||||||
|
| TRecentProjectsWidgetResponse
|
||||||
|
| TRecentCollaboratorsWidgetResponse[];
|
||||||
|
|
||||||
|
// dashboard
|
||||||
|
export type TDashboard = {
|
||||||
|
created_at: string;
|
||||||
|
created_by: string | null;
|
||||||
|
description_html: string;
|
||||||
|
id: string;
|
||||||
|
identifier: string | null;
|
||||||
|
is_default: boolean;
|
||||||
|
name: string;
|
||||||
|
owned_by: string;
|
||||||
|
type: string;
|
||||||
|
updated_at: string;
|
||||||
|
updated_by: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type THomeDashboardResponse = {
|
||||||
|
dashboard: TDashboard;
|
||||||
|
widgets: TWidget[];
|
||||||
|
};
|
1
packages/types/src/index.d.ts
vendored
@ -1,6 +1,7 @@
|
|||||||
export * from "./users";
|
export * from "./users";
|
||||||
export * from "./workspace";
|
export * from "./workspace";
|
||||||
export * from "./cycles";
|
export * from "./cycles";
|
||||||
|
export * from "./dashboard";
|
||||||
export * from "./projects";
|
export * from "./projects";
|
||||||
export * from "./state";
|
export * from "./state";
|
||||||
export * from "./invitation";
|
export * from "./invitation";
|
||||||
|
1
packages/types/src/issues.d.ts
vendored
@ -9,7 +9,6 @@ import type {
|
|||||||
IStateLite,
|
IStateLite,
|
||||||
Properties,
|
Properties,
|
||||||
IIssueDisplayFilterOptions,
|
IIssueDisplayFilterOptions,
|
||||||
IIssueReaction,
|
|
||||||
TIssue,
|
TIssue,
|
||||||
} from "@plane/types";
|
} from "@plane/types";
|
||||||
|
|
||||||
|
@ -6,12 +6,7 @@ export type TIssueRelationTypes =
|
|||||||
| "duplicate"
|
| "duplicate"
|
||||||
| "relates_to";
|
| "relates_to";
|
||||||
|
|
||||||
export type TIssueRelationObject = { issue_detail: TIssue };
|
export type TIssueRelation = Record<TIssueRelationTypes, TIssue[]>;
|
||||||
|
|
||||||
export type TIssueRelation = Record<
|
|
||||||
TIssueRelationTypes,
|
|
||||||
TIssueRelationObject[]
|
|
||||||
>;
|
|
||||||
|
|
||||||
export type TIssueRelationMap = {
|
export type TIssueRelationMap = {
|
||||||
[issue_id: string]: Record<TIssueRelationTypes, string[]>;
|
[issue_id: string]: Record<TIssueRelationTypes, string[]>;
|
||||||
|
4
packages/ui/helpers.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs));
|
@ -17,6 +17,17 @@
|
|||||||
"lint": "eslint src/",
|
"lint": "eslint src/",
|
||||||
"clean": "rm -rf .turbo && rm -rf node_modules && rm -rf dist"
|
"clean": "rm -rf .turbo && rm -rf node_modules && rm -rf dist"
|
||||||
},
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@blueprintjs/core": "^4.16.3",
|
||||||
|
"@blueprintjs/popover2": "^1.13.3",
|
||||||
|
"@headlessui/react": "^1.7.17",
|
||||||
|
"@popperjs/core": "^2.11.8",
|
||||||
|
"clsx": "^2.0.0",
|
||||||
|
"react-color": "^2.19.3",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"react-popper": "^2.3.0",
|
||||||
|
"tailwind-merge": "^2.0.0"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20.5.2",
|
"@types/node": "^20.5.2",
|
||||||
"@types/react": "^18.2.42",
|
"@types/react": "^18.2.42",
|
||||||
@ -29,14 +40,5 @@
|
|||||||
"tsconfig": "*",
|
"tsconfig": "*",
|
||||||
"tsup": "^5.10.1",
|
"tsup": "^5.10.1",
|
||||||
"typescript": "4.7.4"
|
"typescript": "4.7.4"
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@blueprintjs/core": "^4.16.3",
|
|
||||||
"@blueprintjs/popover2": "^1.13.3",
|
|
||||||
"@headlessui/react": "^1.7.17",
|
|
||||||
"@popperjs/core": "^2.11.8",
|
|
||||||
"react-color": "^2.19.3",
|
|
||||||
"react-dom": "^18.2.0",
|
|
||||||
"react-popper": "^2.3.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -141,6 +141,7 @@ export const Avatar: React.FC<Props> = (props) => {
|
|||||||
}
|
}
|
||||||
: {}
|
: {}
|
||||||
}
|
}
|
||||||
|
tabIndex={-1}
|
||||||
>
|
>
|
||||||
{src ? (
|
{src ? (
|
||||||
<img src={src} className={`h-full w-full ${getBorderRadius(shape)} ${className}`} alt={name} />
|
<img src={src} className={`h-full w-full ${getBorderRadius(shape)} ${className}`} alt={name} />
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
import { getIconStyling, getButtonStyling, TButtonVariant, TButtonSizes } from "./helper";
|
import { getIconStyling, getButtonStyling, TButtonVariant, TButtonSizes } from "./helper";
|
||||||
|
import { cn } from "../../helpers";
|
||||||
|
|
||||||
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
variant?: TButtonVariant;
|
variant?: TButtonVariant;
|
||||||
@ -31,7 +32,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>((props, ref) =>
|
|||||||
const buttonIconStyle = getIconStyling(size);
|
const buttonIconStyle = getIconStyling(size);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button ref={ref} type={type} className={`${buttonStyle} ${className}`} disabled={disabled || loading} {...rest}>
|
<button ref={ref} type={type} className={cn(buttonStyle, className)} disabled={disabled || loading} {...rest}>
|
||||||
{prependIcon && <div className={buttonIconStyle}>{React.cloneElement(prependIcon, { strokeWidth: 2 })}</div>}
|
{prependIcon && <div className={buttonIconStyle}>{React.cloneElement(prependIcon, { strokeWidth: 2 })}</div>}
|
||||||
{children}
|
{children}
|
||||||
{appendIcon && <div className={buttonIconStyle}>{React.cloneElement(appendIcon, { strokeWidth: 2 })}</div>}
|
{appendIcon && <div className={buttonIconStyle}>{React.cloneElement(appendIcon, { strokeWidth: 2 })}</div>}
|
||||||
|
@ -22,10 +22,10 @@ export interface IButtonStyling {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum buttonSizeStyling {
|
enum buttonSizeStyling {
|
||||||
sm = `px-3 py-1.5 font-medium text-xs rounded flex items-center gap-1.5 whitespace-nowrap transition-all justify-center inline`,
|
sm = `px-3 py-1.5 font-medium text-xs rounded flex items-center gap-1.5 whitespace-nowrap transition-all justify-center`,
|
||||||
md = `px-4 py-1.5 font-medium text-sm rounded flex items-center gap-1.5 whitespace-nowrap transition-all justify-center inline`,
|
md = `px-4 py-1.5 font-medium text-sm rounded flex items-center gap-1.5 whitespace-nowrap transition-all justify-center`,
|
||||||
lg = `px-5 py-2 font-medium text-sm rounded flex items-center gap-1.5 whitespace-nowrap transition-all justify-center inline`,
|
lg = `px-5 py-2 font-medium text-sm rounded flex items-center gap-1.5 whitespace-nowrap transition-all justify-center`,
|
||||||
xl = `px-5 py-3.5 font-medium text-sm rounded flex items-center gap-1.5 whitespace-nowrap transition-all justify-center inline`,
|
xl = `px-5 py-3.5 font-medium text-sm rounded flex items-center gap-1.5 whitespace-nowrap transition-all justify-center`,
|
||||||
}
|
}
|
||||||
|
|
||||||
enum buttonIconStyling {
|
enum buttonIconStyling {
|
||||||
|
@ -1,2 +1,3 @@
|
|||||||
export * from "./button";
|
export * from "./button";
|
||||||
|
export * from "./helper";
|
||||||
export * from "./toggle-switch";
|
export * from "./toggle-switch";
|
||||||
|
@ -11,6 +11,7 @@ import { Menu } from "@headlessui/react";
|
|||||||
import { ICustomMenuDropdownProps, ICustomMenuItemProps } from "./helper";
|
import { ICustomMenuDropdownProps, ICustomMenuItemProps } from "./helper";
|
||||||
// icons
|
// icons
|
||||||
import { ChevronDown, MoreHorizontal } from "lucide-react";
|
import { ChevronDown, MoreHorizontal } from "lucide-react";
|
||||||
|
import { cn } from "../../helpers";
|
||||||
|
|
||||||
const CustomMenu = (props: ICustomMenuDropdownProps) => {
|
const CustomMenu = (props: ICustomMenuDropdownProps) => {
|
||||||
const {
|
const {
|
||||||
@ -62,7 +63,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
|
|||||||
static
|
static
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`my-1 overflow-y-scroll whitespace-nowrap rounded-md border border-custom-border-300 bg-custom-background-90 p-1 text-xs shadow-custom-shadow-rg focus:outline-none ${
|
className={`my-1 overflow-y-scroll rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none ${
|
||||||
maxHeight === "lg"
|
maxHeight === "lg"
|
||||||
? "max-h-60"
|
? "max-h-60"
|
||||||
: maxHeight === "md"
|
: maxHeight === "md"
|
||||||
@ -72,7 +73,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
|
|||||||
: maxHeight === "sm"
|
: maxHeight === "sm"
|
||||||
? "max-h-28"
|
? "max-h-28"
|
||||||
: ""
|
: ""
|
||||||
} ${width === "auto" ? "min-w-[8rem] whitespace-nowrap" : width} ${optionsClassName}`}
|
} ${width === "auto" ? "min-w-[12rem] whitespace-nowrap" : width} ${optionsClassName}`}
|
||||||
ref={setPopperElement}
|
ref={setPopperElement}
|
||||||
style={styles.popper}
|
style={styles.popper}
|
||||||
{...attributes.popper}
|
{...attributes.popper}
|
||||||
@ -167,9 +168,13 @@ const MenuItem: React.FC<ICustomMenuItemProps> = (props) => {
|
|||||||
{({ active, close }) => (
|
{({ active, close }) => (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`w-full select-none truncate rounded px-1 py-1.5 text-left text-custom-text-200 hover:bg-custom-background-80 ${
|
className={cn(
|
||||||
active ? "bg-custom-background-80" : ""
|
"w-full select-none truncate rounded px-1 py-1.5 text-left text-custom-text-200",
|
||||||
} ${className}`}
|
{
|
||||||
|
"bg-custom-background-80": active,
|
||||||
|
},
|
||||||
|
className
|
||||||
|
)}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
close();
|
close();
|
||||||
onClick && onClick(e);
|
onClick && onClick(e);
|
||||||
|
@ -1,35 +1,79 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { AlertCircle, Ban, SignalHigh, SignalLow, SignalMedium } from "lucide-react";
|
import { AlertCircle, Ban, SignalHigh, SignalLow, SignalMedium } from "lucide-react";
|
||||||
|
import { cn } from "../../helpers";
|
||||||
|
|
||||||
type TIssuePriorities = "urgent" | "high" | "medium" | "low" | "none";
|
type TIssuePriorities = "urgent" | "high" | "medium" | "low" | "none";
|
||||||
|
|
||||||
interface IPriorityIcon {
|
interface IPriorityIcon {
|
||||||
className?: string;
|
className?: string;
|
||||||
|
containerClassName?: string;
|
||||||
priority: TIssuePriorities;
|
priority: TIssuePriorities;
|
||||||
size?: number;
|
size?: number;
|
||||||
|
withContainer?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PriorityIcon: React.FC<IPriorityIcon> = (props) => {
|
export const PriorityIcon: React.FC<IPriorityIcon> = (props) => {
|
||||||
const { priority, className = "", size = 14 } = props;
|
const { priority, className = "", containerClassName = "", size = 14, withContainer = false } = props;
|
||||||
|
|
||||||
// Convert to lowercase for string comparison
|
const priorityClasses = {
|
||||||
const lowercasePriority = priority?.toLowerCase();
|
urgent: "bg-red-500 text-red-500 border-red-500",
|
||||||
|
high: "bg-orange-500/20 text-orange-500 border-orange-500",
|
||||||
//get priority icon
|
medium: "bg-yellow-500/20 text-yellow-500 border-yellow-500",
|
||||||
const getPriorityIcon = (): React.ReactNode => {
|
low: "bg-custom-primary-100/20 text-custom-primary-100 border-custom-primary-100",
|
||||||
switch (lowercasePriority) {
|
none: "bg-custom-background-80 text-custom-text-200 border-custom-border-300",
|
||||||
case "urgent":
|
|
||||||
return <AlertCircle size={size} className={`text-red-500 ${className}`} />;
|
|
||||||
case "high":
|
|
||||||
return <SignalHigh size={size} strokeWidth={3} className={`text-orange-500 ${className}`} />;
|
|
||||||
case "medium":
|
|
||||||
return <SignalMedium size={size} strokeWidth={3} className={`text-yellow-500 ${className}`} />;
|
|
||||||
case "low":
|
|
||||||
return <SignalLow size={size} strokeWidth={3} className={`text-custom-primary-100 ${className}`} />;
|
|
||||||
default:
|
|
||||||
return <Ban size={size} className={`text-custom-text-200 ${className}`} />;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return <>{getPriorityIcon()}</>;
|
// get priority icon
|
||||||
|
const icons = {
|
||||||
|
urgent: AlertCircle,
|
||||||
|
high: SignalHigh,
|
||||||
|
medium: SignalMedium,
|
||||||
|
low: SignalLow,
|
||||||
|
none: Ban,
|
||||||
|
};
|
||||||
|
const Icon = icons[priority];
|
||||||
|
|
||||||
|
if (!Icon) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{withContainer ? (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"grid place-items-center border rounded p-0.5 flex-shrink-0",
|
||||||
|
priorityClasses[priority],
|
||||||
|
containerClassName
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
size={size}
|
||||||
|
className={cn(
|
||||||
|
{
|
||||||
|
"text-white": priority === "urgent",
|
||||||
|
// centre align the icons
|
||||||
|
"translate-x-[0.0625rem]": priority === "high",
|
||||||
|
"translate-x-0.5": priority === "medium",
|
||||||
|
"translate-x-1": priority === "low",
|
||||||
|
},
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Icon
|
||||||
|
size={size}
|
||||||
|
className={cn(
|
||||||
|
{
|
||||||
|
"text-red-500": priority === "urgent",
|
||||||
|
"text-orange-500": priority === "high",
|
||||||
|
"text-yellow-500": priority === "medium",
|
||||||
|
"text-custom-primary-100": priority === "low",
|
||||||
|
"text-custom-text-200": priority === "none",
|
||||||
|
},
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
@ -64,6 +64,7 @@
|
|||||||
0px 1px 32px 0px rgba(16, 24, 40, 0.12);
|
0px 1px 32px 0px rgba(16, 24, 40, 0.12);
|
||||||
--color-shadow-3xl: 0px 12px 24px 0px rgba(0, 0, 0, 0.12), 0px 16px 32px 0px rgba(0, 0, 0, 0.12),
|
--color-shadow-3xl: 0px 12px 24px 0px rgba(0, 0, 0, 0.12), 0px 16px 32px 0px rgba(0, 0, 0, 0.12),
|
||||||
0px 1px 48px 0px rgba(16, 24, 40, 0.12);
|
0px 1px 48px 0px rgba(16, 24, 40, 0.12);
|
||||||
|
--color-shadow-4xl: 0px 8px 40px 0px rgba(0, 0, 61, 0.05), 0px 12px 32px -16px rgba(0, 0, 0, 0.05);
|
||||||
|
|
||||||
--color-sidebar-background-100: var(--color-background-100); /* primary sidebar bg */
|
--color-sidebar-background-100: var(--color-background-100); /* primary sidebar bg */
|
||||||
--color-sidebar-background-90: var(--color-background-90); /* secondary sidebar bg */
|
--color-sidebar-background-90: var(--color-background-90); /* secondary sidebar bg */
|
||||||
@ -88,6 +89,7 @@
|
|||||||
--color-sidebar-shadow-xl: var(--color-shadow-xl);
|
--color-sidebar-shadow-xl: var(--color-shadow-xl);
|
||||||
--color-sidebar-shadow-2xl: var(--color-shadow-2xl);
|
--color-sidebar-shadow-2xl: var(--color-shadow-2xl);
|
||||||
--color-sidebar-shadow-3xl: var(--color-shadow-3xl);
|
--color-sidebar-shadow-3xl: var(--color-shadow-3xl);
|
||||||
|
--color-sidebar-shadow-4xl: var(--color-shadow-4xl);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="light"],
|
[data-theme="light"],
|
||||||
|
@ -3,7 +3,7 @@ import { Triangle } from "lucide-react";
|
|||||||
// types
|
// types
|
||||||
import { IDefaultAnalyticsResponse, TStateGroups } from "@plane/types";
|
import { IDefaultAnalyticsResponse, TStateGroups } from "@plane/types";
|
||||||
// constants
|
// constants
|
||||||
import { STATE_GROUP_COLORS } from "constants/state";
|
import { STATE_GROUPS } from "constants/state";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
defaultAnalytics: IDefaultAnalyticsResponse;
|
defaultAnalytics: IDefaultAnalyticsResponse;
|
||||||
@ -27,7 +27,7 @@ export const AnalyticsDemand: React.FC<Props> = ({ defaultAnalytics }) => (
|
|||||||
<span
|
<span
|
||||||
className="h-2 w-2 rounded-full"
|
className="h-2 w-2 rounded-full"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: STATE_GROUP_COLORS[group.state_group as TStateGroups],
|
backgroundColor: STATE_GROUPS[group.state_group as TStateGroups].color,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<h6 className="capitalize">{group.state_group}</h6>
|
<h6 className="capitalize">{group.state_group}</h6>
|
||||||
@ -42,7 +42,7 @@ export const AnalyticsDemand: React.FC<Props> = ({ defaultAnalytics }) => (
|
|||||||
className="absolute left-0 top-0 h-1 rounded duration-300"
|
className="absolute left-0 top-0 h-1 rounded duration-300"
|
||||||
style={{
|
style={{
|
||||||
width: `${percentage}%`,
|
width: `${percentage}%`,
|
||||||
backgroundColor: STATE_GROUP_COLORS[group.state_group as TStateGroups],
|
backgroundColor: STATE_GROUPS[group.state_group as TStateGroups].color,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
61
web/components/dashboard/home-dashboard-widgets.tsx
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
// hooks
|
||||||
|
import { useApplication, useDashboard } from "hooks/store";
|
||||||
|
// components
|
||||||
|
import {
|
||||||
|
AssignedIssuesWidget,
|
||||||
|
CreatedIssuesWidget,
|
||||||
|
IssuesByPriorityWidget,
|
||||||
|
IssuesByStateGroupWidget,
|
||||||
|
OverviewStatsWidget,
|
||||||
|
RecentActivityWidget,
|
||||||
|
RecentCollaboratorsWidget,
|
||||||
|
RecentProjectsWidget,
|
||||||
|
WidgetProps,
|
||||||
|
} from "components/dashboard";
|
||||||
|
// types
|
||||||
|
import { TWidgetKeys } from "@plane/types";
|
||||||
|
|
||||||
|
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(() => {
|
||||||
|
// store hooks
|
||||||
|
const {
|
||||||
|
router: { workspaceSlug },
|
||||||
|
} = useApplication();
|
||||||
|
const { homeDashboardId, homeDashboardWidgets } = useDashboard();
|
||||||
|
|
||||||
|
const doesWidgetExist = (widgetKey: TWidgetKeys) =>
|
||||||
|
Boolean(homeDashboardWidgets?.find((widget) => widget.key === widgetKey));
|
||||||
|
|
||||||
|
if (!workspaceSlug || !homeDashboardId) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="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 className="col-span-2">
|
||||||
|
<WidgetComponent dashboardId={homeDashboardId} workspaceSlug={workspaceSlug} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
else return <WidgetComponent dashboardId={homeDashboardId} workspaceSlug={workspaceSlug} />;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
3
web/components/dashboard/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export * from "./widgets";
|
||||||
|
export * from "./home-dashboard-widgets";
|
||||||
|
export * from "./project-empty-state";
|
41
web/components/dashboard/project-empty-state.tsx
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import Image from "next/image";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
// hooks
|
||||||
|
import { useApplication, useUser } from "hooks/store";
|
||||||
|
// ui
|
||||||
|
import { Button } from "@plane/ui";
|
||||||
|
// assets
|
||||||
|
import ProjectEmptyStateImage from "public/empty-state/dashboard/project.svg";
|
||||||
|
// constants
|
||||||
|
import { EUserWorkspaceRoles } from "constants/workspace";
|
||||||
|
|
||||||
|
export const DashboardProjectEmptyState = observer(() => {
|
||||||
|
// store hooks
|
||||||
|
const {
|
||||||
|
commandPalette: { toggleCreateProjectModal },
|
||||||
|
} = useApplication();
|
||||||
|
const {
|
||||||
|
membership: { currentWorkspaceRole },
|
||||||
|
} = useUser();
|
||||||
|
// derived values
|
||||||
|
const canCreateProject = currentWorkspaceRole === EUserWorkspaceRoles.ADMIN;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col justify-center lg:w-3/5 mx-auto space-y-4">
|
||||||
|
<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={() => toggleCreateProjectModal(true)}>
|
||||||
|
Build your first project
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
119
web/components/dashboard/widgets/assigned-issues.tsx
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { Tab } from "@headlessui/react";
|
||||||
|
// hooks
|
||||||
|
import { useDashboard } from "hooks/store";
|
||||||
|
// components
|
||||||
|
import {
|
||||||
|
DurationFilterDropdown,
|
||||||
|
TabsList,
|
||||||
|
WidgetIssuesList,
|
||||||
|
WidgetLoader,
|
||||||
|
WidgetProps,
|
||||||
|
} from "components/dashboard/widgets";
|
||||||
|
// helpers
|
||||||
|
import { getCustomDates, getRedirectionFilters } from "helpers/dashboard.helper";
|
||||||
|
// types
|
||||||
|
import { TAssignedIssuesWidgetFilters, TAssignedIssuesWidgetResponse } from "@plane/types";
|
||||||
|
// constants
|
||||||
|
import { ISSUES_TABS_LIST } from "constants/dashboard";
|
||||||
|
|
||||||
|
const WIDGET_KEY = "assigned_issues";
|
||||||
|
|
||||||
|
export const AssignedIssuesWidget: React.FC<WidgetProps> = observer((props) => {
|
||||||
|
const { dashboardId, workspaceSlug } = props;
|
||||||
|
// states
|
||||||
|
const [fetching, setFetching] = useState(false);
|
||||||
|
// store hooks
|
||||||
|
const {
|
||||||
|
fetchWidgetStats,
|
||||||
|
widgetDetails: allWidgetDetails,
|
||||||
|
widgetStats: allWidgetStats,
|
||||||
|
updateDashboardWidgetFilters,
|
||||||
|
} = useDashboard();
|
||||||
|
// derived values
|
||||||
|
const widgetDetails = allWidgetDetails?.[workspaceSlug]?.[dashboardId]?.find((w) => w.key === WIDGET_KEY);
|
||||||
|
const widgetStats = allWidgetStats?.[workspaceSlug]?.[dashboardId]?.[WIDGET_KEY] as TAssignedIssuesWidgetResponse;
|
||||||
|
|
||||||
|
const handleUpdateFilters = async (filters: Partial<TAssignedIssuesWidgetFilters>) => {
|
||||||
|
if (!widgetDetails) return;
|
||||||
|
|
||||||
|
setFetching(true);
|
||||||
|
|
||||||
|
await updateDashboardWidgetFilters(workspaceSlug, dashboardId, widgetDetails.id, {
|
||||||
|
widgetKey: WIDGET_KEY,
|
||||||
|
filters,
|
||||||
|
});
|
||||||
|
|
||||||
|
fetchWidgetStats(workspaceSlug, dashboardId, {
|
||||||
|
widget_key: WIDGET_KEY,
|
||||||
|
issue_type: widgetDetails.widget_filters.tab ?? "upcoming",
|
||||||
|
target_date: getCustomDates(widgetDetails.widget_filters.target_date ?? "this_week"),
|
||||||
|
expand: "issue_relation",
|
||||||
|
}).finally(() => setFetching(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!widgetDetails) return;
|
||||||
|
|
||||||
|
const filterDates = getCustomDates(widgetDetails.widget_filters.target_date ?? "this_week");
|
||||||
|
|
||||||
|
if (!widgetStats)
|
||||||
|
fetchWidgetStats(workspaceSlug, dashboardId, {
|
||||||
|
widget_key: WIDGET_KEY,
|
||||||
|
issue_type: widgetDetails.widget_filters.tab ?? "upcoming",
|
||||||
|
target_date: filterDates,
|
||||||
|
expand: "issue_relation",
|
||||||
|
});
|
||||||
|
}, [dashboardId, fetchWidgetStats, widgetDetails, widgetStats, workspaceSlug]);
|
||||||
|
|
||||||
|
const filterParams = getRedirectionFilters(widgetDetails?.widget_filters.tab ?? "upcoming");
|
||||||
|
const redirectionLink = `/${workspaceSlug}/workspace-views/assigned/${filterParams}`;
|
||||||
|
|
||||||
|
if (!widgetDetails || !widgetStats) return <WidgetLoader widgetKey={WIDGET_KEY} />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-custom-background-100 rounded-xl border-[0.5px] border-custom-border-200 w-full hover:shadow-custom-shadow-4xl duration-300 flex flex-col">
|
||||||
|
<Link href={redirectionLink} className="flex items-center justify-between gap-2 p-6 pl-7">
|
||||||
|
<h4 className="text-lg font-semibold text-custom-text-300">All issues assigned</h4>
|
||||||
|
<DurationFilterDropdown
|
||||||
|
value={widgetDetails.widget_filters.target_date ?? "this_week"}
|
||||||
|
onChange={(val) =>
|
||||||
|
handleUpdateFilters({
|
||||||
|
target_date: val,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
<Tab.Group
|
||||||
|
as="div"
|
||||||
|
defaultIndex={ISSUES_TABS_LIST.findIndex((t) => t.key === widgetDetails.widget_filters.tab ?? "upcoming")}
|
||||||
|
onChange={(i) => {
|
||||||
|
const selectedTab = ISSUES_TABS_LIST[i];
|
||||||
|
handleUpdateFilters({ tab: selectedTab.key ?? "upcoming" });
|
||||||
|
}}
|
||||||
|
className="h-full flex flex-col"
|
||||||
|
>
|
||||||
|
<div className="px-6">
|
||||||
|
<TabsList />
|
||||||
|
</div>
|
||||||
|
<Tab.Panels as="div" className="mt-7 h-full">
|
||||||
|
{ISSUES_TABS_LIST.map((tab) => (
|
||||||
|
<Tab.Panel key={tab.key} as="div" className="h-full flex flex-col">
|
||||||
|
<WidgetIssuesList
|
||||||
|
filter={widgetDetails.widget_filters.target_date}
|
||||||
|
issues={widgetStats.issues}
|
||||||
|
tab={tab.key}
|
||||||
|
totalIssues={widgetStats.count}
|
||||||
|
type="assigned"
|
||||||
|
workspaceSlug={workspaceSlug}
|
||||||
|
isLoading={fetching}
|
||||||
|
/>
|
||||||
|
</Tab.Panel>
|
||||||
|
))}
|
||||||
|
</Tab.Panels>
|
||||||
|
</Tab.Group>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
115
web/components/dashboard/widgets/created-issues.tsx
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { Tab } from "@headlessui/react";
|
||||||
|
// hooks
|
||||||
|
import { useDashboard } from "hooks/store";
|
||||||
|
// components
|
||||||
|
import {
|
||||||
|
DurationFilterDropdown,
|
||||||
|
TabsList,
|
||||||
|
WidgetIssuesList,
|
||||||
|
WidgetLoader,
|
||||||
|
WidgetProps,
|
||||||
|
} from "components/dashboard/widgets";
|
||||||
|
// helpers
|
||||||
|
import { getCustomDates, getRedirectionFilters } from "helpers/dashboard.helper";
|
||||||
|
// types
|
||||||
|
import { TCreatedIssuesWidgetFilters, TCreatedIssuesWidgetResponse } from "@plane/types";
|
||||||
|
// constants
|
||||||
|
import { ISSUES_TABS_LIST } from "constants/dashboard";
|
||||||
|
|
||||||
|
const WIDGET_KEY = "created_issues";
|
||||||
|
|
||||||
|
export const CreatedIssuesWidget: React.FC<WidgetProps> = observer((props) => {
|
||||||
|
const { dashboardId, workspaceSlug } = props;
|
||||||
|
// states
|
||||||
|
const [fetching, setFetching] = useState(false);
|
||||||
|
// store hooks
|
||||||
|
const {
|
||||||
|
fetchWidgetStats,
|
||||||
|
widgetDetails: allWidgetDetails,
|
||||||
|
widgetStats: allWidgetStats,
|
||||||
|
updateDashboardWidgetFilters,
|
||||||
|
} = useDashboard();
|
||||||
|
// derived values
|
||||||
|
const widgetDetails = allWidgetDetails?.[workspaceSlug]?.[dashboardId]?.find((w) => w.key === WIDGET_KEY);
|
||||||
|
const widgetStats = allWidgetStats?.[workspaceSlug]?.[dashboardId]?.[WIDGET_KEY] as TCreatedIssuesWidgetResponse;
|
||||||
|
|
||||||
|
const handleUpdateFilters = async (filters: Partial<TCreatedIssuesWidgetFilters>) => {
|
||||||
|
if (!widgetDetails) return;
|
||||||
|
|
||||||
|
setFetching(true);
|
||||||
|
|
||||||
|
await updateDashboardWidgetFilters(workspaceSlug, dashboardId, widgetDetails.id, {
|
||||||
|
widgetKey: WIDGET_KEY,
|
||||||
|
filters,
|
||||||
|
});
|
||||||
|
|
||||||
|
fetchWidgetStats(workspaceSlug, dashboardId, {
|
||||||
|
widget_key: WIDGET_KEY,
|
||||||
|
issue_type: widgetDetails.widget_filters.tab ?? "upcoming",
|
||||||
|
target_date: getCustomDates(widgetDetails.widget_filters.target_date ?? "this_week"),
|
||||||
|
}).finally(() => setFetching(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!widgetDetails) return;
|
||||||
|
|
||||||
|
if (!widgetStats)
|
||||||
|
fetchWidgetStats(workspaceSlug, dashboardId, {
|
||||||
|
widget_key: WIDGET_KEY,
|
||||||
|
issue_type: widgetDetails.widget_filters.tab ?? "upcoming",
|
||||||
|
target_date: getCustomDates(widgetDetails.widget_filters.target_date ?? "this_week"),
|
||||||
|
});
|
||||||
|
}, [dashboardId, fetchWidgetStats, widgetDetails, widgetStats, workspaceSlug]);
|
||||||
|
|
||||||
|
const filterParams = getRedirectionFilters(widgetDetails?.widget_filters.tab ?? "upcoming");
|
||||||
|
const redirectionLink = `/${workspaceSlug}/workspace-views/created/${filterParams}`;
|
||||||
|
|
||||||
|
if (!widgetDetails || !widgetStats) return <WidgetLoader widgetKey={WIDGET_KEY} />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-custom-background-100 rounded-xl border-[0.5px] border-custom-border-200 w-full hover:shadow-custom-shadow-4xl duration-300 flex flex-col">
|
||||||
|
<Link href={redirectionLink} className="flex items-center justify-between gap-2 p-6 pl-7">
|
||||||
|
<h4 className="text-lg font-semibold text-custom-text-300">All issues created</h4>
|
||||||
|
<DurationFilterDropdown
|
||||||
|
value={widgetDetails.widget_filters.target_date ?? "this_week"}
|
||||||
|
onChange={(val) =>
|
||||||
|
handleUpdateFilters({
|
||||||
|
target_date: val,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
<Tab.Group
|
||||||
|
as="div"
|
||||||
|
defaultIndex={ISSUES_TABS_LIST.findIndex((t) => t.key === widgetDetails.widget_filters.tab ?? "upcoming")}
|
||||||
|
onChange={(i) => {
|
||||||
|
const selectedTab = ISSUES_TABS_LIST[i];
|
||||||
|
handleUpdateFilters({ tab: selectedTab.key ?? "upcoming" });
|
||||||
|
}}
|
||||||
|
className="h-full flex flex-col"
|
||||||
|
>
|
||||||
|
<div className="px-6">
|
||||||
|
<TabsList />
|
||||||
|
</div>
|
||||||
|
<Tab.Panels as="div" className="mt-7 h-full">
|
||||||
|
{ISSUES_TABS_LIST.map((tab) => (
|
||||||
|
<Tab.Panel as="div" className="h-full flex flex-col">
|
||||||
|
<WidgetIssuesList
|
||||||
|
filter={widgetDetails.widget_filters.target_date}
|
||||||
|
issues={widgetStats.issues}
|
||||||
|
tab={tab.key}
|
||||||
|
totalIssues={widgetStats.count}
|
||||||
|
type="created"
|
||||||
|
workspaceSlug={workspaceSlug}
|
||||||
|
isLoading={fetching}
|
||||||
|
/>
|
||||||
|
</Tab.Panel>
|
||||||
|
))}
|
||||||
|
</Tab.Panels>
|
||||||
|
</Tab.Group>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
@ -0,0 +1,41 @@
|
|||||||
|
import { ChevronDown } from "lucide-react";
|
||||||
|
// ui
|
||||||
|
import { CustomMenu } from "@plane/ui";
|
||||||
|
// types
|
||||||
|
import { TDurationFilterOptions } from "@plane/types";
|
||||||
|
// constants
|
||||||
|
import { DURATION_FILTER_OPTIONS } from "constants/dashboard";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onChange: (value: TDurationFilterOptions) => void;
|
||||||
|
value: TDurationFilterOptions;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DurationFilterDropdown: React.FC<Props> = (props) => {
|
||||||
|
const { onChange, value } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CustomMenu
|
||||||
|
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">
|
||||||
|
{DURATION_FILTER_OPTIONS.find((option) => option.key === value)?.label}
|
||||||
|
<ChevronDown className="h-3 w-3" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
placement="bottom-end"
|
||||||
|
>
|
||||||
|
{DURATION_FILTER_OPTIONS.map((option) => (
|
||||||
|
<CustomMenu.MenuItem
|
||||||
|
key={option.key}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
onChange(option.key);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</CustomMenu.MenuItem>
|
||||||
|
))}
|
||||||
|
</CustomMenu>
|
||||||
|
);
|
||||||
|
};
|
1
web/components/dashboard/widgets/dropdowns/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from "./duration-filter";
|
@ -0,0 +1,42 @@
|
|||||||
|
import Image from "next/image";
|
||||||
|
import { useTheme } from "next-themes";
|
||||||
|
// helpers
|
||||||
|
import { cn } from "helpers/common.helper";
|
||||||
|
// types
|
||||||
|
import { TDurationFilterOptions, TIssuesListTypes } from "@plane/types";
|
||||||
|
// constants
|
||||||
|
import { ASSIGNED_ISSUES_EMPTY_STATES } from "constants/dashboard";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
filter: TDurationFilterOptions;
|
||||||
|
type: TIssuesListTypes;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AssignedIssuesEmptyState: React.FC<Props> = (props) => {
|
||||||
|
const { filter, type } = props;
|
||||||
|
// next-themes
|
||||||
|
const { resolvedTheme } = useTheme();
|
||||||
|
|
||||||
|
const typeDetails = ASSIGNED_ISSUES_EMPTY_STATES[type];
|
||||||
|
|
||||||
|
const image = resolvedTheme === "dark" ? typeDetails.darkImage : typeDetails.lightImage;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="text-center space-y-10 mt-16 flex flex-col items-center">
|
||||||
|
<p className="text-sm font-medium text-custom-text-300">{typeDetails.title(filter)}</p>
|
||||||
|
<div
|
||||||
|
className={cn("w-1/2 h-1/3 p-1.5 pb-0 rounded-t-md", {
|
||||||
|
"border border-custom-border-200": resolvedTheme === "dark",
|
||||||
|
})}
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
resolvedTheme === "light"
|
||||||
|
? "linear-gradient(135deg, rgba(235, 243, 255, 0.45) 3.57%, rgba(99, 161, 255, 0.24) 94.16%)"
|
||||||
|
: "",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Image src={image} className="w-full h-full" alt="Assigned issues" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,42 @@
|
|||||||
|
import Image from "next/image";
|
||||||
|
import { useTheme } from "next-themes";
|
||||||
|
// helpers
|
||||||
|
import { cn } from "helpers/common.helper";
|
||||||
|
// types
|
||||||
|
import { TDurationFilterOptions, TIssuesListTypes } from "@plane/types";
|
||||||
|
// constants
|
||||||
|
import { CREATED_ISSUES_EMPTY_STATES } from "constants/dashboard";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
filter: TDurationFilterOptions;
|
||||||
|
type: TIssuesListTypes;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CreatedIssuesEmptyState: React.FC<Props> = (props) => {
|
||||||
|
const { filter, type } = props;
|
||||||
|
// next-themes
|
||||||
|
const { resolvedTheme } = useTheme();
|
||||||
|
|
||||||
|
const typeDetails = CREATED_ISSUES_EMPTY_STATES[type];
|
||||||
|
|
||||||
|
const image = resolvedTheme === "dark" ? typeDetails.darkImage : typeDetails.lightImage;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="text-center space-y-10 mt-16 flex flex-col items-center">
|
||||||
|
<p className="text-sm font-medium text-custom-text-300">{typeDetails.title(filter)}</p>
|
||||||
|
<div
|
||||||
|
className={cn("w-1/2 h-1/3 p-1.5 pb-0 rounded-t-md", {
|
||||||
|
"border border-custom-border-200": resolvedTheme === "dark",
|
||||||
|
})}
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
resolvedTheme === "light"
|
||||||
|
? "linear-gradient(135deg, rgba(235, 243, 255, 0.45) 3.57%, rgba(99, 161, 255, 0.24) 94.16%)"
|
||||||
|
: "",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Image src={image} className="w-full h-full" alt="Created issues" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
6
web/components/dashboard/widgets/empty-states/index.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export * from "./assigned-issues";
|
||||||
|
export * from "./created-issues";
|
||||||
|
export * from "./issues-by-priority";
|
||||||
|
export * from "./issues-by-state-group";
|
||||||
|
export * from "./recent-activity";
|
||||||
|
export * from "./recent-collaborators";
|
@ -0,0 +1,45 @@
|
|||||||
|
import Image from "next/image";
|
||||||
|
import { useTheme } from "next-themes";
|
||||||
|
// assets
|
||||||
|
import DarkImage from "public/empty-state/dashboard/dark/issues-by-priority.svg";
|
||||||
|
import LightImage from "public/empty-state/dashboard/light/issues-by-priority.svg";
|
||||||
|
// helpers
|
||||||
|
import { cn } from "helpers/common.helper";
|
||||||
|
import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper";
|
||||||
|
// types
|
||||||
|
import { TDurationFilterOptions } from "@plane/types";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
filter: TDurationFilterOptions;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const IssuesByPriorityEmptyState: React.FC<Props> = (props) => {
|
||||||
|
const { filter } = props;
|
||||||
|
// next-themes
|
||||||
|
const { resolvedTheme } = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="text-center space-y-10 mt-16 flex flex-col items-center">
|
||||||
|
<p className="text-sm font-medium text-custom-text-300">
|
||||||
|
No assigned issues {replaceUnderscoreIfSnakeCase(filter)}.
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
className={cn("w-1/2 h-1/3 p-1.5 pb-0 rounded-t-md", {
|
||||||
|
"border border-custom-border-200": resolvedTheme === "dark",
|
||||||
|
})}
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
resolvedTheme === "light"
|
||||||
|
? "linear-gradient(135deg, rgba(235, 243, 255, 0.45) 3.57%, rgba(99, 161, 255, 0.24) 94.16%)"
|
||||||
|
: "",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={resolvedTheme === "dark" ? DarkImage : LightImage}
|
||||||
|
className="w-full h-full"
|
||||||
|
alt="Issues by priority"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,45 @@
|
|||||||
|
import Image from "next/image";
|
||||||
|
import { useTheme } from "next-themes";
|
||||||
|
// assets
|
||||||
|
import DarkImage from "public/empty-state/dashboard/dark/issues-by-state-group.svg";
|
||||||
|
import LightImage from "public/empty-state/dashboard/light/issues-by-state-group.svg";
|
||||||
|
// helpers
|
||||||
|
import { cn } from "helpers/common.helper";
|
||||||
|
import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper";
|
||||||
|
// types
|
||||||
|
import { TDurationFilterOptions } from "@plane/types";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
filter: TDurationFilterOptions;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const IssuesByStateGroupEmptyState: React.FC<Props> = (props) => {
|
||||||
|
const { filter } = props;
|
||||||
|
// next-themes
|
||||||
|
const { resolvedTheme } = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="text-center space-y-10 mt-16 flex flex-col items-center">
|
||||||
|
<p className="text-sm font-medium text-custom-text-300">
|
||||||
|
No assigned issues {replaceUnderscoreIfSnakeCase(filter)}.
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
className={cn("w-1/2 h-1/3 p-1.5 pb-0 rounded-t-md", {
|
||||||
|
"border border-custom-border-200": resolvedTheme === "dark",
|
||||||
|
})}
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
resolvedTheme === "light"
|
||||||
|
? "linear-gradient(135deg, rgba(235, 243, 255, 0.45) 3.57%, rgba(99, 161, 255, 0.24) 94.16%)"
|
||||||
|
: "",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={resolvedTheme === "dark" ? DarkImage : LightImage}
|
||||||
|
className="w-full h-full"
|
||||||
|
alt="Issues by state group"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,42 @@
|
|||||||
|
import Image from "next/image";
|
||||||
|
import { useTheme } from "next-themes";
|
||||||
|
// assets
|
||||||
|
import DarkImage from "public/empty-state/dashboard/dark/recent-activity.svg";
|
||||||
|
import LightImage from "public/empty-state/dashboard/light/recent-activity.svg";
|
||||||
|
// helpers
|
||||||
|
import { cn } from "helpers/common.helper";
|
||||||
|
|
||||||
|
type Props = {};
|
||||||
|
|
||||||
|
export const RecentActivityEmptyState: React.FC<Props> = (props) => {
|
||||||
|
const {} = props;
|
||||||
|
// next-themes
|
||||||
|
const { resolvedTheme } = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="text-center space-y-10 mt-16 flex flex-col items-center">
|
||||||
|
<p className="text-sm font-medium text-custom-text-300">
|
||||||
|
Feels new, go and explore our tool in depth and come back
|
||||||
|
<br />
|
||||||
|
to see your activity.
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
className={cn("w-3/5 h-1/3 p-1.5 pb-0 rounded-t-md", {
|
||||||
|
"border border-custom-border-200": resolvedTheme === "dark",
|
||||||
|
})}
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
resolvedTheme === "light"
|
||||||
|
? "linear-gradient(135deg, rgba(235, 243, 255, 0.45) 3.57%, rgba(99, 161, 255, 0.24) 94.16%)"
|
||||||
|
: "",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={resolvedTheme === "dark" ? DarkImage : LightImage}
|
||||||
|
className="w-full h-full"
|
||||||
|
alt="Issues by priority"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,40 @@
|
|||||||
|
import Image from "next/image";
|
||||||
|
import { useTheme } from "next-themes";
|
||||||
|
// assets
|
||||||
|
import DarkImage from "public/empty-state/dashboard/dark/recent-collaborators.svg";
|
||||||
|
import LightImage from "public/empty-state/dashboard/light/recent-collaborators.svg";
|
||||||
|
// helpers
|
||||||
|
import { cn } from "helpers/common.helper";
|
||||||
|
|
||||||
|
type Props = {};
|
||||||
|
|
||||||
|
export const RecentCollaboratorsEmptyState: React.FC<Props> = (props) => {
|
||||||
|
const {} = props;
|
||||||
|
// next-themes
|
||||||
|
const { resolvedTheme } = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-7 px-7 flex justify-between gap-16">
|
||||||
|
<p className="text-sm font-medium text-custom-text-300">
|
||||||
|
People are excited to work with you, once they do you will find your frequent collaborators here.
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
className={cn("w-3/5 h-1/3 p-1.5 pb-0 rounded-t-md flex-shrink-0 self-end", {
|
||||||
|
"border border-custom-border-200": resolvedTheme === "dark",
|
||||||
|
})}
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
resolvedTheme === "light"
|
||||||
|
? "linear-gradient(135deg, rgba(235, 243, 255, 0.45) 3.57%, rgba(99, 161, 255, 0.24) 94.16%)"
|
||||||
|
: "",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={resolvedTheme === "dark" ? DarkImage : LightImage}
|
||||||
|
className="w-full h-full"
|
||||||
|
alt="Recent collaborators"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
12
web/components/dashboard/widgets/index.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
export * from "./dropdowns";
|
||||||
|
export * from "./empty-states";
|
||||||
|
export * from "./issue-panels";
|
||||||
|
export * from "./loaders";
|
||||||
|
export * from "./assigned-issues";
|
||||||
|
export * from "./created-issues";
|
||||||
|
export * from "./issues-by-priority";
|
||||||
|
export * from "./issues-by-state-group";
|
||||||
|
export * from "./overview-stats";
|
||||||
|
export * from "./recent-activity";
|
||||||
|
export * from "./recent-collaborators";
|
||||||
|
export * from "./recent-projects";
|
3
web/components/dashboard/widgets/issue-panels/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export * from "./issue-list-item";
|
||||||
|
export * from "./issues-list";
|
||||||
|
export * from "./tabs-list";
|
@ -0,0 +1,297 @@
|
|||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import isToday from "date-fns/isToday";
|
||||||
|
// hooks
|
||||||
|
import { useIssueDetail, useMember, useProject } from "hooks/store";
|
||||||
|
// ui
|
||||||
|
import { Avatar, AvatarGroup, ControlLink, PriorityIcon } from "@plane/ui";
|
||||||
|
// helpers
|
||||||
|
import { findTotalDaysInRange, renderFormattedDate } from "helpers/date-time.helper";
|
||||||
|
// types
|
||||||
|
import { TIssue, TWidgetIssue } from "@plane/types";
|
||||||
|
|
||||||
|
export type IssueListItemProps = {
|
||||||
|
issueId: string;
|
||||||
|
onClick: (issue: TIssue) => void;
|
||||||
|
workspaceSlug: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AssignedUpcomingIssueListItem: React.FC<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) return null;
|
||||||
|
|
||||||
|
const projectDetails = getProjectById(issueDetails.project_id);
|
||||||
|
|
||||||
|
const blockedByIssues = issueDetails.issue_relation?.filter((issue) => issue.relation_type === "blocked_by") ?? [];
|
||||||
|
|
||||||
|
const blockedByIssueProjectDetails =
|
||||||
|
blockedByIssues.length === 1 ? getProjectById(blockedByIssues[0]?.project_id ?? "") : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ControlLink
|
||||||
|
href={`/${workspaceSlug}/projects/${issueDetails.project_id}/issues/${issueDetails.id}`}
|
||||||
|
onClick={() => onClick(issueDetails)}
|
||||||
|
className="py-2 px-3 hover:bg-custom-background-80 rounded grid grid-cols-6 gap-1"
|
||||||
|
>
|
||||||
|
<div className="col-span-4 flex items-center gap-3">
|
||||||
|
<PriorityIcon priority={issueDetails.priority} withContainer />
|
||||||
|
<span className="text-xs font-medium flex-shrink-0">
|
||||||
|
{projectDetails?.identifier} {issueDetails.sequence_id}
|
||||||
|
</span>
|
||||||
|
<h6 className="text-sm flex-grow truncate">{issueDetails.name}</h6>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-center">
|
||||||
|
{issueDetails.target_date
|
||||||
|
? isToday(new Date(issueDetails.target_date))
|
||||||
|
? "Today"
|
||||||
|
: renderFormattedDate(issueDetails.target_date)
|
||||||
|
: "-"}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-center">
|
||||||
|
{blockedByIssues.length > 0
|
||||||
|
? blockedByIssues.length > 1
|
||||||
|
? `${blockedByIssues.length} blockers`
|
||||||
|
: `${blockedByIssueProjectDetails?.identifier} ${blockedByIssues[0]?.sequence_id}`
|
||||||
|
: "-"}
|
||||||
|
</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) return null;
|
||||||
|
|
||||||
|
const projectDetails = getProjectById(issueDetails.project_id);
|
||||||
|
const blockedByIssues = issueDetails.issue_relation?.filter((issue) => issue.relation_type === "blocked_by") ?? [];
|
||||||
|
|
||||||
|
const blockedByIssueProjectDetails =
|
||||||
|
blockedByIssues.length === 1 ? getProjectById(blockedByIssues[0]?.project_id ?? "") : null;
|
||||||
|
|
||||||
|
const dueBy = findTotalDaysInRange(new Date(issueDetails.target_date ?? ""), new Date(), false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ControlLink
|
||||||
|
href={`/${workspaceSlug}/projects/${issueDetails.project_id}/issues/${issueDetails.id}`}
|
||||||
|
onClick={() => onClick(issueDetails)}
|
||||||
|
className="py-2 px-3 hover:bg-custom-background-80 rounded grid grid-cols-6 gap-1"
|
||||||
|
>
|
||||||
|
<div className="col-span-4 flex items-center gap-3">
|
||||||
|
<PriorityIcon priority={issueDetails.priority} withContainer />
|
||||||
|
<span className="text-xs font-medium flex-shrink-0">
|
||||||
|
{projectDetails?.identifier} {issueDetails.sequence_id}
|
||||||
|
</span>
|
||||||
|
<h6 className="text-sm flex-grow truncate">{issueDetails.name}</h6>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-center">
|
||||||
|
{dueBy} {`day${dueBy > 1 ? "s" : ""}`}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-center">
|
||||||
|
{blockedByIssues.length > 0
|
||||||
|
? blockedByIssues.length > 1
|
||||||
|
? `${blockedByIssues.length} blockers`
|
||||||
|
: `${blockedByIssueProjectDetails?.identifier} ${blockedByIssues[0]?.sequence_id}`
|
||||||
|
: "-"}
|
||||||
|
</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) return null;
|
||||||
|
|
||||||
|
const projectDetails = getProjectById(issueDetails.project_id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ControlLink
|
||||||
|
href={`/${workspaceSlug}/projects/${issueDetails.project_id}/issues/${issueDetails.id}`}
|
||||||
|
onClick={() => onClick(issueDetails)}
|
||||||
|
className="py-2 px-3 hover:bg-custom-background-80 rounded grid grid-cols-6 gap-1"
|
||||||
|
>
|
||||||
|
<div className="col-span-6 flex items-center gap-3">
|
||||||
|
<PriorityIcon priority={issueDetails.priority} withContainer />
|
||||||
|
<span className="text-xs font-medium flex-shrink-0">
|
||||||
|
{projectDetails?.identifier} {issueDetails.sequence_id}
|
||||||
|
</span>
|
||||||
|
<h6 className="text-sm flex-grow truncate">{issueDetails.name}</h6>
|
||||||
|
</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) return null;
|
||||||
|
|
||||||
|
const projectDetails = getProjectById(issue.project_id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ControlLink
|
||||||
|
href={`/${workspaceSlug}/projects/${issue.project_id}/issues/${issue.id}`}
|
||||||
|
onClick={() => onClick(issue)}
|
||||||
|
className="py-2 px-3 hover:bg-custom-background-80 rounded grid grid-cols-6 gap-1"
|
||||||
|
>
|
||||||
|
<div className="col-span-4 flex items-center gap-3">
|
||||||
|
<PriorityIcon priority={issue.priority} withContainer />
|
||||||
|
<span className="text-xs font-medium flex-shrink-0">
|
||||||
|
{projectDetails?.identifier} {issue.sequence_id}
|
||||||
|
</span>
|
||||||
|
<h6 className="text-sm flex-grow truncate">{issue.name}</h6>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-center">
|
||||||
|
{issue.target_date
|
||||||
|
? isToday(new Date(issue.target_date))
|
||||||
|
? "Today"
|
||||||
|
: renderFormattedDate(issue.target_date)
|
||||||
|
: "-"}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs flex justify-center">
|
||||||
|
{issue.assignee_ids.length > 0 ? (
|
||||||
|
<AvatarGroup>
|
||||||
|
{issue.assignee_ids?.map((assigneeId) => {
|
||||||
|
const userDetails = getUserDetails(assigneeId);
|
||||||
|
|
||||||
|
if (!userDetails) return null;
|
||||||
|
|
||||||
|
return <Avatar key={assigneeId} src={userDetails.avatar} 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) return null;
|
||||||
|
|
||||||
|
const projectDetails = getProjectById(issue.project_id);
|
||||||
|
|
||||||
|
const dueBy = findTotalDaysInRange(new Date(issue.target_date ?? ""), new Date(), false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ControlLink
|
||||||
|
href={`/${workspaceSlug}/projects/${issue.project_id}/issues/${issue.id}`}
|
||||||
|
onClick={() => onClick(issue)}
|
||||||
|
className="py-2 px-3 hover:bg-custom-background-80 rounded grid grid-cols-6 gap-1"
|
||||||
|
>
|
||||||
|
<div className="col-span-4 flex items-center gap-3">
|
||||||
|
<PriorityIcon priority={issue.priority} withContainer />
|
||||||
|
<span className="text-xs font-medium flex-shrink-0">
|
||||||
|
{projectDetails?.identifier} {issue.sequence_id}
|
||||||
|
</span>
|
||||||
|
<h6 className="text-sm flex-grow truncate">{issue.name}</h6>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-center">
|
||||||
|
{dueBy} {`day${dueBy > 1 ? "s" : ""}`}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs flex justify-center">
|
||||||
|
{issue.assignee_ids.length > 0 ? (
|
||||||
|
<AvatarGroup>
|
||||||
|
{issue.assignee_ids?.map((assigneeId) => {
|
||||||
|
const userDetails = getUserDetails(assigneeId);
|
||||||
|
|
||||||
|
if (!userDetails) return null;
|
||||||
|
|
||||||
|
return <Avatar key={assigneeId} src={userDetails.avatar} 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) return null;
|
||||||
|
|
||||||
|
const projectDetails = getProjectById(issue.project_id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ControlLink
|
||||||
|
href={`/${workspaceSlug}/projects/${issue.project_id}/issues/${issue.id}`}
|
||||||
|
onClick={() => onClick(issue)}
|
||||||
|
className="py-2 px-3 hover:bg-custom-background-80 rounded grid grid-cols-6 gap-1"
|
||||||
|
>
|
||||||
|
<div className="col-span-5 flex items-center gap-3">
|
||||||
|
<PriorityIcon priority={issue.priority} withContainer />
|
||||||
|
<span className="text-xs font-medium flex-shrink-0">
|
||||||
|
{projectDetails?.identifier} {issue.sequence_id}
|
||||||
|
</span>
|
||||||
|
<h6 className="text-sm flex-grow truncate">{issue.name}</h6>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs flex justify-center">
|
||||||
|
{issue.assignee_ids.length > 0 ? (
|
||||||
|
<AvatarGroup>
|
||||||
|
{issue.assignee_ids?.map((assigneeId) => {
|
||||||
|
const userDetails = getUserDetails(assigneeId);
|
||||||
|
|
||||||
|
if (!userDetails) return null;
|
||||||
|
|
||||||
|
return <Avatar key={assigneeId} src={userDetails.avatar} name={userDetails.display_name} />;
|
||||||
|
})}
|
||||||
|
</AvatarGroup>
|
||||||
|
) : (
|
||||||
|
"-"
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ControlLink>
|
||||||
|
);
|
||||||
|
});
|
124
web/components/dashboard/widgets/issue-panels/issues-list.tsx
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
// hooks
|
||||||
|
import { useIssueDetail } from "hooks/store";
|
||||||
|
// components
|
||||||
|
import {
|
||||||
|
AssignedCompletedIssueListItem,
|
||||||
|
AssignedIssuesEmptyState,
|
||||||
|
AssignedOverdueIssueListItem,
|
||||||
|
AssignedUpcomingIssueListItem,
|
||||||
|
CreatedCompletedIssueListItem,
|
||||||
|
CreatedIssuesEmptyState,
|
||||||
|
CreatedOverdueIssueListItem,
|
||||||
|
CreatedUpcomingIssueListItem,
|
||||||
|
IssueListItemProps,
|
||||||
|
} from "components/dashboard/widgets";
|
||||||
|
// ui
|
||||||
|
import { Loader, getButtonStyling } from "@plane/ui";
|
||||||
|
// helpers
|
||||||
|
import { cn } from "helpers/common.helper";
|
||||||
|
import { getRedirectionFilters } from "helpers/dashboard.helper";
|
||||||
|
// types
|
||||||
|
import { TDurationFilterOptions, TIssue, TIssuesListTypes } from "@plane/types";
|
||||||
|
|
||||||
|
export type WidgetIssuesListProps = {
|
||||||
|
filter: TDurationFilterOptions | undefined;
|
||||||
|
isLoading: boolean;
|
||||||
|
issues: TIssue[];
|
||||||
|
tab: TIssuesListTypes;
|
||||||
|
totalIssues: number;
|
||||||
|
type: "assigned" | "created";
|
||||||
|
workspaceSlug: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WidgetIssuesList: React.FC<WidgetIssuesListProps> = (props) => {
|
||||||
|
const { filter, isLoading, issues, tab, totalIssues, type, workspaceSlug } = props;
|
||||||
|
// store hooks
|
||||||
|
const { setPeekIssue } = useIssueDetail();
|
||||||
|
|
||||||
|
const handleIssuePeekOverview = (issue: TIssue) =>
|
||||||
|
setPeekIssue({ workspaceSlug, projectId: issue.project_id, issueId: issue.id });
|
||||||
|
|
||||||
|
const filterParams = getRedirectionFilters(tab);
|
||||||
|
|
||||||
|
const ISSUE_LIST_ITEM: {
|
||||||
|
[key in string]: {
|
||||||
|
[key in TIssuesListTypes]: React.FC<IssueListItemProps>;
|
||||||
|
};
|
||||||
|
} = {
|
||||||
|
assigned: {
|
||||||
|
upcoming: AssignedUpcomingIssueListItem,
|
||||||
|
overdue: AssignedOverdueIssueListItem,
|
||||||
|
completed: AssignedCompletedIssueListItem,
|
||||||
|
},
|
||||||
|
created: {
|
||||||
|
upcoming: CreatedUpcomingIssueListItem,
|
||||||
|
overdue: CreatedOverdueIssueListItem,
|
||||||
|
completed: CreatedCompletedIssueListItem,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="h-full">
|
||||||
|
{isLoading ? (
|
||||||
|
<Loader className="mx-6 mt-2 space-y-4">
|
||||||
|
<Loader.Item height="25px" />
|
||||||
|
<Loader.Item height="25px" />
|
||||||
|
<Loader.Item height="25px" />
|
||||||
|
<Loader.Item height="25px" />
|
||||||
|
</Loader>
|
||||||
|
) : issues.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<div className="mx-6 border-b-[0.5px] border-custom-border-200 grid grid-cols-6 gap-1 text-xs text-custom-text-300 pb-1">
|
||||||
|
<h6
|
||||||
|
className={cn("pl-1 flex items-center gap-1 col-span-4", {
|
||||||
|
"col-span-6": type === "assigned" && tab === "completed",
|
||||||
|
"col-span-5": type === "created" && tab === "completed",
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
Issues
|
||||||
|
<span className="flex-shrink-0 bg-custom-primary-100/20 text-custom-primary-100 text-xs font-medium py-1 px-1.5 rounded-xl h-4 min-w-6 flex items-center text-center justify-center">
|
||||||
|
{totalIssues}
|
||||||
|
</span>
|
||||||
|
</h6>
|
||||||
|
{tab === "upcoming" && <h6 className="text-center">Due date</h6>}
|
||||||
|
{tab === "overdue" && <h6 className="text-center">Due by</h6>}
|
||||||
|
{type === "assigned" && tab !== "completed" && <h6 className="text-center">Blocked by</h6>}
|
||||||
|
{type === "created" && <h6 className="text-center">Assigned to</h6>}
|
||||||
|
</div>
|
||||||
|
<div className="px-4 pb-3 mt-2">
|
||||||
|
{issues.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 items-end">
|
||||||
|
{type === "assigned" && <AssignedIssuesEmptyState filter={filter ?? "this_week"} type={tab} />}
|
||||||
|
{type === "created" && <CreatedIssuesEmptyState filter={filter ?? "this_week"} type={tab} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{totalIssues > issues.length && (
|
||||||
|
<Link
|
||||||
|
href={`/${workspaceSlug}/workspace-views/${type}/${filterParams}`}
|
||||||
|
className={cn(getButtonStyling("accent-primary", "sm"), "w-min my-3 mx-auto py-1 px-2 text-xs")}
|
||||||
|
>
|
||||||
|
View all issues
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
26
web/components/dashboard/widgets/issue-panels/tabs-list.tsx
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { Tab } from "@headlessui/react";
|
||||||
|
// helpers
|
||||||
|
import { cn } from "helpers/common.helper";
|
||||||
|
// constants
|
||||||
|
import { ISSUES_TABS_LIST } from "constants/dashboard";
|
||||||
|
|
||||||
|
export const TabsList = () => (
|
||||||
|
<Tab.List
|
||||||
|
as="div"
|
||||||
|
className="border-[0.5px] border-custom-border-200 rounded grid grid-cols-3 bg-custom-background-80"
|
||||||
|
>
|
||||||
|
{ISSUES_TABS_LIST.map((tab) => (
|
||||||
|
<Tab
|
||||||
|
key={tab.key}
|
||||||
|
className={({ selected }) =>
|
||||||
|
cn("font-semibold text-xs rounded py-1.5 focus:outline-none", {
|
||||||
|
"bg-custom-background-100 text-custom-text-300 shadow-[2px_0_8px_rgba(167,169,174,0.15)]": selected,
|
||||||
|
"text-custom-text-400": !selected,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</Tab>
|
||||||
|
))}
|
||||||
|
</Tab.List>
|
||||||
|
);
|
208
web/components/dashboard/widgets/issues-by-priority.tsx
Normal file
@ -0,0 +1,208 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
// hooks
|
||||||
|
import { useDashboard } from "hooks/store";
|
||||||
|
// components
|
||||||
|
import { MarimekkoGraph } from "components/ui";
|
||||||
|
import {
|
||||||
|
DurationFilterDropdown,
|
||||||
|
IssuesByPriorityEmptyState,
|
||||||
|
WidgetLoader,
|
||||||
|
WidgetProps,
|
||||||
|
} from "components/dashboard/widgets";
|
||||||
|
// ui
|
||||||
|
import { PriorityIcon } from "@plane/ui";
|
||||||
|
// helpers
|
||||||
|
import { getCustomDates } from "helpers/dashboard.helper";
|
||||||
|
// types
|
||||||
|
import { TIssuesByPriorityWidgetFilters, TIssuesByPriorityWidgetResponse } from "@plane/types";
|
||||||
|
// constants
|
||||||
|
import { PRIORITY_GRAPH_GRADIENTS } from "constants/dashboard";
|
||||||
|
import { ISSUE_PRIORITIES } from "constants/issue";
|
||||||
|
|
||||||
|
const TEXT_COLORS = {
|
||||||
|
urgent: "#F4A9AA",
|
||||||
|
high: "#AB4800",
|
||||||
|
medium: "#AB6400",
|
||||||
|
low: "#1F2D5C",
|
||||||
|
none: "#60646C",
|
||||||
|
};
|
||||||
|
|
||||||
|
const CustomBar = (props: any) => {
|
||||||
|
const { bar, workspaceSlug } = props;
|
||||||
|
// states
|
||||||
|
const [isMouseOver, setIsMouseOver] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link href={`/${workspaceSlug}/workspace-views/assigned?priority=${bar?.id}`}>
|
||||||
|
<g
|
||||||
|
transform={`translate(${bar?.x},${bar?.y})`}
|
||||||
|
onMouseEnter={() => setIsMouseOver(true)}
|
||||||
|
onMouseLeave={() => setIsMouseOver(false)}
|
||||||
|
>
|
||||||
|
<rect
|
||||||
|
x={0}
|
||||||
|
y={isMouseOver ? -6 : 0}
|
||||||
|
width={bar?.width}
|
||||||
|
height={isMouseOver ? bar?.height + 6 : bar?.height}
|
||||||
|
fill={bar?.fill}
|
||||||
|
stroke={bar?.borderColor}
|
||||||
|
strokeWidth={bar?.borderWidth}
|
||||||
|
rx={4}
|
||||||
|
ry={4}
|
||||||
|
className="duration-300"
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x={-bar?.height + 10}
|
||||||
|
y={18}
|
||||||
|
fill={TEXT_COLORS[bar?.id as keyof typeof TEXT_COLORS]}
|
||||||
|
className="capitalize font-medium text-lg -rotate-90"
|
||||||
|
dominantBaseline="text-bottom"
|
||||||
|
>
|
||||||
|
{bar?.id}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const WIDGET_KEY = "issues_by_priority";
|
||||||
|
|
||||||
|
export const IssuesByPriorityWidget: React.FC<WidgetProps> = observer((props) => {
|
||||||
|
const { dashboardId, workspaceSlug } = props;
|
||||||
|
// store hooks
|
||||||
|
const {
|
||||||
|
fetchWidgetStats,
|
||||||
|
widgetDetails: allWidgetDetails,
|
||||||
|
widgetStats: allWidgetStats,
|
||||||
|
updateDashboardWidgetFilters,
|
||||||
|
} = useDashboard();
|
||||||
|
const widgetDetails = allWidgetDetails?.[workspaceSlug]?.[dashboardId]?.find((w) => w.key === WIDGET_KEY);
|
||||||
|
const widgetStats = allWidgetStats?.[workspaceSlug]?.[dashboardId]?.[WIDGET_KEY] as TIssuesByPriorityWidgetResponse[];
|
||||||
|
|
||||||
|
const handleUpdateFilters = async (filters: Partial<TIssuesByPriorityWidgetFilters>) => {
|
||||||
|
if (!widgetDetails) return;
|
||||||
|
|
||||||
|
await updateDashboardWidgetFilters(workspaceSlug, dashboardId, widgetDetails.id, {
|
||||||
|
widgetKey: WIDGET_KEY,
|
||||||
|
filters,
|
||||||
|
});
|
||||||
|
|
||||||
|
fetchWidgetStats(workspaceSlug, dashboardId, {
|
||||||
|
widget_key: WIDGET_KEY,
|
||||||
|
target_date: getCustomDates(widgetDetails.widget_filters.target_date ?? "this_week"),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!widgetDetails) return;
|
||||||
|
|
||||||
|
if (!widgetStats)
|
||||||
|
fetchWidgetStats(workspaceSlug, dashboardId, {
|
||||||
|
widget_key: WIDGET_KEY,
|
||||||
|
target_date: getCustomDates(widgetDetails.widget_filters.target_date ?? "this_week"),
|
||||||
|
});
|
||||||
|
}, [dashboardId, fetchWidgetStats, widgetDetails, widgetStats, workspaceSlug]);
|
||||||
|
|
||||||
|
if (!widgetDetails || !widgetStats) return <WidgetLoader widgetKey={WIDGET_KEY} />;
|
||||||
|
|
||||||
|
const totalCount = widgetStats.reduce((acc, item) => acc + item?.count, 0);
|
||||||
|
const chartData = widgetStats
|
||||||
|
.filter((i) => i.count !== 0)
|
||||||
|
.map((item) => ({
|
||||||
|
priority: item?.priority,
|
||||||
|
percentage: (item?.count / totalCount) * 100,
|
||||||
|
urgent: item?.priority === "urgent" ? 1 : 0,
|
||||||
|
high: item?.priority === "high" ? 1 : 0,
|
||||||
|
medium: item?.priority === "medium" ? 1 : 0,
|
||||||
|
low: item?.priority === "low" ? 1 : 0,
|
||||||
|
none: item?.priority === "none" ? 1 : 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const CustomBarsLayer = (props: any) => {
|
||||||
|
const { bars } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g>
|
||||||
|
{bars
|
||||||
|
?.filter((b: any) => b?.value === 1) // render only bars with value 1
|
||||||
|
.map((bar: any) => (
|
||||||
|
<CustomBar key={bar?.key} bar={bar} workspaceSlug={workspaceSlug} />
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={`/${workspaceSlug}/workspace-views/assigned`}
|
||||||
|
className="bg-custom-background-100 rounded-xl border-[0.5px] border-custom-border-200 w-full py-6 hover:shadow-custom-shadow-4xl duration-300 overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-2 pl-7 pr-6">
|
||||||
|
<h4 className="text-lg font-semibold text-custom-text-300">Priority of assigned issues</h4>
|
||||||
|
<DurationFilterDropdown
|
||||||
|
value={widgetDetails.widget_filters.target_date ?? "this_week"}
|
||||||
|
onChange={(val) =>
|
||||||
|
handleUpdateFilters({
|
||||||
|
target_date: val,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{totalCount > 0 ? (
|
||||||
|
<div className="flex items-center px-11 h-full">
|
||||||
|
<div className="w-full -mt-[11px]">
|
||||||
|
<MarimekkoGraph
|
||||||
|
data={chartData}
|
||||||
|
id="priority"
|
||||||
|
value="percentage"
|
||||||
|
dimensions={ISSUE_PRIORITIES.map((p) => ({
|
||||||
|
id: p.key,
|
||||||
|
value: p.key,
|
||||||
|
}))}
|
||||||
|
axisBottom={null}
|
||||||
|
axisLeft={null}
|
||||||
|
height="119px"
|
||||||
|
margin={{
|
||||||
|
top: 11,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
}}
|
||||||
|
defs={PRIORITY_GRAPH_GRADIENTS}
|
||||||
|
fill={ISSUE_PRIORITIES.map((p) => ({
|
||||||
|
match: {
|
||||||
|
id: p.key,
|
||||||
|
},
|
||||||
|
id: `gradient${p.title}`,
|
||||||
|
}))}
|
||||||
|
tooltip={() => <></>}
|
||||||
|
enableGridX={false}
|
||||||
|
enableGridY={false}
|
||||||
|
layers={[CustomBarsLayer]}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-1 w-full mt-3 text-sm font-semibold text-custom-text-300">
|
||||||
|
{chartData.map((item) => (
|
||||||
|
<p
|
||||||
|
key={item.priority}
|
||||||
|
className="flex items-center gap-1 flex-shrink-0"
|
||||||
|
style={{
|
||||||
|
width: `${item.percentage}%`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PriorityIcon priority={item.priority} withContainer />
|
||||||
|
{item.percentage.toFixed(0)}%
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="h-full grid items-end">
|
||||||
|
<IssuesByPriorityEmptyState filter={widgetDetails.widget_filters.target_date ?? "this_week"} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
});
|
188
web/components/dashboard/widgets/issues-by-state-group.tsx
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
// hooks
|
||||||
|
import { useDashboard } from "hooks/store";
|
||||||
|
// components
|
||||||
|
import { PieGraph } from "components/ui";
|
||||||
|
import {
|
||||||
|
DurationFilterDropdown,
|
||||||
|
IssuesByStateGroupEmptyState,
|
||||||
|
WidgetLoader,
|
||||||
|
WidgetProps,
|
||||||
|
} from "components/dashboard/widgets";
|
||||||
|
// helpers
|
||||||
|
import { getCustomDates } from "helpers/dashboard.helper";
|
||||||
|
// types
|
||||||
|
import { TIssuesByStateGroupsWidgetFilters, TIssuesByStateGroupsWidgetResponse, TStateGroups } from "@plane/types";
|
||||||
|
// constants
|
||||||
|
import { STATE_GROUP_GRAPH_COLORS, STATE_GROUP_GRAPH_GRADIENTS } from "constants/dashboard";
|
||||||
|
import { STATE_GROUPS } from "constants/state";
|
||||||
|
|
||||||
|
const WIDGET_KEY = "issues_by_state_groups";
|
||||||
|
|
||||||
|
export const IssuesByStateGroupWidget: React.FC<WidgetProps> = observer((props) => {
|
||||||
|
const { dashboardId, workspaceSlug } = props;
|
||||||
|
// states
|
||||||
|
const [activeStateGroup, setActiveStateGroup] = useState<TStateGroups>("started");
|
||||||
|
// router
|
||||||
|
const router = useRouter();
|
||||||
|
// store hooks
|
||||||
|
const {
|
||||||
|
fetchWidgetStats,
|
||||||
|
widgetDetails: allWidgetDetails,
|
||||||
|
widgetStats: allWidgetStats,
|
||||||
|
updateDashboardWidgetFilters,
|
||||||
|
} = useDashboard();
|
||||||
|
// derived values
|
||||||
|
const widgetDetails = allWidgetDetails?.[workspaceSlug]?.[dashboardId]?.find((w) => w.key === WIDGET_KEY);
|
||||||
|
const widgetStats = allWidgetStats?.[workspaceSlug]?.[dashboardId]?.[
|
||||||
|
WIDGET_KEY
|
||||||
|
] as TIssuesByStateGroupsWidgetResponse[];
|
||||||
|
|
||||||
|
const handleUpdateFilters = async (filters: Partial<TIssuesByStateGroupsWidgetFilters>) => {
|
||||||
|
if (!widgetDetails) return;
|
||||||
|
|
||||||
|
await updateDashboardWidgetFilters(workspaceSlug, dashboardId, widgetDetails.id, {
|
||||||
|
widgetKey: WIDGET_KEY,
|
||||||
|
filters,
|
||||||
|
});
|
||||||
|
|
||||||
|
fetchWidgetStats(workspaceSlug, dashboardId, {
|
||||||
|
widget_key: WIDGET_KEY,
|
||||||
|
target_date: getCustomDates(widgetDetails.widget_filters.target_date ?? "this_week"),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!widgetDetails) return;
|
||||||
|
|
||||||
|
if (!widgetStats)
|
||||||
|
fetchWidgetStats(workspaceSlug, dashboardId, {
|
||||||
|
widget_key: WIDGET_KEY,
|
||||||
|
target_date: getCustomDates(widgetDetails.widget_filters.target_date ?? "this_week"),
|
||||||
|
});
|
||||||
|
}, [dashboardId, fetchWidgetStats, widgetDetails, widgetStats, workspaceSlug]);
|
||||||
|
|
||||||
|
if (!widgetDetails || !widgetStats) return <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 (
|
||||||
|
<Link
|
||||||
|
href={`/${workspaceSlug?.toString()}/workspace-views/assigned`}
|
||||||
|
className="bg-custom-background-100 rounded-xl border-[0.5px] border-custom-border-200 w-full py-6 hover:shadow-custom-shadow-4xl duration-300 overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-2 pl-7 pr-6">
|
||||||
|
<h4 className="text-lg font-semibold text-custom-text-300">State of assigned issues</h4>
|
||||||
|
<DurationFilterDropdown
|
||||||
|
value={widgetDetails.widget_filters.target_date ?? "this_week"}
|
||||||
|
onChange={(val) =>
|
||||||
|
handleUpdateFilters({
|
||||||
|
target_date: val,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{totalCount > 0 ? (
|
||||||
|
<div className="flex items-center pl-20 md:pl-11 lg:pl-14 pr-11 mt-11">
|
||||||
|
<div className="flex md:flex-col lg:flex-row items-center gap-x-10 gap-y-8 w-full">
|
||||||
|
<div className="w-full flex justify-center">
|
||||||
|
<PieGraph
|
||||||
|
data={chartData}
|
||||||
|
height="220px"
|
||||||
|
width="220px"
|
||||||
|
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)}
|
||||||
|
layers={["arcs", CenteredMetric]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="justify-self-end 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 items-end">
|
||||||
|
<IssuesByStateGroupEmptyState filter={widgetDetails.widget_filters.target_date ?? "this_week"} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
});
|
22
web/components/dashboard/widgets/loaders/assigned-issues.tsx
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
// 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
web/components/dashboard/widgets/loaders/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from "./loader";
|
@ -0,0 +1,15 @@
|
|||||||
|
// 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>
|
||||||
|
);
|
@ -0,0 +1,21 @@
|
|||||||
|
// 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">
|
||||||
|
{Array.from({ length: 5 }).map((_, index) => (
|
||||||
|
<Loader.Item key={index} height="11px" width="100%" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Loader>
|
||||||
|
);
|
31
web/components/dashboard/widgets/loaders/loader.tsx
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
// components
|
||||||
|
import { AssignedIssuesWidgetLoader } from "./assigned-issues";
|
||||||
|
import { IssuesByPriorityWidgetLoader } from "./issues-by-priority";
|
||||||
|
import { IssuesByStateGroupWidgetLoader } from "./issues-by-state-group";
|
||||||
|
import { OverviewStatsWidgetLoader } from "./overview-stats";
|
||||||
|
import { RecentActivityWidgetLoader } from "./recent-activity";
|
||||||
|
import { RecentProjectsWidgetLoader } from "./recent-projects";
|
||||||
|
import { RecentCollaboratorsWidgetLoader } from "./recent-collaborators";
|
||||||
|
// types
|
||||||
|
import { TWidgetKeys } from "@plane/types";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
widgetKey: TWidgetKeys;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WidgetLoader: React.FC<Props> = (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];
|
||||||
|
};
|
13
web/components/dashboard/widgets/loaders/overview-stats.tsx
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
// 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">
|
||||||
|
{Array.from({ length: 4 }).map((_, index) => (
|
||||||
|
<div key={index} className="space-y-3">
|
||||||
|
<Loader.Item height="11px" width="50%" />
|
||||||
|
<Loader.Item height="15px" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Loader>
|
||||||
|
);
|
19
web/components/dashboard/widgets/loaders/recent-activity.tsx
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
// 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%" />
|
||||||
|
{Array.from({ length: 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>
|
||||||
|
);
|
@ -0,0 +1,18 @@
|
|||||||
|
// ui
|
||||||
|
import { Loader } from "@plane/ui";
|
||||||
|
|
||||||
|
export const RecentCollaboratorsWidgetLoader = () => (
|
||||||
|
<Loader className="bg-custom-background-100 rounded-xl p-6 space-y-9">
|
||||||
|
<Loader.Item height="17px" width="20%" />
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 xl:grid-cols-8 gap-2">
|
||||||
|
{Array.from({ length: 8 }).map((_, index) => (
|
||||||
|
<div key={index} 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>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Loader>
|
||||||
|
);
|
19
web/components/dashboard/widgets/loaders/recent-projects.tsx
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
// 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%" />
|
||||||
|
{Array.from({ length: 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>
|
||||||
|
);
|
93
web/components/dashboard/widgets/overview-stats.tsx
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import Link from "next/link";
|
||||||
|
// hooks
|
||||||
|
import { useDashboard } from "hooks/store";
|
||||||
|
// components
|
||||||
|
import { WidgetLoader } from "components/dashboard/widgets";
|
||||||
|
// helpers
|
||||||
|
import { renderFormattedPayloadDate } from "helpers/date-time.helper";
|
||||||
|
import { cn } from "helpers/common.helper";
|
||||||
|
// types
|
||||||
|
import { TOverviewStatsWidgetResponse } from "@plane/types";
|
||||||
|
|
||||||
|
export type WidgetProps = {
|
||||||
|
dashboardId: string;
|
||||||
|
workspaceSlug: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const WIDGET_KEY = "overview_stats";
|
||||||
|
|
||||||
|
export const OverviewStatsWidget: React.FC<WidgetProps> = observer((props) => {
|
||||||
|
const { dashboardId, workspaceSlug } = props;
|
||||||
|
// store hooks
|
||||||
|
const { fetchWidgetStats, widgetStats: allWidgetStats } = useDashboard();
|
||||||
|
// derived values
|
||||||
|
const widgetStats = allWidgetStats?.[workspaceSlug]?.[dashboardId]?.[WIDGET_KEY] as TOverviewStatsWidgetResponse;
|
||||||
|
|
||||||
|
const today = renderFormattedPayloadDate(new Date());
|
||||||
|
const STATS_LIST = [
|
||||||
|
{
|
||||||
|
key: "assigned",
|
||||||
|
title: "Issues assigned",
|
||||||
|
count: widgetStats?.assigned_issues_count,
|
||||||
|
link: `/${workspaceSlug}/workspace-views/assigned`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "overdue",
|
||||||
|
title: "Issues overdue",
|
||||||
|
count: widgetStats?.pending_issues_count,
|
||||||
|
link: `/${workspaceSlug}/workspace-views/assigned/?target_date=${today};before`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "created",
|
||||||
|
title: "Issues created",
|
||||||
|
count: widgetStats?.created_issues_count,
|
||||||
|
link: `/${workspaceSlug}/workspace-views/created`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "completed",
|
||||||
|
title: "Issues completed",
|
||||||
|
count: widgetStats?.completed_issues_count,
|
||||||
|
link: `/${workspaceSlug}/workspace-views/assigned?state_group=completed`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!widgetStats)
|
||||||
|
fetchWidgetStats(workspaceSlug, dashboardId, {
|
||||||
|
widget_key: WIDGET_KEY,
|
||||||
|
});
|
||||||
|
}, [dashboardId, fetchWidgetStats, widgetStats, workspaceSlug]);
|
||||||
|
|
||||||
|
if (!widgetStats) return <WidgetLoader widgetKey={WIDGET_KEY} />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-custom-background-100 rounded-xl border-[0.5px] border-custom-border-200 w-full grid grid-cols-4 p-0.5 hover:shadow-custom-shadow-4xl duration-300">
|
||||||
|
{STATS_LIST.map((stat, index) => {
|
||||||
|
const isFirst = index === 0;
|
||||||
|
const isLast = index === STATS_LIST.length - 1;
|
||||||
|
const isMiddle = !isFirst && !isLast;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={stat.key} className="flex relative">
|
||||||
|
{!isLast && (
|
||||||
|
<div className="absolute right-0 top-1/2 -translate-y-1/2 h-3/5 w-[0.5px] bg-custom-border-200" />
|
||||||
|
)}
|
||||||
|
<Link
|
||||||
|
href={stat.link}
|
||||||
|
className={cn(`py-4 hover:bg-custom-background-80 duration-300 rounded-[10px] w-full break-words`, {
|
||||||
|
"pl-11 pr-[4.725rem] mr-0.5": isFirst,
|
||||||
|
"px-[4.725rem] mx-0.5": isMiddle,
|
||||||
|
"px-[4.725rem] ml-0.5": isLast,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<h5 className="font-semibold text-xl">{stat.count}</h5>
|
||||||
|
<p className="text-custom-text-300">{stat.title}</p>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
105
web/components/dashboard/widgets/recent-activity.tsx
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { History } from "lucide-react";
|
||||||
|
// hooks
|
||||||
|
import { useDashboard, useUser } from "hooks/store";
|
||||||
|
// components
|
||||||
|
import { ActivityIcon, ActivityMessage } from "components/core";
|
||||||
|
import { RecentActivityEmptyState, WidgetLoader, WidgetProps } from "components/dashboard/widgets";
|
||||||
|
// ui
|
||||||
|
import { Avatar } from "@plane/ui";
|
||||||
|
// helpers
|
||||||
|
import { calculateTimeAgo } from "helpers/date-time.helper";
|
||||||
|
// types
|
||||||
|
import { TRecentActivityWidgetResponse } from "@plane/types";
|
||||||
|
|
||||||
|
const WIDGET_KEY = "recent_activity";
|
||||||
|
|
||||||
|
export const RecentActivityWidget: React.FC<WidgetProps> = observer((props) => {
|
||||||
|
const { dashboardId, workspaceSlug } = props;
|
||||||
|
// store hooks
|
||||||
|
const { currentUser } = useUser();
|
||||||
|
// derived values
|
||||||
|
const { fetchWidgetStats, widgetStats: allWidgetStats } = useDashboard();
|
||||||
|
const widgetStats = allWidgetStats?.[workspaceSlug]?.[dashboardId]?.[WIDGET_KEY] as TRecentActivityWidgetResponse[];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!widgetStats)
|
||||||
|
fetchWidgetStats(workspaceSlug, dashboardId, {
|
||||||
|
widget_key: WIDGET_KEY,
|
||||||
|
});
|
||||||
|
}, [dashboardId, fetchWidgetStats, widgetStats, workspaceSlug]);
|
||||||
|
|
||||||
|
if (!widgetStats) return <WidgetLoader widgetKey={WIDGET_KEY} />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href="/profile/activity"
|
||||||
|
className="bg-custom-background-100 rounded-xl border-[0.5px] border-custom-border-200 w-full py-6 hover:shadow-custom-shadow-4xl duration-300"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-2 px-7">
|
||||||
|
<h4 className="text-lg font-semibold text-custom-text-300">My activity</h4>
|
||||||
|
</div>
|
||||||
|
{widgetStats.length > 0 ? (
|
||||||
|
<div className="space-y-6 mt-4 mx-7">
|
||||||
|
{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="h-6 w-6 flex justify-center">
|
||||||
|
<ActivityIcon activity={activity} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : activity.actor_detail.avatar && activity.actor_detail.avatar !== "" ? (
|
||||||
|
<Avatar
|
||||||
|
src={activity.actor_detail.avatar}
|
||||||
|
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-1 break-words">
|
||||||
|
<p className="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 this{" "}
|
||||||
|
<a
|
||||||
|
href={`/${workspaceSlug}/projects/${activity.project}/issues/${activity.issue}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-1 font-medium text-custom-text-200 hover:underline"
|
||||||
|
>
|
||||||
|
Issue.
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-custom-text-200">{calculateTimeAgo(activity.created_at)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="h-full grid items-end">
|
||||||
|
<RecentActivityEmptyState />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
});
|
93
web/components/dashboard/widgets/recent-collaborators.tsx
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
// hooks
|
||||||
|
import { useDashboard, useMember, useUser } from "hooks/store";
|
||||||
|
// components
|
||||||
|
import { RecentCollaboratorsEmptyState, WidgetLoader, WidgetProps } from "components/dashboard/widgets";
|
||||||
|
// ui
|
||||||
|
import { Avatar } from "@plane/ui";
|
||||||
|
// types
|
||||||
|
import { TRecentCollaboratorsWidgetResponse } from "@plane/types";
|
||||||
|
|
||||||
|
type CollaboratorListItemProps = {
|
||||||
|
issueCount: number;
|
||||||
|
userId: string;
|
||||||
|
workspaceSlug: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const WIDGET_KEY = "recent_collaborators";
|
||||||
|
|
||||||
|
const CollaboratorListItem: React.FC<CollaboratorListItemProps> = observer((props) => {
|
||||||
|
const { issueCount, userId, workspaceSlug } = props;
|
||||||
|
// store hooks
|
||||||
|
const { currentUser } = useUser();
|
||||||
|
const { getUserDetails } = useMember();
|
||||||
|
// derived values
|
||||||
|
const userDetails = getUserDetails(userId);
|
||||||
|
const isCurrentUser = userId === currentUser?.id;
|
||||||
|
|
||||||
|
if (!userDetails) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link href={`/${workspaceSlug}/profile/${userId}`} className="group text-center">
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<Avatar
|
||||||
|
src={userDetails.avatar}
|
||||||
|
name={isCurrentUser ? "You" : userDetails.display_name}
|
||||||
|
size={69}
|
||||||
|
className="!text-3xl !font-medium"
|
||||||
|
showTooltip={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<h6 className="mt-6 text-xs font-semibold group-hover:underline truncate">
|
||||||
|
{isCurrentUser ? "You" : userDetails?.display_name}
|
||||||
|
</h6>
|
||||||
|
<p className="text-sm mt-2">
|
||||||
|
{issueCount} active issue{issueCount > 1 ? "s" : ""}
|
||||||
|
</p>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export const RecentCollaboratorsWidget: React.FC<WidgetProps> = observer((props) => {
|
||||||
|
const { dashboardId, workspaceSlug } = props;
|
||||||
|
// store hooks
|
||||||
|
const { fetchWidgetStats, widgetStats: allWidgetStats } = useDashboard();
|
||||||
|
const widgetStats = allWidgetStats?.[workspaceSlug]?.[dashboardId]?.[
|
||||||
|
WIDGET_KEY
|
||||||
|
] as TRecentCollaboratorsWidgetResponse[];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!widgetStats)
|
||||||
|
fetchWidgetStats(workspaceSlug, dashboardId, {
|
||||||
|
widget_key: WIDGET_KEY,
|
||||||
|
});
|
||||||
|
}, [dashboardId, fetchWidgetStats, widgetStats, workspaceSlug]);
|
||||||
|
|
||||||
|
if (!widgetStats) return <WidgetLoader widgetKey={WIDGET_KEY} />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-custom-background-100 rounded-xl border-[0.5px] border-custom-border-200 w-full hover:shadow-custom-shadow-4xl duration-300">
|
||||||
|
<div className="flex items-center justify-between gap-2 px-7 pt-6">
|
||||||
|
<h4 className="text-lg font-semibold text-custom-text-300">Collaborators</h4>
|
||||||
|
</div>
|
||||||
|
{widgetStats.length > 1 ? (
|
||||||
|
<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">
|
||||||
|
{widgetStats.map((user) => (
|
||||||
|
<CollaboratorListItem
|
||||||
|
key={user.user_id}
|
||||||
|
issueCount={user.active_issue_count}
|
||||||
|
userId={user.user_id}
|
||||||
|
workspaceSlug={workspaceSlug}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="h-full grid items-end">
|
||||||
|
<RecentCollaboratorsEmptyState />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
125
web/components/dashboard/widgets/recent-projects.tsx
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { Plus } from "lucide-react";
|
||||||
|
// hooks
|
||||||
|
import { useApplication, useDashboard, useProject, useUser } from "hooks/store";
|
||||||
|
// components
|
||||||
|
import { WidgetLoader, WidgetProps } from "components/dashboard/widgets";
|
||||||
|
// ui
|
||||||
|
import { Avatar, AvatarGroup } from "@plane/ui";
|
||||||
|
// helpers
|
||||||
|
import { renderEmoji } from "helpers/emoji.helper";
|
||||||
|
// types
|
||||||
|
import { TRecentProjectsWidgetResponse } from "@plane/types";
|
||||||
|
// constants
|
||||||
|
import { EUserWorkspaceRoles } from "constants/workspace";
|
||||||
|
import { PROJECT_BACKGROUND_COLORS } from "constants/dashboard";
|
||||||
|
|
||||||
|
const WIDGET_KEY = "recent_projects";
|
||||||
|
|
||||||
|
type ProjectListItemProps = {
|
||||||
|
projectId: string;
|
||||||
|
workspaceSlug: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ProjectListItem: React.FC<ProjectListItemProps> = observer((props) => {
|
||||||
|
const { projectId, workspaceSlug } = props;
|
||||||
|
// store hooks
|
||||||
|
const { getProjectById } = useProject();
|
||||||
|
const projectDetails = getProjectById(projectId);
|
||||||
|
|
||||||
|
const randomBgColor = PROJECT_BACKGROUND_COLORS[Math.floor(Math.random() * PROJECT_BACKGROUND_COLORS.length)];
|
||||||
|
|
||||||
|
if (!projectDetails) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link href={`/${workspaceSlug}/projects/${projectId}/issues`} className="group flex items-center gap-8">
|
||||||
|
<div
|
||||||
|
className={`h-[3.375rem] w-[3.375rem] grid place-items-center rounded border border-transparent flex-shrink-0 ${randomBgColor}`}
|
||||||
|
>
|
||||||
|
{projectDetails.emoji ? (
|
||||||
|
<span className="grid h-7 w-7 flex-shrink-0 place-items-center rounded uppercase">
|
||||||
|
{renderEmoji(projectDetails.emoji)}
|
||||||
|
</span>
|
||||||
|
) : projectDetails.icon_prop ? (
|
||||||
|
<div className="grid h-7 w-7 flex-shrink-0 place-items-center">{renderEmoji(projectDetails.icon_prop)}</div>
|
||||||
|
) : (
|
||||||
|
<span className="grid h-7 w-7 flex-shrink-0 place-items-center rounded bg-gray-700 uppercase text-white">
|
||||||
|
{projectDetails.name.charAt(0)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-grow truncate">
|
||||||
|
<h6 className="text-sm text-custom-text-300 font-medium group-hover:underline group-hover:text-custom-text-100 truncate">
|
||||||
|
{projectDetails.name}
|
||||||
|
</h6>
|
||||||
|
<div className="mt-2">
|
||||||
|
<AvatarGroup>
|
||||||
|
{projectDetails.members?.map((member) => (
|
||||||
|
<Avatar src={member.member__avatar} name={member.member__display_name} />
|
||||||
|
))}
|
||||||
|
</AvatarGroup>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export const RecentProjectsWidget: React.FC<WidgetProps> = observer((props) => {
|
||||||
|
const { dashboardId, workspaceSlug } = props;
|
||||||
|
// store hooks
|
||||||
|
const {
|
||||||
|
commandPalette: { toggleCreateProjectModal },
|
||||||
|
} = useApplication();
|
||||||
|
const {
|
||||||
|
membership: { currentWorkspaceRole },
|
||||||
|
} = useUser();
|
||||||
|
const { fetchWidgetStats, widgetStats: allWidgetStats } = useDashboard();
|
||||||
|
// derived values
|
||||||
|
const widgetStats = allWidgetStats?.[workspaceSlug]?.[dashboardId]?.[WIDGET_KEY] as TRecentProjectsWidgetResponse;
|
||||||
|
const canCreateProject = currentWorkspaceRole === EUserWorkspaceRoles.ADMIN;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!widgetStats)
|
||||||
|
fetchWidgetStats(workspaceSlug, dashboardId, {
|
||||||
|
widget_key: WIDGET_KEY,
|
||||||
|
});
|
||||||
|
}, [dashboardId, fetchWidgetStats, widgetStats, workspaceSlug]);
|
||||||
|
|
||||||
|
if (!widgetStats) return <WidgetLoader widgetKey={WIDGET_KEY} />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={`/${workspaceSlug}/projects`}
|
||||||
|
className="bg-custom-background-100 rounded-xl border-[0.5px] border-custom-border-200 w-full py-6 hover:shadow-custom-shadow-4xl duration-300"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-2 px-7">
|
||||||
|
<h4 className="text-lg font-semibold text-custom-text-300">My projects</h4>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-8 mt-4 mx-7">
|
||||||
|
{canCreateProject && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="group flex items-center gap-8"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
toggleCreateProjectModal(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="h-[3.375rem] w-[3.375rem] bg-custom-primary-100/20 text-custom-primary-100 grid place-items-center rounded border border-dashed border-custom-primary-60 flex-shrink-0">
|
||||||
|
<Plus className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-custom-text-300 font-medium group-hover:underline group-hover:text-custom-text-100">
|
||||||
|
Create new project
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{widgetStats.map((projectId) => (
|
||||||
|
<ProjectListItem key={projectId} projectId={projectId} workspaceSlug={workspaceSlug} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
});
|
@ -258,7 +258,7 @@ export const PriorityDropdown: React.FC<Props> = (props) => {
|
|||||||
>
|
>
|
||||||
<PriorityIcon
|
<PriorityIcon
|
||||||
priority={priority.key}
|
priority={priority.key}
|
||||||
size={12}
|
size={14}
|
||||||
className={cn({
|
className={cn({
|
||||||
"text-white": priority.key === "urgent" && highlightUrgent,
|
"text-white": priority.key === "urgent" && highlightUrgent,
|
||||||
// centre align the icons if text is hidden
|
// centre align the icons if text is hidden
|
||||||
|
@ -37,7 +37,7 @@ export const WorkspaceDashboardHeader = () => {
|
|||||||
className="flex flex-shrink-0 items-center gap-1.5 rounded bg-custom-background-80 px-3 py-1.5 text-xs font-medium"
|
className="flex flex-shrink-0 items-center gap-1.5 rounded bg-custom-background-80 px-3 py-1.5 text-xs font-medium"
|
||||||
>
|
>
|
||||||
<Zap size={14} strokeWidth={2} fill="rgb(var(--color-text-100))" />
|
<Zap size={14} strokeWidth={2} fill="rgb(var(--color-text-100))" />
|
||||||
{"What's New?"}
|
{"What's new?"}
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
className="flex flex-shrink-0 items-center gap-1.5 rounded bg-custom-background-80 px-3 py-1.5 text-xs font-medium"
|
className="flex flex-shrink-0 items-center gap-1.5 rounded bg-custom-background-80 px-3 py-1.5 text-xs font-medium"
|
||||||
|
@ -9,7 +9,7 @@ import {
|
|||||||
// types
|
// types
|
||||||
import { TStateGroups } from "@plane/types";
|
import { TStateGroups } from "@plane/types";
|
||||||
// constants
|
// constants
|
||||||
import { STATE_GROUP_COLORS } from "constants/state";
|
import { STATE_GROUPS } from "constants/state";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
className?: string;
|
className?: string;
|
||||||
@ -31,7 +31,7 @@ export const StateGroupIcon: React.FC<Props> = ({
|
|||||||
<StateGroupBacklogIcon
|
<StateGroupBacklogIcon
|
||||||
width={width}
|
width={width}
|
||||||
height={height}
|
height={height}
|
||||||
color={color ?? STATE_GROUP_COLORS["backlog"]}
|
color={color ?? STATE_GROUPS["backlog"].color}
|
||||||
className={`flex-shrink-0 ${className}`}
|
className={`flex-shrink-0 ${className}`}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@ -40,7 +40,7 @@ export const StateGroupIcon: React.FC<Props> = ({
|
|||||||
<StateGroupCancelledIcon
|
<StateGroupCancelledIcon
|
||||||
width={width}
|
width={width}
|
||||||
height={height}
|
height={height}
|
||||||
color={color ?? STATE_GROUP_COLORS["cancelled"]}
|
color={color ?? STATE_GROUPS["cancelled"].color}
|
||||||
className={`flex-shrink-0 ${className}`}
|
className={`flex-shrink-0 ${className}`}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@ -49,7 +49,7 @@ export const StateGroupIcon: React.FC<Props> = ({
|
|||||||
<StateGroupCompletedIcon
|
<StateGroupCompletedIcon
|
||||||
width={width}
|
width={width}
|
||||||
height={height}
|
height={height}
|
||||||
color={color ?? STATE_GROUP_COLORS["completed"]}
|
color={color ?? STATE_GROUPS["completed"].color}
|
||||||
className={`flex-shrink-0 ${className}`}
|
className={`flex-shrink-0 ${className}`}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@ -58,7 +58,7 @@ export const StateGroupIcon: React.FC<Props> = ({
|
|||||||
<StateGroupStartedIcon
|
<StateGroupStartedIcon
|
||||||
width={width}
|
width={width}
|
||||||
height={height}
|
height={height}
|
||||||
color={color ?? STATE_GROUP_COLORS["started"]}
|
color={color ?? STATE_GROUPS["started"].color}
|
||||||
className={`flex-shrink-0 ${className}`}
|
className={`flex-shrink-0 ${className}`}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@ -67,7 +67,7 @@ export const StateGroupIcon: React.FC<Props> = ({
|
|||||||
<StateGroupUnstartedIcon
|
<StateGroupUnstartedIcon
|
||||||
width={width}
|
width={width}
|
||||||
height={height}
|
height={height}
|
||||||
color={color ?? STATE_GROUP_COLORS["unstarted"]}
|
color={color ?? STATE_GROUPS["unstarted"].color}
|
||||||
className={`flex-shrink-0 ${className}`}
|
className={`flex-shrink-0 ${className}`}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
@ -5,8 +5,8 @@ import { observer } from "mobx-react-lite";
|
|||||||
import { FilterHeader, FilterOption } from "components/issues";
|
import { FilterHeader, FilterOption } from "components/issues";
|
||||||
// icons
|
// icons
|
||||||
import { StateGroupIcon } from "@plane/ui";
|
import { StateGroupIcon } from "@plane/ui";
|
||||||
|
import { STATE_GROUPS } from "constants/state";
|
||||||
// constants
|
// constants
|
||||||
import { ISSUE_STATE_GROUPS } from "constants/issue";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
appliedFilters: string[] | null;
|
appliedFilters: string[] | null;
|
||||||
@ -22,7 +22,7 @@ export const FilterStateGroup: React.FC<Props> = observer((props) => {
|
|||||||
|
|
||||||
const appliedFiltersCount = appliedFilters?.length ?? 0;
|
const appliedFiltersCount = appliedFilters?.length ?? 0;
|
||||||
|
|
||||||
const filteredOptions = ISSUE_STATE_GROUPS.filter((s) => s.key.includes(searchQuery.toLowerCase()));
|
const filteredOptions = Object.values(STATE_GROUPS).filter((s) => s.key.includes(searchQuery.toLowerCase()));
|
||||||
|
|
||||||
const handleViewToggle = () => {
|
const handleViewToggle = () => {
|
||||||
if (!filteredOptions) return;
|
if (!filteredOptions) return;
|
||||||
@ -48,7 +48,7 @@ export const FilterStateGroup: React.FC<Props> = observer((props) => {
|
|||||||
isChecked={appliedFilters?.includes(stateGroup.key) ? true : false}
|
isChecked={appliedFilters?.includes(stateGroup.key) ? true : false}
|
||||||
onClick={() => handleUpdate(stateGroup.key)}
|
onClick={() => handleUpdate(stateGroup.key)}
|
||||||
icon={<StateGroupIcon stateGroup={stateGroup.key} />}
|
icon={<StateGroupIcon stateGroup={stateGroup.key} />}
|
||||||
title={stateGroup.title}
|
title={stateGroup.label}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{filteredOptions.length > 5 && (
|
{filteredOptions.length > 5 && (
|
||||||
|
@ -1,11 +1,12 @@
|
|||||||
import { Avatar, PriorityIcon, StateGroupIcon } from "@plane/ui";
|
import { Avatar, PriorityIcon, StateGroupIcon } from "@plane/ui";
|
||||||
import { ISSUE_PRIORITIES, ISSUE_STATE_GROUPS } from "constants/issue";
|
import { ISSUE_PRIORITIES } from "constants/issue";
|
||||||
import { renderEmoji } from "helpers/emoji.helper";
|
import { renderEmoji } from "helpers/emoji.helper";
|
||||||
import { ILabelRootStore } from "store/label";
|
import { ILabelRootStore } from "store/label";
|
||||||
import { IMemberRootStore } from "store/member";
|
import { IMemberRootStore } from "store/member";
|
||||||
import { IProjectStore } from "store/project/project.store";
|
import { IProjectStore } from "store/project/project.store";
|
||||||
import { IStateStore } from "store/state.store";
|
import { IStateStore } from "store/state.store";
|
||||||
import { GroupByColumnTypes, IGroupByColumn } from "@plane/types";
|
import { GroupByColumnTypes, IGroupByColumn } from "@plane/types";
|
||||||
|
import { STATE_GROUPS } from "constants/state";
|
||||||
|
|
||||||
export const getGroupByColumns = (
|
export const getGroupByColumns = (
|
||||||
groupBy: GroupByColumnTypes | null,
|
groupBy: GroupByColumnTypes | null,
|
||||||
@ -71,11 +72,11 @@ const getStateColumns = (projectState: IStateStore): IGroupByColumn[] | undefine
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getStateGroupColumns = () => {
|
const getStateGroupColumns = () => {
|
||||||
const stateGroups = ISSUE_STATE_GROUPS;
|
const stateGroups = STATE_GROUPS;
|
||||||
|
|
||||||
return stateGroups.map((stateGroup) => ({
|
return Object.values(stateGroups).map((stateGroup) => ({
|
||||||
id: stateGroup.key,
|
id: stateGroup.key,
|
||||||
name: stateGroup.title,
|
name: stateGroup.label,
|
||||||
icon: (
|
icon: (
|
||||||
<div className="w-3.5 h-3.5 rounded-full">
|
<div className="w-3.5 h-3.5 rounded-full">
|
||||||
<StateGroupIcon stateGroup={stateGroup.key} width="14" height="14" />
|
<StateGroupIcon stateGroup={stateGroup.key} width="14" height="14" />
|
||||||
|
@ -361,7 +361,7 @@ export const InviteMembers: React.FC<Props> = (props) => {
|
|||||||
))}
|
))}
|
||||||
|
|
||||||
<div className="relative mt-20">
|
<div className="relative mt-20">
|
||||||
<div className="absolute right-24 mt-1 flex w-full gap-x-2 rounded-full border border-onboarding-border-100 bg-onboarding-background-200 p-2 shadow-onbording-shadow-sm">
|
<div className="absolute right-24 mt-1 flex w-full gap-x-2 rounded-full border border-onboarding-border-100 bg-onboarding-background-200 p-2 shadow-onboarding-shadow-sm">
|
||||||
<div className="h-10 w-10 flex-shrink-0 rounded-full bg-custom-primary-10">
|
<div className="h-10 w-10 flex-shrink-0 rounded-full bg-custom-primary-10">
|
||||||
<Image src={user2} alt="user" />
|
<Image src={user2} alt="user" />
|
||||||
</div>
|
</div>
|
||||||
@ -371,7 +371,7 @@ export const InviteMembers: React.FC<Props> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="absolute right-12 mt-16 flex w-full gap-x-2 rounded-full border border-onboarding-border-100 bg-onboarding-background-200 p-2 shadow-onbording-shadow-sm">
|
<div className="absolute right-12 mt-16 flex w-full gap-x-2 rounded-full border border-onboarding-border-100 bg-onboarding-background-200 p-2 shadow-onboarding-shadow-sm">
|
||||||
<div className="h-10 w-10 flex-shrink-0 rounded-full bg-custom-primary-10">
|
<div className="h-10 w-10 flex-shrink-0 rounded-full bg-custom-primary-10">
|
||||||
<Image src={user1} alt="user" />
|
<Image src={user1} alt="user" />
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,44 +1,24 @@
|
|||||||
import { useState } from "react";
|
import { useEffect } from "react";
|
||||||
import { useRouter } from "next/router";
|
|
||||||
import useSWR from "swr";
|
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
// hooks
|
// hooks
|
||||||
import { useApplication, useProject, useUser } from "hooks/store";
|
import { useApplication, useDashboard, useProject, useUser } from "hooks/store";
|
||||||
// components
|
// components
|
||||||
import { TourRoot } from "components/onboarding";
|
import { TourRoot } from "components/onboarding";
|
||||||
import { UserGreetingsView } from "components/user";
|
import { UserGreetingsView } from "components/user";
|
||||||
import { CompletedIssuesGraph, IssuesList, IssuesPieChart, IssuesStats } from "components/workspace";
|
import { IssuePeekOverview } from "components/issues";
|
||||||
// constants
|
import { DashboardProjectEmptyState, DashboardWidgets } from "components/dashboard";
|
||||||
import { EUserWorkspaceRoles } from "constants/workspace";
|
// ui
|
||||||
// images
|
import { Spinner } from "@plane/ui";
|
||||||
import { NewEmptyState } from "components/common/new-empty-state";
|
|
||||||
import emptyProject from "public/empty-state/dashboard_empty_project.webp";
|
|
||||||
|
|
||||||
export const WorkspaceDashboardView = observer(() => {
|
export const WorkspaceDashboardView = observer(() => {
|
||||||
// states
|
|
||||||
const [month, setMonth] = useState(new Date().getMonth() + 1);
|
|
||||||
// router
|
|
||||||
const router = useRouter();
|
|
||||||
const { workspaceSlug } = router.query;
|
|
||||||
// store hooks
|
// store hooks
|
||||||
const {
|
const {
|
||||||
commandPalette: commandPaletteStore,
|
eventTracker: { postHogEventTracker },
|
||||||
eventTracker: { setTrackElement, postHogEventTracker },
|
router: { workspaceSlug },
|
||||||
} = useApplication();
|
} = useApplication();
|
||||||
const {
|
const { currentUser, updateTourCompleted } = useUser();
|
||||||
currentUser,
|
const { homeDashboardId, fetchHomeDashboardWidgets } = useDashboard();
|
||||||
dashboardInfo: workspaceDashboardInfo,
|
const { joinedProjectIds } = useProject();
|
||||||
fetchUserDashboardInfo,
|
|
||||||
updateTourCompleted,
|
|
||||||
membership: { currentWorkspaceRole },
|
|
||||||
} = useUser();
|
|
||||||
const { workspaceProjectIds } = useProject();
|
|
||||||
// fetch user dashboard info
|
|
||||||
useSWR(
|
|
||||||
workspaceSlug ? `USER_WORKSPACE_DASHBOARD_${workspaceSlug}_${month}` : null,
|
|
||||||
workspaceSlug ? () => fetchUserDashboardInfo(workspaceSlug.toString(), month) : null
|
|
||||||
);
|
|
||||||
const isEditingAllowed = !!currentWorkspaceRole && currentWorkspaceRole >= EUserWorkspaceRoles.MEMBER;
|
|
||||||
|
|
||||||
const handleTourCompleted = () => {
|
const handleTourCompleted = () => {
|
||||||
updateTourCompleted()
|
updateTourCompleted()
|
||||||
@ -54,53 +34,31 @@ export const WorkspaceDashboardView = observer(() => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// fetch home dashboard widgets on workspace change
|
||||||
|
useEffect(() => {
|
||||||
|
if (!workspaceSlug) return;
|
||||||
|
|
||||||
|
fetchHomeDashboardWidgets(workspaceSlug);
|
||||||
|
}, [fetchHomeDashboardWidgets, workspaceSlug]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<IssuePeekOverview />
|
||||||
{currentUser && !currentUser.is_tour_completed && (
|
{currentUser && !currentUser.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">
|
<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} />
|
<TourRoot onComplete={handleTourCompleted} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="space-y-8 p-8">
|
{homeDashboardId && joinedProjectIds ? (
|
||||||
{currentUser && <UserGreetingsView user={currentUser} />}
|
<div className="space-y-7 p-7 bg-custom-background-90 h-full w-full flex flex-col overflow-y-auto">
|
||||||
|
{currentUser && <UserGreetingsView user={currentUser} />}
|
||||||
{workspaceProjectIds ? (
|
{joinedProjectIds.length > 0 ? <DashboardWidgets /> : <DashboardProjectEmptyState />}
|
||||||
workspaceProjectIds.length > 0 ? (
|
</div>
|
||||||
<div className="flex flex-col gap-8">
|
) : (
|
||||||
<IssuesStats data={workspaceDashboardInfo} />
|
<div className="h-full w-full grid place-items-center">
|
||||||
<div className="grid grid-cols-1 gap-8 lg:grid-cols-2">
|
<Spinner />
|
||||||
<IssuesList issues={workspaceDashboardInfo?.overdue_issues} type="overdue" />
|
</div>
|
||||||
<IssuesList issues={workspaceDashboardInfo?.upcoming_issues} type="upcoming" />
|
)}
|
||||||
<IssuesPieChart groupedIssues={workspaceDashboardInfo?.state_distribution} />
|
|
||||||
<CompletedIssuesGraph
|
|
||||||
issues={workspaceDashboardInfo?.completed_issues}
|
|
||||||
month={month}
|
|
||||||
setMonth={setMonth}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<NewEmptyState
|
|
||||||
image={emptyProject}
|
|
||||||
title="Overview of your projects, activity, and metrics"
|
|
||||||
description="When you have created a project and have issues assigned, you will see metrics, activity, and things you care about here. This is personalized to your role in projects, so project admins will see more than members."
|
|
||||||
comicBox={{
|
|
||||||
title: "Everything starts with a project in Plane",
|
|
||||||
direction: "right",
|
|
||||||
description: "A project could be a product’s roadmap, a marketing campaign, or launching a new car.",
|
|
||||||
}}
|
|
||||||
primaryButton={{
|
|
||||||
text: "Build your first project",
|
|
||||||
onClick: () => {
|
|
||||||
setTrackElement("DASHBOARD_PAGE");
|
|
||||||
commandPaletteStore.toggleCreateProjectModal(true);
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
disabled={!isEditingAllowed}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -5,7 +5,7 @@ import stateGraph from "public/empty-state/state_graph.svg";
|
|||||||
// types
|
// types
|
||||||
import { IUserProfileData, IUserStateDistribution } from "@plane/types";
|
import { IUserProfileData, IUserStateDistribution } from "@plane/types";
|
||||||
// constants
|
// constants
|
||||||
import { STATE_GROUP_COLORS } from "constants/state";
|
import { STATE_GROUPS } from "constants/state";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
stateDistribution: IUserStateDistribution[];
|
stateDistribution: IUserStateDistribution[];
|
||||||
@ -28,7 +28,7 @@ export const ProfileStateDistribution: React.FC<Props> = ({ stateDistribution, u
|
|||||||
id: group.state_group,
|
id: group.state_group,
|
||||||
label: group.state_group,
|
label: group.state_group,
|
||||||
value: group.state_count,
|
value: group.state_count,
|
||||||
color: STATE_GROUP_COLORS[group.state_group],
|
color: STATE_GROUPS[group.state_group].color,
|
||||||
})) ?? []
|
})) ?? []
|
||||||
}
|
}
|
||||||
height="250px"
|
height="250px"
|
||||||
@ -62,7 +62,7 @@ export const ProfileStateDistribution: React.FC<Props> = ({ stateDistribution, u
|
|||||||
<div
|
<div
|
||||||
className="h-2.5 w-2.5 rounded-sm"
|
className="h-2.5 w-2.5 rounded-sm"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: STATE_GROUP_COLORS[group.state_group],
|
backgroundColor: STATE_GROUPS[group.state_group].color,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="whitespace-nowrap capitalize">{group.state_group}</div>
|
<div className="whitespace-nowrap capitalize">{group.state_group}</div>
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
// types
|
// types
|
||||||
import { IUserStateDistribution } from "@plane/types";
|
import { IUserStateDistribution } from "@plane/types";
|
||||||
// constants
|
// constants
|
||||||
import { STATE_GROUP_COLORS } from "constants/state";
|
import { STATE_GROUPS } from "constants/state";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
stateDistribution: IUserStateDistribution[];
|
stateDistribution: IUserStateDistribution[];
|
||||||
@ -17,7 +17,7 @@ export const ProfileWorkload: React.FC<Props> = ({ stateDistribution }) => (
|
|||||||
<div
|
<div
|
||||||
className="h-3 w-3 rounded-sm"
|
className="h-3 w-3 rounded-sm"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: STATE_GROUP_COLORS[group.state_group],
|
backgroundColor: STATE_GROUPS[group.state_group].color,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="-mt-1 space-y-1">
|
<div className="-mt-1 space-y-1">
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
export * from "./bar-graph";
|
export * from "./bar-graph";
|
||||||
export * from "./calendar-graph";
|
export * from "./calendar-graph";
|
||||||
export * from "./line-graph";
|
export * from "./line-graph";
|
||||||
|
export * from "./marimekko-graph";
|
||||||
export * from "./pie-graph";
|
export * from "./pie-graph";
|
||||||
export * from "./scatter-plot-graph";
|
export * from "./scatter-plot-graph";
|
||||||
|
48
web/components/ui/graphs/marimekko-graph.tsx
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
// nivo
|
||||||
|
import { ResponsiveMarimekko, SvgProps } from "@nivo/marimekko";
|
||||||
|
// helpers
|
||||||
|
import { generateYAxisTickValues } from "helpers/graph.helper";
|
||||||
|
// types
|
||||||
|
import { TGraph } from "./types";
|
||||||
|
// constants
|
||||||
|
import { CHARTS_THEME, DEFAULT_MARGIN } from "constants/graph";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
id: string;
|
||||||
|
value: string;
|
||||||
|
customYAxisTickValues?: number[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MarimekkoGraph: React.FC<Props & TGraph & Omit<SvgProps<any>, "height" | "width">> = ({
|
||||||
|
id,
|
||||||
|
value,
|
||||||
|
customYAxisTickValues,
|
||||||
|
height = "400px",
|
||||||
|
width = "100%",
|
||||||
|
margin,
|
||||||
|
theme,
|
||||||
|
...rest
|
||||||
|
}) => (
|
||||||
|
<div style={{ height, width }}>
|
||||||
|
<ResponsiveMarimekko
|
||||||
|
id={id}
|
||||||
|
value={value}
|
||||||
|
margin={{ ...DEFAULT_MARGIN, ...(margin ?? {}) }}
|
||||||
|
innerPadding={rest.innerPadding ?? 4}
|
||||||
|
axisLeft={{
|
||||||
|
tickSize: 0,
|
||||||
|
tickPadding: 10,
|
||||||
|
tickValues: customYAxisTickValues ? generateYAxisTickValues(customYAxisTickValues) : undefined,
|
||||||
|
}}
|
||||||
|
axisBottom={{
|
||||||
|
tickSize: 0,
|
||||||
|
tickPadding: 10,
|
||||||
|
tickRotation: rest.data.length > 7 ? -45 : 0,
|
||||||
|
}}
|
||||||
|
labelTextColor={{ from: "color", modifiers: [["darker", 1.6]] }}
|
||||||
|
theme={{ ...CHARTS_THEME, ...(theme ?? {}) }}
|
||||||
|
animate
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
@ -3,7 +3,6 @@ export * from "./datepicker";
|
|||||||
export * from "./empty-space";
|
export * from "./empty-space";
|
||||||
export * from "./labels-list";
|
export * from "./labels-list";
|
||||||
export * from "./multi-level-dropdown";
|
export * from "./multi-level-dropdown";
|
||||||
export * from "./multi-level-select";
|
|
||||||
export * from "./markdown-to-component";
|
export * from "./markdown-to-component";
|
||||||
export * from "./integration-and-import-export-banner";
|
export * from "./integration-and-import-export-banner";
|
||||||
export * from "./range-datepicker";
|
export * from "./range-datepicker";
|
||||||
|
@ -1,149 +0,0 @@
|
|||||||
import React, { useState } from "react";
|
|
||||||
|
|
||||||
// headless ui
|
|
||||||
import { Listbox, Transition } from "@headlessui/react";
|
|
||||||
// icons
|
|
||||||
import { Check, ChevronsUpDown } from "lucide-react";
|
|
||||||
|
|
||||||
type TSelectOption = {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
value: any;
|
|
||||||
children?:
|
|
||||||
| (TSelectOption & {
|
|
||||||
children?: null;
|
|
||||||
})[]
|
|
||||||
| null;
|
|
||||||
};
|
|
||||||
|
|
||||||
type TMultipleSelectProps = {
|
|
||||||
options: TSelectOption[];
|
|
||||||
selected: TSelectOption | null;
|
|
||||||
setSelected: (value: any) => void;
|
|
||||||
label: string;
|
|
||||||
direction?: "left" | "right";
|
|
||||||
};
|
|
||||||
|
|
||||||
export const MultiLevelSelect: React.FC<TMultipleSelectProps> = ({
|
|
||||||
options,
|
|
||||||
selected,
|
|
||||||
setSelected,
|
|
||||||
label,
|
|
||||||
direction = "right",
|
|
||||||
}) => {
|
|
||||||
const [openChildFor, setOpenChildFor] = useState<TSelectOption | null>(null);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed top-16 w-72">
|
|
||||||
<Listbox
|
|
||||||
value={selected}
|
|
||||||
onChange={(value) => {
|
|
||||||
if (value?.children === null) {
|
|
||||||
setSelected(value);
|
|
||||||
setOpenChildFor(null);
|
|
||||||
} else setOpenChildFor(value);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{({ open }) => (
|
|
||||||
<div className="relative mt-1">
|
|
||||||
<Listbox.Button
|
|
||||||
onClick={() => setOpenChildFor(null)}
|
|
||||||
className="relative w-full cursor-default rounded-lg bg-custom-background-80 py-2 pl-3 pr-10 text-left shadow-md sm:text-sm"
|
|
||||||
>
|
|
||||||
<span className="block truncate">{selected?.label ?? label}</span>
|
|
||||||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
|
||||||
<ChevronsUpDown className="h-5 w-5 text-custom-text-200" />
|
|
||||||
</span>
|
|
||||||
</Listbox.Button>
|
|
||||||
<Transition
|
|
||||||
as={React.Fragment}
|
|
||||||
show={open}
|
|
||||||
leave="transition ease-in duration-100"
|
|
||||||
leaveFrom="opacity-100"
|
|
||||||
leaveTo="opacity-0"
|
|
||||||
>
|
|
||||||
<Listbox.Options
|
|
||||||
static
|
|
||||||
className="absolute mt-1 max-h-60 w-full rounded-md bg-custom-background-80 py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm"
|
|
||||||
>
|
|
||||||
{options.map((option) => (
|
|
||||||
<Listbox.Option
|
|
||||||
key={option.id}
|
|
||||||
className={
|
|
||||||
"relative cursor-default select-none py-2 pl-10 pr-4 hover:bg-custom-background-90 hover:text-custom-text-100"
|
|
||||||
}
|
|
||||||
onClick={(e: any) => {
|
|
||||||
if (option.children !== null) {
|
|
||||||
e.preventDefault();
|
|
||||||
setOpenChildFor(option);
|
|
||||||
}
|
|
||||||
if (option.id === openChildFor?.id) {
|
|
||||||
e.preventDefault();
|
|
||||||
setOpenChildFor(null);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
value={option}
|
|
||||||
>
|
|
||||||
{({ selected }) => (
|
|
||||||
<>
|
|
||||||
{openChildFor?.id === option.id && (
|
|
||||||
<div
|
|
||||||
className={`absolute h-auto max-h-72 w-72 rounded-lg border border-custom-border-200 bg-custom-background-80 ${
|
|
||||||
direction === "right"
|
|
||||||
? "left-full translate-x-2 rounded-tl-none shadow-md"
|
|
||||||
: "right-full -translate-x-2 rounded-tr-none shadow-md"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{option.children?.map((child) => (
|
|
||||||
<Listbox.Option
|
|
||||||
key={child.id}
|
|
||||||
className={
|
|
||||||
"relative cursor-default select-none py-2 pl-10 pr-4 hover:bg-custom-background-90 hover:text-custom-text-100"
|
|
||||||
}
|
|
||||||
as="div"
|
|
||||||
value={child}
|
|
||||||
>
|
|
||||||
{({ selected }) => (
|
|
||||||
<>
|
|
||||||
<span className={`block truncate ${selected ? "font-medium" : "font-normal"}`}>
|
|
||||||
{child.label}
|
|
||||||
</span>
|
|
||||||
{selected ? (
|
|
||||||
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-custom-text-200">
|
|
||||||
<Check className="h-5 w-5" aria-hidden="true" />
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Listbox.Option>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<div
|
|
||||||
className={`absolute h-0 w-0 border-t-8 border-custom-border-200 ${
|
|
||||||
direction === "right"
|
|
||||||
? "left-0 top-0 -translate-x-2 border-b-8 border-r-8 border-b-transparent border-l-transparent border-t-transparent"
|
|
||||||
: "right-0 top-0 translate-x-2 border-b-8 border-l-8 border-b-transparent border-r-transparent border-t-transparent"
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<span className={`block truncate ${selected ? "font-medium" : "font-normal"}`}>
|
|
||||||
{option.label}
|
|
||||||
</span>
|
|
||||||
{selected ? (
|
|
||||||
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-custom-text-200">
|
|
||||||
<Check className="h-5 w-5" aria-hidden="true" />
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Listbox.Option>
|
|
||||||
))}
|
|
||||||
</Listbox.Options>
|
|
||||||
</Transition>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Listbox>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
@ -35,7 +35,7 @@ export const UserGreetingsView: FC<IUserGreetingsView> = (props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-2xl font-semibold">
|
<h3 className="text-xl font-semibold">
|
||||||
Good {greeting}, {user?.first_name} {user?.last_name}
|
Good {greeting}, {user?.first_name} {user?.last_name}
|
||||||
</h3>
|
</h3>
|
||||||
<h6 className="flex items-center gap-2 font-medium text-custom-text-400">
|
<h6 className="flex items-center gap-2 font-medium text-custom-text-400">
|
||||||
|
@ -1,130 +0,0 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
// ui
|
|
||||||
import { Tooltip } from "@plane/ui";
|
|
||||||
// helpers
|
|
||||||
import { renderFormattedPayloadDate, renderFormattedDate } from "helpers/date-time.helper";
|
|
||||||
// types
|
|
||||||
import { IUserActivity } from "@plane/types";
|
|
||||||
// constants
|
|
||||||
import { DAYS, MONTHS } from "constants/project";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
activities: IUserActivity[] | undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ActivityGraph: React.FC<Props> = ({ activities }) => {
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const [width, setWidth] = useState(0);
|
|
||||||
|
|
||||||
const today = new Date();
|
|
||||||
const lastMonth = new Date(today.getFullYear(), today.getMonth() - 1, 1);
|
|
||||||
const twoMonthsAgo = new Date(today.getFullYear(), today.getMonth() - 2, 1);
|
|
||||||
const threeMonthsAgo = new Date(today.getFullYear(), today.getMonth() - 3, 1);
|
|
||||||
const fourMonthsAgo = new Date(today.getFullYear(), today.getMonth() - 4, 1);
|
|
||||||
const fiveMonthsAgo = new Date(today.getFullYear(), today.getMonth() - 5, 1);
|
|
||||||
|
|
||||||
const recentMonths = [fiveMonthsAgo, fourMonthsAgo, threeMonthsAgo, twoMonthsAgo, lastMonth, today];
|
|
||||||
|
|
||||||
const getDatesOfMonth = (dateOfMonth: Date) => {
|
|
||||||
const month = dateOfMonth.getMonth();
|
|
||||||
const year = dateOfMonth.getFullYear();
|
|
||||||
|
|
||||||
const dates = [];
|
|
||||||
const date = new Date(year, month, 1);
|
|
||||||
|
|
||||||
while (date.getMonth() === month && date < new Date()) {
|
|
||||||
dates.push(renderFormattedPayloadDate(new Date(date)) ?? "");
|
|
||||||
date.setDate(date.getDate() + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
return dates;
|
|
||||||
};
|
|
||||||
|
|
||||||
const recentDates = [
|
|
||||||
...getDatesOfMonth(recentMonths[0]),
|
|
||||||
...getDatesOfMonth(recentMonths[1]),
|
|
||||||
...getDatesOfMonth(recentMonths[2]),
|
|
||||||
...getDatesOfMonth(recentMonths[3]),
|
|
||||||
...getDatesOfMonth(recentMonths[4]),
|
|
||||||
...getDatesOfMonth(recentMonths[5]),
|
|
||||||
];
|
|
||||||
|
|
||||||
const activitiesIntensity = (activityCount: number) => {
|
|
||||||
if (activityCount <= 3) return "opacity-20";
|
|
||||||
else if (activityCount > 3 && activityCount <= 6) return "opacity-40";
|
|
||||||
else if (activityCount > 6 && activityCount <= 9) return "opacity-80";
|
|
||||||
else return "";
|
|
||||||
};
|
|
||||||
|
|
||||||
const addPaddingTiles = () => {
|
|
||||||
const firstDateDay = new Date(recentDates[0]).getDay();
|
|
||||||
|
|
||||||
for (let i = 0; i < firstDateDay; i++) recentDates.unshift("");
|
|
||||||
};
|
|
||||||
addPaddingTiles();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!ref.current) return;
|
|
||||||
|
|
||||||
setWidth(ref.current.offsetWidth);
|
|
||||||
}, [ref]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid place-items-center overflow-x-scroll">
|
|
||||||
<div className="flex items-start gap-4">
|
|
||||||
<div className="flex flex-col gap-2 pt-6">
|
|
||||||
{DAYS.map((day, index) => (
|
|
||||||
<h6 key={day} className="h-4 text-xs">
|
|
||||||
{index % 2 === 0 && day.substring(0, 3)}
|
|
||||||
</h6>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center justify-between" style={{ width: `${width}px` }}>
|
|
||||||
{recentMonths.map((month, index) => (
|
|
||||||
<h6 key={index} className="w-full text-xs">
|
|
||||||
{MONTHS[month.getMonth()].substring(0, 3)}
|
|
||||||
</h6>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="mt-2 grid w-full grid-flow-col gap-2"
|
|
||||||
style={{ gridTemplateRows: "repeat(7, minmax(0, 1fr))" }}
|
|
||||||
ref={ref}
|
|
||||||
>
|
|
||||||
{recentDates.map((date, index) => {
|
|
||||||
const isActive = activities?.find((a) => a.created_date === date);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tooltip
|
|
||||||
key={`${date}-${index}`}
|
|
||||||
tooltipContent={`${
|
|
||||||
isActive ? isActive.activity_count : 0
|
|
||||||
} activities on ${renderFormattedDate(date)}`}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={`${date === "" ? "pointer-events-none opacity-0" : ""} h-4 w-4 rounded ${
|
|
||||||
isActive
|
|
||||||
? `bg-custom-primary ${activitiesIntensity(isActive.activity_count)}`
|
|
||||||
: "bg-custom-background-80"
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<div className="mt-8 flex items-center gap-2 text-xs">
|
|
||||||
<span>Less</span>
|
|
||||||
<span className="h-4 w-4 rounded bg-custom-background-80" />
|
|
||||||
<span className="h-4 w-4 rounded bg-custom-primary opacity-20" />
|
|
||||||
<span className="h-4 w-4 rounded bg-custom-primary opacity-40" />
|
|
||||||
<span className="h-4 w-4 rounded bg-custom-primary opacity-80" />
|
|
||||||
<span className="h-4 w-4 rounded bg-custom-primary" />
|
|
||||||
<span>More</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,85 +0,0 @@
|
|||||||
// ui
|
|
||||||
import { LineGraph } from "components/ui";
|
|
||||||
import { CustomMenu } from "@plane/ui";
|
|
||||||
// constants
|
|
||||||
import { MONTHS } from "constants/project";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
issues:
|
|
||||||
| {
|
|
||||||
week_in_month: number;
|
|
||||||
completed_count: number;
|
|
||||||
}[]
|
|
||||||
| undefined;
|
|
||||||
month: number;
|
|
||||||
setMonth: React.Dispatch<React.SetStateAction<number>>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const CompletedIssuesGraph: React.FC<Props> = ({ month, issues, setMonth }) => {
|
|
||||||
const weeks = month === 2 ? 4 : 5;
|
|
||||||
|
|
||||||
const data: any[] = [];
|
|
||||||
|
|
||||||
for (let i = 1; i <= weeks; i++) {
|
|
||||||
data.push({
|
|
||||||
week_in_month: `Week ${i}`,
|
|
||||||
completed_count: issues?.find((item) => item.week_in_month === i)?.completed_count ?? 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="mb-0.5 flex justify-between">
|
|
||||||
<h3 className="font-semibold">Issues closed by you</h3>
|
|
||||||
<CustomMenu label={<span className="text-sm">{MONTHS[month - 1]}</span>} noBorder>
|
|
||||||
{MONTHS.map((month, index) => (
|
|
||||||
<CustomMenu.MenuItem key={month} onClick={() => setMonth(index + 1)}>
|
|
||||||
{month}
|
|
||||||
</CustomMenu.MenuItem>
|
|
||||||
))}
|
|
||||||
</CustomMenu>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-[10px] border border-custom-border-200 bg-custom-background-100 p-8 pl-4">
|
|
||||||
{data.every((item) => item.completed_count === 0) ? (
|
|
||||||
<div className="flex h-72 items-center justify-center">
|
|
||||||
<h4 className="text-[#d687ff]">No issues closed this month</h4>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<LineGraph
|
|
||||||
height="250px"
|
|
||||||
data={[
|
|
||||||
{
|
|
||||||
id: "completed_issues",
|
|
||||||
color: "#d687ff",
|
|
||||||
data: data.map((item) => ({
|
|
||||||
x: item.week_in_month,
|
|
||||||
y: item.completed_count,
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
margin={{ top: 20, right: 20, bottom: 20, left: 20 }}
|
|
||||||
customYAxisTickValues={data.map((item) => item.completed_count)}
|
|
||||||
colors={(datum) => datum.color}
|
|
||||||
enableSlices="x"
|
|
||||||
sliceTooltip={(datum) => (
|
|
||||||
<div className="rounded-md border border-custom-border-200 bg-custom-background-80 p-2 text-xs">
|
|
||||||
{datum.slice.points[0].data.yFormatted}
|
|
||||||
<span className="text-custom-text-200"> issues closed in </span>
|
|
||||||
{datum.slice.points[0].data.xFormatted}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
theme={{
|
|
||||||
background: "rgb(var(--color-background-100))",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<h4 className="mt-4 flex items-center justify-center gap-2 text-[#d687ff]">
|
|
||||||
<span className="h-2 w-2 bg-[#d687ff]" />
|
|
||||||
Completed Issues
|
|
||||||
</h4>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,14 +1,9 @@
|
|||||||
export * from "./settings";
|
export * from "./settings";
|
||||||
export * from "./views";
|
export * from "./views";
|
||||||
export * from "./activity-graph";
|
|
||||||
export * from "./completed-issues-graph";
|
|
||||||
export * from "./confirm-workspace-member-remove";
|
export * from "./confirm-workspace-member-remove";
|
||||||
export * from "./create-workspace-form";
|
export * from "./create-workspace-form";
|
||||||
export * from "./delete-workspace-modal";
|
export * from "./delete-workspace-modal";
|
||||||
export * from "./help-section";
|
export * from "./help-section";
|
||||||
export * from "./issues-list";
|
|
||||||
export * from "./issues-pie-chart";
|
|
||||||
export * from "./issues-stats";
|
|
||||||
export * from "./send-workspace-invitation-modal";
|
export * from "./send-workspace-invitation-modal";
|
||||||
export * from "./sidebar-dropdown";
|
export * from "./sidebar-dropdown";
|
||||||
export * from "./sidebar-menu";
|
export * from "./sidebar-menu";
|
||||||
|
@ -1,96 +0,0 @@
|
|||||||
import { useRouter } from "next/router";
|
|
||||||
import Link from "next/link";
|
|
||||||
// icons
|
|
||||||
import { AlertTriangle } from "lucide-react";
|
|
||||||
import { LayersIcon, Loader } from "@plane/ui";
|
|
||||||
// helpers
|
|
||||||
import { renderFormattedDate } from "helpers/date-time.helper";
|
|
||||||
import { truncateText } from "helpers/string.helper";
|
|
||||||
// types
|
|
||||||
import { IIssueLite } from "@plane/types";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
issues: IIssueLite[] | undefined;
|
|
||||||
type: "overdue" | "upcoming";
|
|
||||||
};
|
|
||||||
|
|
||||||
export const IssuesList: React.FC<Props> = ({ issues, type }) => {
|
|
||||||
const router = useRouter();
|
|
||||||
const { workspaceSlug } = router.query;
|
|
||||||
|
|
||||||
const getDateDifference = (date: Date) => {
|
|
||||||
const today = new Date();
|
|
||||||
|
|
||||||
let diffTime = 0;
|
|
||||||
|
|
||||||
if (type === "overdue") diffTime = Math.abs(today.valueOf() - date.valueOf());
|
|
||||||
else diffTime = Math.abs(date.valueOf() - today.valueOf());
|
|
||||||
|
|
||||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
|
||||||
|
|
||||||
return diffDays;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h3 className="mb-2 font-semibold capitalize">{type} Issues</h3>
|
|
||||||
{issues ? (
|
|
||||||
<div className="h-[calc(100%-2.25rem)] rounded-[10px] border border-custom-border-200 bg-custom-background-100 p-4 text-sm">
|
|
||||||
<div
|
|
||||||
className={`mb-2 grid grid-cols-4 gap-2 rounded-lg px-3 py-2 font-medium ${
|
|
||||||
type === "overdue" ? "bg-red-500/20 bg-opacity-20" : "bg-custom-background-80"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<h4 className="capitalize">{type}</h4>
|
|
||||||
<h4 className="col-span-2">Issue</h4>
|
|
||||||
<h4>{type === "overdue" ? "Due" : "Start"} Date</h4>
|
|
||||||
</div>
|
|
||||||
<div className="max-h-72 overflow-y-scroll">
|
|
||||||
{issues.length > 0 ? (
|
|
||||||
issues.map((issue) => {
|
|
||||||
const date = type === "overdue" ? issue.target_date : issue.start_date;
|
|
||||||
|
|
||||||
const dateDifference = getDateDifference(new Date(date as string));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Link href={`/${workspaceSlug}/projects/${issue.project_id}/issues/${issue.id}`} key={issue.id}>
|
|
||||||
<span>
|
|
||||||
<div className="grid grid-cols-4 gap-2 px-3 py-2">
|
|
||||||
<h5
|
|
||||||
className={`flex cursor-default items-center gap-2 ${
|
|
||||||
type === "overdue" ? (dateDifference > 6 ? "text-red-500" : "text-yellow-400") : ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{type === "overdue" && <AlertTriangle className="h-3.5 w-3.5" />}
|
|
||||||
{dateDifference} {dateDifference > 1 ? "days" : "day"}
|
|
||||||
</h5>
|
|
||||||
<h5 className="col-span-2">{truncateText(issue.name, 30)}</h5>
|
|
||||||
<h5 className="cursor-default">
|
|
||||||
{renderFormattedDate(new Date(date?.toString() ?? ""))}
|
|
||||||
</h5>
|
|
||||||
</div>
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
) : (
|
|
||||||
<div className="grid h-full place-items-center">
|
|
||||||
<div className="my-5 flex flex-col items-center gap-4">
|
|
||||||
<LayersIcon height={60} width={60} />
|
|
||||||
<span className="text-custom-text-200">
|
|
||||||
No issues found. Use <pre className="inline rounded bg-custom-background-80 px-2 py-1">C</pre>{" "}
|
|
||||||
shortcut to create a new issue
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Loader>
|
|
||||||
<Loader.Item height="200" />
|
|
||||||
</Loader>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,65 +0,0 @@
|
|||||||
// ui
|
|
||||||
import { PieGraph } from "components/ui";
|
|
||||||
// types
|
|
||||||
import { IUserStateDistribution, TStateGroups } from "@plane/types";
|
|
||||||
// constants
|
|
||||||
import { STATE_GROUP_COLORS } from "constants/state";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
groupedIssues: IUserStateDistribution[] | undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const IssuesPieChart: React.FC<Props> = ({ groupedIssues }) => (
|
|
||||||
<div>
|
|
||||||
<h3 className="mb-2 font-semibold">Issues by States</h3>
|
|
||||||
<div className="rounded-[10px] border border-custom-border-200 bg-custom-background-100 p-4">
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-4">
|
|
||||||
<div className="sm:col-span-3">
|
|
||||||
<PieGraph
|
|
||||||
data={
|
|
||||||
groupedIssues?.map((cell) => ({
|
|
||||||
id: cell.state_group,
|
|
||||||
label: cell.state_group,
|
|
||||||
value: cell.state_count,
|
|
||||||
color: STATE_GROUP_COLORS[cell.state_group.toLowerCase() as TStateGroups],
|
|
||||||
})) ?? []
|
|
||||||
}
|
|
||||||
height="320px"
|
|
||||||
innerRadius={0.6}
|
|
||||||
cornerRadius={5}
|
|
||||||
padAngle={2}
|
|
||||||
enableArcLabels
|
|
||||||
arcLabelsTextColor="#000000"
|
|
||||||
enableArcLinkLabels={false}
|
|
||||||
activeInnerRadiusOffset={5}
|
|
||||||
colors={(datum) => datum.data.color}
|
|
||||||
tooltip={(datum) => (
|
|
||||||
<div className="flex items-center gap-2 rounded-md border border-custom-border-200 bg-custom-background-90 p-2 text-xs">
|
|
||||||
<span className="capitalize text-custom-text-200">{datum.datum.label} issues:</span> {datum.datum.value}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
margin={{
|
|
||||||
top: 32,
|
|
||||||
right: 0,
|
|
||||||
bottom: 32,
|
|
||||||
left: 0,
|
|
||||||
}}
|
|
||||||
theme={{
|
|
||||||
background: "transparent",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap items-center justify-center gap-3 sm:block sm:space-y-2 sm:self-end sm:justify-self-end sm:px-8 sm:pb-8">
|
|
||||||
{groupedIssues?.map((cell) => (
|
|
||||||
<div key={cell.state_group} className="flex items-center gap-2">
|
|
||||||
<div className="h-2 w-2" style={{ backgroundColor: STATE_GROUP_COLORS[cell.state_group] }} />
|
|
||||||
<div className="whitespace-nowrap text-xs capitalize text-custom-text-200">
|
|
||||||
{cell.state_group}- {cell.state_count}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
@ -1,73 +0,0 @@
|
|||||||
// components
|
|
||||||
import { ActivityGraph } from "components/workspace";
|
|
||||||
// ui
|
|
||||||
import { Tooltip } from "@plane/ui";
|
|
||||||
// icons
|
|
||||||
import { Info } from "lucide-react";
|
|
||||||
// types
|
|
||||||
import { IUserWorkspaceDashboard } from "@plane/types";
|
|
||||||
import { useRouter } from "next/router";
|
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
data: IUserWorkspaceDashboard | undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const IssuesStats: React.FC<Props> = ({ data }) => {
|
|
||||||
const router = useRouter();
|
|
||||||
const { workspaceSlug } = router.query;
|
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-1 rounded-[10px] border border-custom-border-200 bg-custom-background-100 lg:grid-cols-3">
|
|
||||||
<div className="grid grid-cols-1 divide-y divide-custom-border-200 border-b border-custom-border-200 lg:border-b-0 lg:border-r">
|
|
||||||
<div className="flex">
|
|
||||||
<Link className="basis-1/2 p-4" href={`/${workspaceSlug}/workspace-views/assigned`}>
|
|
||||||
<div>
|
|
||||||
<h4 className="text-sm">Issues assigned to you</h4>
|
|
||||||
<h5 className="mt-2 text-2xl font-semibold">
|
|
||||||
<div className="cursor-pointer">{data?.assigned_issues_count}</div>
|
|
||||||
</h5>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
className="basis-1/2 border-l border-custom-border-200 p-4"
|
|
||||||
href={`/${workspaceSlug}/workspace-views/all-issues`}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<h4 className="text-sm">Pending issues</h4>
|
|
||||||
<h5 className="mt-2 text-2xl font-semibold">{data?.pending_issues_count}</h5>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
<div className="flex">
|
|
||||||
<Link className="basis-1/2 p-4" href={`/${workspaceSlug}/workspace-views/all-issues`}>
|
|
||||||
<div>
|
|
||||||
<h4 className="text-sm">Completed issues</h4>
|
|
||||||
<h5 className="mt-2 text-2xl font-semibold">{data?.completed_issues_count}</h5>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
className="basis-1/2 border-l border-custom-border-200 p-4"
|
|
||||||
href={`/${workspaceSlug}/workspace-views/all-issues`}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<h4 className="text-sm">Issues due by this week</h4>
|
|
||||||
<h5 className="mt-2 text-2xl font-semibold">{data?.issues_due_week_count}</h5>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="p-4 lg:col-span-2">
|
|
||||||
<h3 className="mb-2 flex items-center gap-2 font-semibold capitalize">
|
|
||||||
Activity Graph
|
|
||||||
<Tooltip
|
|
||||||
tooltipContent="Your profile activity graph is a record of actions you've performed on issues across the workspace."
|
|
||||||
className="w-72 border border-custom-border-200"
|
|
||||||
>
|
|
||||||
<Info className="h-3 w-3" />
|
|
||||||
</Tooltip>
|
|
||||||
</h3>
|
|
||||||
<ActivityGraph activities={data?.issue_activities} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
248
web/constants/dashboard.ts
Normal file
@ -0,0 +1,248 @@
|
|||||||
|
import { linearGradientDef } from "@nivo/core";
|
||||||
|
// assets
|
||||||
|
import UpcomingAssignedIssuesDark from "public/empty-state/dashboard/dark/upcoming-assigned-issues.svg";
|
||||||
|
import UpcomingAssignedIssuesLight from "public/empty-state/dashboard/light/upcoming-assigned-issues.svg";
|
||||||
|
import OverdueAssignedIssuesDark from "public/empty-state/dashboard/dark/overdue-assigned-issues.svg";
|
||||||
|
import OverdueAssignedIssuesLight from "public/empty-state/dashboard/light/overdue-assigned-issues.svg";
|
||||||
|
import CompletedAssignedIssuesDark from "public/empty-state/dashboard/dark/completed-assigned-issues.svg";
|
||||||
|
import CompletedAssignedIssuesLight from "public/empty-state/dashboard/light/completed-assigned-issues.svg";
|
||||||
|
import UpcomingCreatedIssuesDark from "public/empty-state/dashboard/dark/upcoming-created-issues.svg";
|
||||||
|
import UpcomingCreatedIssuesLight from "public/empty-state/dashboard/light/upcoming-created-issues.svg";
|
||||||
|
import OverdueCreatedIssuesDark from "public/empty-state/dashboard/dark/overdue-created-issues.svg";
|
||||||
|
import OverdueCreatedIssuesLight from "public/empty-state/dashboard/light/overdue-created-issues.svg";
|
||||||
|
import CompletedCreatedIssuesDark from "public/empty-state/dashboard/dark/completed-created-issues.svg";
|
||||||
|
import CompletedCreatedIssuesLight from "public/empty-state/dashboard/light/completed-created-issues.svg";
|
||||||
|
// types
|
||||||
|
import { TDurationFilterOptions, TIssuesListTypes, TStateGroups } from "@plane/types";
|
||||||
|
|
||||||
|
// 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",
|
||||||
|
};
|
||||||
|
|
||||||
|
// filter duration options
|
||||||
|
export const DURATION_FILTER_OPTIONS: {
|
||||||
|
key: TDurationFilterOptions;
|
||||||
|
label: string;
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
key: "today",
|
||||||
|
label: "Today",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "this_week",
|
||||||
|
label: "This week",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "this_month",
|
||||||
|
label: "This month",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "this_year",
|
||||||
|
label: "This year",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// 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 ISSUES_TABS_LIST: {
|
||||||
|
key: TIssuesListTypes;
|
||||||
|
label: string;
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
key: "upcoming",
|
||||||
|
label: "Upcoming",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "overdue",
|
||||||
|
label: "Overdue",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "completed",
|
||||||
|
label: "Completed",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// empty state constants
|
||||||
|
const ASSIGNED_ISSUES_DURATION_TITLES: {
|
||||||
|
[type in TIssuesListTypes]: {
|
||||||
|
[duration in TDurationFilterOptions]: string;
|
||||||
|
};
|
||||||
|
} = {
|
||||||
|
upcoming: {
|
||||||
|
today: "today",
|
||||||
|
this_week: "yet in this week",
|
||||||
|
this_month: "yet in this month",
|
||||||
|
this_year: "yet in this year",
|
||||||
|
},
|
||||||
|
overdue: {
|
||||||
|
today: "today",
|
||||||
|
this_week: "in this week",
|
||||||
|
this_month: "in this month",
|
||||||
|
this_year: "in this year",
|
||||||
|
},
|
||||||
|
completed: {
|
||||||
|
today: "today",
|
||||||
|
this_week: "this week",
|
||||||
|
this_month: "this month",
|
||||||
|
this_year: "this year",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const CREATED_ISSUES_DURATION_TITLES: {
|
||||||
|
[duration in TDurationFilterOptions]: string;
|
||||||
|
} = {
|
||||||
|
today: "today",
|
||||||
|
this_week: "in this week",
|
||||||
|
this_month: "in this month",
|
||||||
|
this_year: "in this year",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ASSIGNED_ISSUES_EMPTY_STATES = {
|
||||||
|
upcoming: {
|
||||||
|
title: (duration: TDurationFilterOptions) =>
|
||||||
|
`No issues assigned to you ${ASSIGNED_ISSUES_DURATION_TITLES.upcoming[duration]}.`,
|
||||||
|
darkImage: UpcomingAssignedIssuesDark,
|
||||||
|
lightImage: UpcomingAssignedIssuesLight,
|
||||||
|
},
|
||||||
|
overdue: {
|
||||||
|
title: (duration: TDurationFilterOptions) =>
|
||||||
|
`No issues with due dates ${ASSIGNED_ISSUES_DURATION_TITLES.overdue[duration]} are open.`,
|
||||||
|
darkImage: OverdueAssignedIssuesDark,
|
||||||
|
lightImage: OverdueAssignedIssuesLight,
|
||||||
|
},
|
||||||
|
completed: {
|
||||||
|
title: (duration: TDurationFilterOptions) =>
|
||||||
|
`No issues completed by you ${ASSIGNED_ISSUES_DURATION_TITLES.completed[duration]}.`,
|
||||||
|
darkImage: CompletedAssignedIssuesDark,
|
||||||
|
lightImage: CompletedAssignedIssuesLight,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CREATED_ISSUES_EMPTY_STATES = {
|
||||||
|
upcoming: {
|
||||||
|
title: (duration: TDurationFilterOptions) =>
|
||||||
|
`No created issues have deadlines coming up ${CREATED_ISSUES_DURATION_TITLES[duration]}.`,
|
||||||
|
darkImage: UpcomingCreatedIssuesDark,
|
||||||
|
lightImage: UpcomingCreatedIssuesLight,
|
||||||
|
},
|
||||||
|
overdue: {
|
||||||
|
title: (duration: TDurationFilterOptions) =>
|
||||||
|
`No created issues with due dates ${CREATED_ISSUES_DURATION_TITLES[duration]} are open.`,
|
||||||
|
darkImage: OverdueCreatedIssuesDark,
|
||||||
|
lightImage: OverdueCreatedIssuesLight,
|
||||||
|
},
|
||||||
|
completed: {
|
||||||
|
title: (duration: TDurationFilterOptions) =>
|
||||||
|
`No created issues are completed ${CREATED_ISSUES_DURATION_TITLES[duration]}.`,
|
||||||
|
darkImage: CompletedCreatedIssuesDark,
|
||||||
|
lightImage: CompletedCreatedIssuesLight,
|
||||||
|
},
|
||||||
|
};
|
@ -10,7 +10,6 @@ import {
|
|||||||
TIssueOrderByOptions,
|
TIssueOrderByOptions,
|
||||||
TIssuePriorities,
|
TIssuePriorities,
|
||||||
TIssueTypeFilters,
|
TIssueTypeFilters,
|
||||||
TStateGroups,
|
|
||||||
} from "@plane/types";
|
} from "@plane/types";
|
||||||
|
|
||||||
export enum EIssuesStoreType {
|
export enum EIssuesStoreType {
|
||||||
@ -50,21 +49,6 @@ export const ISSUE_PRIORITIES: {
|
|||||||
{ key: "none", title: "None" },
|
{ key: "none", title: "None" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const issuePriorityByKey = (key: string) => ISSUE_PRIORITIES.find((item) => item.key === key) || null;
|
|
||||||
|
|
||||||
export const ISSUE_STATE_GROUPS: {
|
|
||||||
key: TStateGroups;
|
|
||||||
title: string;
|
|
||||||
}[] = [
|
|
||||||
{ key: "backlog", title: "Backlog" },
|
|
||||||
{ key: "unstarted", title: "Unstarted" },
|
|
||||||
{ key: "started", title: "Started" },
|
|
||||||
{ key: "completed", title: "Completed" },
|
|
||||||
{ key: "cancelled", title: "Cancelled" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const issueStateGroupByKey = (key: string) => ISSUE_STATE_GROUPS.find((item) => item.key === key) || null;
|
|
||||||
|
|
||||||
export const ISSUE_START_DATE_OPTIONS = [
|
export const ISSUE_START_DATE_OPTIONS = [
|
||||||
{ key: "last_week", title: "Last Week" },
|
{ key: "last_week", title: "Last Week" },
|
||||||
{ key: "2_weeks_from_now", title: "2 weeks from now" },
|
{ key: "2_weeks_from_now", title: "2 weeks from now" },
|
||||||
|
@ -28,8 +28,6 @@ export const GROUP_CHOICES = {
|
|||||||
cancelled: "Cancelled",
|
cancelled: "Cancelled",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const STATE_GROUP = ["Backlog", "Unstarted", "Started", "Completed", "Cancelled"];
|
|
||||||
|
|
||||||
export const MONTHS = [
|
export const MONTHS = [
|
||||||
"January",
|
"January",
|
||||||
"February",
|
"February",
|
||||||
@ -55,8 +53,6 @@ export const PROJECT_AUTOMATION_MONTHS = [
|
|||||||
{ label: "12 Months", value: 12 },
|
{ label: "12 Months", value: 12 },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const STATE_GROUP_KEYS = ["backlog", "unstarted", "started", "completed", "cancelled"];
|
|
||||||
|
|
||||||
export const PROJECT_UNSPLASH_COVERS = [
|
export const PROJECT_UNSPLASH_COVERS = [
|
||||||
"https://images.unsplash.com/photo-1531045535792-b515d59c3d1f?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=870&q=80",
|
"https://images.unsplash.com/photo-1531045535792-b515d59c3d1f?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=870&q=80",
|
||||||
"https://images.unsplash.com/photo-1693027407934-e3aa8a54c7ae?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=870&q=80",
|
"https://images.unsplash.com/photo-1693027407934-e3aa8a54c7ae?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=870&q=80",
|
||||||
|
@ -1,11 +1,35 @@
|
|||||||
import { TStateGroups } from "@plane/types";
|
import { TStateGroups } from "@plane/types";
|
||||||
|
|
||||||
export const STATE_GROUP_COLORS: {
|
export const STATE_GROUPS: {
|
||||||
[key in TStateGroups]: string;
|
[key in TStateGroups]: {
|
||||||
|
key: TStateGroups;
|
||||||
|
label: string;
|
||||||
|
color: string;
|
||||||
|
};
|
||||||
} = {
|
} = {
|
||||||
backlog: "#d9d9d9",
|
backlog: {
|
||||||
unstarted: "#3f76ff",
|
key: "backlog",
|
||||||
started: "#f59e0b",
|
label: "Backlog",
|
||||||
completed: "#16a34a",
|
color: "#d9d9d9",
|
||||||
cancelled: "#dc2626",
|
},
|
||||||
|
unstarted: {
|
||||||
|
key: "unstarted",
|
||||||
|
label: "Unstarted",
|
||||||
|
color: "#3f76ff",
|
||||||
|
},
|
||||||
|
started: {
|
||||||
|
key: "started",
|
||||||
|
label: "Started",
|
||||||
|
color: "#f59e0b",
|
||||||
|
},
|
||||||
|
completed: {
|
||||||
|
key: "completed",
|
||||||
|
label: "Completed",
|
||||||
|
color: "#16a34a",
|
||||||
|
},
|
||||||
|
cancelled: {
|
||||||
|
key: "cancelled",
|
||||||
|
label: "Canceled",
|
||||||
|
color: "#dc2626",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
@ -5,7 +5,7 @@ import { addSpaceIfCamelCase, capitalizeFirstLetter, generateRandomColor } from
|
|||||||
// types
|
// types
|
||||||
import { IAnalyticsData, IAnalyticsParams, IAnalyticsResponse, TStateGroups } from "@plane/types";
|
import { IAnalyticsData, IAnalyticsParams, IAnalyticsResponse, TStateGroups } from "@plane/types";
|
||||||
// constants
|
// constants
|
||||||
import { STATE_GROUP_COLORS } from "constants/state";
|
import { STATE_GROUPS } from "constants/state";
|
||||||
import { MONTHS_LIST } from "constants/calendar";
|
import { MONTHS_LIST } from "constants/calendar";
|
||||||
import { DATE_KEYS } from "constants/analytics";
|
import { DATE_KEYS } from "constants/analytics";
|
||||||
|
|
||||||
@ -75,7 +75,7 @@ export const generateBarColor = (
|
|||||||
if (params[type] === "labels__id")
|
if (params[type] === "labels__id")
|
||||||
color = analytics?.extras.label_details.find((l) => l.labels__id === value)?.labels__color ?? undefined;
|
color = analytics?.extras.label_details.find((l) => l.labels__id === value)?.labels__color ?? undefined;
|
||||||
|
|
||||||
if (params[type] === "state__group") color = STATE_GROUP_COLORS[value.toLowerCase() as TStateGroups];
|
if (params[type] === "state__group") color = STATE_GROUPS[value.toLowerCase() as TStateGroups].color;
|
||||||
|
|
||||||
if (params[type] === "priority") {
|
if (params[type] === "priority") {
|
||||||
const priority = value.toLowerCase();
|
const priority = value.toLowerCase();
|
||||||
|
42
web/helpers/dashboard.helper.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import { endOfMonth, endOfWeek, endOfYear, startOfMonth, startOfWeek, startOfYear } from "date-fns";
|
||||||
|
// helpers
|
||||||
|
import { renderFormattedPayloadDate } from "./date-time.helper";
|
||||||
|
// types
|
||||||
|
import { TDurationFilterOptions, TIssuesListTypes } from "@plane/types";
|
||||||
|
|
||||||
|
export const getCustomDates = (duration: TDurationFilterOptions): string => {
|
||||||
|
const today = new Date();
|
||||||
|
let firstDay, lastDay;
|
||||||
|
|
||||||
|
switch (duration) {
|
||||||
|
case "today":
|
||||||
|
firstDay = renderFormattedPayloadDate(today);
|
||||||
|
lastDay = renderFormattedPayloadDate(today);
|
||||||
|
return `${firstDay};after,${lastDay};before`;
|
||||||
|
case "this_week":
|
||||||
|
firstDay = renderFormattedPayloadDate(startOfWeek(today));
|
||||||
|
lastDay = renderFormattedPayloadDate(endOfWeek(today));
|
||||||
|
return `${firstDay};after,${lastDay};before`;
|
||||||
|
case "this_month":
|
||||||
|
firstDay = renderFormattedPayloadDate(startOfMonth(today));
|
||||||
|
lastDay = renderFormattedPayloadDate(endOfMonth(today));
|
||||||
|
return `${firstDay};after,${lastDay};before`;
|
||||||
|
case "this_year":
|
||||||
|
firstDay = renderFormattedPayloadDate(startOfYear(today));
|
||||||
|
lastDay = renderFormattedPayloadDate(endOfYear(today));
|
||||||
|
return `${firstDay};after,${lastDay};before`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getRedirectionFilters = (type: TIssuesListTypes): string => {
|
||||||
|
const today = renderFormattedPayloadDate(new Date());
|
||||||
|
|
||||||
|
const filterParams =
|
||||||
|
type === "upcoming"
|
||||||
|
? `?target_date=${today};after`
|
||||||
|
: type === "overdue"
|
||||||
|
? `?target_date=${today};before`
|
||||||
|
: "?state_group=completed";
|
||||||
|
|
||||||
|
return filterParams;
|
||||||
|
};
|
@ -1,5 +1,5 @@
|
|||||||
// types
|
// types
|
||||||
import { STATE_GROUP_KEYS } from "constants/project";
|
import { STATE_GROUPS } from "constants/state";
|
||||||
import { IState, IStateResponse } from "@plane/types";
|
import { IState, IStateResponse } from "@plane/types";
|
||||||
|
|
||||||
export const orderStateGroups = (unorderedStateGroups: IStateResponse | undefined): IStateResponse | undefined => {
|
export const orderStateGroups = (unorderedStateGroups: IStateResponse | undefined): IStateResponse | undefined => {
|
||||||
@ -14,6 +14,6 @@ export const sortStates = (states: IState[]) => {
|
|||||||
if (stateA.group === stateB.group) {
|
if (stateA.group === stateB.group) {
|
||||||
return stateA.sequence - stateB.sequence;
|
return stateA.sequence - stateB.sequence;
|
||||||
}
|
}
|
||||||
return STATE_GROUP_KEYS.indexOf(stateA.group) - STATE_GROUP_KEYS.indexOf(stateB.group);
|
return Object.keys(STATE_GROUPS).indexOf(stateA.group) - Object.keys(STATE_GROUPS).indexOf(stateB.group);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
export * from "./use-application";
|
export * from "./use-application";
|
||||||
|
export * from "./use-calendar-view";
|
||||||
export * from "./use-cycle";
|
export * from "./use-cycle";
|
||||||
|
export * from "./use-dashboard";
|
||||||
export * from "./use-estimate";
|
export * from "./use-estimate";
|
||||||
export * from "./use-global-view";
|
export * from "./use-global-view";
|
||||||
export * from "./use-inbox";
|
export * from "./use-inbox";
|
||||||
@ -18,6 +20,5 @@ export * from "./use-user";
|
|||||||
export * from "./use-webhook";
|
export * from "./use-webhook";
|
||||||
export * from "./use-workspace";
|
export * from "./use-workspace";
|
||||||
export * from "./use-issues";
|
export * from "./use-issues";
|
||||||
export * from "./use-calendar-view";
|
|
||||||
export * from "./use-kanban-view";
|
export * from "./use-kanban-view";
|
||||||
export * from "./use-issue-detail";
|
export * from "./use-issue-detail";
|
||||||
|
11
web/hooks/store/use-dashboard.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { useContext } from "react";
|
||||||
|
// mobx store
|
||||||
|
import { StoreContext } from "contexts/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;
|
||||||
|
};
|
@ -20,6 +20,7 @@
|
|||||||
"@nivo/core": "0.80.0",
|
"@nivo/core": "0.80.0",
|
||||||
"@nivo/legends": "0.80.0",
|
"@nivo/legends": "0.80.0",
|
||||||
"@nivo/line": "0.80.0",
|
"@nivo/line": "0.80.0",
|
||||||
|
"@nivo/marimekko": "0.80.0",
|
||||||
"@nivo/pie": "0.80.0",
|
"@nivo/pie": "0.80.0",
|
||||||
"@nivo/scatterplot": "0.80.0",
|
"@nivo/scatterplot": "0.80.0",
|
||||||
"@plane/document-editor": "*",
|
"@plane/document-editor": "*",
|
||||||
|
After Width: | Height: | Size: 144 KiB |
After Width: | Height: | Size: 249 KiB |
105
web/public/empty-state/dashboard/dark/issues-by-priority.svg
Normal file
After Width: | Height: | Size: 46 KiB |
After Width: | Height: | Size: 74 KiB |
After Width: | Height: | Size: 202 KiB |
171
web/public/empty-state/dashboard/dark/overdue-created-issues.svg
Normal file
After Width: | Height: | Size: 275 KiB |
102
web/public/empty-state/dashboard/dark/recent-activity.svg
Normal file
After Width: | Height: | Size: 363 KiB |