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>
This commit is contained in:
Bavisetti Narayan 2024-01-18 15:49:54 +05:30 committed by sriram veeraghanta
parent f347c1cd69
commit c9337d4a41
122 changed files with 6790 additions and 849 deletions

View File

@ -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

View File

@ -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:

View 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"
]

View File

@ -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",

View File

@ -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,

View 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",
),
]

View File

@ -176,3 +176,8 @@ from .webhook import (
WebhookLogsEndpoint, WebhookLogsEndpoint,
WebhookSecretRegenerateEndpoint, WebhookSecretRegenerateEndpoint,
) )
from .dashboard import (
DashboardEndpoint,
WidgetsEndpoint
)

View 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
)

View File

@ -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,

View File

@ -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')},
},
),
]

View 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),
]

View File

@ -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

View 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",)

View File

@ -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 = {}

View File

@ -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
View 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[];
};

View File

@ -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";

View File

@ -9,7 +9,6 @@ import type {
IStateLite, IStateLite,
Properties, Properties,
IIssueDisplayFilterOptions, IIssueDisplayFilterOptions,
IIssueReaction,
TIssue, TIssue,
} from "@plane/types"; } from "@plane/types";

View File

@ -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
View File

@ -0,0 +1,4 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs));

View File

@ -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"
} }
} }

View File

@ -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} />

View File

@ -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>}

View File

@ -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 {

View File

@ -1,2 +1,3 @@
export * from "./button"; export * from "./button";
export * from "./helper";
export * from "./toggle-switch"; export * from "./toggle-switch";

View File

@ -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);

View File

@ -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
)}
/>
)}
</>
);
}; };

View File

@ -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"],

View File

@ -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>

View 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>
);
});

View File

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

View 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>
);
});

View 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>
);
});

View 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>
);
});

View File

@ -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>
);
};

View File

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

View File

@ -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>
);
};

View File

@ -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>
);
};

View 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";

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View 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";

View File

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

View File

@ -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>
);
});

View 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>
)}
</>
);
};

View 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>
);

View 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>
);
});

View 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>
);
});

View 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>
);

View File

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

View File

@ -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>
);

View File

@ -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>
);

View 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];
};

View 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>
);

View 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>
);

View File

@ -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>
);

View 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>
);

View 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>
);
});

View 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>
);
});

View 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>
);
});

View 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>
);
});

View File

@ -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

View File

@ -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"

View File

@ -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}`}
/> />
); );

View File

@ -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 && (

View File

@ -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" />

View File

@ -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>

View File

@ -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 products 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>
</> </>
); );
}); });

View File

@ -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>

View File

@ -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">

View File

@ -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";

View 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>
);

View File

@ -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";

View File

@ -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>
);
};

View File

@ -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">

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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";

View File

@ -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>
);
};

View File

@ -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>
);

View File

@ -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
View 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,
},
};

View File

@ -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" },

View File

@ -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",

View File

@ -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",
},
}; };

View File

@ -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();

View 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;
};

View File

@ -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);
}); });
}; };

View File

@ -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";

View 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;
};

View File

@ -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": "*",

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 144 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 249 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 46 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 74 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 202 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 275 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 363 KiB

Some files were not shown because too many files have changed in this diff Show More