Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 35512057e3 | |||
| e975abff21 | |||
| f68e6023c3 | |||
| 67414983da | |||
| 37ddc64b83 | |||
| 57c25c9a5a | |||
| af5057defa | |||
| c5e7c2f6a8 | |||
| c593d5df1b | |||
| 9065b5d368 | |||
| a9e2e21641 | |||
| e175d50ab7 | |||
| eda1e46a2d | |||
| 75b8e3350a |
@@ -1,7 +1,8 @@
|
||||
name: Bug report
|
||||
description: Create a bug report to help us improve Plane
|
||||
title: "[bug]: "
|
||||
labels: [bug, need testing]
|
||||
labels: [🐛bug]
|
||||
assignees: [srinivaspendem, pushya-plane]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
name: Feature request
|
||||
description: Suggest a feature to improve Plane
|
||||
title: "[feature]: "
|
||||
labels: [feature]
|
||||
labels: [✨feature]
|
||||
assignees: [srinivaspendem, pushya-plane]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
|
||||
@@ -68,7 +68,6 @@ from .issue import (
|
||||
IssueRelationSerializer,
|
||||
RelatedIssueSerializer,
|
||||
IssuePublicSerializer,
|
||||
IssueRelationLiteSerializer,
|
||||
)
|
||||
|
||||
from .module import (
|
||||
@@ -120,3 +119,5 @@ from .notification import NotificationSerializer
|
||||
from .exporter import ExporterHistorySerializer
|
||||
|
||||
from .webhook import WebhookSerializer, WebhookLogSerializer
|
||||
|
||||
from .dashboard import DashboardSerializer, WidgetSerializer
|
||||
|
||||
@@ -59,6 +59,7 @@ class DynamicBaseSerializer(BaseSerializer):
|
||||
LabelSerializer,
|
||||
CycleIssueSerializer,
|
||||
IssueFlatSerializer,
|
||||
IssueRelationSerializer,
|
||||
)
|
||||
|
||||
# Expansion mapper
|
||||
@@ -78,14 +79,10 @@ class DynamicBaseSerializer(BaseSerializer):
|
||||
"labels": LabelSerializer,
|
||||
"issue_cycle": CycleIssueSerializer,
|
||||
"parent": IssueSerializer,
|
||||
"issue_relation": IssueRelationSerializer,
|
||||
}
|
||||
|
||||
self.fields[field] = expansion[field](
|
||||
many=True
|
||||
if field
|
||||
in ["members", "assignees", "labels", "issue_cycle"]
|
||||
else False
|
||||
)
|
||||
|
||||
self.fields[field] = expansion[field](many=True if field in ["members", "assignees", "labels", "issue_cycle", "issue_relation"] else False)
|
||||
|
||||
return self.fields
|
||||
|
||||
@@ -105,6 +102,7 @@ class DynamicBaseSerializer(BaseSerializer):
|
||||
IssueSerializer,
|
||||
LabelSerializer,
|
||||
CycleIssueSerializer,
|
||||
IssueRelationSerializer,
|
||||
)
|
||||
|
||||
# Expansion mapper
|
||||
@@ -124,6 +122,7 @@ class DynamicBaseSerializer(BaseSerializer):
|
||||
"labels": LabelSerializer,
|
||||
"issue_cycle": CycleIssueSerializer,
|
||||
"parent": IssueSerializer,
|
||||
"issue_relation": IssueRelationSerializer
|
||||
}
|
||||
# Check if field in expansion then expand the field
|
||||
if expand in expansion:
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# Module imports
|
||||
from .base import BaseSerializer
|
||||
from plane.db.models import Dashboard, Widget
|
||||
|
||||
# Third party frameworks
|
||||
from rest_framework import serializers
|
||||
|
||||
|
||||
class DashboardSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = Dashboard
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class WidgetSerializer(BaseSerializer):
|
||||
is_visible = serializers.BooleanField(read_only=True)
|
||||
widget_filters = serializers.JSONField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Widget
|
||||
fields = [
|
||||
"id",
|
||||
"key",
|
||||
"is_visible",
|
||||
"widget_filters"
|
||||
]
|
||||
@@ -293,31 +293,19 @@ class IssueLabelSerializer(BaseSerializer):
|
||||
]
|
||||
|
||||
|
||||
class IssueRelationLiteSerializer(DynamicBaseSerializer):
|
||||
project_id = serializers.PrimaryKeyRelatedField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Issue
|
||||
fields = [
|
||||
"id",
|
||||
"project_id",
|
||||
"sequence_id",
|
||||
]
|
||||
read_only_fields = [
|
||||
"workspace",
|
||||
"project",
|
||||
]
|
||||
|
||||
|
||||
class IssueRelationSerializer(BaseSerializer):
|
||||
issue_detail = IssueRelationLiteSerializer(
|
||||
read_only=True, source="related_issue"
|
||||
)
|
||||
id = serializers.UUIDField(source="related_issue.id", read_only=True)
|
||||
project_id = serializers.PrimaryKeyRelatedField(source="related_issue.project_id", read_only=True)
|
||||
sequence_id = serializers.IntegerField(source="related_issue.sequence_id", read_only=True)
|
||||
relation_type = serializers.CharField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = IssueRelation
|
||||
fields = [
|
||||
"issue_detail",
|
||||
"id",
|
||||
"project_id",
|
||||
"sequence_id",
|
||||
"relation_type",
|
||||
]
|
||||
read_only_fields = [
|
||||
"workspace",
|
||||
@@ -326,12 +314,18 @@ class IssueRelationSerializer(BaseSerializer):
|
||||
|
||||
|
||||
class RelatedIssueSerializer(BaseSerializer):
|
||||
issue_detail = IssueRelationLiteSerializer(read_only=True, source="issue")
|
||||
id = serializers.UUIDField(source="issue.id", read_only=True)
|
||||
project_id = serializers.PrimaryKeyRelatedField(source="issue.project_id", read_only=True)
|
||||
sequence_id = serializers.IntegerField(source="issue.sequence_id", read_only=True)
|
||||
relation_type = serializers.CharField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = IssueRelation
|
||||
fields = [
|
||||
"issue_detail",
|
||||
"id",
|
||||
"project_id",
|
||||
"sequence_id",
|
||||
"relation_type",
|
||||
]
|
||||
read_only_fields = [
|
||||
"workspace",
|
||||
|
||||
@@ -3,6 +3,7 @@ from .asset import urlpatterns as asset_urls
|
||||
from .authentication import urlpatterns as authentication_urls
|
||||
from .config import urlpatterns as configuration_urls
|
||||
from .cycle import urlpatterns as cycle_urls
|
||||
from .dashboard import urlpatterns as dashboard_urls
|
||||
from .estimate import urlpatterns as estimate_urls
|
||||
from .external import urlpatterns as external_urls
|
||||
from .importer import urlpatterns as importer_urls
|
||||
@@ -28,6 +29,7 @@ urlpatterns = [
|
||||
*authentication_urls,
|
||||
*configuration_urls,
|
||||
*cycle_urls,
|
||||
*dashboard_urls,
|
||||
*estimate_urls,
|
||||
*external_urls,
|
||||
*importer_urls,
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
]
|
||||
@@ -177,3 +177,8 @@ from .webhook import (
|
||||
WebhookLogsEndpoint,
|
||||
WebhookSecretRegenerateEndpoint,
|
||||
)
|
||||
|
||||
from .dashboard import (
|
||||
DashboardEndpoint,
|
||||
WidgetsEndpoint
|
||||
)
|
||||
@@ -325,7 +325,7 @@ class MagicSignInEndpoint(BaseAPIView):
|
||||
)
|
||||
|
||||
user_token = request.data.get("token", "").strip()
|
||||
key = request.data.get("key", False).strip().lower()
|
||||
key = request.data.get("key", "").strip().lower()
|
||||
|
||||
if not key or user_token == "":
|
||||
return Response(
|
||||
|
||||
@@ -0,0 +1,658 @@
|
||||
# Django imports
|
||||
from django.db.models import (
|
||||
Q,
|
||||
Case,
|
||||
When,
|
||||
Value,
|
||||
CharField,
|
||||
Count,
|
||||
F,
|
||||
Exists,
|
||||
OuterRef,
|
||||
Max,
|
||||
Subquery,
|
||||
JSONField,
|
||||
Func,
|
||||
Prefetch,
|
||||
)
|
||||
from django.utils import timezone
|
||||
|
||||
# Third Party imports
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
|
||||
# Module imports
|
||||
from . import BaseAPIView
|
||||
from plane.db.models import (
|
||||
Issue,
|
||||
IssueActivity,
|
||||
ProjectMember,
|
||||
Widget,
|
||||
DashboardWidget,
|
||||
Dashboard,
|
||||
Project,
|
||||
IssueLink,
|
||||
IssueAttachment,
|
||||
IssueRelation,
|
||||
)
|
||||
from plane.app.serializers import (
|
||||
IssueActivitySerializer,
|
||||
IssueSerializer,
|
||||
DashboardSerializer,
|
||||
WidgetSerializer,
|
||||
)
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
|
||||
|
||||
def dashboard_overview_stats(self, request, slug):
|
||||
assigned_issues = Issue.issue_objects.filter(
|
||||
project__project_projectmember__is_active=True,
|
||||
project__project_projectmember__member=request.user,
|
||||
workspace__slug=slug,
|
||||
assignees__in=[request.user],
|
||||
).count()
|
||||
|
||||
pending_issues_count = Issue.issue_objects.filter(
|
||||
~Q(state__group__in=["completed", "cancelled"]),
|
||||
project__project_projectmember__is_active=True,
|
||||
project__project_projectmember__member=request.user,
|
||||
workspace__slug=slug,
|
||||
assignees__in=[request.user],
|
||||
).count()
|
||||
|
||||
created_issues_count = Issue.issue_objects.filter(
|
||||
workspace__slug=slug,
|
||||
project__project_projectmember__is_active=True,
|
||||
project__project_projectmember__member=request.user,
|
||||
created_by_id=request.user.id,
|
||||
).count()
|
||||
|
||||
completed_issues_count = Issue.issue_objects.filter(
|
||||
workspace__slug=slug,
|
||||
project__project_projectmember__is_active=True,
|
||||
project__project_projectmember__member=request.user,
|
||||
assignees__in=[request.user],
|
||||
state__group="completed",
|
||||
).count()
|
||||
|
||||
return Response(
|
||||
{
|
||||
"assigned_issues_count": assigned_issues,
|
||||
"pending_issues_count": pending_issues_count,
|
||||
"completed_issues_count": completed_issues_count,
|
||||
"created_issues_count": created_issues_count,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
|
||||
def dashboard_assigned_issues(self, request, slug):
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
issue_type = request.GET.get("issue_type", None)
|
||||
|
||||
# get all the assigned issues
|
||||
assigned_issues = (
|
||||
Issue.issue_objects.filter(
|
||||
workspace__slug=slug,
|
||||
project__project_projectmember__member=request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
assignees__in=[request.user],
|
||||
)
|
||||
.filter(**filters)
|
||||
.select_related("workspace", "project", "state", "parent")
|
||||
.prefetch_related("assignees", "labels")
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_relation",
|
||||
queryset=IssueRelation.objects.select_related(
|
||||
"related_issue"
|
||||
).select_related("issue"),
|
||||
)
|
||||
)
|
||||
.annotate(cycle_id=F("issue_cycle__cycle_id"))
|
||||
.annotate(module_id=F("issue_module__module_id"))
|
||||
.annotate(
|
||||
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
attachment_count=IssueAttachment.objects.filter(
|
||||
issue=OuterRef("id")
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.order_by("created_at")
|
||||
)
|
||||
|
||||
# Priority Ordering
|
||||
priority_order = ["urgent", "high", "medium", "low", "none"]
|
||||
assigned_issues = assigned_issues.annotate(
|
||||
priority_order=Case(
|
||||
*[
|
||||
When(priority=p, then=Value(i))
|
||||
for i, p in enumerate(priority_order)
|
||||
],
|
||||
output_field=CharField(),
|
||||
)
|
||||
).order_by("priority_order")
|
||||
|
||||
if issue_type == "completed":
|
||||
completed_issues_count = assigned_issues.filter(
|
||||
state__group__in=["completed"]
|
||||
).count()
|
||||
completed_issues = assigned_issues.filter(
|
||||
state__group__in=["completed"]
|
||||
)[:5]
|
||||
return Response(
|
||||
{
|
||||
"issues": IssueSerializer(
|
||||
completed_issues, many=True, expand=self.expand
|
||||
).data,
|
||||
"count": completed_issues_count,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
if issue_type == "overdue":
|
||||
overdue_issues_count = assigned_issues.filter(
|
||||
state__group__in=["backlog", "unstarted", "started"],
|
||||
target_date__lt=timezone.now()
|
||||
).count()
|
||||
overdue_issues = assigned_issues.filter(
|
||||
state__group__in=["backlog", "unstarted", "started"],
|
||||
target_date__lt=timezone.now()
|
||||
)[:5]
|
||||
return Response(
|
||||
{
|
||||
"issues": IssueSerializer(
|
||||
overdue_issues, many=True, expand=self.expand
|
||||
).data,
|
||||
"count": overdue_issues_count,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
if issue_type == "upcoming":
|
||||
upcoming_issues_count = assigned_issues.filter(
|
||||
state__group__in=["backlog", "unstarted", "started"],
|
||||
target_date__gte=timezone.now()
|
||||
).count()
|
||||
upcoming_issues = assigned_issues.filter(
|
||||
state__group__in=["backlog", "unstarted", "started"],
|
||||
target_date__gte=timezone.now()
|
||||
)[:5]
|
||||
return Response(
|
||||
{
|
||||
"issues": IssueSerializer(
|
||||
upcoming_issues, many=True, expand=self.expand
|
||||
).data,
|
||||
"count": upcoming_issues_count,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
return Response(
|
||||
{"error": "Please specify a valid issue type"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
def dashboard_created_issues(self, request, slug):
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
issue_type = request.GET.get("issue_type", None)
|
||||
|
||||
# get all the assigned issues
|
||||
created_issues = (
|
||||
Issue.issue_objects.filter(
|
||||
workspace__slug=slug,
|
||||
project__project_projectmember__member=request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
created_by=request.user,
|
||||
)
|
||||
.filter(**filters)
|
||||
.select_related("workspace", "project", "state", "parent")
|
||||
.prefetch_related("assignees", "labels")
|
||||
.annotate(cycle_id=F("issue_cycle__cycle_id"))
|
||||
.annotate(module_id=F("issue_module__module_id"))
|
||||
.annotate(
|
||||
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
attachment_count=IssueAttachment.objects.filter(
|
||||
issue=OuterRef("id")
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.order_by("created_at")
|
||||
)
|
||||
|
||||
# Priority Ordering
|
||||
priority_order = ["urgent", "high", "medium", "low", "none"]
|
||||
created_issues = created_issues.annotate(
|
||||
priority_order=Case(
|
||||
*[
|
||||
When(priority=p, then=Value(i))
|
||||
for i, p in enumerate(priority_order)
|
||||
],
|
||||
output_field=CharField(),
|
||||
)
|
||||
).order_by("priority_order")
|
||||
|
||||
if issue_type == "completed":
|
||||
completed_issues_count = created_issues.filter(
|
||||
state__group__in=["completed"]
|
||||
).count()
|
||||
completed_issues = created_issues.filter(
|
||||
state__group__in=["completed"]
|
||||
)[:5]
|
||||
return Response(
|
||||
{
|
||||
"issues": IssueSerializer(completed_issues, many=True).data,
|
||||
"count": completed_issues_count,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
if issue_type == "overdue":
|
||||
overdue_issues_count = created_issues.filter(
|
||||
state__group__in=["backlog", "unstarted", "started"],
|
||||
target_date__lt=timezone.now()
|
||||
).count()
|
||||
overdue_issues = created_issues.filter(
|
||||
state__group__in=["backlog", "unstarted", "started"],
|
||||
target_date__lt=timezone.now()
|
||||
)[:5]
|
||||
return Response(
|
||||
{
|
||||
"issues": IssueSerializer(overdue_issues, many=True).data,
|
||||
"count": overdue_issues_count,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
if issue_type == "upcoming":
|
||||
upcoming_issues_count = created_issues.filter(
|
||||
state__group__in=["backlog", "unstarted", "started"],
|
||||
target_date__gte=timezone.now()
|
||||
).count()
|
||||
upcoming_issues = created_issues.filter(
|
||||
state__group__in=["backlog", "unstarted", "started"],
|
||||
target_date__gte=timezone.now()
|
||||
)[:5]
|
||||
return Response(
|
||||
{
|
||||
"issues": IssueSerializer(upcoming_issues, many=True).data,
|
||||
"count": upcoming_issues_count,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
return Response(
|
||||
{"error": "Please specify a valid issue type"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
def dashboard_issues_by_state_groups(self, request, slug):
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
state_order = ["backlog", "unstarted", "started", "completed", "cancelled"]
|
||||
issues_by_state_groups = (
|
||||
Issue.issue_objects.filter(
|
||||
workspace__slug=slug,
|
||||
project__project_projectmember__is_active=True,
|
||||
project__project_projectmember__member=request.user,
|
||||
assignees__in=[request.user],
|
||||
)
|
||||
.filter(**filters)
|
||||
.values("state__group")
|
||||
.annotate(count=Count("id"))
|
||||
)
|
||||
|
||||
# default state
|
||||
all_groups = {state: 0 for state in state_order}
|
||||
|
||||
# Update counts for existing groups
|
||||
for entry in issues_by_state_groups:
|
||||
all_groups[entry["state__group"]] = entry["count"]
|
||||
|
||||
# Prepare output including all groups with their counts
|
||||
output_data = [
|
||||
{"state": group, "count": count} for group, count in all_groups.items()
|
||||
]
|
||||
|
||||
return Response(output_data, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
def dashboard_issues_by_priority(self, request, slug):
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
priority_order = ["urgent", "high", "medium", "low", "none"]
|
||||
|
||||
issues_by_priority = (
|
||||
Issue.issue_objects.filter(
|
||||
workspace__slug=slug,
|
||||
project__project_projectmember__is_active=True,
|
||||
project__project_projectmember__member=request.user,
|
||||
assignees__in=[request.user],
|
||||
)
|
||||
.filter(**filters)
|
||||
.values("priority")
|
||||
.annotate(count=Count("id"))
|
||||
)
|
||||
|
||||
# default priority
|
||||
all_groups = {priority: 0 for priority in priority_order}
|
||||
|
||||
# Update counts for existing groups
|
||||
for entry in issues_by_priority:
|
||||
all_groups[entry["priority"]] = entry["count"]
|
||||
|
||||
# Prepare output including all groups with their counts
|
||||
output_data = [
|
||||
{"priority": group, "count": count}
|
||||
for group, count in all_groups.items()
|
||||
]
|
||||
|
||||
return Response(output_data, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
def dashboard_recent_activity(self, request, slug):
|
||||
queryset = IssueActivity.objects.filter(
|
||||
~Q(field__in=["comment", "vote", "reaction", "draft"]),
|
||||
workspace__slug=slug,
|
||||
project__project_projectmember__member=request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
actor=request.user,
|
||||
).select_related("actor", "workspace", "issue", "project")[:8]
|
||||
|
||||
return Response(
|
||||
IssueActivitySerializer(queryset, many=True).data,
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
|
||||
def dashboard_recent_projects(self, request, slug):
|
||||
project_ids = (
|
||||
IssueActivity.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project__project_projectmember__member=request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
actor=request.user,
|
||||
)
|
||||
.values_list("project_id", flat=True)
|
||||
.distinct()
|
||||
)
|
||||
|
||||
# Extract project IDs from the recent projects
|
||||
unique_project_ids = set(project_id for project_id in project_ids)
|
||||
|
||||
# Fetch additional projects only if needed
|
||||
if len(unique_project_ids) < 4:
|
||||
additional_projects = Project.objects.filter(
|
||||
project_projectmember__member=request.user,
|
||||
project_projectmember__is_active=True,
|
||||
workspace__slug=slug,
|
||||
).exclude(id__in=unique_project_ids)
|
||||
|
||||
# Append additional project IDs to the existing list
|
||||
unique_project_ids.update(additional_projects.values_list("id", flat=True))
|
||||
|
||||
return Response(
|
||||
list(unique_project_ids)[:4],
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
|
||||
def dashboard_recent_collaborators(self, request, slug):
|
||||
# Fetch all project IDs where the user belongs to
|
||||
user_projects = Project.objects.filter(
|
||||
project_projectmember__member=request.user,
|
||||
project_projectmember__is_active=True,
|
||||
workspace__slug=slug,
|
||||
).values_list("id", flat=True)
|
||||
|
||||
# Fetch all users who have performed an activity in the projects where the user exists
|
||||
users_with_activities = (
|
||||
IssueActivity.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id__in=user_projects,
|
||||
)
|
||||
.values("actor")
|
||||
.exclude(actor=request.user)
|
||||
.annotate(num_activities=Count("actor"))
|
||||
.order_by("-num_activities")
|
||||
)[:7]
|
||||
|
||||
# Get the count of active issues for each user in users_with_activities
|
||||
users_with_active_issues = []
|
||||
for user_activity in users_with_activities:
|
||||
user_id = user_activity["actor"]
|
||||
active_issue_count = Issue.objects.filter(
|
||||
assignees__in=[user_id],
|
||||
state__group__in=["unstarted", "started"],
|
||||
).count()
|
||||
users_with_active_issues.append(
|
||||
{"user_id": user_id, "active_issue_count": active_issue_count}
|
||||
)
|
||||
|
||||
# Insert the logged-in user's ID and their active issue count at the beginning
|
||||
active_issue_count = Issue.objects.filter(
|
||||
assignees__in=[request.user],
|
||||
state__group__in=["unstarted", "started"],
|
||||
).count()
|
||||
|
||||
if users_with_activities.count() < 7:
|
||||
# Calculate the additional collaborators needed
|
||||
additional_collaborators_needed = 7 - users_with_activities.count()
|
||||
|
||||
# Fetch additional collaborators from the project_member table
|
||||
additional_collaborators = list(
|
||||
set(
|
||||
ProjectMember.objects.filter(
|
||||
~Q(member=request.user),
|
||||
project_id__in=user_projects,
|
||||
workspace__slug=slug,
|
||||
)
|
||||
.exclude(
|
||||
member__in=[
|
||||
user["actor"] for user in users_with_activities
|
||||
]
|
||||
)
|
||||
.values_list("member", flat=True)
|
||||
)
|
||||
)
|
||||
|
||||
additional_collaborators = additional_collaborators[
|
||||
:additional_collaborators_needed
|
||||
]
|
||||
|
||||
# Append additional collaborators to the list
|
||||
for collaborator_id in additional_collaborators:
|
||||
active_issue_count = Issue.objects.filter(
|
||||
assignees__in=[collaborator_id],
|
||||
state__group__in=["unstarted", "started"],
|
||||
).count()
|
||||
users_with_active_issues.append(
|
||||
{
|
||||
"user_id": str(collaborator_id),
|
||||
"active_issue_count": active_issue_count,
|
||||
}
|
||||
)
|
||||
|
||||
users_with_active_issues.insert(
|
||||
0,
|
||||
{"user_id": request.user.id, "active_issue_count": active_issue_count},
|
||||
)
|
||||
|
||||
return Response(users_with_active_issues, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class DashboardEndpoint(BaseAPIView):
|
||||
def create(self, request, slug):
|
||||
serializer = DashboardSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def patch(self, request, slug, pk):
|
||||
serializer = DashboardSerializer(data=request.data, partial=True)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def delete(self, request, slug, pk):
|
||||
serializer = DashboardSerializer(data=request.data, partial=True)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return Response(serializer.data, status=status.HTTP_204_NO_CONTENT)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def get(self, request, slug, dashboard_id=None):
|
||||
if not dashboard_id:
|
||||
dashboard_type = request.GET.get("dashboard_type", None)
|
||||
if dashboard_type == "home":
|
||||
dashboard, created = Dashboard.objects.get_or_create(
|
||||
type_identifier=dashboard_type, owned_by=request.user, is_default=True
|
||||
)
|
||||
|
||||
if created:
|
||||
widgets_to_fetch = [
|
||||
"overview_stats",
|
||||
"assigned_issues",
|
||||
"created_issues",
|
||||
"issues_by_state_groups",
|
||||
"issues_by_priority",
|
||||
"recent_activity",
|
||||
"recent_projects",
|
||||
"recent_collaborators",
|
||||
]
|
||||
|
||||
updated_dashboard_widgets = []
|
||||
for widget_key in widgets_to_fetch:
|
||||
widget = Widget.objects.filter(key=widget_key).values_list("id", flat=True)
|
||||
if widget:
|
||||
updated_dashboard_widgets.append(
|
||||
DashboardWidget(
|
||||
widget_id=widget,
|
||||
dashboard_id=dashboard.id,
|
||||
)
|
||||
)
|
||||
|
||||
DashboardWidget.objects.bulk_create(
|
||||
updated_dashboard_widgets, batch_size=100
|
||||
)
|
||||
|
||||
widgets = (
|
||||
Widget.objects.annotate(
|
||||
is_visible=Exists(
|
||||
DashboardWidget.objects.filter(
|
||||
widget_id=OuterRef("pk"),
|
||||
dashboard_id=dashboard.id,
|
||||
is_visible=True,
|
||||
)
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
dashboard_filters=Subquery(
|
||||
DashboardWidget.objects.filter(
|
||||
widget_id=OuterRef("pk"),
|
||||
dashboard_id=dashboard.id,
|
||||
filters__isnull=False,
|
||||
)
|
||||
.exclude(filters={})
|
||||
.values("filters")[:1]
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
widget_filters=Case(
|
||||
When(
|
||||
dashboard_filters__isnull=False,
|
||||
then=F("dashboard_filters"),
|
||||
),
|
||||
default=F("filters"),
|
||||
output_field=JSONField(),
|
||||
)
|
||||
)
|
||||
)
|
||||
return Response(
|
||||
{
|
||||
"dashboard": DashboardSerializer(dashboard).data,
|
||||
"widgets": WidgetSerializer(widgets, many=True).data,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
return Response(
|
||||
{"error": "Please specify a valid dashboard type"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
widget_key = request.GET.get("widget_key", "overview_stats")
|
||||
|
||||
WIDGETS_MAPPER = {
|
||||
"overview_stats": dashboard_overview_stats,
|
||||
"assigned_issues": dashboard_assigned_issues,
|
||||
"created_issues": dashboard_created_issues,
|
||||
"issues_by_state_groups": dashboard_issues_by_state_groups,
|
||||
"issues_by_priority": dashboard_issues_by_priority,
|
||||
"recent_activity": dashboard_recent_activity,
|
||||
"recent_projects": dashboard_recent_projects,
|
||||
"recent_collaborators": dashboard_recent_collaborators,
|
||||
}
|
||||
|
||||
func = WIDGETS_MAPPER.get(widget_key)
|
||||
if func is not None:
|
||||
response = func(
|
||||
self,
|
||||
request=request,
|
||||
slug=slug,
|
||||
)
|
||||
if isinstance(response, Response):
|
||||
return response
|
||||
|
||||
return Response(
|
||||
{"error": "Please specify a valid widget key"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class WidgetsEndpoint(BaseAPIView):
|
||||
def patch(self, request, dashboard_id, widget_id):
|
||||
dashboard_widget = DashboardWidget.objects.filter(
|
||||
widget_id=widget_id,
|
||||
dashboard_id=dashboard_id,
|
||||
).first()
|
||||
dashboard_widget.is_visible = request.data.get(
|
||||
"is_visible", dashboard_widget.is_visible
|
||||
)
|
||||
dashboard_widget.sort_order = request.data.get(
|
||||
"sort_order", dashboard_widget.sort_order
|
||||
)
|
||||
dashboard_widget.filters = request.data.get(
|
||||
"filters", dashboard_widget.filters
|
||||
)
|
||||
dashboard_widget.save()
|
||||
return Response(
|
||||
{"message": "successfully updated"}, status=status.HTTP_200_OK
|
||||
)
|
||||
@@ -52,7 +52,6 @@ from plane.app.serializers import (
|
||||
IssueRelationSerializer,
|
||||
RelatedIssueSerializer,
|
||||
IssuePublicSerializer,
|
||||
IssueRelationLiteSerializer,
|
||||
)
|
||||
from plane.app.permissions import (
|
||||
ProjectEntityPermission,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Python imports
|
||||
from datetime import timedelta, date, datetime
|
||||
from datetime import date, datetime, timedelta
|
||||
|
||||
# Django imports
|
||||
from django.db import connection
|
||||
@@ -7,30 +7,19 @@ from django.db.models import Exists, OuterRef, Q
|
||||
from django.utils import timezone
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.gzip import gzip_page
|
||||
|
||||
# Third party imports
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
|
||||
# Module imports
|
||||
from .base import BaseViewSet, BaseAPIView
|
||||
from plane.app.permissions import ProjectEntityPermission
|
||||
from plane.db.models import (
|
||||
Page,
|
||||
PageFavorite,
|
||||
Issue,
|
||||
IssueAssignee,
|
||||
IssueActivity,
|
||||
PageLog,
|
||||
ProjectMember,
|
||||
)
|
||||
from plane.app.serializers import (
|
||||
PageSerializer,
|
||||
PageFavoriteSerializer,
|
||||
PageLogSerializer,
|
||||
IssueLiteSerializer,
|
||||
SubPageSerializer,
|
||||
)
|
||||
from plane.app.serializers import (IssueLiteSerializer, PageFavoriteSerializer,
|
||||
PageLogSerializer, PageSerializer,
|
||||
SubPageSerializer)
|
||||
from plane.db.models import (Issue, IssueActivity, IssueAssignee, Page,
|
||||
PageFavorite, PageLog, ProjectMember)
|
||||
|
||||
# Module imports
|
||||
from .base import BaseAPIView, BaseViewSet
|
||||
|
||||
|
||||
def unarchive_archive_page_and_descendants(page_id, archived_at):
|
||||
@@ -175,7 +164,7 @@ class PageViewSet(BaseViewSet):
|
||||
project_id=project_id,
|
||||
member=request.user,
|
||||
is_active=True,
|
||||
role__gt=20,
|
||||
role__gte=20,
|
||||
).exists()
|
||||
or request.user.id != page.owned_by_id
|
||||
):
|
||||
|
||||
@@ -41,7 +41,7 @@ from plane.app.serializers import (
|
||||
ProjectMemberSerializer,
|
||||
WorkspaceThemeSerializer,
|
||||
IssueActivitySerializer,
|
||||
IssueLiteSerializer,
|
||||
IssueSerializer,
|
||||
WorkspaceMemberAdminSerializer,
|
||||
WorkspaceMemberMeSerializer,
|
||||
ProjectMemberRoleSerializer,
|
||||
@@ -1339,23 +1339,10 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
|
||||
project__project_projectmember__member=request.user,
|
||||
)
|
||||
.filter(**filters)
|
||||
.annotate(
|
||||
sub_issues_count=Issue.issue_objects.filter(
|
||||
parent=OuterRef("id")
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.select_related("project", "workspace", "state", "parent")
|
||||
.select_related("workspace", "project", "state", "parent")
|
||||
.prefetch_related("assignees", "labels")
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_reactions",
|
||||
queryset=IssueReaction.objects.select_related("actor"),
|
||||
)
|
||||
)
|
||||
.order_by("-created_at")
|
||||
.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()
|
||||
@@ -1370,6 +1357,13 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
|
||||
.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")
|
||||
).distinct()
|
||||
|
||||
# Priority Ordering
|
||||
@@ -1432,7 +1426,7 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
|
||||
else:
|
||||
issue_queryset = issue_queryset.order_by(order_by_param)
|
||||
|
||||
issues = IssueLiteSerializer(
|
||||
issues = IssueSerializer(
|
||||
issue_queryset, many=True, fields=fields if fields else None
|
||||
).data
|
||||
return Response(issues, status=status.HTTP_200_OK)
|
||||
|
||||
@@ -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')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,97 @@
|
||||
# Generated by Django 4.2.7 on 2024-01-08 06:48
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def create_widgets(apps, schema_editor):
|
||||
Widget = apps.get_model("db", "Widget")
|
||||
widgets_list = [
|
||||
{"key": "overview_stats", "filters": {}},
|
||||
{
|
||||
"key": "assigned_issues",
|
||||
"filters": {
|
||||
"duration": "this_week",
|
||||
"tab": "upcoming",
|
||||
},
|
||||
},
|
||||
{
|
||||
"key": "created_issues",
|
||||
"filters": {
|
||||
"duration": "this_week",
|
||||
"tab": "upcoming",
|
||||
},
|
||||
},
|
||||
{
|
||||
"key": "issues_by_state_groups",
|
||||
"filters": {
|
||||
"duration": "this_week",
|
||||
},
|
||||
},
|
||||
{
|
||||
"key": "issues_by_priority",
|
||||
"filters": {
|
||||
"duration": "this_week",
|
||||
},
|
||||
},
|
||||
{"key": "recent_activity", "filters": {}},
|
||||
{"key": "recent_projects", "filters": {}},
|
||||
{"key": "recent_collaborators", "filters": {}},
|
||||
]
|
||||
Widget.objects.bulk_create(
|
||||
[
|
||||
Widget(
|
||||
key=widget["key"],
|
||||
filters=widget["filters"],
|
||||
)
|
||||
for widget in widgets_list
|
||||
],
|
||||
batch_size=10,
|
||||
)
|
||||
|
||||
|
||||
def create_dashboards(apps, schema_editor):
|
||||
Dashboard = apps.get_model("db", "Dashboard")
|
||||
User = apps.get_model("db", "User")
|
||||
Dashboard.objects.bulk_create(
|
||||
[
|
||||
Dashboard(
|
||||
name="Home dashboard",
|
||||
description_html="<p></p>",
|
||||
identifier=None,
|
||||
owned_by_id=user_id,
|
||||
type_identifier="home",
|
||||
is_default=True,
|
||||
)
|
||||
for user_id in User.objects.values_list('id', flat=True)
|
||||
],
|
||||
batch_size=2000,
|
||||
)
|
||||
|
||||
|
||||
def create_dashboard_widgets(apps, schema_editor):
|
||||
Widget = apps.get_model("db", "Widget")
|
||||
Dashboard = apps.get_model("db", "Dashboard")
|
||||
DashboardWidget = apps.get_model("db", "DashboardWidget")
|
||||
|
||||
updated_dashboard_widget = [
|
||||
DashboardWidget(
|
||||
widget_id=widget_id,
|
||||
dashboard_id=dashboard_id,
|
||||
)
|
||||
for widget_id in Widget.objects.values_list('id', flat=True)
|
||||
for dashboard_id in Dashboard.objects.values_list('id', flat=True)
|
||||
]
|
||||
|
||||
DashboardWidget.objects.bulk_create(updated_dashboard_widget, batch_size=2000)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("db", "0054_dashboard_widget_dashboardwidget"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(create_widgets),
|
||||
migrations.RunPython(create_dashboards),
|
||||
migrations.RunPython(create_dashboard_widgets),
|
||||
]
|
||||
@@ -90,3 +90,5 @@ from .notification import Notification
|
||||
from .exporter import ExporterHistory
|
||||
|
||||
from .webhook import Webhook, WebhookLog
|
||||
|
||||
from .dashboard import Dashboard, DashboardWidget, Widget
|
||||
@@ -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",)
|
||||
@@ -7,19 +7,17 @@ from . import ProjectBaseModel
|
||||
|
||||
|
||||
def get_default_filters():
|
||||
return (
|
||||
{
|
||||
"priority": None,
|
||||
"state": None,
|
||||
"state_group": None,
|
||||
"assignees": None,
|
||||
"created_by": None,
|
||||
"labels": None,
|
||||
"start_date": None,
|
||||
"target_date": None,
|
||||
"subscriber": None,
|
||||
},
|
||||
)
|
||||
return {
|
||||
"priority": None,
|
||||
"state": None,
|
||||
"state_group": None,
|
||||
"assignees": None,
|
||||
"created_by": None,
|
||||
"labels": None,
|
||||
"start_date": None,
|
||||
"target_date": None,
|
||||
"subscriber": None,
|
||||
}
|
||||
|
||||
|
||||
def get_default_display_filters():
|
||||
|
||||
@@ -3,7 +3,6 @@ import uuid
|
||||
from datetime import timedelta
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
# The date from pattern
|
||||
pattern = re.compile(r"\d+_(weeks|months)$")
|
||||
|
||||
@@ -464,7 +463,7 @@ def filter_start_target_date_issues(params, filter, method):
|
||||
filter["target_date__isnull"] = False
|
||||
filter["start_date__isnull"] = False
|
||||
return filter
|
||||
|
||||
|
||||
|
||||
def issue_filters(query_params, method):
|
||||
filter = {}
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
"@tiptap/extension-code": "^2.1.13",
|
||||
"@tiptap/extension-code-block-lowlight": "^2.1.13",
|
||||
"@tiptap/extension-color": "^2.1.13",
|
||||
"@tiptap/extension-hard-break": "^2.1.16",
|
||||
"@tiptap/extension-image": "^2.1.13",
|
||||
"@tiptap/extension-list-item": "^2.1.13",
|
||||
"@tiptap/extension-mention": "^2.1.13",
|
||||
|
||||
@@ -5,14 +5,18 @@ interface EditorContainerProps {
|
||||
editor: Editor | null;
|
||||
editorClassNames: string;
|
||||
children: ReactNode;
|
||||
hideDragHandle?: () => void;
|
||||
}
|
||||
|
||||
export const EditorContainer = ({ editor, editorClassNames, children }: EditorContainerProps) => (
|
||||
export const EditorContainer = ({ editor, editorClassNames, hideDragHandle, children }: EditorContainerProps) => (
|
||||
<div
|
||||
id="editor-container"
|
||||
onClick={() => {
|
||||
editor?.chain().focus().run();
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
hideDragHandle?.();
|
||||
}}
|
||||
className={`cursor-text ${editorClassNames}`}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -27,6 +27,7 @@ import { IMentionSuggestion } from "src/types/mention-suggestion";
|
||||
import { RestoreImage } from "src/types/restore-image";
|
||||
import { CustomLinkExtension } from "src/ui/extensions/custom-link";
|
||||
import { CustomCodeInlineExtension } from "./code-inline";
|
||||
import HardBreak from "@tiptap/extension-hard-break";
|
||||
|
||||
export const CoreEditorExtensions = (
|
||||
mentionConfig: {
|
||||
@@ -65,6 +66,11 @@ export const CoreEditorExtensions = (
|
||||
CustomQuoteExtension.configure({
|
||||
HTMLAttributes: { className: "border-l-4 border-custom-border-300" },
|
||||
}),
|
||||
HardBreak.configure({
|
||||
HTMLAttributes: {
|
||||
class: "p-2",
|
||||
},
|
||||
}),
|
||||
CustomKeymap,
|
||||
ListKeymap,
|
||||
CustomLinkExtension.configure({
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"@plane/editor-core": "*",
|
||||
"@plane/editor-extensions": "*",
|
||||
"@plane/ui": "*",
|
||||
"@tippyjs/react": "^4.2.6",
|
||||
"@tiptap/core": "^2.1.13",
|
||||
"@tiptap/extension-placeholder": "^2.1.13",
|
||||
"@tiptap/pm": "^2.1.13",
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
|
||||
type IPageRenderer = {
|
||||
documentDetails: DocumentDetails;
|
||||
updatePageTitle: (title: string) => Promise<void>;
|
||||
updatePageTitle: (title: string) => void;
|
||||
editor: Editor;
|
||||
onActionCompleteHandler: (action: {
|
||||
title: string;
|
||||
@@ -30,18 +30,6 @@ type IPageRenderer = {
|
||||
readonly: boolean;
|
||||
};
|
||||
|
||||
const debounce = (func: (...args: any[]) => void, wait: number) => {
|
||||
let timeout: NodeJS.Timeout | null = null;
|
||||
return function executedFunction(...args: any[]) {
|
||||
const later = () => {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
func(...args);
|
||||
};
|
||||
if (timeout) clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
};
|
||||
};
|
||||
|
||||
export const PageRenderer = (props: IPageRenderer) => {
|
||||
const { documentDetails, editor, editorClassNames, editorContentCustomClassNames, updatePageTitle, readonly } = props;
|
||||
|
||||
@@ -64,11 +52,9 @@ export const PageRenderer = (props: IPageRenderer) => {
|
||||
|
||||
const { getFloatingProps } = useInteractions([dismiss]);
|
||||
|
||||
const debouncedUpdatePageTitle = debounce(updatePageTitle, 300);
|
||||
|
||||
const handlePageTitleChange = (title: string) => {
|
||||
setPagetitle(title);
|
||||
debouncedUpdatePageTitle(title);
|
||||
updatePageTitle(title);
|
||||
};
|
||||
|
||||
const [cleanup, setcleanup] = useState(() => () => {});
|
||||
|
||||
@@ -26,7 +26,7 @@ export const DocumentEditorExtensions = (
|
||||
.focus()
|
||||
.insertContentAt(
|
||||
range,
|
||||
"<p class='text-sm bg-gray-300 w-fit pl-3 pr-3 pt-1 pb-1 rounded shadow-sm'>#issue_</p>"
|
||||
"<p class='text-sm bg-gray-300 w-fit pl-3 pr-3 pt-1 pb-1 rounded shadow-sm'>#issue_</p>\n"
|
||||
)
|
||||
.run();
|
||||
},
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@ export const IssueSuggestions = (suggestions: any[]) => {
|
||||
title: suggestion.name,
|
||||
priority: suggestion.priority.toString(),
|
||||
identifier: `${suggestion.project_detail.identifier}-${suggestion.sequence_id}`,
|
||||
state: suggestion.state_detail.name,
|
||||
state: suggestion.state_detail && suggestion.state_detail.name ? suggestion.state_detail.name : "Todo",
|
||||
command: ({ editor, range }) => {
|
||||
editor
|
||||
.chain()
|
||||
|
||||
+2
-3
@@ -9,6 +9,8 @@ export const IssueEmbedSuggestions = Extension.create({
|
||||
addOptions() {
|
||||
return {
|
||||
suggestion: {
|
||||
char: "#issue_",
|
||||
allowSpaces: true,
|
||||
command: ({ editor, range, props }: { editor: Editor; range: Range; props: any }) => {
|
||||
props.command({ editor, range });
|
||||
},
|
||||
@@ -18,11 +20,8 @@ export const IssueEmbedSuggestions = Extension.create({
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
Suggestion({
|
||||
char: "#issue_",
|
||||
pluginKey: new PluginKey("issue-embed-suggestions"),
|
||||
editor: this.editor,
|
||||
allowSpaces: true,
|
||||
|
||||
...this.options.suggestion,
|
||||
}),
|
||||
];
|
||||
|
||||
+12
-8
@@ -53,7 +53,7 @@ const IssueSuggestionList = ({
|
||||
const commandListContainer = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let newDisplayedItems: { [key: string]: IssueSuggestionProps[] } = {};
|
||||
const newDisplayedItems: { [key: string]: IssueSuggestionProps[] } = {};
|
||||
let totalLength = 0;
|
||||
sections.forEach((section) => {
|
||||
newDisplayedItems[section] = items.filter((item) => item.state === section).slice(0, 5);
|
||||
@@ -65,8 +65,8 @@ const IssueSuggestionList = ({
|
||||
}, [items]);
|
||||
|
||||
const selectItem = useCallback(
|
||||
(index: number) => {
|
||||
const item = displayedItems[currentSection][index];
|
||||
(section: string, index: number) => {
|
||||
const item = displayedItems[section][index];
|
||||
if (item) {
|
||||
command(item);
|
||||
}
|
||||
@@ -87,6 +87,7 @@ const IssueSuggestionList = ({
|
||||
setSelectedIndex(
|
||||
(selectedIndex + displayedItems[currentSection].length - 1) % displayedItems[currentSection].length
|
||||
);
|
||||
e.stopPropagation();
|
||||
return true;
|
||||
}
|
||||
if (e.key === "ArrowDown") {
|
||||
@@ -101,10 +102,12 @@ const IssueSuggestionList = ({
|
||||
[currentSection]: [...prevItems[currentSection], ...nextItems],
|
||||
}));
|
||||
}
|
||||
e.stopPropagation();
|
||||
return true;
|
||||
}
|
||||
if (e.key === "Enter") {
|
||||
selectItem(selectedIndex);
|
||||
selectItem(currentSection, selectedIndex);
|
||||
e.stopPropagation();
|
||||
return true;
|
||||
}
|
||||
if (e.key === "Tab") {
|
||||
@@ -112,6 +115,7 @@ const IssueSuggestionList = ({
|
||||
const nextSectionIndex = (currentSectionIndex + 1) % sections.length;
|
||||
setCurrentSection(sections[nextSectionIndex]);
|
||||
setSelectedIndex(0);
|
||||
e.stopPropagation();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -172,7 +176,7 @@ const IssueSuggestionList = ({
|
||||
}
|
||||
)}
|
||||
key={item.identifier}
|
||||
onClick={() => selectItem(index)}
|
||||
onClick={() => selectItem(section, index)}
|
||||
>
|
||||
<h5 className="whitespace-nowrap text-xs text-custom-text-300">{item.identifier}</h5>
|
||||
<PriorityIcon priority={item.priority} />
|
||||
@@ -195,7 +199,7 @@ export const IssueListRenderer = () => {
|
||||
let popup: any | null = null;
|
||||
|
||||
return {
|
||||
onStart: (props: { editor: Editor; clientRect: DOMRect }) => {
|
||||
onStart: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => {
|
||||
component = new ReactRenderer(IssueSuggestionList, {
|
||||
props,
|
||||
// @ts-ignore
|
||||
@@ -210,10 +214,10 @@ export const IssueListRenderer = () => {
|
||||
showOnCreate: true,
|
||||
interactive: true,
|
||||
trigger: "manual",
|
||||
placement: "right",
|
||||
placement: "bottom-start",
|
||||
});
|
||||
},
|
||||
onUpdate: (props: { editor: Editor; clientRect: DOMRect }) => {
|
||||
onUpdate: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => {
|
||||
component?.updateProps(props);
|
||||
|
||||
popup &&
|
||||
|
||||
+4
-3
@@ -15,8 +15,7 @@ export const IssueWidgetCard = (props) => {
|
||||
setIssueDetails(issue);
|
||||
setLoading(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
.catch(() => {
|
||||
setLoading(-1);
|
||||
});
|
||||
}, []);
|
||||
@@ -30,7 +29,9 @@ export const IssueWidgetCard = (props) => {
|
||||
{loading == 0 ? (
|
||||
<div
|
||||
onClick={completeIssueEmbedAction}
|
||||
className="w-full cursor-pointer space-y-2 rounded-md border-[0.5px] border-custom-border-200 p-3 shadow-custom-shadow-2xs"
|
||||
className={`${
|
||||
props.selected ? "border-custom-primary-200 border-[2px]" : ""
|
||||
} w-full cursor-pointer space-y-2 rounded-md border-[0.5px] border-custom-border-200 p-3 shadow-custom-shadow-2xs`}
|
||||
>
|
||||
<h5 className="text-xs text-custom-text-300">
|
||||
{issueDetails.project_detail.identifier}-{issueDetails.sequence_id}
|
||||
|
||||
@@ -16,7 +16,7 @@ interface IDocumentEditor {
|
||||
// document info
|
||||
documentDetails: DocumentDetails;
|
||||
value: string;
|
||||
rerenderOnPropsChange: {
|
||||
rerenderOnPropsChange?: {
|
||||
id: string;
|
||||
description_html: string;
|
||||
};
|
||||
@@ -39,7 +39,7 @@ interface IDocumentEditor {
|
||||
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void;
|
||||
setShouldShowAlert?: (showAlert: boolean) => void;
|
||||
forwardedRef?: any;
|
||||
updatePageTitle: (title: string) => Promise<void>;
|
||||
updatePageTitle: (title: string) => void;
|
||||
debouncedUpdatesEnabled?: boolean;
|
||||
isSubmitting: "submitting" | "submitted" | "saved";
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Extension } from "@tiptap/core";
|
||||
import { PluginKey, NodeSelection, Plugin } from "@tiptap/pm/state";
|
||||
// @ts-ignore
|
||||
import { __serializeForClipboard, EditorView } from "@tiptap/pm/view";
|
||||
import React from "react";
|
||||
|
||||
function createDragHandleElement(): HTMLElement {
|
||||
const dragHandleElement = document.createElement("div");
|
||||
@@ -30,6 +31,7 @@ function createDragHandleElement(): HTMLElement {
|
||||
|
||||
export interface DragHandleOptions {
|
||||
dragHandleWidth: number;
|
||||
setHideDragHandle?: (hideDragHandlerFromDragDrop: () => void) => void;
|
||||
}
|
||||
|
||||
function absoluteRect(node: Element) {
|
||||
@@ -151,6 +153,8 @@ function DragHandle(options: DragHandleOptions) {
|
||||
}
|
||||
}
|
||||
|
||||
options.setHideDragHandle?.(hideDragHandle);
|
||||
|
||||
return new Plugin({
|
||||
key: new PluginKey("dragHandle"),
|
||||
view: (view) => {
|
||||
@@ -238,14 +242,16 @@ function DragHandle(options: DragHandleOptions) {
|
||||
});
|
||||
}
|
||||
|
||||
export const DragAndDrop = Extension.create({
|
||||
name: "dragAndDrop",
|
||||
export const DragAndDrop = (setHideDragHandle?: (hideDragHandlerFromDragDrop: () => void) => void) =>
|
||||
Extension.create({
|
||||
name: "dragAndDrop",
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
DragHandle({
|
||||
dragHandleWidth: 24,
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
DragHandle({
|
||||
dragHandleWidth: 24,
|
||||
setHideDragHandle,
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { SlashCommand, DragAndDrop } from "@plane/editor-extensions";
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
import { UploadImage } from "@plane/editor-core";
|
||||
import { DragAndDrop, SlashCommand } from "@plane/editor-extensions";
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
|
||||
export const RichTextEditorExtensions = (
|
||||
uploadFile: UploadImage,
|
||||
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void,
|
||||
dragDropEnabled?: boolean
|
||||
dragDropEnabled?: boolean,
|
||||
setHideDragHandle?: (hideDragHandlerFromDragDrop: () => void) => void
|
||||
) => [
|
||||
SlashCommand(uploadFile, setIsSubmitting),
|
||||
dragDropEnabled === true && DragAndDrop,
|
||||
dragDropEnabled === true && DragAndDrop(setHideDragHandle),
|
||||
Placeholder.configure({
|
||||
placeholder: ({ node }) => {
|
||||
if (node.type.name === "heading") {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
"use client";
|
||||
import * as React from "react";
|
||||
import {
|
||||
DeleteImage,
|
||||
EditorContainer,
|
||||
@@ -10,8 +9,9 @@ import {
|
||||
UploadImage,
|
||||
useEditor,
|
||||
} from "@plane/editor-core";
|
||||
import { EditorBubbleMenu } from "src/ui/menus/bubble-menu";
|
||||
import * as React from "react";
|
||||
import { RichTextEditorExtensions } from "src/ui/extensions";
|
||||
import { EditorBubbleMenu } from "src/ui/menus/bubble-menu";
|
||||
|
||||
export type IRichTextEditor = {
|
||||
value: string;
|
||||
@@ -66,6 +66,14 @@ const RichTextEditor = ({
|
||||
rerenderOnPropsChange,
|
||||
mentionSuggestions,
|
||||
}: RichTextEditorProps) => {
|
||||
const [hideDragHandleOnMouseLeave, setHideDragHandleOnMouseLeave] = React.useState<() => void>(() => {});
|
||||
|
||||
// this essentially sets the hideDragHandle function from the DragAndDrop extension as the Plugin
|
||||
// loads such that we can invoke it from react when the cursor leaves the container
|
||||
const setHideDragHandleFunction = (hideDragHandlerFromDragDrop: () => void) => {
|
||||
setHideDragHandleOnMouseLeave(() => hideDragHandlerFromDragDrop);
|
||||
};
|
||||
|
||||
const editor = useEditor({
|
||||
onChange,
|
||||
debouncedUpdatesEnabled,
|
||||
@@ -78,7 +86,7 @@ const RichTextEditor = ({
|
||||
restoreFile,
|
||||
forwardedRef,
|
||||
rerenderOnPropsChange,
|
||||
extensions: RichTextEditorExtensions(uploadFile, setIsSubmitting, dragDropEnabled),
|
||||
extensions: RichTextEditorExtensions(uploadFile, setIsSubmitting, dragDropEnabled, setHideDragHandleFunction),
|
||||
mentionHighlights,
|
||||
mentionSuggestions,
|
||||
});
|
||||
@@ -92,7 +100,7 @@ const RichTextEditor = ({
|
||||
if (!editor) return null;
|
||||
|
||||
return (
|
||||
<EditorContainer editor={editor} editorClassNames={editorClassNames}>
|
||||
<EditorContainer hideDragHandle={hideDragHandleOnMouseLeave} editor={editor} editorClassNames={editorClassNames}>
|
||||
{editor && <EditorBubbleMenu editor={editor} />}
|
||||
<div className="flex flex-col">
|
||||
<EditorContentWrapper editor={editor} editorContentCustomClassNames={editorContentCustomClassNames} />
|
||||
|
||||
@@ -27,6 +27,7 @@ module.exports = {
|
||||
"custom-shadow-xl": "var(--color-shadow-xl)",
|
||||
"custom-shadow-2xl": "var(--color-shadow-2xl)",
|
||||
"custom-shadow-3xl": "var(--color-shadow-3xl)",
|
||||
"custom-shadow-4xl": "var(--color-shadow-4xl)",
|
||||
"custom-sidebar-shadow-2xs": "var(--color-sidebar-shadow-2xs)",
|
||||
"custom-sidebar-shadow-xs": "var(--color-sidebar-shadow-xs)",
|
||||
"custom-sidebar-shadow-sm": "var(--color-sidebar-shadow-sm)",
|
||||
@@ -36,8 +37,8 @@ module.exports = {
|
||||
"custom-sidebar-shadow-xl": "var(--color-sidebar-shadow-xl)",
|
||||
"custom-sidebar-shadow-2xl": "var(--color-sidebar-shadow-2xl)",
|
||||
"custom-sidebar-shadow-3xl": "var(--color-sidebar-shadow-3xl)",
|
||||
"onbording-shadow-sm": "var(--color-onboarding-shadow-sm)",
|
||||
|
||||
"custom-sidebar-shadow-4xl": "var(--color-sidebar-shadow-4xl)",
|
||||
"onboarding-shadow-sm": "var(--color-onboarding-shadow-sm)",
|
||||
},
|
||||
colors: {
|
||||
custom: {
|
||||
@@ -212,7 +213,7 @@ module.exports = {
|
||||
to: { left: "100%" },
|
||||
},
|
||||
},
|
||||
typography: ({ theme }) => ({
|
||||
typography: () => ({
|
||||
brand: {
|
||||
css: {
|
||||
"--tw-prose-body": convertToRGB("--color-text-100"),
|
||||
@@ -225,12 +226,12 @@ module.exports = {
|
||||
"--tw-prose-bullets": convertToRGB("--color-text-100"),
|
||||
"--tw-prose-hr": convertToRGB("--color-text-100"),
|
||||
"--tw-prose-quotes": convertToRGB("--color-text-100"),
|
||||
"--tw-prose-quote-borders": convertToRGB("--color-border"),
|
||||
"--tw-prose-quote-borders": convertToRGB("--color-border-200"),
|
||||
"--tw-prose-code": convertToRGB("--color-text-100"),
|
||||
"--tw-prose-pre-code": convertToRGB("--color-text-100"),
|
||||
"--tw-prose-pre-bg": convertToRGB("--color-background-100"),
|
||||
"--tw-prose-th-borders": convertToRGB("--color-border"),
|
||||
"--tw-prose-td-borders": convertToRGB("--color-border"),
|
||||
"--tw-prose-th-borders": convertToRGB("--color-border-200"),
|
||||
"--tw-prose-td-borders": convertToRGB("--color-border-200"),
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
Vendored
+175
@@ -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[];
|
||||
};
|
||||
Vendored
+1
@@ -1,6 +1,7 @@
|
||||
export * from "./users";
|
||||
export * from "./workspace";
|
||||
export * from "./cycles";
|
||||
export * from "./dashboard";
|
||||
export * from "./projects";
|
||||
export * from "./state";
|
||||
export * from "./invitation";
|
||||
|
||||
Vendored
-1
@@ -9,7 +9,6 @@ import type {
|
||||
IStateLite,
|
||||
Properties,
|
||||
IIssueDisplayFilterOptions,
|
||||
IIssueReaction,
|
||||
TIssue,
|
||||
} from "@plane/types";
|
||||
|
||||
|
||||
+1
-6
@@ -6,12 +6,7 @@ export type TIssueRelationTypes =
|
||||
| "duplicate"
|
||||
| "relates_to";
|
||||
|
||||
export type TIssueRelationObject = { issue_detail: TIssue };
|
||||
|
||||
export type TIssueRelation = Record<
|
||||
TIssueRelationTypes,
|
||||
TIssueRelationObject[]
|
||||
>;
|
||||
export type TIssueRelation = Record<TIssueRelationTypes, TIssue[]>;
|
||||
|
||||
export type TIssueRelationMap = {
|
||||
[issue_id: string]: Record<TIssueRelationTypes, string[]>;
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs));
|
||||
@@ -17,6 +17,17 @@
|
||||
"lint": "eslint src/",
|
||||
"clean": "rm -rf .turbo && rm -rf node_modules && rm -rf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@blueprintjs/core": "^4.16.3",
|
||||
"@blueprintjs/popover2": "^1.13.3",
|
||||
"@headlessui/react": "^1.7.17",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"clsx": "^2.0.0",
|
||||
"react-color": "^2.19.3",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-popper": "^2.3.0",
|
||||
"tailwind-merge": "^2.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.5.2",
|
||||
"@types/react": "^18.2.42",
|
||||
@@ -29,14 +40,5 @@
|
||||
"tsconfig": "*",
|
||||
"tsup": "^5.10.1",
|
||||
"typescript": "4.7.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"@blueprintjs/core": "^4.16.3",
|
||||
"@blueprintjs/popover2": "^1.13.3",
|
||||
"@headlessui/react": "^1.7.17",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"react-color": "^2.19.3",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-popper": "^2.3.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,6 +141,7 @@ export const Avatar: React.FC<Props> = (props) => {
|
||||
}
|
||||
: {}
|
||||
}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{src ? (
|
||||
<img src={src} className={`h-full w-full ${getBorderRadius(shape)} ${className}`} alt={name} />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { getIconStyling, getButtonStyling, TButtonVariant, TButtonSizes } from "./helper";
|
||||
import { cn } from "../../helpers";
|
||||
|
||||
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: TButtonVariant;
|
||||
@@ -31,7 +32,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>((props, ref) =>
|
||||
const buttonIconStyle = getIconStyling(size);
|
||||
|
||||
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>}
|
||||
{children}
|
||||
{appendIcon && <div className={buttonIconStyle}>{React.cloneElement(appendIcon, { strokeWidth: 2 })}</div>}
|
||||
|
||||
@@ -22,10 +22,10 @@ export interface IButtonStyling {
|
||||
}
|
||||
|
||||
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`,
|
||||
md = `px-4 py-1.5 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 inline`,
|
||||
xl = `px-5 py-3.5 font-medium text-sm 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`,
|
||||
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`,
|
||||
}
|
||||
|
||||
enum buttonIconStyling {
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from "./button";
|
||||
export * from "./helper";
|
||||
export * from "./toggle-switch";
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Menu } from "@headlessui/react";
|
||||
import { ICustomMenuDropdownProps, ICustomMenuItemProps } from "./helper";
|
||||
// icons
|
||||
import { ChevronDown, MoreHorizontal } from "lucide-react";
|
||||
import { cn } from "../../helpers";
|
||||
|
||||
const CustomMenu = (props: ICustomMenuDropdownProps) => {
|
||||
const {
|
||||
@@ -62,7 +63,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
|
||||
static
|
||||
>
|
||||
<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"
|
||||
? "max-h-60"
|
||||
: maxHeight === "md"
|
||||
@@ -72,7 +73,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
|
||||
: maxHeight === "sm"
|
||||
? "max-h-28"
|
||||
: ""
|
||||
} ${width === "auto" ? "min-w-[8rem] whitespace-nowrap" : width} ${optionsClassName}`}
|
||||
} ${width === "auto" ? "min-w-[12rem] whitespace-nowrap" : width} ${optionsClassName}`}
|
||||
ref={setPopperElement}
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
@@ -167,9 +168,13 @@ const MenuItem: React.FC<ICustomMenuItemProps> = (props) => {
|
||||
{({ active, close }) => (
|
||||
<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 ${
|
||||
active ? "bg-custom-background-80" : ""
|
||||
} ${className}`}
|
||||
className={cn(
|
||||
"w-full select-none truncate rounded px-1 py-1.5 text-left text-custom-text-200",
|
||||
{
|
||||
"bg-custom-background-80": active,
|
||||
},
|
||||
className
|
||||
)}
|
||||
onClick={(e) => {
|
||||
close();
|
||||
onClick && onClick(e);
|
||||
|
||||
@@ -1,35 +1,79 @@
|
||||
import * as React from "react";
|
||||
import { AlertCircle, Ban, SignalHigh, SignalLow, SignalMedium } from "lucide-react";
|
||||
import { cn } from "../../helpers";
|
||||
|
||||
type TIssuePriorities = "urgent" | "high" | "medium" | "low" | "none";
|
||||
|
||||
interface IPriorityIcon {
|
||||
className?: string;
|
||||
containerClassName?: string;
|
||||
priority: TIssuePriorities;
|
||||
size?: number;
|
||||
withContainer?: boolean;
|
||||
}
|
||||
|
||||
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 lowercasePriority = priority?.toLowerCase();
|
||||
|
||||
//get priority icon
|
||||
const getPriorityIcon = (): React.ReactNode => {
|
||||
switch (lowercasePriority) {
|
||||
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}`} />;
|
||||
}
|
||||
const priorityClasses = {
|
||||
urgent: "bg-red-500 text-red-500 border-red-500",
|
||||
high: "bg-orange-500/20 text-orange-500 border-orange-500",
|
||||
medium: "bg-yellow-500/20 text-yellow-500 border-yellow-500",
|
||||
low: "bg-custom-primary-100/20 text-custom-primary-100 border-custom-primary-100",
|
||||
none: "bg-custom-background-80 text-custom-text-200 border-custom-border-300",
|
||||
};
|
||||
|
||||
return <>{getPriorityIcon()}</>;
|
||||
// get priority icon
|
||||
const icons = {
|
||||
urgent: AlertCircle,
|
||||
high: SignalHigh,
|
||||
medium: SignalMedium,
|
||||
low: SignalLow,
|
||||
none: Ban,
|
||||
};
|
||||
const Icon = icons[priority];
|
||||
|
||||
if (!Icon) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{withContainer ? (
|
||||
<div
|
||||
className={cn(
|
||||
"grid place-items-center border rounded p-0.5 flex-shrink-0",
|
||||
priorityClasses[priority],
|
||||
containerClassName
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
size={size}
|
||||
className={cn(
|
||||
{
|
||||
"text-white": priority === "urgent",
|
||||
// centre align the icons
|
||||
"translate-x-[0.0625rem]": priority === "high",
|
||||
"translate-x-0.5": priority === "medium",
|
||||
"translate-x-1": priority === "low",
|
||||
},
|
||||
className
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Icon
|
||||
size={size}
|
||||
className={cn(
|
||||
{
|
||||
"text-red-500": priority === "urgent",
|
||||
"text-orange-500": priority === "high",
|
||||
"text-yellow-500": priority === "medium",
|
||||
"text-custom-primary-100": priority === "low",
|
||||
"text-custom-text-200": priority === "none",
|
||||
},
|
||||
className
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -64,6 +64,7 @@
|
||||
0px 1px 32px 0px rgba(16, 24, 40, 0.12);
|
||||
--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);
|
||||
--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-90: var(--color-background-90); /* secondary sidebar bg */
|
||||
@@ -88,6 +89,7 @@
|
||||
--color-sidebar-shadow-xl: var(--color-shadow-xl);
|
||||
--color-sidebar-shadow-2xl: var(--color-shadow-2xl);
|
||||
--color-sidebar-shadow-3xl: var(--color-shadow-3xl);
|
||||
--color-sidebar-shadow-4xl: var(--color-shadow-4xl);
|
||||
}
|
||||
|
||||
[data-theme="light"],
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Triangle } from "lucide-react";
|
||||
// types
|
||||
import { IDefaultAnalyticsResponse, TStateGroups } from "@plane/types";
|
||||
// constants
|
||||
import { STATE_GROUP_COLORS } from "constants/state";
|
||||
import { STATE_GROUPS } from "constants/state";
|
||||
|
||||
type Props = {
|
||||
defaultAnalytics: IDefaultAnalyticsResponse;
|
||||
@@ -27,7 +27,7 @@ export const AnalyticsDemand: React.FC<Props> = ({ defaultAnalytics }) => (
|
||||
<span
|
||||
className="h-2 w-2 rounded-full"
|
||||
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>
|
||||
@@ -42,7 +42,7 @@ export const AnalyticsDemand: React.FC<Props> = ({ defaultAnalytics }) => (
|
||||
className="absolute left-0 top-0 h-1 rounded duration-300"
|
||||
style={{
|
||||
width: `${percentage}%`,
|
||||
backgroundColor: STATE_GROUP_COLORS[group.state_group as TStateGroups],
|
||||
backgroundColor: STATE_GROUPS[group.state_group as TStateGroups].color,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -60,6 +60,7 @@ export const CommandPalette: FC = observer(() => {
|
||||
isDeleteIssueModalOpen,
|
||||
toggleDeleteIssueModal,
|
||||
isAnyModalOpen,
|
||||
createIssueStoreType,
|
||||
} = commandPalette;
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
@@ -216,6 +217,7 @@ export const CommandPalette: FC = observer(() => {
|
||||
isOpen={isCreateIssueModalOpen}
|
||||
onClose={() => toggleCreateIssueModal(false)}
|
||||
data={cycleId ? { cycle_id: cycleId.toString() } : moduleId ? { module_id: moduleId.toString() } : undefined}
|
||||
storeType={createIssueStoreType}
|
||||
/>
|
||||
|
||||
{workspaceSlug && projectId && issueId && issueDetails && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./widgets";
|
||||
export * from "./home-dashboard-widgets";
|
||||
export * from "./project-empty-state";
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Tab } from "@headlessui/react";
|
||||
// hooks
|
||||
import { useDashboard } from "hooks/store";
|
||||
// components
|
||||
import {
|
||||
DurationFilterDropdown,
|
||||
TabsList,
|
||||
WidgetIssuesList,
|
||||
WidgetLoader,
|
||||
WidgetProps,
|
||||
} from "components/dashboard/widgets";
|
||||
// helpers
|
||||
import { getCustomDates, getRedirectionFilters } from "helpers/dashboard.helper";
|
||||
// types
|
||||
import { TCreatedIssuesWidgetFilters, TCreatedIssuesWidgetResponse } from "@plane/types";
|
||||
// constants
|
||||
import { ISSUES_TABS_LIST } from "constants/dashboard";
|
||||
|
||||
const WIDGET_KEY = "created_issues";
|
||||
|
||||
export const CreatedIssuesWidget: React.FC<WidgetProps> = observer((props) => {
|
||||
const { dashboardId, workspaceSlug } = props;
|
||||
// states
|
||||
const [fetching, setFetching] = useState(false);
|
||||
// store hooks
|
||||
const {
|
||||
fetchWidgetStats,
|
||||
widgetDetails: allWidgetDetails,
|
||||
widgetStats: allWidgetStats,
|
||||
updateDashboardWidgetFilters,
|
||||
} = useDashboard();
|
||||
// derived values
|
||||
const widgetDetails = allWidgetDetails?.[workspaceSlug]?.[dashboardId]?.find((w) => w.key === WIDGET_KEY);
|
||||
const widgetStats = allWidgetStats?.[workspaceSlug]?.[dashboardId]?.[WIDGET_KEY] as TCreatedIssuesWidgetResponse;
|
||||
|
||||
const handleUpdateFilters = async (filters: Partial<TCreatedIssuesWidgetFilters>) => {
|
||||
if (!widgetDetails) return;
|
||||
|
||||
setFetching(true);
|
||||
|
||||
await updateDashboardWidgetFilters(workspaceSlug, dashboardId, widgetDetails.id, {
|
||||
widgetKey: WIDGET_KEY,
|
||||
filters,
|
||||
});
|
||||
|
||||
fetchWidgetStats(workspaceSlug, dashboardId, {
|
||||
widget_key: WIDGET_KEY,
|
||||
issue_type: widgetDetails.widget_filters.tab ?? "upcoming",
|
||||
target_date: getCustomDates(widgetDetails.widget_filters.target_date ?? "this_week"),
|
||||
}).finally(() => setFetching(false));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!widgetDetails) return;
|
||||
|
||||
if (!widgetStats)
|
||||
fetchWidgetStats(workspaceSlug, dashboardId, {
|
||||
widget_key: WIDGET_KEY,
|
||||
issue_type: widgetDetails.widget_filters.tab ?? "upcoming",
|
||||
target_date: getCustomDates(widgetDetails.widget_filters.target_date ?? "this_week"),
|
||||
});
|
||||
}, [dashboardId, fetchWidgetStats, widgetDetails, widgetStats, workspaceSlug]);
|
||||
|
||||
const filterParams = getRedirectionFilters(widgetDetails?.widget_filters.tab ?? "upcoming");
|
||||
const redirectionLink = `/${workspaceSlug}/workspace-views/created/${filterParams}`;
|
||||
|
||||
if (!widgetDetails || !widgetStats) return <WidgetLoader widgetKey={WIDGET_KEY} />;
|
||||
|
||||
return (
|
||||
<div className="bg-custom-background-100 rounded-xl border-[0.5px] border-custom-border-200 w-full hover:shadow-custom-shadow-4xl duration-300 flex flex-col">
|
||||
<Link href={redirectionLink} className="flex items-center justify-between gap-2 p-6 pl-7">
|
||||
<h4 className="text-lg font-semibold text-custom-text-300">All issues created</h4>
|
||||
<DurationFilterDropdown
|
||||
value={widgetDetails.widget_filters.target_date ?? "this_week"}
|
||||
onChange={(val) =>
|
||||
handleUpdateFilters({
|
||||
target_date: val,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Link>
|
||||
<Tab.Group
|
||||
as="div"
|
||||
defaultIndex={ISSUES_TABS_LIST.findIndex((t) => t.key === widgetDetails.widget_filters.tab ?? "upcoming")}
|
||||
onChange={(i) => {
|
||||
const selectedTab = ISSUES_TABS_LIST[i];
|
||||
handleUpdateFilters({ tab: selectedTab.key ?? "upcoming" });
|
||||
}}
|
||||
className="h-full flex flex-col"
|
||||
>
|
||||
<div className="px-6">
|
||||
<TabsList />
|
||||
</div>
|
||||
<Tab.Panels as="div" className="mt-7 h-full">
|
||||
{ISSUES_TABS_LIST.map((tab) => (
|
||||
<Tab.Panel as="div" className="h-full flex flex-col">
|
||||
<WidgetIssuesList
|
||||
filter={widgetDetails.widget_filters.target_date}
|
||||
issues={widgetStats.issues}
|
||||
tab={tab.key}
|
||||
totalIssues={widgetStats.count}
|
||||
type="created"
|
||||
workspaceSlug={workspaceSlug}
|
||||
isLoading={fetching}
|
||||
/>
|
||||
</Tab.Panel>
|
||||
))}
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { ChevronDown } from "lucide-react";
|
||||
// ui
|
||||
import { CustomMenu } from "@plane/ui";
|
||||
// types
|
||||
import { TDurationFilterOptions } from "@plane/types";
|
||||
// constants
|
||||
import { DURATION_FILTER_OPTIONS } from "constants/dashboard";
|
||||
|
||||
type Props = {
|
||||
onChange: (value: TDurationFilterOptions) => void;
|
||||
value: TDurationFilterOptions;
|
||||
};
|
||||
|
||||
export const DurationFilterDropdown: React.FC<Props> = (props) => {
|
||||
const { onChange, value } = props;
|
||||
|
||||
return (
|
||||
<CustomMenu
|
||||
customButton={
|
||||
<div className="px-3 py-2 border-[0.5px] border-custom-border-300 hover:bg-custom-background-80 focus:bg-custom-background-80 text-xs font-medium whitespace-nowrap rounded-md outline-none flex items-center gap-2">
|
||||
{DURATION_FILTER_OPTIONS.find((option) => option.key === value)?.label}
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</div>
|
||||
}
|
||||
placement="bottom-end"
|
||||
>
|
||||
{DURATION_FILTER_OPTIONS.map((option) => (
|
||||
<CustomMenu.MenuItem
|
||||
key={option.key}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onChange(option.key);
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
</CustomMenu.MenuItem>
|
||||
))}
|
||||
</CustomMenu>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./duration-filter";
|
||||
@@ -0,0 +1,42 @@
|
||||
import Image from "next/image";
|
||||
import { useTheme } from "next-themes";
|
||||
// helpers
|
||||
import { cn } from "helpers/common.helper";
|
||||
// types
|
||||
import { TDurationFilterOptions, TIssuesListTypes } from "@plane/types";
|
||||
// constants
|
||||
import { ASSIGNED_ISSUES_EMPTY_STATES } from "constants/dashboard";
|
||||
|
||||
type Props = {
|
||||
filter: TDurationFilterOptions;
|
||||
type: TIssuesListTypes;
|
||||
};
|
||||
|
||||
export const AssignedIssuesEmptyState: React.FC<Props> = (props) => {
|
||||
const { filter, type } = props;
|
||||
// next-themes
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
const typeDetails = ASSIGNED_ISSUES_EMPTY_STATES[type];
|
||||
|
||||
const image = resolvedTheme === "dark" ? typeDetails.darkImage : typeDetails.lightImage;
|
||||
|
||||
return (
|
||||
<div className="text-center space-y-10 mt-16 flex flex-col items-center">
|
||||
<p className="text-sm font-medium text-custom-text-300">{typeDetails.title(filter)}</p>
|
||||
<div
|
||||
className={cn("w-1/2 h-1/3 p-1.5 pb-0 rounded-t-md", {
|
||||
"border border-custom-border-200": resolvedTheme === "dark",
|
||||
})}
|
||||
style={{
|
||||
background:
|
||||
resolvedTheme === "light"
|
||||
? "linear-gradient(135deg, rgba(235, 243, 255, 0.45) 3.57%, rgba(99, 161, 255, 0.24) 94.16%)"
|
||||
: "",
|
||||
}}
|
||||
>
|
||||
<Image src={image} className="w-full h-full" alt="Assigned issues" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import Image from "next/image";
|
||||
import { useTheme } from "next-themes";
|
||||
// helpers
|
||||
import { cn } from "helpers/common.helper";
|
||||
// types
|
||||
import { TDurationFilterOptions, TIssuesListTypes } from "@plane/types";
|
||||
// constants
|
||||
import { CREATED_ISSUES_EMPTY_STATES } from "constants/dashboard";
|
||||
|
||||
type Props = {
|
||||
filter: TDurationFilterOptions;
|
||||
type: TIssuesListTypes;
|
||||
};
|
||||
|
||||
export const CreatedIssuesEmptyState: React.FC<Props> = (props) => {
|
||||
const { filter, type } = props;
|
||||
// next-themes
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
const typeDetails = CREATED_ISSUES_EMPTY_STATES[type];
|
||||
|
||||
const image = resolvedTheme === "dark" ? typeDetails.darkImage : typeDetails.lightImage;
|
||||
|
||||
return (
|
||||
<div className="text-center space-y-10 mt-16 flex flex-col items-center">
|
||||
<p className="text-sm font-medium text-custom-text-300">{typeDetails.title(filter)}</p>
|
||||
<div
|
||||
className={cn("w-1/2 h-1/3 p-1.5 pb-0 rounded-t-md", {
|
||||
"border border-custom-border-200": resolvedTheme === "dark",
|
||||
})}
|
||||
style={{
|
||||
background:
|
||||
resolvedTheme === "light"
|
||||
? "linear-gradient(135deg, rgba(235, 243, 255, 0.45) 3.57%, rgba(99, 161, 255, 0.24) 94.16%)"
|
||||
: "",
|
||||
}}
|
||||
>
|
||||
<Image src={image} className="w-full h-full" alt="Created issues" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from "./assigned-issues";
|
||||
export * from "./created-issues";
|
||||
export * from "./issues-by-priority";
|
||||
export * from "./issues-by-state-group";
|
||||
export * from "./recent-activity";
|
||||
export * from "./recent-collaborators";
|
||||
@@ -0,0 +1,45 @@
|
||||
import Image from "next/image";
|
||||
import { useTheme } from "next-themes";
|
||||
// assets
|
||||
import DarkImage from "public/empty-state/dashboard/dark/issues-by-priority.svg";
|
||||
import LightImage from "public/empty-state/dashboard/light/issues-by-priority.svg";
|
||||
// helpers
|
||||
import { cn } from "helpers/common.helper";
|
||||
import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper";
|
||||
// types
|
||||
import { TDurationFilterOptions } from "@plane/types";
|
||||
|
||||
type Props = {
|
||||
filter: TDurationFilterOptions;
|
||||
};
|
||||
|
||||
export const IssuesByPriorityEmptyState: React.FC<Props> = (props) => {
|
||||
const { filter } = props;
|
||||
// next-themes
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<div className="text-center space-y-10 mt-16 flex flex-col items-center">
|
||||
<p className="text-sm font-medium text-custom-text-300">
|
||||
No assigned issues {replaceUnderscoreIfSnakeCase(filter)}.
|
||||
</p>
|
||||
<div
|
||||
className={cn("w-1/2 h-1/3 p-1.5 pb-0 rounded-t-md", {
|
||||
"border border-custom-border-200": resolvedTheme === "dark",
|
||||
})}
|
||||
style={{
|
||||
background:
|
||||
resolvedTheme === "light"
|
||||
? "linear-gradient(135deg, rgba(235, 243, 255, 0.45) 3.57%, rgba(99, 161, 255, 0.24) 94.16%)"
|
||||
: "",
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src={resolvedTheme === "dark" ? DarkImage : LightImage}
|
||||
className="w-full h-full"
|
||||
alt="Issues by priority"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import Image from "next/image";
|
||||
import { useTheme } from "next-themes";
|
||||
// assets
|
||||
import DarkImage from "public/empty-state/dashboard/dark/issues-by-state-group.svg";
|
||||
import LightImage from "public/empty-state/dashboard/light/issues-by-state-group.svg";
|
||||
// helpers
|
||||
import { cn } from "helpers/common.helper";
|
||||
import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper";
|
||||
// types
|
||||
import { TDurationFilterOptions } from "@plane/types";
|
||||
|
||||
type Props = {
|
||||
filter: TDurationFilterOptions;
|
||||
};
|
||||
|
||||
export const IssuesByStateGroupEmptyState: React.FC<Props> = (props) => {
|
||||
const { filter } = props;
|
||||
// next-themes
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<div className="text-center space-y-10 mt-16 flex flex-col items-center">
|
||||
<p className="text-sm font-medium text-custom-text-300">
|
||||
No assigned issues {replaceUnderscoreIfSnakeCase(filter)}.
|
||||
</p>
|
||||
<div
|
||||
className={cn("w-1/2 h-1/3 p-1.5 pb-0 rounded-t-md", {
|
||||
"border border-custom-border-200": resolvedTheme === "dark",
|
||||
})}
|
||||
style={{
|
||||
background:
|
||||
resolvedTheme === "light"
|
||||
? "linear-gradient(135deg, rgba(235, 243, 255, 0.45) 3.57%, rgba(99, 161, 255, 0.24) 94.16%)"
|
||||
: "",
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src={resolvedTheme === "dark" ? DarkImage : LightImage}
|
||||
className="w-full h-full"
|
||||
alt="Issues by state group"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import Image from "next/image";
|
||||
import { useTheme } from "next-themes";
|
||||
// assets
|
||||
import DarkImage from "public/empty-state/dashboard/dark/recent-activity.svg";
|
||||
import LightImage from "public/empty-state/dashboard/light/recent-activity.svg";
|
||||
// helpers
|
||||
import { cn } from "helpers/common.helper";
|
||||
|
||||
type Props = {};
|
||||
|
||||
export const RecentActivityEmptyState: React.FC<Props> = (props) => {
|
||||
const {} = props;
|
||||
// next-themes
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<div className="text-center space-y-10 mt-16 flex flex-col items-center">
|
||||
<p className="text-sm font-medium text-custom-text-300">
|
||||
Feels new, go and explore our tool in depth and come back
|
||||
<br />
|
||||
to see your activity.
|
||||
</p>
|
||||
<div
|
||||
className={cn("w-3/5 h-1/3 p-1.5 pb-0 rounded-t-md", {
|
||||
"border border-custom-border-200": resolvedTheme === "dark",
|
||||
})}
|
||||
style={{
|
||||
background:
|
||||
resolvedTheme === "light"
|
||||
? "linear-gradient(135deg, rgba(235, 243, 255, 0.45) 3.57%, rgba(99, 161, 255, 0.24) 94.16%)"
|
||||
: "",
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src={resolvedTheme === "dark" ? DarkImage : LightImage}
|
||||
className="w-full h-full"
|
||||
alt="Issues by priority"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import Image from "next/image";
|
||||
import { useTheme } from "next-themes";
|
||||
// assets
|
||||
import DarkImage from "public/empty-state/dashboard/dark/recent-collaborators.svg";
|
||||
import LightImage from "public/empty-state/dashboard/light/recent-collaborators.svg";
|
||||
// helpers
|
||||
import { cn } from "helpers/common.helper";
|
||||
|
||||
type Props = {};
|
||||
|
||||
export const RecentCollaboratorsEmptyState: React.FC<Props> = (props) => {
|
||||
const {} = props;
|
||||
// next-themes
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<div className="mt-7 px-7 flex justify-between gap-16">
|
||||
<p className="text-sm font-medium text-custom-text-300">
|
||||
People are excited to work with you, once they do you will find your frequent collaborators here.
|
||||
</p>
|
||||
<div
|
||||
className={cn("w-3/5 h-1/3 p-1.5 pb-0 rounded-t-md flex-shrink-0 self-end", {
|
||||
"border border-custom-border-200": resolvedTheme === "dark",
|
||||
})}
|
||||
style={{
|
||||
background:
|
||||
resolvedTheme === "light"
|
||||
? "linear-gradient(135deg, rgba(235, 243, 255, 0.45) 3.57%, rgba(99, 161, 255, 0.24) 94.16%)"
|
||||
: "",
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src={resolvedTheme === "dark" ? DarkImage : LightImage}
|
||||
className="w-full h-full"
|
||||
alt="Recent collaborators"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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";
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./issue-list-item";
|
||||
export * from "./issues-list";
|
||||
export * from "./tabs-list";
|
||||
@@ -0,0 +1,297 @@
|
||||
import { observer } from "mobx-react-lite";
|
||||
import isToday from "date-fns/isToday";
|
||||
// hooks
|
||||
import { useIssueDetail, useMember, useProject } from "hooks/store";
|
||||
// ui
|
||||
import { Avatar, AvatarGroup, ControlLink, PriorityIcon } from "@plane/ui";
|
||||
// helpers
|
||||
import { findTotalDaysInRange, renderFormattedDate } from "helpers/date-time.helper";
|
||||
// types
|
||||
import { TIssue, TWidgetIssue } from "@plane/types";
|
||||
|
||||
export type IssueListItemProps = {
|
||||
issueId: string;
|
||||
onClick: (issue: TIssue) => void;
|
||||
workspaceSlug: string;
|
||||
};
|
||||
|
||||
export const AssignedUpcomingIssueListItem: React.FC<IssueListItemProps> = observer((props) => {
|
||||
const { issueId, onClick, workspaceSlug } = props;
|
||||
// store hooks
|
||||
const { getProjectById } = useProject();
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
} = useIssueDetail();
|
||||
// derived values
|
||||
const issueDetails = getIssueById(issueId) as TWidgetIssue | undefined;
|
||||
|
||||
if (!issueDetails) return null;
|
||||
|
||||
const projectDetails = getProjectById(issueDetails.project_id);
|
||||
|
||||
const blockedByIssues = issueDetails.issue_relation?.filter((issue) => issue.relation_type === "blocked_by") ?? [];
|
||||
|
||||
const blockedByIssueProjectDetails =
|
||||
blockedByIssues.length === 1 ? getProjectById(blockedByIssues[0]?.project_id ?? "") : null;
|
||||
|
||||
return (
|
||||
<ControlLink
|
||||
href={`/${workspaceSlug}/projects/${issueDetails.project_id}/issues/${issueDetails.id}`}
|
||||
onClick={() => onClick(issueDetails)}
|
||||
className="py-2 px-3 hover:bg-custom-background-80 rounded grid grid-cols-6 gap-1"
|
||||
>
|
||||
<div className="col-span-4 flex items-center gap-3">
|
||||
<PriorityIcon priority={issueDetails.priority} withContainer />
|
||||
<span className="text-xs font-medium flex-shrink-0">
|
||||
{projectDetails?.identifier} {issueDetails.sequence_id}
|
||||
</span>
|
||||
<h6 className="text-sm flex-grow truncate">{issueDetails.name}</h6>
|
||||
</div>
|
||||
<div className="text-xs text-center">
|
||||
{issueDetails.target_date
|
||||
? isToday(new Date(issueDetails.target_date))
|
||||
? "Today"
|
||||
: renderFormattedDate(issueDetails.target_date)
|
||||
: "-"}
|
||||
</div>
|
||||
<div className="text-xs text-center">
|
||||
{blockedByIssues.length > 0
|
||||
? blockedByIssues.length > 1
|
||||
? `${blockedByIssues.length} blockers`
|
||||
: `${blockedByIssueProjectDetails?.identifier} ${blockedByIssues[0]?.sequence_id}`
|
||||
: "-"}
|
||||
</div>
|
||||
</ControlLink>
|
||||
);
|
||||
});
|
||||
|
||||
export const AssignedOverdueIssueListItem: React.FC<IssueListItemProps> = observer((props) => {
|
||||
const { issueId, onClick, workspaceSlug } = props;
|
||||
// store hooks
|
||||
const { getProjectById } = useProject();
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
} = useIssueDetail();
|
||||
// derived values
|
||||
const issueDetails = getIssueById(issueId) as TWidgetIssue | undefined;
|
||||
|
||||
if (!issueDetails) return null;
|
||||
|
||||
const projectDetails = getProjectById(issueDetails.project_id);
|
||||
const blockedByIssues = issueDetails.issue_relation?.filter((issue) => issue.relation_type === "blocked_by") ?? [];
|
||||
|
||||
const blockedByIssueProjectDetails =
|
||||
blockedByIssues.length === 1 ? getProjectById(blockedByIssues[0]?.project_id ?? "") : null;
|
||||
|
||||
const dueBy = findTotalDaysInRange(new Date(issueDetails.target_date ?? ""), new Date(), false);
|
||||
|
||||
return (
|
||||
<ControlLink
|
||||
href={`/${workspaceSlug}/projects/${issueDetails.project_id}/issues/${issueDetails.id}`}
|
||||
onClick={() => onClick(issueDetails)}
|
||||
className="py-2 px-3 hover:bg-custom-background-80 rounded grid grid-cols-6 gap-1"
|
||||
>
|
||||
<div className="col-span-4 flex items-center gap-3">
|
||||
<PriorityIcon priority={issueDetails.priority} withContainer />
|
||||
<span className="text-xs font-medium flex-shrink-0">
|
||||
{projectDetails?.identifier} {issueDetails.sequence_id}
|
||||
</span>
|
||||
<h6 className="text-sm flex-grow truncate">{issueDetails.name}</h6>
|
||||
</div>
|
||||
<div className="text-xs text-center">
|
||||
{dueBy} {`day${dueBy > 1 ? "s" : ""}`}
|
||||
</div>
|
||||
<div className="text-xs text-center">
|
||||
{blockedByIssues.length > 0
|
||||
? blockedByIssues.length > 1
|
||||
? `${blockedByIssues.length} blockers`
|
||||
: `${blockedByIssueProjectDetails?.identifier} ${blockedByIssues[0]?.sequence_id}`
|
||||
: "-"}
|
||||
</div>
|
||||
</ControlLink>
|
||||
);
|
||||
});
|
||||
|
||||
export const AssignedCompletedIssueListItem: React.FC<IssueListItemProps> = observer((props) => {
|
||||
const { issueId, onClick, workspaceSlug } = props;
|
||||
// store hooks
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
} = useIssueDetail();
|
||||
const { getProjectById } = useProject();
|
||||
// derived values
|
||||
const issueDetails = getIssueById(issueId);
|
||||
|
||||
if (!issueDetails) return null;
|
||||
|
||||
const projectDetails = getProjectById(issueDetails.project_id);
|
||||
|
||||
return (
|
||||
<ControlLink
|
||||
href={`/${workspaceSlug}/projects/${issueDetails.project_id}/issues/${issueDetails.id}`}
|
||||
onClick={() => onClick(issueDetails)}
|
||||
className="py-2 px-3 hover:bg-custom-background-80 rounded grid grid-cols-6 gap-1"
|
||||
>
|
||||
<div className="col-span-6 flex items-center gap-3">
|
||||
<PriorityIcon priority={issueDetails.priority} withContainer />
|
||||
<span className="text-xs font-medium flex-shrink-0">
|
||||
{projectDetails?.identifier} {issueDetails.sequence_id}
|
||||
</span>
|
||||
<h6 className="text-sm flex-grow truncate">{issueDetails.name}</h6>
|
||||
</div>
|
||||
</ControlLink>
|
||||
);
|
||||
});
|
||||
|
||||
export const CreatedUpcomingIssueListItem: React.FC<IssueListItemProps> = observer((props) => {
|
||||
const { issueId, onClick, workspaceSlug } = props;
|
||||
// store hooks
|
||||
const { getUserDetails } = useMember();
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
} = useIssueDetail();
|
||||
const { getProjectById } = useProject();
|
||||
// derived values
|
||||
const issue = getIssueById(issueId);
|
||||
|
||||
if (!issue) return null;
|
||||
|
||||
const projectDetails = getProjectById(issue.project_id);
|
||||
|
||||
return (
|
||||
<ControlLink
|
||||
href={`/${workspaceSlug}/projects/${issue.project_id}/issues/${issue.id}`}
|
||||
onClick={() => onClick(issue)}
|
||||
className="py-2 px-3 hover:bg-custom-background-80 rounded grid grid-cols-6 gap-1"
|
||||
>
|
||||
<div className="col-span-4 flex items-center gap-3">
|
||||
<PriorityIcon priority={issue.priority} withContainer />
|
||||
<span className="text-xs font-medium flex-shrink-0">
|
||||
{projectDetails?.identifier} {issue.sequence_id}
|
||||
</span>
|
||||
<h6 className="text-sm flex-grow truncate">{issue.name}</h6>
|
||||
</div>
|
||||
<div className="text-xs text-center">
|
||||
{issue.target_date
|
||||
? isToday(new Date(issue.target_date))
|
||||
? "Today"
|
||||
: renderFormattedDate(issue.target_date)
|
||||
: "-"}
|
||||
</div>
|
||||
<div className="text-xs flex justify-center">
|
||||
{issue.assignee_ids.length > 0 ? (
|
||||
<AvatarGroup>
|
||||
{issue.assignee_ids?.map((assigneeId) => {
|
||||
const userDetails = getUserDetails(assigneeId);
|
||||
|
||||
if (!userDetails) return null;
|
||||
|
||||
return <Avatar key={assigneeId} src={userDetails.avatar} name={userDetails.display_name} />;
|
||||
})}
|
||||
</AvatarGroup>
|
||||
) : (
|
||||
"-"
|
||||
)}
|
||||
</div>
|
||||
</ControlLink>
|
||||
);
|
||||
});
|
||||
|
||||
export const CreatedOverdueIssueListItem: React.FC<IssueListItemProps> = observer((props) => {
|
||||
const { issueId, onClick, workspaceSlug } = props;
|
||||
// store hooks
|
||||
const { getUserDetails } = useMember();
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
} = useIssueDetail();
|
||||
const { getProjectById } = useProject();
|
||||
// derived values
|
||||
const issue = getIssueById(issueId);
|
||||
|
||||
if (!issue) return null;
|
||||
|
||||
const projectDetails = getProjectById(issue.project_id);
|
||||
|
||||
const dueBy = findTotalDaysInRange(new Date(issue.target_date ?? ""), new Date(), false);
|
||||
|
||||
return (
|
||||
<ControlLink
|
||||
href={`/${workspaceSlug}/projects/${issue.project_id}/issues/${issue.id}`}
|
||||
onClick={() => onClick(issue)}
|
||||
className="py-2 px-3 hover:bg-custom-background-80 rounded grid grid-cols-6 gap-1"
|
||||
>
|
||||
<div className="col-span-4 flex items-center gap-3">
|
||||
<PriorityIcon priority={issue.priority} withContainer />
|
||||
<span className="text-xs font-medium flex-shrink-0">
|
||||
{projectDetails?.identifier} {issue.sequence_id}
|
||||
</span>
|
||||
<h6 className="text-sm flex-grow truncate">{issue.name}</h6>
|
||||
</div>
|
||||
<div className="text-xs text-center">
|
||||
{dueBy} {`day${dueBy > 1 ? "s" : ""}`}
|
||||
</div>
|
||||
<div className="text-xs flex justify-center">
|
||||
{issue.assignee_ids.length > 0 ? (
|
||||
<AvatarGroup>
|
||||
{issue.assignee_ids?.map((assigneeId) => {
|
||||
const userDetails = getUserDetails(assigneeId);
|
||||
|
||||
if (!userDetails) return null;
|
||||
|
||||
return <Avatar key={assigneeId} src={userDetails.avatar} name={userDetails.display_name} />;
|
||||
})}
|
||||
</AvatarGroup>
|
||||
) : (
|
||||
"-"
|
||||
)}
|
||||
</div>
|
||||
</ControlLink>
|
||||
);
|
||||
});
|
||||
|
||||
export const CreatedCompletedIssueListItem: React.FC<IssueListItemProps> = observer((props) => {
|
||||
const { issueId, onClick, workspaceSlug } = props;
|
||||
// store hooks
|
||||
const { getUserDetails } = useMember();
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
} = useIssueDetail();
|
||||
const { getProjectById } = useProject();
|
||||
// derived values
|
||||
const issue = getIssueById(issueId);
|
||||
|
||||
if (!issue) return null;
|
||||
|
||||
const projectDetails = getProjectById(issue.project_id);
|
||||
|
||||
return (
|
||||
<ControlLink
|
||||
href={`/${workspaceSlug}/projects/${issue.project_id}/issues/${issue.id}`}
|
||||
onClick={() => onClick(issue)}
|
||||
className="py-2 px-3 hover:bg-custom-background-80 rounded grid grid-cols-6 gap-1"
|
||||
>
|
||||
<div className="col-span-5 flex items-center gap-3">
|
||||
<PriorityIcon priority={issue.priority} withContainer />
|
||||
<span className="text-xs font-medium flex-shrink-0">
|
||||
{projectDetails?.identifier} {issue.sequence_id}
|
||||
</span>
|
||||
<h6 className="text-sm flex-grow truncate">{issue.name}</h6>
|
||||
</div>
|
||||
<div className="text-xs flex justify-center">
|
||||
{issue.assignee_ids.length > 0 ? (
|
||||
<AvatarGroup>
|
||||
{issue.assignee_ids?.map((assigneeId) => {
|
||||
const userDetails = getUserDetails(assigneeId);
|
||||
|
||||
if (!userDetails) return null;
|
||||
|
||||
return <Avatar key={assigneeId} src={userDetails.avatar} name={userDetails.display_name} />;
|
||||
})}
|
||||
</AvatarGroup>
|
||||
) : (
|
||||
"-"
|
||||
)}
|
||||
</div>
|
||||
</ControlLink>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./loader";
|
||||
@@ -0,0 +1,15 @@
|
||||
// ui
|
||||
import { Loader } from "@plane/ui";
|
||||
|
||||
export const IssuesByPriorityWidgetLoader = () => (
|
||||
<Loader className="bg-custom-background-100 rounded-xl p-6">
|
||||
<Loader.Item height="17px" width="35%" />
|
||||
<div className="flex items-center gap-1 h-full">
|
||||
<Loader.Item height="119px" width="14%" />
|
||||
<Loader.Item height="119px" width="26%" />
|
||||
<Loader.Item height="119px" width="36%" />
|
||||
<Loader.Item height="119px" width="18%" />
|
||||
<Loader.Item height="119px" width="6%" />
|
||||
</div>
|
||||
</Loader>
|
||||
);
|
||||
@@ -0,0 +1,21 @@
|
||||
// ui
|
||||
import { Loader } from "@plane/ui";
|
||||
|
||||
export const IssuesByStateGroupWidgetLoader = () => (
|
||||
<Loader className="bg-custom-background-100 rounded-xl p-6">
|
||||
<Loader.Item height="17px" width="35%" />
|
||||
<div className="flex items-center justify-between gap-32 mt-12 pl-6">
|
||||
<div className="w-1/2 grid place-items-center">
|
||||
<div className="rounded-full overflow-hidden relative flex-shrink-0 h-[184px] w-[184px]">
|
||||
<Loader.Item height="184px" width="184px" />
|
||||
<div className="absolute h-[100px] w-[100px] top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-custom-background-100 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-1/2 space-y-7 flex-shrink-0">
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<Loader.Item key={index} height="11px" width="100%" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Loader>
|
||||
);
|
||||
@@ -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];
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
@@ -0,0 +1,19 @@
|
||||
// ui
|
||||
import { Loader } from "@plane/ui";
|
||||
|
||||
export const RecentActivityWidgetLoader = () => (
|
||||
<Loader className="bg-custom-background-100 rounded-xl p-6 space-y-6">
|
||||
<Loader.Item height="17px" width="35%" />
|
||||
{Array.from({ length: 7 }).map((_, index) => (
|
||||
<div key={index} className="flex items-start gap-3.5">
|
||||
<div className="flex-shrink-0">
|
||||
<Loader.Item height="16px" width="16px" />
|
||||
</div>
|
||||
<div className="space-y-3 flex-shrink-0 w-full">
|
||||
<Loader.Item height="15px" width="70%" />
|
||||
<Loader.Item height="11px" width="10%" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</Loader>
|
||||
);
|
||||
@@ -0,0 +1,18 @@
|
||||
// ui
|
||||
import { Loader } from "@plane/ui";
|
||||
|
||||
export const RecentCollaboratorsWidgetLoader = () => (
|
||||
<Loader className="bg-custom-background-100 rounded-xl p-6 space-y-9">
|
||||
<Loader.Item height="17px" width="20%" />
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 xl:grid-cols-8 gap-2">
|
||||
{Array.from({ length: 8 }).map((_, index) => (
|
||||
<div key={index} className="space-y-11 flex flex-col items-center">
|
||||
<div className="rounded-full overflow-hidden h-[69px] w-[69px]">
|
||||
<Loader.Item height="69px" width="69px" />
|
||||
</div>
|
||||
<Loader.Item height="11px" width="70%" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Loader>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Plus } from "lucide-react";
|
||||
// hooks
|
||||
import { useApplication, useDashboard, useProject, useUser } from "hooks/store";
|
||||
// components
|
||||
import { WidgetLoader, WidgetProps } from "components/dashboard/widgets";
|
||||
// ui
|
||||
import { Avatar, AvatarGroup } from "@plane/ui";
|
||||
// helpers
|
||||
import { renderEmoji } from "helpers/emoji.helper";
|
||||
// types
|
||||
import { TRecentProjectsWidgetResponse } from "@plane/types";
|
||||
// constants
|
||||
import { EUserWorkspaceRoles } from "constants/workspace";
|
||||
import { PROJECT_BACKGROUND_COLORS } from "constants/dashboard";
|
||||
|
||||
const WIDGET_KEY = "recent_projects";
|
||||
|
||||
type ProjectListItemProps = {
|
||||
projectId: string;
|
||||
workspaceSlug: string;
|
||||
};
|
||||
|
||||
const ProjectListItem: React.FC<ProjectListItemProps> = observer((props) => {
|
||||
const { projectId, workspaceSlug } = props;
|
||||
// store hooks
|
||||
const { getProjectById } = useProject();
|
||||
const projectDetails = getProjectById(projectId);
|
||||
|
||||
const randomBgColor = PROJECT_BACKGROUND_COLORS[Math.floor(Math.random() * PROJECT_BACKGROUND_COLORS.length)];
|
||||
|
||||
if (!projectDetails) return null;
|
||||
|
||||
return (
|
||||
<Link href={`/${workspaceSlug}/projects/${projectId}/issues`} className="group flex items-center gap-8">
|
||||
<div
|
||||
className={`h-[3.375rem] w-[3.375rem] grid place-items-center rounded border border-transparent flex-shrink-0 ${randomBgColor}`}
|
||||
>
|
||||
{projectDetails.emoji ? (
|
||||
<span className="grid h-7 w-7 flex-shrink-0 place-items-center rounded uppercase">
|
||||
{renderEmoji(projectDetails.emoji)}
|
||||
</span>
|
||||
) : projectDetails.icon_prop ? (
|
||||
<div className="grid h-7 w-7 flex-shrink-0 place-items-center">{renderEmoji(projectDetails.icon_prop)}</div>
|
||||
) : (
|
||||
<span className="grid h-7 w-7 flex-shrink-0 place-items-center rounded bg-gray-700 uppercase text-white">
|
||||
{projectDetails.name.charAt(0)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-grow truncate">
|
||||
<h6 className="text-sm text-custom-text-300 font-medium group-hover:underline group-hover:text-custom-text-100 truncate">
|
||||
{projectDetails.name}
|
||||
</h6>
|
||||
<div className="mt-2">
|
||||
<AvatarGroup>
|
||||
{projectDetails.members?.map((member) => (
|
||||
<Avatar src={member.member__avatar} name={member.member__display_name} />
|
||||
))}
|
||||
</AvatarGroup>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
});
|
||||
|
||||
export const RecentProjectsWidget: React.FC<WidgetProps> = observer((props) => {
|
||||
const { dashboardId, workspaceSlug } = props;
|
||||
// store hooks
|
||||
const {
|
||||
commandPalette: { toggleCreateProjectModal },
|
||||
} = useApplication();
|
||||
const {
|
||||
membership: { currentWorkspaceRole },
|
||||
} = useUser();
|
||||
const { fetchWidgetStats, widgetStats: allWidgetStats } = useDashboard();
|
||||
// derived values
|
||||
const widgetStats = allWidgetStats?.[workspaceSlug]?.[dashboardId]?.[WIDGET_KEY] as TRecentProjectsWidgetResponse;
|
||||
const canCreateProject = currentWorkspaceRole === EUserWorkspaceRoles.ADMIN;
|
||||
|
||||
useEffect(() => {
|
||||
if (!widgetStats)
|
||||
fetchWidgetStats(workspaceSlug, dashboardId, {
|
||||
widget_key: WIDGET_KEY,
|
||||
});
|
||||
}, [dashboardId, fetchWidgetStats, widgetStats, workspaceSlug]);
|
||||
|
||||
if (!widgetStats) return <WidgetLoader widgetKey={WIDGET_KEY} />;
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/${workspaceSlug}/projects`}
|
||||
className="bg-custom-background-100 rounded-xl border-[0.5px] border-custom-border-200 w-full py-6 hover:shadow-custom-shadow-4xl duration-300"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 px-7">
|
||||
<h4 className="text-lg font-semibold text-custom-text-300">My projects</h4>
|
||||
</div>
|
||||
<div className="space-y-8 mt-4 mx-7">
|
||||
{canCreateProject && (
|
||||
<button
|
||||
type="button"
|
||||
className="group flex items-center gap-8"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
toggleCreateProjectModal(true);
|
||||
}}
|
||||
>
|
||||
<div className="h-[3.375rem] w-[3.375rem] bg-custom-primary-100/20 text-custom-primary-100 grid place-items-center rounded border border-dashed border-custom-primary-60 flex-shrink-0">
|
||||
<Plus className="h-6 w-6" />
|
||||
</div>
|
||||
<p className="text-sm text-custom-text-300 font-medium group-hover:underline group-hover:text-custom-text-100">
|
||||
Create new project
|
||||
</p>
|
||||
</button>
|
||||
)}
|
||||
{widgetStats.map((projectId) => (
|
||||
<ProjectListItem key={projectId} projectId={projectId} workspaceSlug={workspaceSlug} />
|
||||
))}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
});
|
||||
@@ -258,7 +258,7 @@ export const PriorityDropdown: React.FC<Props> = (props) => {
|
||||
>
|
||||
<PriorityIcon
|
||||
priority={priority.key}
|
||||
size={12}
|
||||
size={14}
|
||||
className={cn({
|
||||
"text-white": priority.key === "urgent" && highlightUrgent,
|
||||
// centre align the icons if text is hidden
|
||||
|
||||
@@ -12,7 +12,12 @@ import { Breadcrumbs, Button, LayersIcon, PhotoFilterIcon, Tooltip } from "@plan
|
||||
// icons
|
||||
import { List, PlusIcon, Sheet } from "lucide-react";
|
||||
// types
|
||||
import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "@plane/types";
|
||||
import {
|
||||
IIssueDisplayFilterOptions,
|
||||
IIssueDisplayProperties,
|
||||
IIssueFilterOptions,
|
||||
TStaticViewTypes,
|
||||
} from "@plane/types";
|
||||
// constants
|
||||
import { EIssueFilterType, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "constants/issue";
|
||||
import { EUserWorkspaceRoles } from "constants/workspace";
|
||||
@@ -35,7 +40,7 @@ export const GlobalIssuesHeader: React.FC<Props> = observer((props) => {
|
||||
const { workspaceSlug, globalViewId } = router.query;
|
||||
// store hooks
|
||||
const {
|
||||
issuesFilter: { issueFilters, updateFilters },
|
||||
issuesFilter: { filters, updateFilters },
|
||||
} = useIssues(EIssuesStoreType.GLOBAL);
|
||||
const {
|
||||
membership: { currentWorkspaceRole },
|
||||
@@ -47,6 +52,8 @@ export const GlobalIssuesHeader: React.FC<Props> = observer((props) => {
|
||||
workspace: { workspaceMemberIds },
|
||||
} = useMember();
|
||||
|
||||
const issueFilters = globalViewId ? filters[globalViewId.toString()] : undefined;
|
||||
|
||||
const handleFiltersUpdate = useCallback(
|
||||
(key: keyof IIssueFilterOptions, value: string | string[]) => {
|
||||
if (!workspaceSlug || !globalViewId) return;
|
||||
|
||||
@@ -1,23 +1,17 @@
|
||||
import { FC } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import useSWR from "swr";
|
||||
import { FileText, Plus } from "lucide-react";
|
||||
// hooks
|
||||
import { useApplication, useProject } from "hooks/store";
|
||||
// services
|
||||
import { PageService } from "services/page.service";
|
||||
import { useApplication, usePage, useProject } from "hooks/store";
|
||||
// ui
|
||||
import { Breadcrumbs, Button } from "@plane/ui";
|
||||
// helpers
|
||||
import { renderEmoji } from "helpers/emoji.helper";
|
||||
// fetch-keys
|
||||
import { PAGE_DETAILS } from "constants/fetch-keys";
|
||||
|
||||
export interface IPagesHeaderProps {
|
||||
showButton?: boolean;
|
||||
}
|
||||
const pageService = new PageService();
|
||||
|
||||
export const PageDetailsHeader: FC<IPagesHeaderProps> = observer((props) => {
|
||||
const { showButton = false } = props;
|
||||
@@ -28,12 +22,7 @@ export const PageDetailsHeader: FC<IPagesHeaderProps> = observer((props) => {
|
||||
const { commandPalette: commandPaletteStore } = useApplication();
|
||||
const { currentProjectDetails } = useProject();
|
||||
|
||||
const { data: pageDetails } = useSWR(
|
||||
workspaceSlug && currentProjectDetails?.id && pageId ? PAGE_DETAILS(pageId as string) : null,
|
||||
workspaceSlug && currentProjectDetails?.id
|
||||
? () => pageService.getPageDetails(workspaceSlug as string, currentProjectDetails.id, pageId as string)
|
||||
: null
|
||||
);
|
||||
const pageDetails = usePage(pageId as string);
|
||||
|
||||
return (
|
||||
<div className="relative z-10 flex h-[3.75rem] w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 border-b border-custom-border-200 bg-custom-sidebar-background-100 p-4">
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
<Zap size={14} strokeWidth={2} fill="rgb(var(--color-text-100))" />
|
||||
{"What's New?"}
|
||||
{"What's new?"}
|
||||
</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"
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
// types
|
||||
import { TStateGroups } from "@plane/types";
|
||||
// constants
|
||||
import { STATE_GROUP_COLORS } from "constants/state";
|
||||
import { STATE_GROUPS } from "constants/state";
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
@@ -31,7 +31,7 @@ export const StateGroupIcon: React.FC<Props> = ({
|
||||
<StateGroupBacklogIcon
|
||||
width={width}
|
||||
height={height}
|
||||
color={color ?? STATE_GROUP_COLORS["backlog"]}
|
||||
color={color ?? STATE_GROUPS["backlog"].color}
|
||||
className={`flex-shrink-0 ${className}`}
|
||||
/>
|
||||
);
|
||||
@@ -40,7 +40,7 @@ export const StateGroupIcon: React.FC<Props> = ({
|
||||
<StateGroupCancelledIcon
|
||||
width={width}
|
||||
height={height}
|
||||
color={color ?? STATE_GROUP_COLORS["cancelled"]}
|
||||
color={color ?? STATE_GROUPS["cancelled"].color}
|
||||
className={`flex-shrink-0 ${className}`}
|
||||
/>
|
||||
);
|
||||
@@ -49,7 +49,7 @@ export const StateGroupIcon: React.FC<Props> = ({
|
||||
<StateGroupCompletedIcon
|
||||
width={width}
|
||||
height={height}
|
||||
color={color ?? STATE_GROUP_COLORS["completed"]}
|
||||
color={color ?? STATE_GROUPS["completed"].color}
|
||||
className={`flex-shrink-0 ${className}`}
|
||||
/>
|
||||
);
|
||||
@@ -58,7 +58,7 @@ export const StateGroupIcon: React.FC<Props> = ({
|
||||
<StateGroupStartedIcon
|
||||
width={width}
|
||||
height={height}
|
||||
color={color ?? STATE_GROUP_COLORS["started"]}
|
||||
color={color ?? STATE_GROUPS["started"].color}
|
||||
className={`flex-shrink-0 ${className}`}
|
||||
/>
|
||||
);
|
||||
@@ -67,7 +67,7 @@ export const StateGroupIcon: React.FC<Props> = ({
|
||||
<StateGroupUnstartedIcon
|
||||
width={width}
|
||||
height={height}
|
||||
color={color ?? STATE_GROUP_COLORS["unstarted"]}
|
||||
color={color ?? STATE_GROUPS["unstarted"].color}
|
||||
className={`flex-shrink-0 ${className}`}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -75,7 +75,7 @@ export const ModuleEmptyState: React.FC<Props> = observer((props) => {
|
||||
icon: <PlusIcon className="h-3 w-3" strokeWidth={2} />,
|
||||
onClick: () => {
|
||||
setTrackElement("MODULE_EMPTY_STATE");
|
||||
toggleCreateIssueModal(true);
|
||||
toggleCreateIssueModal(true, EIssuesStoreType.MODULE);
|
||||
},
|
||||
}}
|
||||
secondaryButton={
|
||||
|
||||
@@ -25,13 +25,14 @@ type Props = {
|
||||
handleRemoveFilter: (key: keyof IIssueFilterOptions, value: string | null) => void;
|
||||
labels?: IIssueLabel[] | undefined;
|
||||
states?: IState[] | undefined;
|
||||
alwaysAllowEditing?: boolean;
|
||||
};
|
||||
|
||||
const membersFilters = ["assignees", "mentions", "created_by", "subscriber"];
|
||||
const dateFilters = ["start_date", "target_date"];
|
||||
|
||||
export const AppliedFiltersList: React.FC<Props> = observer((props) => {
|
||||
const { appliedFilters, handleClearAllFilters, handleRemoveFilter, labels, states } = props;
|
||||
const { appliedFilters, handleClearAllFilters, handleRemoveFilter, labels, states, alwaysAllowEditing } = props;
|
||||
// store hooks
|
||||
const {
|
||||
membership: { currentProjectRole },
|
||||
@@ -41,7 +42,7 @@ export const AppliedFiltersList: React.FC<Props> = observer((props) => {
|
||||
|
||||
if (Object.keys(appliedFilters).length === 0) return null;
|
||||
|
||||
const isEditingAllowed = currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER;
|
||||
const isEditingAllowed = alwaysAllowEditing || (currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER);
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-stretch gap-2 bg-custom-background-100">
|
||||
|
||||
@@ -15,13 +15,13 @@ export const AppliedMembersFilters: React.FC<Props> = observer((props) => {
|
||||
const { handleRemove, values, editable } = props;
|
||||
|
||||
const {
|
||||
project: { getProjectMemberDetails },
|
||||
workspace: { getWorkspaceMemberDetails },
|
||||
} = useMember();
|
||||
|
||||
return (
|
||||
<>
|
||||
{values.map((memberId) => {
|
||||
const memberDetails = getProjectMemberDetails(memberId)?.member;
|
||||
const memberDetails = getWorkspaceMemberDetails(memberId)?.member;
|
||||
|
||||
if (!memberDetails) return null;
|
||||
|
||||
|
||||
+45
-30
@@ -1,32 +1,48 @@
|
||||
import { useRouter } from "next/router";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import isEqual from "lodash/isEqual";
|
||||
// hooks
|
||||
import { useIssues, useLabel } from "hooks/store";
|
||||
import { useGlobalView, useIssues, useLabel, useUser } from "hooks/store";
|
||||
//ui
|
||||
import { Button } from "@plane/ui";
|
||||
// components
|
||||
import { AppliedFiltersList } from "components/issues";
|
||||
// types
|
||||
import { IIssueFilterOptions } from "@plane/types";
|
||||
import { IIssueFilterOptions, TStaticViewTypes } from "@plane/types";
|
||||
import { EIssueFilterType, EIssuesStoreType } from "constants/issue";
|
||||
import { DEFAULT_GLOBAL_VIEWS_LIST, EUserWorkspaceRoles } from "constants/workspace";
|
||||
|
||||
export const GlobalViewsAppliedFiltersRoot = observer(() => {
|
||||
type Props = {
|
||||
globalViewId: string;
|
||||
};
|
||||
|
||||
export const GlobalViewsAppliedFiltersRoot = observer((props: Props) => {
|
||||
const { globalViewId } = props;
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, globalViewId } = router.query;
|
||||
const { workspaceSlug } = router.query;
|
||||
// store hooks
|
||||
const {
|
||||
issuesFilter: { issueFilters, updateFilters },
|
||||
issuesFilter: { filters, updateFilters },
|
||||
} = useIssues(EIssuesStoreType.GLOBAL);
|
||||
const {
|
||||
workspace: { workspaceLabels },
|
||||
} = useLabel();
|
||||
const { globalViewMap, updateGlobalView } = useGlobalView();
|
||||
const {
|
||||
membership: { currentWorkspaceRole },
|
||||
} = useUser();
|
||||
|
||||
// derived values
|
||||
const userFilters = issueFilters?.filters;
|
||||
const userFilters = filters?.[globalViewId]?.filters;
|
||||
const viewDetails = globalViewMap[globalViewId];
|
||||
|
||||
// filters whose value not null or empty array
|
||||
const appliedFilters: IIssueFilterOptions = {};
|
||||
let appliedFilters: IIssueFilterOptions | undefined = undefined;
|
||||
Object.entries(userFilters ?? {}).forEach(([key, value]) => {
|
||||
if (!value) return;
|
||||
if (Array.isArray(value) && value.length === 0) return;
|
||||
if (!appliedFilters) appliedFilters = {};
|
||||
appliedFilters[key as keyof IIssueFilterOptions] = value;
|
||||
});
|
||||
|
||||
@@ -70,29 +86,24 @@ export const GlobalViewsAppliedFiltersRoot = observer(() => {
|
||||
);
|
||||
};
|
||||
|
||||
// const handleUpdateView = () => {
|
||||
// if (!workspaceSlug || !globalViewId || !viewDetails) return;
|
||||
const handleUpdateView = () => {
|
||||
if (!workspaceSlug || !globalViewId) return;
|
||||
|
||||
// globalViewsStore.updateGlobalView(workspaceSlug.toString(), globalViewId.toString(), {
|
||||
// query_data: {
|
||||
// ...viewDetails.query_data,
|
||||
// filters: {
|
||||
// ...(storedFilters ?? {}),
|
||||
// },
|
||||
// },
|
||||
// });
|
||||
// };
|
||||
updateGlobalView(workspaceSlug.toString(), globalViewId.toString(), {
|
||||
filters: {
|
||||
...(appliedFilters ?? {}),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// update stored filters when view details are fetched
|
||||
// useEffect(() => {
|
||||
// if (!globalViewId || !viewDetails) return;
|
||||
const areFiltersEqual = isEqual(appliedFilters, viewDetails?.filters);
|
||||
|
||||
// if (!globalViewFiltersStore.storedFilters[globalViewId.toString()])
|
||||
// globalViewFiltersStore.updateStoredFilters(globalViewId.toString(), viewDetails?.query_data?.filters ?? {});
|
||||
// }, [globalViewId, globalViewFiltersStore, viewDetails]);
|
||||
const isAuthorizedUser = !!currentWorkspaceRole && currentWorkspaceRole >= EUserWorkspaceRoles.MEMBER;
|
||||
|
||||
const isDefaultView = DEFAULT_GLOBAL_VIEWS_LIST.map((view) => view.key).includes(globalViewId as TStaticViewTypes);
|
||||
|
||||
// return if no filters are applied
|
||||
if (Object.keys(appliedFilters).length === 0) return null;
|
||||
if (!appliedFilters && areFiltersEqual) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-4 p-4">
|
||||
@@ -101,13 +112,17 @@ export const GlobalViewsAppliedFiltersRoot = observer(() => {
|
||||
appliedFilters={appliedFilters ?? {}}
|
||||
handleClearAllFilters={handleClearAllFilters}
|
||||
handleRemoveFilter={handleRemoveFilter}
|
||||
alwaysAllowEditing
|
||||
/>
|
||||
|
||||
{/* {storedFilters && viewDetails && areFiltersDifferent(storedFilters, viewDetails.query_data.filters ?? {}) && (
|
||||
<Button variant="primary" onClick={handleUpdateView}>
|
||||
Update view
|
||||
</Button>
|
||||
)} */}
|
||||
{!isDefaultView && !areFiltersEqual && isAuthorizedUser && (
|
||||
<>
|
||||
<div />
|
||||
<Button variant="primary" onClick={handleUpdateView}>
|
||||
Update view
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
+16
-12
@@ -1,13 +1,12 @@
|
||||
import { useRouter } from "next/router";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import isEqual from "lodash/isEqual";
|
||||
// hooks
|
||||
import { useIssues, useLabel, useProjectState, useProjectView } from "hooks/store";
|
||||
// components
|
||||
import { AppliedFiltersList } from "components/issues";
|
||||
// ui
|
||||
import { Button } from "@plane/ui";
|
||||
// helpers
|
||||
import { areFiltersDifferent } from "helpers/filter.helper";
|
||||
// types
|
||||
import { IIssueFilterOptions } from "@plane/types";
|
||||
import { EIssueFilterType, EIssuesStoreType } from "constants/issue";
|
||||
@@ -33,10 +32,11 @@ export const ProjectViewAppliedFiltersRoot: React.FC = observer(() => {
|
||||
const viewDetails = viewId ? viewMap[viewId.toString()] : null;
|
||||
const userFilters = issueFilters?.filters;
|
||||
// filters whose value not null or empty array
|
||||
const appliedFilters: IIssueFilterOptions = {};
|
||||
let appliedFilters: IIssueFilterOptions | undefined = undefined;
|
||||
Object.entries(userFilters ?? {}).forEach(([key, value]) => {
|
||||
if (!value) return;
|
||||
if (Array.isArray(value) && value.length === 0) return;
|
||||
if (!appliedFilters) appliedFilters = {};
|
||||
appliedFilters[key as keyof IIssueFilterOptions] = value;
|
||||
});
|
||||
|
||||
@@ -78,9 +78,9 @@ export const ProjectViewAppliedFiltersRoot: React.FC = observer(() => {
|
||||
updateFilters(workspaceSlug, projectId, EIssueFilterType.FILTERS, { ...newFilters }, viewId);
|
||||
};
|
||||
|
||||
const areFiltersEqual = isEqual(appliedFilters, viewDetails?.filters);
|
||||
// return if no filters are applied
|
||||
if (Object.keys(appliedFilters).length === 0 && !areFiltersDifferent(appliedFilters, viewDetails?.filters ?? {}))
|
||||
return null;
|
||||
if (!appliedFilters && areFiltersEqual) return null;
|
||||
|
||||
const handleUpdateView = () => {
|
||||
if (!workspaceSlug || !projectId || !viewId || !viewDetails) return;
|
||||
@@ -95,19 +95,23 @@ export const ProjectViewAppliedFiltersRoot: React.FC = observer(() => {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4 p-4">
|
||||
<AppliedFiltersList
|
||||
appliedFilters={appliedFilters}
|
||||
appliedFilters={appliedFilters ?? {}}
|
||||
handleClearAllFilters={handleClearAllFilters}
|
||||
handleRemoveFilter={handleRemoveFilter}
|
||||
labels={projectLabels ?? []}
|
||||
states={projectStates}
|
||||
alwaysAllowEditing
|
||||
/>
|
||||
|
||||
{viewDetails?.filters && areFiltersDifferent(appliedFilters, viewDetails?.filters ?? {}) && (
|
||||
<div className="flex flex-shrink-0 items-center justify-center">
|
||||
<Button variant="primary" size="sm" onClick={handleUpdateView}>
|
||||
Update view
|
||||
</Button>
|
||||
</div>
|
||||
{!areFiltersEqual && (
|
||||
<>
|
||||
<div />
|
||||
<div className="flex flex-shrink-0 items-center justify-center">
|
||||
<Button variant="primary" size="sm" onClick={handleUpdateView}>
|
||||
Update view
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -5,8 +5,8 @@ import { observer } from "mobx-react-lite";
|
||||
import { FilterHeader, FilterOption } from "components/issues";
|
||||
// icons
|
||||
import { StateGroupIcon } from "@plane/ui";
|
||||
import { STATE_GROUPS } from "constants/state";
|
||||
// constants
|
||||
import { ISSUE_STATE_GROUPS } from "constants/issue";
|
||||
|
||||
type Props = {
|
||||
appliedFilters: string[] | null;
|
||||
@@ -22,7 +22,7 @@ export const FilterStateGroup: React.FC<Props> = observer((props) => {
|
||||
|
||||
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 = () => {
|
||||
if (!filteredOptions) return;
|
||||
@@ -48,7 +48,7 @@ export const FilterStateGroup: React.FC<Props> = observer((props) => {
|
||||
isChecked={appliedFilters?.includes(stateGroup.key) ? true : false}
|
||||
onClick={() => handleUpdate(stateGroup.key)}
|
||||
icon={<StateGroupIcon stateGroup={stateGroup.key} />}
|
||||
title={stateGroup.title}
|
||||
title={stateGroup.label}
|
||||
/>
|
||||
))}
|
||||
{filteredOptions.length > 5 && (
|
||||
|
||||
@@ -43,7 +43,7 @@ export interface IBaseKanBanLayout {
|
||||
};
|
||||
showLoader?: boolean;
|
||||
viewId?: string;
|
||||
currentStore?: TCreateModalStoreTypes;
|
||||
storeType?: TCreateModalStoreTypes;
|
||||
addIssuesToView?: (issueIds: string[]) => Promise<TIssue>;
|
||||
canEditPropertiesBasedOnProject?: (projectId: string) => boolean;
|
||||
}
|
||||
@@ -62,7 +62,7 @@ export const BaseKanBanRoot: React.FC<IBaseKanBanLayout> = observer((props: IBas
|
||||
issueActions,
|
||||
showLoader,
|
||||
viewId,
|
||||
currentStore,
|
||||
storeType,
|
||||
addIssuesToView,
|
||||
canEditPropertiesBasedOnProject,
|
||||
} = props;
|
||||
@@ -277,7 +277,7 @@ export const BaseKanBanRoot: React.FC<IBaseKanBanLayout> = observer((props: IBas
|
||||
viewId={viewId}
|
||||
disableIssueCreation={!enableIssueCreation || !isEditingAllowed}
|
||||
canEditProperties={canEditProperties}
|
||||
currentStore={currentStore}
|
||||
storeType={storeType}
|
||||
addIssuesToView={addIssuesToView}
|
||||
/>
|
||||
</DragDropContext>
|
||||
|
||||
@@ -42,7 +42,7 @@ export interface IGroupByKanBan {
|
||||
) => Promise<TIssue | undefined>;
|
||||
viewId?: string;
|
||||
disableIssueCreation?: boolean;
|
||||
currentStore?: TCreateModalStoreTypes;
|
||||
storeType?: TCreateModalStoreTypes;
|
||||
addIssuesToView?: (issueIds: string[]) => Promise<TIssue>;
|
||||
canEditProperties: (projectId: string | undefined) => boolean;
|
||||
}
|
||||
@@ -64,7 +64,7 @@ const GroupByKanBan: React.FC<IGroupByKanBan> = observer((props) => {
|
||||
quickAddCallback,
|
||||
viewId,
|
||||
disableIssueCreation,
|
||||
currentStore,
|
||||
storeType,
|
||||
addIssuesToView,
|
||||
canEditProperties,
|
||||
} = props;
|
||||
@@ -107,7 +107,7 @@ const GroupByKanBan: React.FC<IGroupByKanBan> = observer((props) => {
|
||||
count={(issueIds as TGroupedIssues)?.[_list.id]?.length || 0}
|
||||
issuePayload={_list.payload}
|
||||
disableIssueCreation={disableIssueCreation || isGroupByCreatedBy}
|
||||
currentStore={currentStore}
|
||||
storeType={storeType}
|
||||
addIssuesToView={addIssuesToView}
|
||||
kanbanFilters={kanbanFilters}
|
||||
handleKanbanFilters={handleKanbanFilters}
|
||||
@@ -163,7 +163,7 @@ export interface IKanBan {
|
||||
) => Promise<TIssue | undefined>;
|
||||
viewId?: string;
|
||||
disableIssueCreation?: boolean;
|
||||
currentStore?: TCreateModalStoreTypes;
|
||||
storeType?: TCreateModalStoreTypes;
|
||||
addIssuesToView?: (issueIds: string[]) => Promise<TIssue>;
|
||||
canEditProperties: (projectId: string | undefined) => boolean;
|
||||
}
|
||||
@@ -184,7 +184,7 @@ export const KanBan: React.FC<IKanBan> = observer((props) => {
|
||||
quickAddCallback,
|
||||
viewId,
|
||||
disableIssueCreation,
|
||||
currentStore,
|
||||
storeType,
|
||||
addIssuesToView,
|
||||
canEditProperties,
|
||||
} = props;
|
||||
@@ -208,7 +208,7 @@ export const KanBan: React.FC<IKanBan> = observer((props) => {
|
||||
quickAddCallback={quickAddCallback}
|
||||
viewId={viewId}
|
||||
disableIssueCreation={disableIssueCreation}
|
||||
currentStore={currentStore}
|
||||
storeType={storeType}
|
||||
addIssuesToView={addIssuesToView}
|
||||
canEditProperties={canEditProperties}
|
||||
/>
|
||||
|
||||
@@ -25,7 +25,7 @@ interface IHeaderGroupByCard {
|
||||
handleKanbanFilters: any;
|
||||
issuePayload: Partial<TIssue>;
|
||||
disableIssueCreation?: boolean;
|
||||
currentStore?: TCreateModalStoreTypes;
|
||||
storeType?: TCreateModalStoreTypes;
|
||||
addIssuesToView?: (issueIds: string[]) => Promise<TIssue>;
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ export const HeaderGroupByCard: FC<IHeaderGroupByCard> = observer((props) => {
|
||||
handleKanbanFilters,
|
||||
issuePayload,
|
||||
disableIssueCreation,
|
||||
storeType,
|
||||
addIssuesToView,
|
||||
} = props;
|
||||
const verticalAlignPosition = sub_group_by ? false : kanbanFilters?.group_by.includes(column_id);
|
||||
@@ -83,7 +84,12 @@ export const HeaderGroupByCard: FC<IHeaderGroupByCard> = observer((props) => {
|
||||
fieldsToShow={["all"]}
|
||||
/>
|
||||
) : (
|
||||
<CreateUpdateIssueModal isOpen={isOpen} onClose={() => setIsOpen(false)} data={issuePayload} />
|
||||
<CreateUpdateIssueModal
|
||||
isOpen={isOpen}
|
||||
onClose={() => setIsOpen(false)}
|
||||
data={issuePayload}
|
||||
storeType={storeType}
|
||||
/>
|
||||
)}
|
||||
{renderExistingIssueModal && (
|
||||
<ExistingIssuesListModal
|
||||
|
||||
@@ -50,7 +50,7 @@ export const CycleKanBanLayout: React.FC = observer(() => {
|
||||
showLoader={true}
|
||||
QuickActions={CycleIssueQuickActions}
|
||||
viewId={cycleId?.toString() ?? ""}
|
||||
currentStore={EIssuesStoreType.CYCLE}
|
||||
storeType={EIssuesStoreType.CYCLE}
|
||||
addIssuesToView={(issueIds: string[]) => {
|
||||
if (!workspaceSlug || !projectId || !cycleId) throw new Error();
|
||||
return issues.addIssueToCycle(workspaceSlug.toString(), projectId.toString(), cycleId.toString(), issueIds);
|
||||
|
||||
@@ -50,7 +50,7 @@ export const ModuleKanBanLayout: React.FC = observer(() => {
|
||||
showLoader={true}
|
||||
QuickActions={ModuleIssueQuickActions}
|
||||
viewId={moduleId?.toString()}
|
||||
currentStore={EIssuesStoreType.MODULE}
|
||||
storeType={EIssuesStoreType.MODULE}
|
||||
addIssuesToView={(issueIds: string[]) => {
|
||||
if (!workspaceSlug || !projectId || !moduleId) throw new Error();
|
||||
return issues.addIssueToModule(workspaceSlug.toString(), projectId.toString(), moduleId.toString(), issueIds);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user